@startsimpli/ui 0.4.14 → 0.4.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +457 -398
- package/package.json +18 -13
- package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
- package/src/components/__tests__/chat.test.tsx +129 -0
- package/src/components/__tests__/meetings-list.test.tsx +114 -0
- package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
- package/src/components/__tests__/workspace.test.tsx +106 -0
- package/src/components/account/__tests__/account.test.tsx +5 -32
- package/src/components/account/change-password-form.tsx +1 -28
- package/src/components/calendar/calendar-view.tsx +31 -0
- package/src/components/calendar/index.ts +7 -0
- package/src/components/calendar/meetings-list.tsx +202 -0
- package/src/components/calendar/upcoming-meetings.tsx +5 -5
- package/src/components/chat/ChatComposer.tsx +113 -0
- package/src/components/chat/ChatMessage.tsx +81 -0
- package/src/components/chat/ChatThread.tsx +57 -0
- package/src/components/chat/index.ts +12 -0
- package/src/components/chat/types.ts +20 -0
- package/src/components/index.ts +13 -0
- package/src/components/slide-deck/SlideCanvas.tsx +68 -0
- package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
- package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
- package/src/components/slide-deck/index.ts +7 -0
- package/src/components/slide-deck/types.ts +18 -0
- package/src/components/team/DomainClaimCard.tsx +170 -0
- package/src/components/team/InviteMemberDialog.tsx +182 -0
- package/src/components/team/LeaveTeamDialog.tsx +130 -0
- package/src/components/team/MembersTable.tsx +138 -0
- package/src/components/team/OrgSwitcher.tsx +68 -0
- package/src/components/team/PendingInvitationCallout.tsx +106 -0
- package/src/components/team/RoleSelector.tsx +68 -0
- package/src/components/team/__tests__/team-components.test.tsx +352 -0
- package/src/components/team/__tests__/team-settings-page.test.tsx +146 -0
- package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
- package/src/components/team/index.ts +62 -0
- package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
- package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
- package/src/components/team/members-table-default-class-names.ts +39 -0
- package/src/components/team/org-switcher-default-class-names.ts +13 -0
- package/src/components/team/pages/DomainsSettingsPage.tsx +289 -0
- package/src/components/team/pages/TeamSettingsPage.tsx +423 -0
- package/src/components/team/pages/domains-settings-page-default-class-names.ts +89 -0
- package/src/components/team/pages/index.ts +33 -0
- package/src/components/team/pages/team-settings-page-default-class-names.ts +116 -0
- package/src/components/team/pages/types.ts +135 -0
- package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
- package/src/components/team/role-selector-default-class-names.ts +11 -0
- package/src/components/team/types.ts +97 -0
- package/src/components/workflows/ExecNodeDetails.tsx +83 -0
- package/src/components/workflows/ExecutionTimeline.tsx +146 -0
- package/src/components/workflows/NodeInspector.tsx +257 -0
- package/src/components/workflows/NodePalette.tsx +119 -0
- package/src/components/workflows/WorkflowCanvas.tsx +113 -0
- package/src/components/workflows/WorkflowEdge.tsx +65 -0
- package/src/components/workflows/WorkflowEditor.tsx +130 -0
- package/src/components/workflows/WorkflowNode.tsx +198 -0
- package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
- package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
- package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
- package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
- package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
- package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
- package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
- package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
- package/src/components/workflows/__tests__/serialization.test.ts +278 -0
- package/src/components/workflows/exec-status.ts +90 -0
- package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
- package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
- package/src/components/workflows/index.ts +78 -0
- package/src/components/workflows/layout/auto-layout.ts +142 -0
- package/src/components/workflows/node-icons.ts +31 -0
- package/src/components/workflows/serialization.ts +171 -0
- package/src/components/workflows/theme/categories.ts +96 -0
- package/src/components/workflows/types.ts +231 -0
- package/src/components/workflows/workflows.css +29 -0
- package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
- package/src/components/workspace/SplitPane.tsx +174 -0
- package/src/components/workspace/index.ts +4 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Narrow API-surface interfaces for the shared TeamSettingsPage /
|
|
3
|
+
* DomainsSettingsPage composers.
|
|
4
|
+
*
|
|
5
|
+
* Apps pass in the @startsimpli/api `api` instance and these interfaces
|
|
6
|
+
* structurally describe the slice the pages actually use. Keeping the slice
|
|
7
|
+
* shape inline here means @startsimpli/ui does NOT depend on @startsimpli/api
|
|
8
|
+
* directly. startsim-o7s.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
InvitationLite,
|
|
13
|
+
MemberRow,
|
|
14
|
+
TeamRole,
|
|
15
|
+
DomainClaimLite,
|
|
16
|
+
} from '../types'
|
|
17
|
+
|
|
18
|
+
/** A page-level `member` row hits the API as-is — keep the shape thin. */
|
|
19
|
+
export interface ApiMember {
|
|
20
|
+
id: string
|
|
21
|
+
userId: string
|
|
22
|
+
teamId: string
|
|
23
|
+
role: TeamRole
|
|
24
|
+
joinedAt: string
|
|
25
|
+
user?: MemberRow['user']
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Paginated wrapper used by @startsimpli/api list endpoints. */
|
|
29
|
+
export interface ApiPaginated<T> {
|
|
30
|
+
count?: number
|
|
31
|
+
next?: string | null
|
|
32
|
+
previous?: string | null
|
|
33
|
+
results: T[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A team membership row from /team-members/my-teams/. */
|
|
37
|
+
export interface ApiMyTeamMembership {
|
|
38
|
+
id: string
|
|
39
|
+
userId: string
|
|
40
|
+
teamId: string
|
|
41
|
+
role: TeamRole
|
|
42
|
+
joinedAt: string
|
|
43
|
+
team?: {
|
|
44
|
+
id: string
|
|
45
|
+
slug: string
|
|
46
|
+
name: string
|
|
47
|
+
companyId: string
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A team row from /teams/{idOrSlug}/. */
|
|
52
|
+
export interface ApiTeam {
|
|
53
|
+
id: string
|
|
54
|
+
slug: string
|
|
55
|
+
name: string
|
|
56
|
+
companyId: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A company row from /companies/{idOrSlug}/. */
|
|
60
|
+
export interface ApiCompany {
|
|
61
|
+
id: string
|
|
62
|
+
slug: string
|
|
63
|
+
name: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** An invitation row from /team-invitations/. */
|
|
67
|
+
export interface ApiInvitation extends InvitationLite {
|
|
68
|
+
createdAt: string
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A domain-claim row from /team-domain-claims/. */
|
|
72
|
+
export interface ApiDomainClaim extends DomainClaimLite {}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Minimum API surface the TeamSettingsPage composer needs. The real
|
|
76
|
+
* @startsimpli/api `api` instance satisfies this structurally.
|
|
77
|
+
*/
|
|
78
|
+
export interface TeamSettingsApi {
|
|
79
|
+
teams: {
|
|
80
|
+
myTeams: () => Promise<ApiMyTeamMembership[]>
|
|
81
|
+
retrieve: (idOrSlug: string) => Promise<ApiTeam>
|
|
82
|
+
members: (idOrSlug: string) => Promise<ApiMember[]>
|
|
83
|
+
updateRole: (
|
|
84
|
+
idOrSlug: string,
|
|
85
|
+
userId: string,
|
|
86
|
+
role: TeamRole,
|
|
87
|
+
) => Promise<unknown>
|
|
88
|
+
removeMember: (idOrSlug: string, userId: string) => Promise<unknown>
|
|
89
|
+
bulkInvite: (
|
|
90
|
+
idOrSlug: string,
|
|
91
|
+
invitations: Array<{ email: string; role: TeamRole }>,
|
|
92
|
+
) => Promise<{
|
|
93
|
+
invited: ApiInvitation[]
|
|
94
|
+
skipped?: Array<{ email: string; reason: string }>
|
|
95
|
+
}>
|
|
96
|
+
}
|
|
97
|
+
teamInvitations: {
|
|
98
|
+
list: (params: { teamId: string }) => Promise<ApiPaginated<ApiInvitation>>
|
|
99
|
+
create: (input: {
|
|
100
|
+
email: string
|
|
101
|
+
teamId: string
|
|
102
|
+
role: TeamRole
|
|
103
|
+
}) => Promise<ApiInvitation>
|
|
104
|
+
revoke: (id: string) => Promise<void>
|
|
105
|
+
}
|
|
106
|
+
companies: {
|
|
107
|
+
retrieve: (idOrSlug: string) => Promise<ApiCompany>
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Minimum API surface the DomainsSettingsPage composer needs. The real
|
|
113
|
+
* @startsimpli/api `api` instance satisfies this structurally.
|
|
114
|
+
*/
|
|
115
|
+
export interface DomainsSettingsApi {
|
|
116
|
+
domainClaims: {
|
|
117
|
+
list: (params: { companyId: string }) => Promise<ApiPaginated<ApiDomainClaim>>
|
|
118
|
+
create: (input: {
|
|
119
|
+
companyId: string
|
|
120
|
+
domain: string
|
|
121
|
+
}) => Promise<ApiDomainClaim>
|
|
122
|
+
verifyDns: (id: string) => Promise<ApiDomainClaim>
|
|
123
|
+
verifyEmailInitiate: (id: string) => Promise<{ detail: string }>
|
|
124
|
+
verifyEmailCode: (id: string, code: string) => Promise<ApiDomainClaim>
|
|
125
|
+
revoke: (id: string) => Promise<void>
|
|
126
|
+
}
|
|
127
|
+
/** Used to pick the default companyId when the prop is omitted. */
|
|
128
|
+
teams: {
|
|
129
|
+
myTeams: () => Promise<ApiMyTeamMembership[]>
|
|
130
|
+
retrieve: (idOrSlug: string) => Promise<ApiTeam>
|
|
131
|
+
}
|
|
132
|
+
companies: {
|
|
133
|
+
retrieve: (idOrSlug: string) => Promise<ApiCompany>
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Default Tailwind classes for {@link PendingInvitationCallout}. startsim-o7s. */
|
|
2
|
+
export interface PendingInvitationCalloutClassNames {
|
|
3
|
+
root?: string
|
|
4
|
+
message?: string
|
|
5
|
+
emphasized?: string
|
|
6
|
+
actions?: string
|
|
7
|
+
acceptButton?: string
|
|
8
|
+
declineButton?: string
|
|
9
|
+
errorText?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const PENDING_INVITE_DEFAULTS: Required<PendingInvitationCalloutClassNames> = {
|
|
13
|
+
root: 'flex items-center gap-3 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3',
|
|
14
|
+
message: 'flex-1 text-sm text-blue-900',
|
|
15
|
+
emphasized: 'font-semibold',
|
|
16
|
+
actions: 'flex items-center gap-2',
|
|
17
|
+
acceptButton:
|
|
18
|
+
'rounded-md bg-blue-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-50',
|
|
19
|
+
declineButton:
|
|
20
|
+
'rounded-md border border-blue-300 px-3 py-1.5 text-sm font-medium text-blue-700 hover:bg-blue-100',
|
|
21
|
+
errorText: 'mt-2 text-sm text-red-600',
|
|
22
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Default Tailwind classes for {@link RoleSelector}. startsim-o7s. */
|
|
2
|
+
export interface RoleSelectorClassNames {
|
|
3
|
+
root?: string
|
|
4
|
+
select?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export const ROLE_SELECTOR_DEFAULTS: Required<RoleSelectorClassNames> = {
|
|
8
|
+
root: 'inline-block',
|
|
9
|
+
select:
|
|
10
|
+
'rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-900 outline-none focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50',
|
|
11
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the team-management components.
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the @startsimpli/api shapes, but redeclared here so the UI
|
|
5
|
+
* package doesn't pull @startsimpli/api as a peer. Each component accepts
|
|
6
|
+
* the data it actually renders — apps pass through whatever @startsimpli/api
|
|
7
|
+
* gave them. startsim-o7s.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type * as React from 'react'
|
|
11
|
+
|
|
12
|
+
export type TeamRole = 'owner' | 'admin' | 'member' | 'viewer'
|
|
13
|
+
|
|
14
|
+
export interface TeamLite {
|
|
15
|
+
id: string
|
|
16
|
+
slug: string
|
|
17
|
+
name: string
|
|
18
|
+
companyId: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CompanyLite {
|
|
22
|
+
id: string
|
|
23
|
+
slug: string
|
|
24
|
+
name: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** User shape rendered next to each row. Optional fields stay optional. */
|
|
28
|
+
export interface MemberUserLite {
|
|
29
|
+
id: string
|
|
30
|
+
email: string
|
|
31
|
+
firstName?: string
|
|
32
|
+
lastName?: string
|
|
33
|
+
fullName?: string
|
|
34
|
+
/** Optional URL to an avatar image. */
|
|
35
|
+
avatarUrl?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MemberRow {
|
|
39
|
+
id: string
|
|
40
|
+
userId: string
|
|
41
|
+
teamId: string
|
|
42
|
+
role: TeamRole
|
|
43
|
+
joinedAt: string
|
|
44
|
+
/** Optionally embedded; when absent, the email + role-only render path is used. */
|
|
45
|
+
user?: MemberUserLite
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface InvitationLite {
|
|
49
|
+
id: string
|
|
50
|
+
email: string
|
|
51
|
+
teamId: string
|
|
52
|
+
role: TeamRole
|
|
53
|
+
/** Only present on the create-response; otherwise the backend redacts. */
|
|
54
|
+
token?: string
|
|
55
|
+
expiresAt: string
|
|
56
|
+
acceptedAt?: string | null
|
|
57
|
+
revokedAt?: string | null
|
|
58
|
+
isExpired: boolean
|
|
59
|
+
isAccepted: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type DomainVerificationMethod = 'dns_txt' | 'email_attestation'
|
|
63
|
+
|
|
64
|
+
export interface DomainClaimLite {
|
|
65
|
+
id: string
|
|
66
|
+
companyId: string
|
|
67
|
+
domain: string
|
|
68
|
+
verified: boolean
|
|
69
|
+
verificationMethod?: DomainVerificationMethod | null
|
|
70
|
+
verificationToken?: string | null
|
|
71
|
+
verifiedAt?: string | null
|
|
72
|
+
createdAt: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Helper: render a human-friendly label for a user — fullName wins, then
|
|
77
|
+
* firstName + lastName, then email.
|
|
78
|
+
*/
|
|
79
|
+
export function userDisplayName(user: MemberUserLite | undefined): string {
|
|
80
|
+
if (!user) return ''
|
|
81
|
+
if (user.fullName) return user.fullName
|
|
82
|
+
const fl = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
|
|
83
|
+
return fl || user.email
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Helper: render initials for the avatar fallback. */
|
|
87
|
+
export function userInitials(user: MemberUserLite | undefined): string {
|
|
88
|
+
if (!user) return '?'
|
|
89
|
+
const name = userDisplayName(user)
|
|
90
|
+
const parts = name.split(/\s+/).filter(Boolean)
|
|
91
|
+
if (parts.length === 0) return '?'
|
|
92
|
+
if (parts.length === 1) return parts[0][0].toUpperCase()
|
|
93
|
+
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Common React-prop alias used by every team component. */
|
|
97
|
+
export type ChildrenProps = { children?: React.ReactNode }
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { cn } from '../../lib/utils';
|
|
5
|
+
import { StatusBadge } from '../badge/StatusBadge';
|
|
6
|
+
import { EmptyState } from '../states/EmptyState';
|
|
7
|
+
import type { NodeExecView } from './types';
|
|
8
|
+
import { NODE_STATUS_BADGE, formatDuration } from './exec-status';
|
|
9
|
+
|
|
10
|
+
export interface ExecNodeDetailsProps {
|
|
11
|
+
/** The selected node execution, or null when nothing is selected. */
|
|
12
|
+
node: NodeExecView | null | undefined;
|
|
13
|
+
className?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function JsonBlock({
|
|
17
|
+
label,
|
|
18
|
+
value,
|
|
19
|
+
}: {
|
|
20
|
+
label: string;
|
|
21
|
+
value: Record<string, unknown> | null | undefined;
|
|
22
|
+
}) {
|
|
23
|
+
const isEmpty = value == null || Object.keys(value).length === 0;
|
|
24
|
+
return (
|
|
25
|
+
<div className="flex min-h-0 flex-col">
|
|
26
|
+
<h4 className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
27
|
+
{label}
|
|
28
|
+
</h4>
|
|
29
|
+
{isEmpty ? (
|
|
30
|
+
<p className="text-xs italic text-muted-foreground">No {label.toLowerCase()}.</p>
|
|
31
|
+
) : (
|
|
32
|
+
<pre className="overflow-auto rounded-md border border-border bg-muted/40 p-2 text-xs leading-relaxed">
|
|
33
|
+
{JSON.stringify(value, null, 2)}
|
|
34
|
+
</pre>
|
|
35
|
+
)}
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read-only detail panel for a single node execution: name, type, status
|
|
42
|
+
* badge, duration, error, and pretty-printed input/output JSON. Purpose-built
|
|
43
|
+
* for the execution monitor — deliberately NOT the editor's NodeInspector.
|
|
44
|
+
*/
|
|
45
|
+
export function ExecNodeDetails({ node, className }: ExecNodeDetailsProps) {
|
|
46
|
+
if (!node) {
|
|
47
|
+
return (
|
|
48
|
+
<EmptyState
|
|
49
|
+
title="No node selected"
|
|
50
|
+
description="Select a node in the timeline to inspect its input and output."
|
|
51
|
+
className={className}
|
|
52
|
+
/>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const duration = formatDuration(node.executionTimeMs);
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className={cn('flex h-full min-h-0 flex-col gap-4 overflow-auto p-4', className)}>
|
|
60
|
+
<header className="flex flex-col gap-2">
|
|
61
|
+
<div className="flex items-center gap-2">
|
|
62
|
+
<h3 className="truncate text-base font-semibold text-foreground">
|
|
63
|
+
{node.name ?? node.nodeId}
|
|
64
|
+
</h3>
|
|
65
|
+
<StatusBadge status={node.status} config={NODE_STATUS_BADGE} size="sm" />
|
|
66
|
+
</div>
|
|
67
|
+
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
68
|
+
{node.type && <span>{node.type}</span>}
|
|
69
|
+
{duration && <span className="tabular-nums">{duration}</span>}
|
|
70
|
+
</div>
|
|
71
|
+
</header>
|
|
72
|
+
|
|
73
|
+
{node.status === 'error' && node.error && (
|
|
74
|
+
<p className="break-words rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
|
75
|
+
{node.error}
|
|
76
|
+
</p>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
<JsonBlock label="Input" value={node.inputData} />
|
|
80
|
+
<JsonBlock label="Output" value={node.outputData} />
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { cn } from '../../lib/utils';
|
|
5
|
+
import { StatusBadge } from '../badge/StatusBadge';
|
|
6
|
+
import { EmptyState } from '../states/EmptyState';
|
|
7
|
+
import type { NodeExecView } from './types';
|
|
8
|
+
import { NODE_STATUS_BADGE, formatDuration, isLiveStatus } from './exec-status';
|
|
9
|
+
|
|
10
|
+
export interface ExecutionTimelineProps {
|
|
11
|
+
/** Per-node execution overlays; rendered sorted by `executionOrder`. */
|
|
12
|
+
nodeExecutions: NodeExecView[];
|
|
13
|
+
/** Currently selected node id (row highlighted as active). */
|
|
14
|
+
activeNodeId?: string;
|
|
15
|
+
/** Called with a node id when its row is activated. */
|
|
16
|
+
onSelectNode?: (nodeId: string) => void;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function byExecutionOrder(a: NodeExecView, b: NodeExecView): number {
|
|
21
|
+
const ao = a.executionOrder ?? 0;
|
|
22
|
+
const bo = b.executionOrder ?? 0;
|
|
23
|
+
return ao - bo;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Vertical execution timeline: one row per node execution (ordered by
|
|
28
|
+
* `executionOrder`), each showing the node name, a status badge, the run
|
|
29
|
+
* duration, and inline error text for failed nodes. Running nodes show a
|
|
30
|
+
* pulsing live indicator; terminal nodes render statically.
|
|
31
|
+
*
|
|
32
|
+
* Read-only and app-agnostic — selection is surfaced via `onSelectNode`.
|
|
33
|
+
*/
|
|
34
|
+
export function ExecutionTimeline({
|
|
35
|
+
nodeExecutions,
|
|
36
|
+
activeNodeId,
|
|
37
|
+
onSelectNode,
|
|
38
|
+
className,
|
|
39
|
+
}: ExecutionTimelineProps) {
|
|
40
|
+
const ordered = React.useMemo(
|
|
41
|
+
() => [...nodeExecutions].sort(byExecutionOrder),
|
|
42
|
+
[nodeExecutions]
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (ordered.length === 0) {
|
|
46
|
+
return (
|
|
47
|
+
<EmptyState
|
|
48
|
+
title="No node executions"
|
|
49
|
+
description="This run has not produced any node activity yet."
|
|
50
|
+
className={className}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<ol className={cn('flex flex-col', className)} aria-label="Execution timeline">
|
|
57
|
+
{ordered.map((node) => {
|
|
58
|
+
const live = isLiveStatus(node.status);
|
|
59
|
+
const isActive = activeNodeId != null && node.nodeId === activeNodeId;
|
|
60
|
+
const duration = formatDuration(node.executionTimeMs);
|
|
61
|
+
const selectable = onSelectNode != null;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<li
|
|
65
|
+
key={node.nodeId}
|
|
66
|
+
aria-current={isActive ? 'true' : undefined}
|
|
67
|
+
data-node-id={node.nodeId}
|
|
68
|
+
data-status={node.status}
|
|
69
|
+
className={cn(
|
|
70
|
+
'group flex items-start gap-3 border-b border-border px-3 py-2.5 text-sm last:border-b-0',
|
|
71
|
+
selectable && 'cursor-pointer hover:bg-accent/50',
|
|
72
|
+
isActive && 'bg-accent'
|
|
73
|
+
)}
|
|
74
|
+
onClick={selectable ? () => onSelectNode!(node.nodeId) : undefined}
|
|
75
|
+
onKeyDown={
|
|
76
|
+
selectable
|
|
77
|
+
? (e) => {
|
|
78
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
onSelectNode!(node.nodeId);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
: undefined
|
|
84
|
+
}
|
|
85
|
+
tabIndex={selectable ? 0 : undefined}
|
|
86
|
+
>
|
|
87
|
+
{/* Status rail dot */}
|
|
88
|
+
<span className="relative mt-1 flex h-2.5 w-2.5 shrink-0 items-center justify-center">
|
|
89
|
+
{live ? (
|
|
90
|
+
<>
|
|
91
|
+
<span
|
|
92
|
+
data-live="true"
|
|
93
|
+
aria-hidden="true"
|
|
94
|
+
className="absolute inline-flex h-full w-full animate-ping rounded-full bg-blue-400 opacity-75"
|
|
95
|
+
/>
|
|
96
|
+
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-blue-500" />
|
|
97
|
+
</>
|
|
98
|
+
) : (
|
|
99
|
+
<span
|
|
100
|
+
className={cn(
|
|
101
|
+
'inline-flex h-2.5 w-2.5 rounded-full',
|
|
102
|
+
node.status === 'success' && 'bg-green-500',
|
|
103
|
+
node.status === 'error' && 'bg-red-500',
|
|
104
|
+
node.status === 'skipped' && 'bg-gray-300',
|
|
105
|
+
node.status === 'pending' && 'bg-amber-400',
|
|
106
|
+
node.status === 'idle' && 'bg-gray-300'
|
|
107
|
+
)}
|
|
108
|
+
/>
|
|
109
|
+
)}
|
|
110
|
+
</span>
|
|
111
|
+
|
|
112
|
+
<div className="min-w-0 flex-1">
|
|
113
|
+
<div className="flex items-center gap-2">
|
|
114
|
+
<span className="truncate font-medium text-foreground">
|
|
115
|
+
{node.name ?? node.nodeId}
|
|
116
|
+
</span>
|
|
117
|
+
{duration && (
|
|
118
|
+
<span className="ml-auto shrink-0 tabular-nums text-xs text-muted-foreground">
|
|
119
|
+
{duration}
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
<div className="mt-1 flex items-center gap-2">
|
|
124
|
+
<StatusBadge
|
|
125
|
+
status={node.status}
|
|
126
|
+
config={NODE_STATUS_BADGE}
|
|
127
|
+
size="sm"
|
|
128
|
+
/>
|
|
129
|
+
{node.type && (
|
|
130
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
131
|
+
{node.type}
|
|
132
|
+
</span>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
{node.status === 'error' && node.error && (
|
|
136
|
+
<p className="mt-1.5 break-words text-xs text-red-600">
|
|
137
|
+
{node.error}
|
|
138
|
+
</p>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
</li>
|
|
142
|
+
);
|
|
143
|
+
})}
|
|
144
|
+
</ol>
|
|
145
|
+
);
|
|
146
|
+
}
|