Skip to content

Commit 2b4fa54

Browse files
feat(empty-states): add tests and changelog empty-state graphics (#8976)
1 parent fa5f0db commit 2b4fa54

5 files changed

Lines changed: 246 additions & 6 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/changelog/changelog.tsx‎

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
import { useState } from 'react'
44
import { Chip, cn, scrollFadeAttributes, scrollFadeClass, useScrollEdges } from '@sim/emcn'
55
import { Rss } from '@sim/emcn/icons'
6-
import { EmptyState } from '@/components/empty-state/empty-state'
76
import type { ChangelogRelease } from '@/lib/api/contracts/changelog'
87
import {
98
ReleaseDetailsModal,
109
ReleaseEntry,
1110
} from '@/app/workspace/[workspaceId]/changelog/components'
1211
import ChangelogLoading from '@/app/workspace/[workspaceId]/changelog/loading'
12+
import { ChangelogEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
1313
import { Resource } from '@/app/workspace/[workspaceId]/components/resource/resource'
1414
import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider'
1515
import { PermissionAccessBoundary } from '@/ee/access-requests/components/permission-access-boundary'
@@ -47,10 +47,7 @@ function ChangelogContent({ workspaceId }: ChangelogProps) {
4747
<Chip onClick={() => query.refetch()}>Retry</Chip>
4848
</div>
4949
) : releases.length === 0 ? (
50-
<EmptyState
51-
title='Changelog'
52-
description='Sim publishes a release here when it ships changes to this workspace.'
53-
/>
50+
<ChangelogEmptyState />
5451
) : (
5552
<div
5653
ref={setScrollElement}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { cn } from '@sim/emcn'
2+
import { EmptyState } from '@/components/empty-state/empty-state'
3+
import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask'
4+
5+
/**
6+
* Skeleton ink — see the `INK` note in `tables-empty-state.tsx` for why not the surface
7+
* ramp. `mark` is the tables selection ring's opaque mix, so the latest release reads as
8+
* the one drawn entry rather than more placeholder.
9+
*/
10+
const INK = {
11+
title: 'color-mix(in srgb, var(--text-secondary) 30%, transparent)',
12+
detail: 'color-mix(in srgb, var(--text-secondary) 15%, transparent)',
13+
mark: 'color-mix(in srgb, var(--text-secondary) 46%, var(--bg))',
14+
} as const
15+
16+
/** The logs feed's falloff — a release history is the same repeating structure. */
17+
const TIMELINE_FADE =
18+
'[-webkit-mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)] [mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)]'
19+
20+
interface Release {
21+
top: number
22+
notes: readonly number[]
23+
}
24+
25+
const LATEST: Release = { top: 10, notes: [118, 92] }
26+
27+
const EARLIER: readonly Release[] = [
28+
{ top: 70, notes: [104, 126, 80] },
29+
{ top: 142, notes: [96, 112] },
30+
] as const
31+
32+
const SPINE_X = 24
33+
const NOTE_PITCH = 12
34+
35+
interface ReleaseNotesProps {
36+
release: Release
37+
}
38+
39+
function ReleaseNotes({ release }: ReleaseNotesProps) {
40+
return release.notes.map((width, index) => {
41+
const y = release.top + 26 + index * NOTE_PITCH
42+
return (
43+
<g key={y}>
44+
<circle cx='46' cy={y + 2} r='1.8' fill={INK.title} />
45+
<rect x='54' y={y} width={width} height='4' rx='2' fill={INK.detail} />
46+
</g>
47+
)
48+
})
49+
}
50+
51+
/** A release timeline: the latest release ringed at the head, earlier ones falling away below. */
52+
function ChangelogGraphic() {
53+
const latestY = LATEST.top + 8
54+
return (
55+
<svg
56+
viewBox='0 0 260 148'
57+
width={260}
58+
height={148}
59+
fill='none'
60+
aria-hidden='true'
61+
focusable='false'
62+
className={cn('block max-w-none shrink-0', TIMELINE_FADE, MASK_NO_REPEAT)}
63+
>
64+
<line x1={SPINE_X} x2={SPINE_X} y1='18' y2='160' stroke='var(--border)' strokeWidth={1.4} />
65+
66+
<circle
67+
cx={SPINE_X}
68+
cy={latestY}
69+
r='6'
70+
fill='var(--surface-2)'
71+
stroke={INK.mark}
72+
strokeWidth={1.6}
73+
/>
74+
<circle cx={SPINE_X} cy={latestY} r='2.4' fill={INK.mark} />
75+
<rect
76+
x='42'
77+
y={LATEST.top + 2}
78+
width='40'
79+
height='13'
80+
rx='6.5'
81+
fill='var(--surface-2)'
82+
stroke={INK.mark}
83+
strokeWidth={1.3}
84+
/>
85+
<rect x='50' y={LATEST.top + 6.5} width='24' height='4' rx='2' fill={INK.mark} />
86+
<rect x='92' y={LATEST.top + 6.5} width='34' height='4' rx='2' fill={INK.detail} />
87+
<ReleaseNotes release={LATEST} />
88+
89+
{EARLIER.map((release) => (
90+
<g key={release.top}>
91+
<circle cx={SPINE_X} cy={release.top + 8} r='4' fill={INK.title} />
92+
<rect x='42' y={release.top + 2} width='40' height='13' rx='6.5' fill={INK.detail} />
93+
<rect x='50' y={release.top + 6.5} width='24' height='4' rx='2' fill={INK.title} />
94+
<rect x='92' y={release.top + 6.5} width='34' height='4' rx='2' fill={INK.detail} />
95+
<ReleaseNotes release={release} />
96+
</g>
97+
))}
98+
</svg>
99+
)
100+
}
101+
102+
/** Empty state for a workspace that has no published releases yet. */
103+
export function ChangelogEmptyState() {
104+
return (
105+
<EmptyState
106+
graphic={<ChangelogGraphic />}
107+
title='Changelog'
108+
description='Sim publishes a release here when it ships changes to this workspace.'
109+
/>
110+
)
111+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
export { ChangelogEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/changelog-empty-state'
12
export { DashboardEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/dashboard-empty-state'
23
export { DocumentsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/documents-empty-state'
34
export { FilesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/files-empty-state'
45
export { KnowledgeEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/knowledge-empty-state'
56
export { LogsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/logs-empty-state'
67
export { ResourceNoResults } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/resource-no-results'
78
export { TablesEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tables-empty-state'
9+
export { TestsEmptyState } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/tests-empty-state'
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { cn } from '@sim/emcn'
2+
import { EmptyState } from '@/components/empty-state/empty-state'
3+
import { HAIRLINE } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/hairline'
4+
import { MASK_NO_REPEAT } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state/mask'
5+
6+
/**
7+
* Skeleton ink — see the `INK` note in `tables-empty-state.tsx` for why not the surface
8+
* ramp. `mark` is the tables selection ring's opaque mix, so a status glyph reads as
9+
* drawn rather than as more placeholder.
10+
*/
11+
const INK = {
12+
title: 'color-mix(in srgb, var(--text-secondary) 30%, transparent)',
13+
detail: 'color-mix(in srgb, var(--text-secondary) 15%, transparent)',
14+
mark: 'color-mix(in srgb, var(--text-secondary) 46%, var(--bg))',
15+
} as const
16+
17+
/** The logs feed's falloff — a run report is the same repeating structure. */
18+
const REPORT_FADE =
19+
'[-webkit-mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)] [mask-image:linear-gradient(to_bottom,#000_44%,transparent_100%)]'
20+
21+
type TestStatus = 'pass' | 'fail'
22+
23+
interface ReportRow {
24+
status: TestStatus
25+
title: number
26+
detail: number
27+
}
28+
29+
/**
30+
* The failure sits second, inside the fade's opaque head, so the one row held in a card
31+
* never dissolves mid-stroke.
32+
*/
33+
const ROWS: readonly ReportRow[] = [
34+
{ status: 'pass', title: 72, detail: 110 },
35+
{ status: 'fail', title: 64, detail: 124 },
36+
{ status: 'pass', title: 80, detail: 96 },
37+
{ status: 'pass', title: 58, detail: 116 },
38+
] as const
39+
40+
const ROW_PITCH = 38
41+
const BADGE_RADIUS = 8
42+
const GLYPH = BADGE_RADIUS * 0.42
43+
44+
const MARK_STROKE = {
45+
stroke: INK.mark,
46+
strokeWidth: 1.4,
47+
strokeLinecap: 'round',
48+
strokeLinejoin: 'round',
49+
} as const
50+
51+
interface StatusBadgeProps {
52+
cx: number
53+
cy: number
54+
status: TestStatus
55+
}
56+
57+
/** A pass ticks on a hairline ring; a fail crosses on the heavier ring it shares with its card. */
58+
function StatusBadge({ cx, cy, status }: StatusBadgeProps) {
59+
const glyph =
60+
status === 'pass'
61+
? `M ${cx - GLYPH} ${cy} L ${cx - GLYPH * 0.25} ${cy + GLYPH * 0.75} L ${cx + GLYPH} ${cy - GLYPH * 0.7}`
62+
: `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}`
63+
return (
64+
<g>
65+
<circle
66+
cx={cx}
67+
cy={cy}
68+
r={BADGE_RADIUS}
69+
fill='var(--surface-2)'
70+
stroke={status === 'pass' ? 'var(--border)' : INK.mark}
71+
strokeWidth={status === 'pass' ? HAIRLINE.strokeWidth : 1.5}
72+
/>
73+
<path d={glyph} {...MARK_STROKE} />
74+
</g>
75+
)
76+
}
77+
78+
/** A test run report: passing rows, the one failure held in a card like the logs feed's latest run. */
79+
function TestsGraphic() {
80+
return (
81+
<svg
82+
viewBox='0 0 286 148'
83+
width={286}
84+
height={148}
85+
fill='none'
86+
aria-hidden='true'
87+
focusable='false'
88+
className={cn('block max-w-none shrink-0', REPORT_FADE, MASK_NO_REPEAT)}
89+
>
90+
{ROWS.map((row, index) => {
91+
const y = 6 + index * ROW_PITCH
92+
return (
93+
<g key={y}>
94+
{row.status === 'fail' ? (
95+
<rect
96+
x='0.5'
97+
y={y - 2}
98+
width='285'
99+
height='36'
100+
rx='8'
101+
fill='var(--surface-2)'
102+
{...HAIRLINE}
103+
/>
104+
) : null}
105+
<StatusBadge cx={22} cy={y + 16} status={row.status} />
106+
<rect x='40' y={y + 8} width={row.title} height='6' rx='3' fill={INK.title} />
107+
<rect x='40' y={y + 20} width={row.detail} height='4' rx='2' fill={INK.detail} />
108+
<rect x='236' y={y + 13} width='30' height='5' rx='2.5' fill={INK.detail} />
109+
</g>
110+
)
111+
})}
112+
</svg>
113+
)
114+
}
115+
116+
/** Empty state for a workspace with no tests yet. */
117+
export function TestsEmptyState() {
118+
return (
119+
<EmptyState
120+
graphic={<TestsGraphic />}
121+
title='Tests'
122+
description='Write tests to check your workflows behave before you deploy.'
123+
/>
124+
)
125+
}

‎apps/sim/app/workspace/[workspaceId]/tests/tests.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ import { useQueryStates } from 'nuqs'
88
import type { WorkflowTestRecord } from '@/lib/api/contracts/workflow-tests'
99
import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state'
1010
import { ownerCell } from '@/app/workspace/[workspaceId]/components/resource/components/owner-cell'
11-
import { ResourceNoResults } from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
11+
import {
12+
ResourceNoResults,
13+
TestsEmptyState,
14+
} from '@/app/workspace/[workspaceId]/components/resource/components/resource-empty-state'
1215
import type {
1316
SearchConfig,
1417
SortConfig,
@@ -154,6 +157,8 @@ export function Tests({ workspaceId }: TestsProps) {
154157
<p className='text-[var(--text-error)] text-small'>{query.error.message}</p>
155158
<Chip onClick={() => query.refetch()}>Retry</Chip>
156159
</div>
160+
) : query.data?.length === 0 ? (
161+
<TestsEmptyState />
157162
) : debouncedSearch.trim() && query.data?.length ? (
158163
<ResourceNoResults
159164
search={debouncedSearch}

0 commit comments

Comments
 (0)