Skip to content

Commit 9afe42a

Browse files
feat: FIT-983: [FSM UI] StateChip popover with history (#8809)
Co-authored-by: bmartel <brandonmartel@gmail.com> Co-authored-by: yyassi-heartex <yyassi-heartex@users.noreply.github.com>
1 parent 192d7db commit 9afe42a

File tree

17 files changed

+972
-79
lines changed

17 files changed

+972
-79
lines changed

web/apps/labelstudio/src/config/ApiConfig.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ export const API_CONFIG = {
9292

9393
accessTokenSettings: "GET:/jwt/settings",
9494
accessTokenUpdateSettings: "POST:/jwt/settings",
95+
96+
// FSM
97+
fsmStateHistory: "GET:/fsm/entities/:entityType/:entityId/history",
9598
},
9699
alwaysExpectJSON: false,
97100
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* AnnotationStateChip - Annotation-specific state chip with history popover
3+
*/
4+
5+
import { useState } from "react";
6+
import { StateChip } from "@humansignal/ui";
7+
import { getStateColorClass, formatStateName, getStateDescription } from "./utils";
8+
import { StateHistoryPopoverContent } from "./state-history-popover-content";
9+
10+
export interface AnnotationStateChipProps {
11+
/**
12+
* Current state of the annotation
13+
*/
14+
state: string;
15+
16+
/**
17+
* Annotation ID for fetching state history
18+
*/
19+
annotationId?: number;
20+
21+
/**
22+
* Whether the chip should be interactive (show history popover)
23+
*/
24+
interactive?: boolean;
25+
}
26+
27+
export function AnnotationStateChip({ state, annotationId, interactive = true }: AnnotationStateChipProps) {
28+
const [open, setOpen] = useState(false);
29+
30+
const label = formatStateName(state);
31+
const description = getStateDescription(state, "annotation");
32+
const colorClasses = getStateColorClass(state);
33+
34+
const popoverContent = annotationId ? (
35+
<StateHistoryPopoverContent entityType="annotation" entityId={annotationId} isOpen={open} />
36+
) : undefined;
37+
38+
return (
39+
<StateChip
40+
label={label}
41+
description={description}
42+
className={colorClasses}
43+
interactive={interactive && !!annotationId}
44+
popoverContent={popoverContent}
45+
open={open}
46+
onOpenChange={setOpen}
47+
/>
48+
);
49+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export { TaskStateChip, type TaskStateChipProps } from "./task-state-chip";
2+
export { AnnotationStateChip, type AnnotationStateChipProps } from "./annotation-state-chip";
3+
export { ProjectStateChip, type ProjectStateChipProps } from "./project-state-chip";
4+
export { StateHistoryPopoverContent, type StateHistoryPopoverContentProps } from "./state-history-popover-content";
5+
export { StateHistoryPopover, type StateHistoryPopoverProps } from "./state-history-popover";
6+
export * from "./utils";
7+
export { stateRegistry, StateType, type EntityType, type StateMetadata } from "./state-registry";
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
/**
2+
* ProjectStateChip - Project-specific state chip with history popover
3+
*/
4+
5+
import { useState } from "react";
6+
import { StateChip } from "@humansignal/ui";
7+
import { getStateColorClass, formatStateName, getStateDescription } from "./utils";
8+
import { StateHistoryPopoverContent } from "./state-history-popover-content";
9+
10+
export interface ProjectStateChipProps {
11+
/**
12+
* Current state of the project
13+
*/
14+
state: string;
15+
16+
/**
17+
* Project ID for fetching state history
18+
*/
19+
projectId?: number;
20+
21+
/**
22+
* Whether the chip should be interactive (show history popover)
23+
*/
24+
interactive?: boolean;
25+
}
26+
27+
export function ProjectStateChip({ state, projectId, interactive = true }: ProjectStateChipProps) {
28+
const [open, setOpen] = useState(false);
29+
30+
const label = formatStateName(state);
31+
const description = getStateDescription(state, "project");
32+
const colorClasses = getStateColorClass(state);
33+
34+
const popoverContent = projectId ? (
35+
<StateHistoryPopoverContent entityType="project" entityId={projectId} isOpen={open} />
36+
) : undefined;
37+
38+
return (
39+
<StateChip
40+
label={label}
41+
description={description}
42+
className={colorClasses}
43+
interactive={interactive && !!projectId}
44+
popoverContent={popoverContent}
45+
open={open}
46+
onOpenChange={setOpen}
47+
/>
48+
);
49+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* StateHistoryPopoverContent - Popover content for displaying state history
3+
*/
4+
5+
import { Badge, Button, Typography } from "@humansignal/ui";
6+
import { IconSync, IconError, IconHistoryRewind } from "@humansignal/icons";
7+
import { useStateHistory, type StateHistoryItem } from "../../hooks/useStateHistory";
8+
import { getStateColorClass, formatStateName, formatTimestamp, formatUserName } from "./utils";
9+
10+
export interface StateHistoryPopoverContentProps {
11+
entityType: "task" | "annotation" | "project";
12+
entityId: number;
13+
isOpen: boolean;
14+
}
15+
16+
export function StateHistoryPopoverContent({ entityType, entityId, isOpen }: StateHistoryPopoverContentProps) {
17+
const { data, isLoading, isError, error, refetch } = useStateHistory({
18+
entityType,
19+
entityId,
20+
enabled: isOpen,
21+
});
22+
23+
const history = (data?.results || []) as StateHistoryItem[];
24+
25+
return (
26+
<div
27+
className="flex flex-col w-[320px] max-h-[400px] bg-primary-background rounded-lg shadow-lg"
28+
onClick={(e) => e.stopPropagation()}
29+
>
30+
{/* Header */}
31+
<div className="px-4 py-3 border-b border-neutral-border">
32+
<div className="flex items-center gap-2">
33+
<IconHistoryRewind className="w-4 h-4 text-muted-foreground" />
34+
<Typography variant="body" size="small" className="font-medium text-neutral-foreground">
35+
State History
36+
</Typography>
37+
</div>
38+
</div>
39+
40+
{/* Content */}
41+
<div className="flex-1 overflow-y-auto p-4">
42+
{isLoading && (
43+
<div className="flex flex-col items-center justify-center py-8 gap-3">
44+
<IconSync className="w-8 h-8 text-primary-icon animate-spin" />
45+
<Typography variant="body" size="small" className="text-neutral-content-subtle">
46+
Loading...
47+
</Typography>
48+
</div>
49+
)}
50+
51+
{isError && (
52+
<div className="flex flex-col items-center justify-center py-8 gap-3">
53+
<IconError className="w-8 h-8 text-negative-icon" />
54+
<Typography variant="body" size="small" className="text-neutral-foreground">
55+
Failed to load history
56+
</Typography>
57+
<Typography variant="body" size="smallest" className="text-neutral-content-subtle text-center">
58+
{error instanceof Error ? error.message : "Unknown error"}
59+
</Typography>
60+
<Button
61+
onClick={(e) => {
62+
e.stopPropagation();
63+
refetch();
64+
}}
65+
className="mt-tight"
66+
size="smaller"
67+
variant="primary"
68+
type="button"
69+
>
70+
Retry
71+
</Button>
72+
</div>
73+
)}
74+
75+
{!isLoading && !isError && history.length === 0 && (
76+
<div className="flex flex-col items-center justify-center py-8 gap-3">
77+
<IconHistoryRewind className="w-8 h-8 text-neutral-content-subtler" />
78+
<Typography variant="body" size="small" className="text-neutral-content-subtle">
79+
No history available
80+
</Typography>
81+
</div>
82+
)}
83+
84+
{!isLoading && !isError && history.length > 0 && (
85+
<div className="space-y-3">
86+
{history.map((item: StateHistoryItem, index: number) => (
87+
<div key={index} className="pb-3 border-b border-neutral-border last:border-0 last:pb-0">
88+
<div className="flex items-center justify-between mb-2">
89+
<Badge className={getStateColorClass(item.state)}>{formatStateName(item.state)}</Badge>
90+
<Typography variant="body" size="smallest" className="text-neutral-content-subtle">
91+
{formatTimestamp(item.created_at)}
92+
</Typography>
93+
</div>
94+
<Typography variant="body" size="smallest" className="text-muted-foreground">
95+
By: {formatUserName(item.triggered_by)}
96+
</Typography>
97+
</div>
98+
))}
99+
</div>
100+
)}
101+
</div>
102+
</div>
103+
);
104+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
/**
2+
* StateHistoryPopover component
3+
* Displays the complete FSM state transition history for an entity
4+
*/
5+
6+
import type React from "react";
7+
import { Popover, Badge, Button, Typography } from "@humansignal/ui";
8+
import { IconSync, IconError, IconHistoryRewind } from "@humansignal/icons";
9+
import { useStateHistory, type StateHistoryItem } from "../../hooks/useStateHistory";
10+
import { getStateColorClass, formatStateName, formatTimestamp, formatUserName } from "./utils";
11+
12+
export interface StateHistoryPopoverProps {
13+
trigger: React.ReactNode;
14+
entityType: "task" | "annotation" | "project";
15+
entityId: number;
16+
currentState: string;
17+
open?: boolean;
18+
onOpenChange?: (open: boolean) => void;
19+
}
20+
21+
export function StateHistoryPopover({
22+
trigger,
23+
entityType,
24+
entityId,
25+
currentState,
26+
open,
27+
onOpenChange,
28+
}: StateHistoryPopoverProps) {
29+
const { data, isLoading, isError, error, refetch } = useStateHistory({
30+
entityType,
31+
entityId,
32+
enabled: open ?? true,
33+
});
34+
35+
const history = (data?.results || []) as StateHistoryItem[];
36+
37+
return (
38+
<Popover trigger={trigger} open={open} onOpenChange={onOpenChange} align="start" sideOffset={8}>
39+
<div
40+
className="flex flex-col w-[320px] max-h-[400px] bg-primary-background rounded-lg shadow-lg"
41+
onClick={(e) => e.stopPropagation()}
42+
>
43+
{/* Header */}
44+
<div className="px-4 py-3 border-b border-neutral-border">
45+
<div className="flex items-center gap-2">
46+
<IconHistoryRewind className="w-4 h-4 " />
47+
<Typography variant="body" size="small" className="font-medium text-neutral-foreground">
48+
State History
49+
</Typography>
50+
</div>
51+
</div>
52+
53+
{/* Content */}
54+
<div className="flex-1 overflow-y-auto p-4">
55+
{isLoading && (
56+
<div className="flex flex-col items-center justify-center py-8 gap-3">
57+
<IconSync className="w-8 h-8 text-primary-icon animate-spin" />
58+
<Typography variant="body" size="small" className="text-neutral-content-subtle">
59+
Loading...
60+
</Typography>
61+
</div>
62+
)}
63+
64+
{isError && (
65+
<div className="flex flex-col items-center justify-center py-8 gap-3">
66+
<IconError className="w-8 h-8 text-negative-icon" />
67+
<Typography variant="body" size="small" className="text-neutral-foreground">
68+
Failed to load history
69+
</Typography>
70+
<Typography variant="body" size="smallest" className="text-neutral-content-subtle text-center">
71+
{error instanceof Error ? error.message : "Unknown error"}
72+
</Typography>
73+
<Button
74+
type="button"
75+
onClick={(e) => {
76+
e.stopPropagation();
77+
refetch();
78+
}}
79+
className="mt-tight"
80+
size="smaller"
81+
variant="primary"
82+
>
83+
Retry
84+
</Button>
85+
</div>
86+
)}
87+
88+
{!isLoading && !isError && history.length === 0 && (
89+
<div className="flex flex-col items-center justify-center py-8 gap-3">
90+
<IconHistoryRewind className="w-8 h-8 text-neutral-content-subtler" />
91+
<Typography variant="body" size="small" className="text-neutral-content-subtle">
92+
No history available
93+
</Typography>
94+
</div>
95+
)}
96+
97+
{!isLoading && !isError && history.length > 0 && (
98+
<div className="space-y-3">
99+
{history.map((item: StateHistoryItem, index: number) => (
100+
<div key={index} className="pb-3 border-b border-neutral-border last:border-0 last:pb-0">
101+
<div className="flex items-center justify-between mb-2">
102+
<Badge className={getStateColorClass(item.state)}>{formatStateName(item.state)}</Badge>
103+
<Typography variant="body" size="smallest" className="text-neutral-content-subtle">
104+
{formatTimestamp(item.created_at)}
105+
</Typography>
106+
</div>
107+
<div>
108+
<Typography variant="body" size="smallest" className="text-muted-foreground">
109+
By: {formatUserName(item.triggered_by)}
110+
</Typography>
111+
{item.transition_name && (
112+
<Typography variant="body" size="smallest" className="mt-1 text-neutral-content-subtle">
113+
{item.transition_name}
114+
</Typography>
115+
)}
116+
</div>
117+
</div>
118+
))}
119+
</div>
120+
)}
121+
</div>
122+
</div>
123+
</Popover>
124+
);
125+
}

0 commit comments

Comments
 (0)