Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions apps/sim/app/workspace/[workspaceId]/changelog/changelog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
import { useState } from 'react'
import { Chip, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'
import { Rss } from '@sim/emcn/icons'
import { EmptyState } from '@/components/empty-state/empty-state'
import type { ChangelogRelease } from '@/lib/api/contracts/changelog'
import {
ReleaseDetailsModal,
ReleaseEntry,
} from '@/app/workspace/[workspaceId]/changelog/components'
import ChangelogLoading from '@/app/workspace/[workspaceId]/changelog/loading'
import { ChangelogEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource'
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary'
Expand Down Expand Up @@ -47,10 +47,7 @@ function ChangelogContent({ workspaceId }: ChangelogProps) {
<Chip onClick={() => query.refetch()}>Retry</Chip>
</div>
) : releases.length === 0 ? (
<EmptyState
title='Changelog'
description='Sim publishes a release here when it ships changes to this workspace.'
/>
<ChangelogEmptyState />
) : (
<div
ref={setScrollElement}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { cn } from '@sim/emcn'
import { EmptyState } from '@/components/empty-state/empty-state'
import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask'

/**
* Skeleton ink — see the `INK` note in `tables-empty-state.tsx` for why not the surface
* ramp. `mark` is the tables selection ring's opaque mix, so the latest release reads as
* the one drawn entry rather than more placeholder.
*/
const INK = {
title: 'color-mix(in srgb, var(--text-secondary) 30%, transparent)',
detail: 'color-mix(in srgb, var(--text-secondary) 15%, transparent)',
mark: 'color-mix(in srgb, var(--text-secondary) 46%, var(--bg))',
} as const

/** The logs feed's falloff — a release history is the same repeating structure. */
const TIMELINE_FADE =
'[-webkit-mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)] [mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)]'

interface Release {
top: number
notes: readonly number[]
}

const LATEST: Release = { top: 10, notes: [118, 92] }

const EARLIER: readonly Release[] = [
{ top: 70, notes: [104, 126, 80] },
{ top: 142, notes: [96, 112] },
] as const

const SPINE_X = 24
const NOTE_PITCH = 12

interface ReleaseNotesProps {
release: Release
}

function ReleaseNotes({ release }: ReleaseNotesProps) {
return release.notes.map((width, index) => {
const y = release.top + 26 + index * NOTE_PITCH
return (
<g key={y}>
<circle cx='46' cy={y + 2} r='1.8' fill={INK.title} />
<rect x='54' y={y} width={width} height='4' rx='2' fill={INK.detail} />
</g>
)
})
}

/** A release timeline: the latest release ringed at the head, earlier ones falling away below. */
function ChangelogGraphic() {
const latestY = LATEST.top + 8
return (
<svg
viewBox='0 0 260 148'
width={260}
height={148}
fill='none'
aria-hidden='true'
focusable='false'
className={cn('block max-w-none shrink-0', TIMELINE_FADE, MASK_NO_REPEAT)}
>
<line x1={SPINE_X} x2={SPINE_X} y1='18' y2='160' stroke='var(--border)' strokeWidth={1.4} />

<circle
cx={SPINE_X}
cy={latestY}
r='6'
fill='var(--surface-2)'
stroke={INK.mark}
strokeWidth={1.6}
/>
<circle cx={SPINE_X} cy={latestY} r='2.4' fill={INK.mark} />
<rect
x='42'
y={LATEST.top + 2}
width='40'
height='13'
rx='6.5'
fill='var(--surface-2)'
stroke={INK.mark}
strokeWidth={1.3}
/>
<rect x='50' y={LATEST.top + 6.5} width='24' height='4' rx='2' fill={INK.mark} />
<rect x='92' y={LATEST.top + 6.5} width='34' height='4' rx='2' fill={INK.detail} />
<ReleaseNotes release={LATEST} />

{EARLIER.map((release) => (
<g key={release.top}>
<circle cx={SPINE_X} cy={release.top + 8} r='4' fill={INK.title} />
<rect x='42' y={release.top + 2} width='40' height='13' rx='6.5' fill={INK.detail} />
<rect x='50' y={release.top + 6.5} width='24' height='4' rx='2' fill={INK.title} />
<rect x='92' y={release.top + 6.5} width='34' height='4' rx='2' fill={INK.detail} />
<ReleaseNotes release={release} />
</g>
))}
</svg>
)
}

/** Empty state for a workspace that has no published releases yet. */
export function ChangelogEmptyState() {
Comment thread
TheodoreSpeaks marked this conversation as resolved.
return (
<EmptyState
graphic={<ChangelogGraphic />}
title='Changelog'
description='Sim publishes a release here when it ships changes to this workspace.'
/>
)
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
export { ChangelogEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/changelog-empty-state'
export { DashboardEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/dashboard-empty-state'
export { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/documents-empty-state'
export { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state'
export { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state'
export { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state'
export { ResourceNoResults } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results'
export { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state'
export { TestsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tests-empty-state'
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { cn } from '@sim/emcn'
import { EmptyState } from '@/components/empty-state/empty-state'
import { HAIRLINE } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline'
import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask'

/**
* Skeleton ink — see the `INK` note in `tables-empty-state.tsx` for why not the surface
* ramp. `mark` is the tables selection ring's opaque mix, so a status glyph reads as
* drawn rather than as more placeholder.
*/
const INK = {
title: 'color-mix(in srgb, var(--text-secondary) 30%, transparent)',
detail: 'color-mix(in srgb, var(--text-secondary) 15%, transparent)',
mark: 'color-mix(in srgb, var(--text-secondary) 46%, var(--bg))',
} as const

/** The logs feed's falloff — a run report is the same repeating structure. */
const REPORT_FADE =
'[-webkit-mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)] [mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)]'

type TestStatus = 'pass' | 'fail'

interface ReportRow {
status: TestStatus
title: number
detail: number
}

/**
* The failure sits second, inside the fade's opaque head, so the one row held in a card
* never dissolves mid-stroke.
*/
const ROWS: readonly ReportRow[] = [
{ status: 'pass', title: 72, detail: 110 },
{ status: 'fail', title: 64, detail: 124 },
{ status: 'pass', title: 80, detail: 96 },
{ status: 'pass', title: 58, detail: 116 },
] as const

const ROW_PITCH = 38
const BADGE_RADIUS = 8
const GLYPH = BADGE_RADIUS * 0.42

const MARK_STROKE = {
stroke: INK.mark,
strokeWidth: 1.4,
strokeLinecap: 'round',
strokeLinejoin: 'round',
} as const

interface StatusBadgeProps {
cx: number
cy: number
status: TestStatus
}

/** A pass ticks on a hairline ring; a fail crosses on the heavier ring it shares with its card. */
function StatusBadge({ cx, cy, status }: StatusBadgeProps) {
const glyph =
status === 'pass'
? `M ${cx - GLYPH} ${cy} L ${cx - GLYPH * 0.25} ${cy + GLYPH * 0.75} L ${cx + GLYPH} ${cy - GLYPH * 0.7}`
: `M ${cx - GLYPH * 0.75} ${cy - GLYPH * 0.75} L ${cx + GLYPH * 0.75} ${cy + GLYPH * 0.75} M ${cx + GLYPH * 0.75} ${cy - GLYPH * 0.75} L ${cx - GLYPH * 0.75} ${cy + GLYPH * 0.75}`
return (
<g>
<circle
cx={cx}
cy={cy}
r={BADGE_RADIUS}
fill='var(--surface-2)'
stroke={status === 'pass' ? 'var(--border)' : INK.mark}
strokeWidth={status === 'pass' ? HAIRLINE.strokeWidth : 1.5}
/>
<path d={glyph} {...MARK_STROKE} />
</g>
)
}

/** A test run report: passing rows, the one failure held in a card like the logs feed's latest run. */
function TestsGraphic() {
return (
<svg
viewBox='0 0 286 148'
width={286}
height={148}
fill='none'
aria-hidden='true'
focusable='false'
className={cn('block max-w-none shrink-0', REPORT_FADE, MASK_NO_REPEAT)}
>
{ROWS.map((row, index) => {
const y = 6 + index * ROW_PITCH
return (
<g key={y}>
{row.status === 'fail' ? (
<rect
x='0.5'
y={y - 2}
width='285'
height='36'
rx='8'
fill='var(--surface-2)'
{...HAIRLINE}
/>
) : null}
<StatusBadge cx={22} cy={y + 16} status={row.status} />
<rect x='40' y={y + 8} width={row.title} height='6' rx='3' fill={INK.title} />
<rect x='40' y={y + 20} width={row.detail} height='4' rx='2' fill={INK.detail} />
<rect x='236' y={y + 13} width='30' height='5' rx='2.5' fill={INK.detail} />
</g>
)
})}
</svg>
)
}

/** Empty state for a workspace with no tests yet. */
export function TestsEmptyState() {
return (
<EmptyState
graphic={<TestsGraphic />}
title='Tests'
description='Write tests to check your workflows behave before you deploy.'
/>
)
}
7 changes: 6 additions & 1 deletion apps/sim/app/workspace/[workspaceId]/tests/tests.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import { useQueryStates } from 'nuqs'
import type { WorkflowTestRecord } from '@/lib/api/contracts/workflow-tests'
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
import { ownerCell } from '@/app/workspace/[workspaceId]/components/resource/components/owner-cell'
import { ResourceNoResults } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
import {
ResourceNoResults,
TestsEmptyState,
} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
import type {
SearchConfig,
SortConfig,
Expand Down Expand Up @@ -154,6 +157,8 @@ export function Tests({ workspaceId }: TestsProps) {
<p className='text-[var(--text-error)] text-small'>{query.error.message}</p>
<Chip onClick={() => query.refetch()}>Retry</Chip>
</div>
) : query.data?.length === 0 ? (
<TestsEmptyState />
) : debouncedSearch.trim() && query.data?.length ? (
<ResourceNoResults
search={debouncedSearch}
Expand Down
Loading