From d684b89c2c1e593d425854afb39ef8be3ce851aa Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Wed, 12 Nov 2025 15:31:08 -0500 Subject: [PATCH 01/21] feat: FIT-976: [FSM UI] Data Manager State Column --- .../Organization/PeoplePage/PeopleList.jsx | 2 - .../src/components/CellViews/TaskState.jsx | 87 +++++++++++++++++++ .../src/components/CellViews/index.js | 1 + .../src/components/Filters/types/index.js | 2 +- .../src/stores/Tabs/tab_column.jsx | 1 + 5 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 web/libs/datamanager/src/components/CellViews/TaskState.jsx diff --git a/web/apps/labelstudio/src/pages/Organization/PeoplePage/PeopleList.jsx b/web/apps/labelstudio/src/pages/Organization/PeoplePage/PeopleList.jsx index 6079020274d5..65fba562229c 100644 --- a/web/apps/labelstudio/src/pages/Organization/PeoplePage/PeopleList.jsx +++ b/web/apps/labelstudio/src/pages/Organization/PeoplePage/PeopleList.jsx @@ -16,8 +16,6 @@ export const PeopleList = ({ onSelect, selectedUser, defaultSelected }) => { const [currentPageSize] = usePageSize("page_size", 30); const [totalItems, setTotalItems] = useState(0); - console.log({ currentPage, currentPageSize }); - const fetchUsers = useCallback(async (page, pageSize) => { const response = await api.callApi("memberships", { params: { diff --git a/web/libs/datamanager/src/components/CellViews/TaskState.jsx b/web/libs/datamanager/src/components/CellViews/TaskState.jsx new file mode 100644 index 000000000000..dcf706ba6bc5 --- /dev/null +++ b/web/libs/datamanager/src/components/CellViews/TaskState.jsx @@ -0,0 +1,87 @@ +import { isDefined } from "../../utils/utils"; +import { Badge } from "@humansignal/ui"; +import { Tooltip } from "@humansignal/ui"; + +// Map state values to human-readable labels +const stateLabels = { + CREATED: "Created", + ANNOTATION_IN_PROGRESS: "Annotating", + ANNOTATION_COMPLETE: "Annotated", + REVIEW_IN_PROGRESS: "In Review", + REVIEW_COMPLETE: "Reviewed", + ARBITRATION_NEEDED: "Needs Arbitration", + ARBITRATION_IN_PROGRESS: "In Arbitration", + ARBITRATION_COMPLETE: "Arbitrated", + COMPLETED: "Done", +}; + +// Map state values to descriptions for tooltips +const stateDescriptions = { + CREATED: "Task has been created and is ready for annotation", + ANNOTATION_IN_PROGRESS: "Task is currently being annotated", + ANNOTATION_COMPLETE: "Annotation has been completed", + REVIEW_IN_PROGRESS: "Task is under review", + REVIEW_COMPLETE: "Review has been completed", + ARBITRATION_NEEDED: "Task requires arbitration due to disagreements", + ARBITRATION_IN_PROGRESS: "Task is currently in arbitration", + ARBITRATION_COMPLETE: "Arbitration has been completed", + COMPLETED: "Task is fully complete", +}; + +// State color mapping following the 4-color system +// Grey: Initial states, Blue: In-progress, Yellow: Attention/Churn, Green: Terminal/Complete +const STATE_COLORS = { + // Grey - Initial + CREATED: "grey", + + // Blue - In Progress + ANNOTATION_IN_PROGRESS: "blue", + REVIEW_IN_PROGRESS: "blue", + ARBITRATION_IN_PROGRESS: "blue", + + // Yellow - Attention/Churn + ARBITRATION_NEEDED: "yellow", + + // Green - Complete/Terminal + ANNOTATION_COMPLETE: "green", + REVIEW_COMPLETE: "green", + ARBITRATION_COMPLETE: "green", + COMPLETED: "green", +}; + +// Map colors to Tailwind CSS classes for chip styling +const colorToClasses = { + grey: "bg-neutral-emphasis border-neutral-border text-neutral-content", + blue: "bg-primary-emphasis border-primary-border-subtlest text-primary-content", + yellow: "bg-warning-emphasis border-warning-border-subtlest text-warning-content", + green: "bg-positive-emphasis border-positive-border-subtlest text-positive-content", +}; + +export const TaskState = (cell) => { + const { value } = cell; + + if (!isDefined(value) || value === null || value === "") { + return null; + } + + const label = stateLabels[value] || value; + const description = stateDescriptions[value] || value; + const color = STATE_COLORS[value] || "grey"; + const colorClasses = colorToClasses[color]; + + return ( +
+ + + {label} + + +
+ ); +}; + +TaskState.userSelectable = false; + +TaskState.style = { + minWidth: 140, +}; diff --git a/web/libs/datamanager/src/components/CellViews/index.js b/web/libs/datamanager/src/components/CellViews/index.js index 8c76e56b8f11..4f5e909d6cab 100644 --- a/web/libs/datamanager/src/components/CellViews/index.js +++ b/web/libs/datamanager/src/components/CellViews/index.js @@ -17,6 +17,7 @@ export { StringCell as String } from "./StringCell"; export { StringCell as Text } from "./StringCell"; export { VideoCell as Video } from "./VideoCell"; export { ProjectCell as Project } from "./ProjectCell"; +export { TaskState } from "./TaskState"; export function normalizeCellAlias(alias) { // remove trailing separators to make `toStudlyCaps` safe diff --git a/web/libs/datamanager/src/components/Filters/types/index.js b/web/libs/datamanager/src/components/Filters/types/index.js index 6d3787de4ad1..b30f50316477 100644 --- a/web/libs/datamanager/src/components/Filters/types/index.js +++ b/web/libs/datamanager/src/components/Filters/types/index.js @@ -4,4 +4,4 @@ export { DateFilter as Date } from "./Date"; export { DatetimeFilter as Datetime } from "./Datetime"; export { ListFilter as List } from "./List"; export { NumberFilter as Number, NumberFilter as AgreementSelected } from "./Number"; -export { StringFilter as Image, StringFilter as String } from "./String"; +export { StringFilter as Image, StringFilter as String, StringFilter as TaskState } from "./String"; diff --git a/web/libs/datamanager/src/stores/Tabs/tab_column.jsx b/web/libs/datamanager/src/stores/Tabs/tab_column.jsx index 699669272ae1..7ffc5edbef2d 100644 --- a/web/libs/datamanager/src/stores/Tabs/tab_column.jsx +++ b/web/libs/datamanager/src/stores/Tabs/tab_column.jsx @@ -29,6 +29,7 @@ export const ViewColumnType = types.enumeration([ "TimeSeries", "Unknown", "AgreementSelected", + "TaskState", ]); const typeShortMap = { From fcbe07bc1209ae07821929d3a6f993367c410dfa Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Wed, 12 Nov 2025 17:20:03 -0500 Subject: [PATCH 02/21] feat: FIT-983: [FSM UI] StateChip popover with history --- label_studio/tasks/api.py | 128 +++++++++ label_studio/tasks/urls.py | 3 + .../components/FSM/StateHistoryPopover.tsx | 132 +++++++++ .../src/components/FSM/formatters.ts | 115 ++++++++ .../labelstudio/src/components/FSM/index.ts | 8 + .../src/components/FSM/useStateHistory.ts | 75 +++++ .../src/components/CellViews/StateChip.tsx | 260 ++++++++++++++++++ .../src/components/CellViews/TaskState.jsx | 22 +- 8 files changed, 735 insertions(+), 8 deletions(-) create mode 100644 web/apps/labelstudio/src/components/FSM/StateHistoryPopover.tsx create mode 100644 web/apps/labelstudio/src/components/FSM/formatters.ts create mode 100644 web/apps/labelstudio/src/components/FSM/index.ts create mode 100644 web/apps/labelstudio/src/components/FSM/useStateHistory.ts create mode 100644 web/libs/datamanager/src/components/CellViews/StateChip.tsx diff --git a/label_studio/tasks/api.py b/label_studio/tasks/api.py index 27b62bf1cb70..9f6410c2cc0a 100644 --- a/label_studio/tasks/api.py +++ b/label_studio/tasks/api.py @@ -903,3 +903,131 @@ def post(self, request, *args, **kwargs): emit_webhooks_for_instance(organization, project, WebhookAction.ANNOTATIONS_DELETED, [pk]) data = AnnotationDraftSerializer(instance=draft).data return Response(status=201, data=data) + + +@method_decorator( + name='get', + decorator=extend_schema( + tags=['Tasks'], + summary='Get task state history', + description='Retrieve the complete state transition history for a task.', + parameters=[ + OpenApiParameter(name='pk', type=OpenApiTypes.INT, location='path', description='Task ID'), + ], + responses={ + '200': OpenApiResponse( + description='State history', + ) + }, + extensions={ + 'x-fern-audiences': ['internal'], + }, + ), +) +class TaskStateHistoryAPI(generics.RetrieveAPIView): + """ + API endpoint to retrieve state transition history for a task. + """ + + permission_required = all_permissions.tasks_view + queryset = Task.objects.all() + + def get(self, request, *args, **kwargs): + from fsm.state_manager import StateManager + + task = self.get_object() + + try: + # Get state history from the FSM state manager + history = StateManager.get_state_history(task, limit=100) + + # Serialize the history + results = [] + for state_record in history: + triggered_by_data = None + if state_record.triggered_by: + triggered_by_data = { + 'first_name': state_record.triggered_by.first_name, + 'last_name': state_record.triggered_by.last_name, + 'email': state_record.triggered_by.email, + } + + results.append( + { + 'state': state_record.state, + 'previous_state': state_record.previous_state, + 'created_at': state_record.created_at.isoformat(), + 'triggered_by': triggered_by_data, + 'transition_name': state_record.transition_name, + 'reason': state_record.reason, + } + ) + + return Response({'results': results}) + except Exception as e: + logger.error(f'Error fetching state history for task {task.id}: {e}') + return Response({'results': []}, status=200) + + +@method_decorator( + name='get', + decorator=extend_schema( + tags=['Annotations'], + summary='Get annotation state history', + description='Retrieve the complete state transition history for an annotation.', + parameters=[ + OpenApiParameter(name='pk', type=OpenApiTypes.INT, location='path', description='Annotation ID'), + ], + responses={ + '200': OpenApiResponse( + description='State history', + ) + }, + extensions={ + 'x-fern-audiences': ['internal'], + }, + ), +) +class AnnotationStateHistoryAPI(generics.RetrieveAPIView): + """ + API endpoint to retrieve state transition history for an annotation. + """ + + permission_required = all_permissions.annotations_view + queryset = Annotation.objects.all() + + def get(self, request, *args, **kwargs): + from fsm.state_manager import StateManager + + annotation = self.get_object() + + try: + # Get state history from the FSM state manager + history = StateManager.get_state_history(annotation, limit=100) + + # Serialize the history + results = [] + for state_record in history: + triggered_by_data = None + if state_record.triggered_by: + triggered_by_data = { + 'first_name': state_record.triggered_by.first_name, + 'last_name': state_record.triggered_by.last_name, + 'email': state_record.triggered_by.email, + } + + results.append( + { + 'state': state_record.state, + 'previous_state': state_record.previous_state, + 'created_at': state_record.created_at.isoformat(), + 'triggered_by': triggered_by_data, + 'transition_name': state_record.transition_name, + 'reason': state_record.reason, + } + ) + + return Response({'results': results}) + except Exception as e: + logger.error(f'Error fetching state history for annotation {annotation.id}: {e}') + return Response({'results': []}, status=200) diff --git a/label_studio/tasks/urls.py b/label_studio/tasks/urls.py index 251ec76ec4b3..0f7f8c63911e 100644 --- a/label_studio/tasks/urls.py +++ b/label_studio/tasks/urls.py @@ -21,11 +21,14 @@ api.AnnotationDraftListAPI.as_view(), name='task-annotations-drafts', ), + # State history + path('/state-history/', api.TaskStateHistoryAPI.as_view(), name='task-state-history'), ] _api_annotations_urlpatterns = [ path('/', api.AnnotationAPI.as_view(), name='annotation-detail'), path('/convert-to-draft', api.AnnotationConvertAPI.as_view(), name='annotation-convert-to-draft'), + path('/state-history/', api.AnnotationStateHistoryAPI.as_view(), name='annotation-state-history'), ] _api_drafts_urlpatterns = [ diff --git a/web/apps/labelstudio/src/components/FSM/StateHistoryPopover.tsx b/web/apps/labelstudio/src/components/FSM/StateHistoryPopover.tsx new file mode 100644 index 000000000000..28a5f9986173 --- /dev/null +++ b/web/apps/labelstudio/src/components/FSM/StateHistoryPopover.tsx @@ -0,0 +1,132 @@ +/** + * StateHistoryPopover component + * Displays the complete FSM state transition history for an entity + */ + +import type React from "react"; +import { Popover } from "@humansignal/ui"; +import { Badge } from "@humansignal/ui"; +import { IconSync, IconError, IconHistoryRewind } from "@humansignal/icons"; +import { useStateHistory, type StateHistoryItem } from "./useStateHistory"; +import { formatStateName, formatTimestamp, formatUserName } from "./formatters"; + +interface StateHistoryPopoverProps { + trigger: React.ReactNode; + entityType: "task" | "annotation" | "project"; + entityId: number; + currentState: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +// State color mapping following the 4-color system +const STATE_COLORS = { + // Grey - Initial + CREATED: "grey", + // Blue - In Progress + ANNOTATION_IN_PROGRESS: "blue", + REVIEW_IN_PROGRESS: "blue", + ARBITRATION_IN_PROGRESS: "blue", + IN_PROGRESS: "blue", + // Yellow - Attention/Churn + ARBITRATION_NEEDED: "yellow", + // Green - Complete/Terminal + ANNOTATION_COMPLETE: "green", + REVIEW_COMPLETE: "green", + ARBITRATION_COMPLETE: "green", + COMPLETED: "green", +}; + +// Map colors to Tailwind CSS classes for chip styling +const colorToClasses: Record = { + grey: "bg-neutral-emphasis border-neutral-border text-neutral-content", + blue: "bg-primary-emphasis border-primary-border-subtlest text-primary-content", + yellow: "bg-warning-emphasis border-warning-border-subtlest text-warning-content", + green: "bg-positive-emphasis border-positive-border-subtlest text-positive-content", +}; + +function getStateColorClass(state: string): string { + const color = STATE_COLORS[state as keyof typeof STATE_COLORS] || "grey"; + return colorToClasses[color]; +} + +export function StateHistoryPopover({ + trigger, + entityType, + entityId, + currentState, + open, + onOpenChange, +}: StateHistoryPopoverProps) { + const { data, isLoading, isError, error, refetch } = useStateHistory({ + entityType, + entityId, + enabled: open ?? true, + }); + + const history = (data?.results || []) as StateHistoryItem[]; + + return ( + +
+ {/* Header */} +
+
+ + State History +
+
+ + {/* Content */} +
+ {isLoading && ( +
+ + Loading... +
+ )} + + {isError && ( +
+ + Failed to load history + + {error instanceof Error ? error.message : "Unknown error"} + + +
+ )} + + {!isLoading && !isError && history.length === 0 && ( +
+ + No history available +
+ )} + + {!isLoading && !isError && history.length > 0 && ( +
+ {history.map((item: StateHistoryItem, index: number) => ( +
+
+ {formatStateName(item.state)} + {formatTimestamp(item.created_at)} +
+
+
By: {formatUserName(item.triggered_by)}
+ {item.transition_name &&
{item.transition_name}
} +
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/web/apps/labelstudio/src/components/FSM/formatters.ts b/web/apps/labelstudio/src/components/FSM/formatters.ts new file mode 100644 index 000000000000..67e05a2b58c4 --- /dev/null +++ b/web/apps/labelstudio/src/components/FSM/formatters.ts @@ -0,0 +1,115 @@ +/** + * Formatters for FSM state history display + */ + +// Map state values to human-readable labels +const stateLabels: Record = { + CREATED: "Created", + ANNOTATION_IN_PROGRESS: "Annotating", + ANNOTATION_COMPLETE: "Annotated", + REVIEW_IN_PROGRESS: "In Review", + REVIEW_COMPLETE: "Reviewed", + ARBITRATION_NEEDED: "Needs Arbitration", + ARBITRATION_IN_PROGRESS: "In Arbitration", + ARBITRATION_COMPLETE: "Arbitrated", + COMPLETED: "Done", + IN_PROGRESS: "In Progress", +}; + +/** + * Format state name from UPPER_SNAKE_CASE to readable format + * Falls back to transforming the state name if not in the predefined list + */ +export function formatStateName(state: string): string { + if (stateLabels[state]) { + return stateLabels[state]; + } + + // Fallback: Convert UPPER_SNAKE_CASE to Title Case + return state + .split("_") + .map((word) => word.charAt(0) + word.slice(1).toLowerCase()) + .join(" "); +} + +/** + * Format timestamp to localized, readable format + */ +export function formatTimestamp(timestamp: string): string { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + // Less than 1 minute + if (diffMins < 1) { + return "Just now"; + } + + // Less than 1 hour + if (diffMins < 60) { + return `${diffMins} ${diffMins === 1 ? "minute" : "minutes"} ago`; + } + + // Less than 24 hours + if (diffHours < 24) { + return `${diffHours} ${diffHours === 1 ? "hour" : "hours"} ago`; + } + + // Less than 7 days + if (diffDays < 7) { + return `${diffDays} ${diffDays === 1 ? "day" : "days"} ago`; + } + + // More than 7 days - show full date + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +/** + * Format user name from triggered_by object + * Returns "System" if no user information is available + */ +export function formatUserName( + triggeredBy: { + first_name?: string; + last_name?: string; + email?: string; + } | null, +): string { + if (!triggeredBy) { + return "System"; + } + + const { first_name, last_name, email } = triggeredBy; + + // Prefer full name + if (first_name && last_name) { + return `${first_name} ${last_name}`; + } + + // Fall back to first name only + if (first_name) { + return first_name; + } + + // Fall back to last name only + if (last_name) { + return last_name; + } + + // Fall back to email + if (email) { + return email; + } + + // No user information + return "System"; +} diff --git a/web/apps/labelstudio/src/components/FSM/index.ts b/web/apps/labelstudio/src/components/FSM/index.ts new file mode 100644 index 000000000000..6460ccaed0a2 --- /dev/null +++ b/web/apps/labelstudio/src/components/FSM/index.ts @@ -0,0 +1,8 @@ +/** + * FSM Components - State management visualization + */ + +export { StateHistoryPopover } from "./StateHistoryPopover"; +export { useStateHistory } from "./useStateHistory"; +export { formatStateName, formatTimestamp, formatUserName } from "./formatters"; +export type { StateHistoryItem, StateHistoryResponse } from "./useStateHistory"; diff --git a/web/apps/labelstudio/src/components/FSM/useStateHistory.ts b/web/apps/labelstudio/src/components/FSM/useStateHistory.ts new file mode 100644 index 000000000000..1028b00e5c4a --- /dev/null +++ b/web/apps/labelstudio/src/components/FSM/useStateHistory.ts @@ -0,0 +1,75 @@ +/** + * Hook for fetching state history from the API + */ + +import { useQuery } from "@tanstack/react-query"; +import { getApiInstance } from "@humansignal/core/lib/api-provider/api-instance"; + +export interface StateHistoryItem { + state: string; + created_at: string; + triggered_by: { + first_name?: string; + last_name?: string; + email?: string; + } | null; + previous_state?: string; + transition_name?: string; + reason?: string; +} + +export interface StateHistoryResponse { + results: StateHistoryItem[]; +} + +export interface UseStateHistoryOptions { + entityType: "task" | "annotation" | "project"; + entityId: number; + enabled?: boolean; +} + +/** + * Hook to fetch state transition history for an entity + * + * @param options - Configuration options + * @returns Query result with state history data + */ +export function useStateHistory({ entityType, entityId, enabled = true }: UseStateHistoryOptions) { + const queryKey = ["state-history", entityType, entityId]; + + const { data, isLoading, isError, error, refetch } = useQuery({ + queryKey, + queryFn: async () => { + const api = getApiInstance(); + + // Construct the endpoint based on entity type + const endpoint = `/api/${entityType}s/${entityId}/state-history/`; + + try { + const response = await fetch(endpoint, { + headers: { + "Content-Type": "application/json", + // Add any necessary auth headers + }, + credentials: "include", + }); + + if (!response.ok) { + throw new Error(`Failed to fetch state history: ${response.statusText}`); + } + + const result = await response.json(); + return result as StateHistoryResponse; + } catch (error) { + console.error("Error fetching state history:", error); + throw error; + } + }, + enabled: enabled && !!entityId, + staleTime: 30 * 1000, // Cache for 30 seconds + cacheTime: 5 * 60 * 1000, // Keep in cache for 5 minutes + retry: 2, + }); + + return { data, isLoading, isError, error, refetch }; +} diff --git a/web/libs/datamanager/src/components/CellViews/StateChip.tsx b/web/libs/datamanager/src/components/CellViews/StateChip.tsx new file mode 100644 index 000000000000..55f68fd9e23d --- /dev/null +++ b/web/libs/datamanager/src/components/CellViews/StateChip.tsx @@ -0,0 +1,260 @@ +/** + * StateChip - Interactive state display with history popover + */ + +import { useState } from "react"; +import { Badge, Tooltip, Popover } from "@humansignal/ui"; +import { IconSync, IconError, IconHistoryRewind } from "@humansignal/icons"; +import { useQuery } from "@tanstack/react-query"; + +interface StateChipProps { + state: string; + label: string; + description: string; + colorClasses: string; + entityType: "task" | "annotation" | "project"; + entityId?: number; + interactive?: boolean; +} + +interface StateHistoryItem { + state: string; + created_at: string; + triggered_by: { + first_name?: string; + last_name?: string; + email?: string; + } | null; + previous_state?: string; + transition_name?: string; + reason?: string; +} + +interface StateHistoryResponse { + results: StateHistoryItem[]; +} + +// State color mapping following the 4-color system +const STATE_COLORS: Record = { + CREATED: "grey", + ANNOTATION_IN_PROGRESS: "blue", + REVIEW_IN_PROGRESS: "blue", + ARBITRATION_IN_PROGRESS: "blue", + IN_PROGRESS: "blue", + ARBITRATION_NEEDED: "yellow", + ANNOTATION_COMPLETE: "green", + REVIEW_COMPLETE: "green", + ARBITRATION_COMPLETE: "green", + COMPLETED: "green", +}; + +const colorToClasses: Record = { + grey: "bg-neutral-emphasis border-neutral-border text-neutral-content", + blue: "bg-primary-emphasis border-primary-border-subtlest text-primary-content", + yellow: "bg-warning-emphasis border-warning-border-subtlest text-warning-content", + green: "bg-positive-emphasis border-positive-border-subtlest text-positive-content", +}; + +function getStateColorClass(state: string): string { + const color = STATE_COLORS[state] || "grey"; + return colorToClasses[color]; +} + +// Formatters +function formatStateName(state: string): string { + const stateLabels: Record = { + CREATED: "Created", + ANNOTATION_IN_PROGRESS: "Annotating", + ANNOTATION_COMPLETE: "Annotated", + REVIEW_IN_PROGRESS: "In Review", + REVIEW_COMPLETE: "Reviewed", + ARBITRATION_NEEDED: "Needs Arbitration", + ARBITRATION_IN_PROGRESS: "In Arbitration", + ARBITRATION_COMPLETE: "Arbitrated", + COMPLETED: "Done", + IN_PROGRESS: "In Progress", + }; + + if (stateLabels[state]) { + return stateLabels[state]; + } + + return state + .split("_") + .map((word) => word.charAt(0) + word.slice(1).toLowerCase()) + .join(" "); +} + +function formatTimestamp(timestamp: string): string { + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return "Just now"; + if (diffMins < 60) return `${diffMins} ${diffMins === 1 ? "minute" : "minutes"} ago`; + if (diffHours < 24) return `${diffHours} ${diffHours === 1 ? "hour" : "hours"} ago`; + if (diffDays < 7) return `${diffDays} ${diffDays === 1 ? "day" : "days"} ago`; + + return date.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatUserName( + triggeredBy: { + first_name?: string; + last_name?: string; + email?: string; + } | null, +): string { + if (!triggeredBy) return "System"; + + const { first_name, last_name, email } = triggeredBy; + + if (first_name && last_name) return `${first_name} ${last_name}`; + if (first_name) return first_name; + if (last_name) return last_name; + if (email) return email; + + return "System"; +} + +export function StateChip({ + state, + label, + description, + colorClasses, + entityType, + entityId, + interactive = true, +}: StateChipProps) { + const [open, setOpen] = useState(false); + + // Fetch state history when popover is opened + const queryKey = ["state-history", entityType, entityId]; + + const { data, isLoading, isError, error, refetch } = useQuery({ + queryKey, + queryFn: async () => { + const endpoint = `/api/${entityType}s/${entityId}/state-history/`; + + const response = await fetch(endpoint, { + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + }); + + if (!response.ok) { + throw new Error(`Failed to fetch state history: ${response.statusText}`); + } + + const result = await response.json(); + return result as StateHistoryResponse; + }, + enabled: open && !!entityId && interactive, + staleTime: 30 * 1000, + cacheTime: 5 * 60 * 1000, + retry: 2, + }); + + const history = (data?.results || []) as StateHistoryItem[]; + + // Non-interactive chip - just show the badge with tooltip + if (!interactive || !entityId) { + return ( + + + {label} + + + ); + } + + // Interactive chip with popover + const trigger = ( + + ); + + return ( + +
+ {/* Header */} +
+
+ + State History +
+
+ + {/* Content */} +
+ {isLoading && ( +
+ + Loading... +
+ )} + + {isError && ( +
+ + Failed to load history + + {error instanceof Error ? error.message : "Unknown error"} + + +
+ )} + + {!isLoading && !isError && history.length === 0 && ( +
+ + No history available +
+ )} + + {!isLoading && !isError && history.length > 0 && ( +
+ {history.map((item: StateHistoryItem, index: number) => ( +
+
+ {formatStateName(item.state)} + {formatTimestamp(item.created_at)} +
+
+
By: {formatUserName(item.triggered_by)}
+ {item.transition_name &&
{item.transition_name}
} +
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/web/libs/datamanager/src/components/CellViews/TaskState.jsx b/web/libs/datamanager/src/components/CellViews/TaskState.jsx index dcf706ba6bc5..33e577c75ce8 100644 --- a/web/libs/datamanager/src/components/CellViews/TaskState.jsx +++ b/web/libs/datamanager/src/components/CellViews/TaskState.jsx @@ -1,6 +1,5 @@ import { isDefined } from "../../utils/utils"; -import { Badge } from "@humansignal/ui"; -import { Tooltip } from "@humansignal/ui"; +import { StateChip } from "./StateChip"; // Map state values to human-readable labels const stateLabels = { @@ -58,7 +57,7 @@ const colorToClasses = { }; export const TaskState = (cell) => { - const { value } = cell; + const { value, original } = cell; if (!isDefined(value) || value === null || value === "") { return null; @@ -69,13 +68,20 @@ export const TaskState = (cell) => { const color = STATE_COLORS[value] || "grey"; const colorClasses = colorToClasses[color]; + // Extract task ID from the original row data + const taskId = original?.id; + return (
- - - {label} - - +
); }; From eccb3952eb0fa9a153dd2e562839b2dde520a254 Mon Sep 17 00:00:00 2001 From: Yousif Yassi Date: Thu, 13 Nov 2025 14:19:39 -0500 Subject: [PATCH 03/21] improved state filter visuals --- .../src/components/CellViews/TaskState.jsx | 6 +- .../Filters/types/TaskStateFilter.jsx | 59 +++++++++++++++++++ .../src/components/Filters/types/index.js | 3 +- 3 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 web/libs/datamanager/src/components/Filters/types/TaskStateFilter.jsx diff --git a/web/libs/datamanager/src/components/CellViews/TaskState.jsx b/web/libs/datamanager/src/components/CellViews/TaskState.jsx index dcf706ba6bc5..1ee4cb7c84ab 100644 --- a/web/libs/datamanager/src/components/CellViews/TaskState.jsx +++ b/web/libs/datamanager/src/components/CellViews/TaskState.jsx @@ -3,7 +3,7 @@ import { Badge } from "@humansignal/ui"; import { Tooltip } from "@humansignal/ui"; // Map state values to human-readable labels -const stateLabels = { +export const stateLabels = { CREATED: "Created", ANNOTATION_IN_PROGRESS: "Annotating", ANNOTATION_COMPLETE: "Annotated", @@ -30,7 +30,7 @@ const stateDescriptions = { // State color mapping following the 4-color system // Grey: Initial states, Blue: In-progress, Yellow: Attention/Churn, Green: Terminal/Complete -const STATE_COLORS = { +export const STATE_COLORS = { // Grey - Initial CREATED: "grey", @@ -50,7 +50,7 @@ const STATE_COLORS = { }; // Map colors to Tailwind CSS classes for chip styling -const colorToClasses = { +export const colorToClasses = { grey: "bg-neutral-emphasis border-neutral-border text-neutral-content", blue: "bg-primary-emphasis border-primary-border-subtlest text-primary-content", yellow: "bg-warning-emphasis border-warning-border-subtlest text-warning-content", diff --git a/web/libs/datamanager/src/components/Filters/types/TaskStateFilter.jsx b/web/libs/datamanager/src/components/Filters/types/TaskStateFilter.jsx new file mode 100644 index 000000000000..4f9e9ca9f379 --- /dev/null +++ b/web/libs/datamanager/src/components/Filters/types/TaskStateFilter.jsx @@ -0,0 +1,59 @@ +import { observer } from "mobx-react"; +import { Select, Badge } from "@humansignal/ui"; +import { stateLabels, STATE_COLORS, colorToClasses } from "../../CellViews/TaskState"; +import { useMemo } from "react"; + +const BaseInput = observer(({ value, onChange, placeholder }) => { + const options = useMemo(() => { + return Object.keys(stateLabels).map((key) => { + const textLabel = stateLabels[key]; + const color = STATE_COLORS[key] || "grey"; + const colorClasses = colorToClasses[color]; + + return { + value: key, + textLabel, + label: {textLabel}, + }; + }); + }, []); + + return ( +