diff --git a/web/apps/labelstudio/src/config/ApiConfig.js b/web/apps/labelstudio/src/config/ApiConfig.js index 62c8f95cb572..969a654b490a 100644 --- a/web/apps/labelstudio/src/config/ApiConfig.js +++ b/web/apps/labelstudio/src/config/ApiConfig.js @@ -92,6 +92,9 @@ export const API_CONFIG = { accessTokenSettings: "GET:/jwt/settings", accessTokenUpdateSettings: "POST:/jwt/settings", + + // FSM + fsmStateHistory: "GET:/fsm/entities/:entityType/:entityId/history", }, alwaysExpectJSON: false, }; diff --git a/web/libs/app-common/src/components/state-chips/annotation-state-chip.tsx b/web/libs/app-common/src/components/state-chips/annotation-state-chip.tsx new file mode 100644 index 000000000000..fbb402880a3e --- /dev/null +++ b/web/libs/app-common/src/components/state-chips/annotation-state-chip.tsx @@ -0,0 +1,49 @@ +/** + * AnnotationStateChip - Annotation-specific state chip with history popover + */ + +import { useState } from "react"; +import { StateChip } from "@humansignal/ui"; +import { getStateColorClass, formatStateName, getStateDescription } from "./utils"; +import { StateHistoryPopoverContent } from "./state-history-popover-content"; + +export interface AnnotationStateChipProps { + /** + * Current state of the annotation + */ + state: string; + + /** + * Annotation ID for fetching state history + */ + annotationId?: number; + + /** + * Whether the chip should be interactive (show history popover) + */ + interactive?: boolean; +} + +export function AnnotationStateChip({ state, annotationId, interactive = true }: AnnotationStateChipProps) { + const [open, setOpen] = useState(false); + + const label = formatStateName(state); + const description = getStateDescription(state, "annotation"); + const colorClasses = getStateColorClass(state); + + const popoverContent = annotationId ? ( + + ) : undefined; + + return ( + + ); +} diff --git a/web/libs/app-common/src/components/state-chips/index.ts b/web/libs/app-common/src/components/state-chips/index.ts new file mode 100644 index 000000000000..7bf31f97e3e7 --- /dev/null +++ b/web/libs/app-common/src/components/state-chips/index.ts @@ -0,0 +1,7 @@ +export { TaskStateChip, type TaskStateChipProps } from "./task-state-chip"; +export { AnnotationStateChip, type AnnotationStateChipProps } from "./annotation-state-chip"; +export { ProjectStateChip, type ProjectStateChipProps } from "./project-state-chip"; +export { StateHistoryPopoverContent, type StateHistoryPopoverContentProps } from "./state-history-popover-content"; +export { StateHistoryPopover, type StateHistoryPopoverProps } from "./state-history-popover"; +export * from "./utils"; +export { stateRegistry, StateType, type EntityType, type StateMetadata } from "./state-registry"; diff --git a/web/libs/app-common/src/components/state-chips/project-state-chip.tsx b/web/libs/app-common/src/components/state-chips/project-state-chip.tsx new file mode 100644 index 000000000000..e326fc7b9cfe --- /dev/null +++ b/web/libs/app-common/src/components/state-chips/project-state-chip.tsx @@ -0,0 +1,49 @@ +/** + * ProjectStateChip - Project-specific state chip with history popover + */ + +import { useState } from "react"; +import { StateChip } from "@humansignal/ui"; +import { getStateColorClass, formatStateName, getStateDescription } from "./utils"; +import { StateHistoryPopoverContent } from "./state-history-popover-content"; + +export interface ProjectStateChipProps { + /** + * Current state of the project + */ + state: string; + + /** + * Project ID for fetching state history + */ + projectId?: number; + + /** + * Whether the chip should be interactive (show history popover) + */ + interactive?: boolean; +} + +export function ProjectStateChip({ state, projectId, interactive = true }: ProjectStateChipProps) { + const [open, setOpen] = useState(false); + + const label = formatStateName(state); + const description = getStateDescription(state, "project"); + const colorClasses = getStateColorClass(state); + + const popoverContent = projectId ? ( + + ) : undefined; + + return ( + + ); +} diff --git a/web/libs/app-common/src/components/state-chips/state-history-popover-content.tsx b/web/libs/app-common/src/components/state-chips/state-history-popover-content.tsx new file mode 100644 index 000000000000..d16c594367f0 --- /dev/null +++ b/web/libs/app-common/src/components/state-chips/state-history-popover-content.tsx @@ -0,0 +1,104 @@ +/** + * StateHistoryPopoverContent - Popover content for displaying state history + */ + +import { Badge, Button, Typography } from "@humansignal/ui"; +import { IconSync, IconError, IconHistoryRewind } from "@humansignal/icons"; +import { useStateHistory, type StateHistoryItem } from "../../hooks/useStateHistory"; +import { getStateColorClass, formatStateName, formatTimestamp, formatUserName } from "./utils"; + +export interface StateHistoryPopoverContentProps { + entityType: "task" | "annotation" | "project"; + entityId: number; + isOpen: boolean; +} + +export function StateHistoryPopoverContent({ entityType, entityId, isOpen }: StateHistoryPopoverContentProps) { + const { data, isLoading, isError, error, refetch } = useStateHistory({ + entityType, + entityId, + enabled: isOpen, + }); + + const history = (data?.results || []) as StateHistoryItem[]; + + return ( +
e.stopPropagation()} + > + {/* 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)} + +
+ ))} +
+ )} +
+
+ ); +} diff --git a/web/libs/app-common/src/components/state-chips/state-history-popover.tsx b/web/libs/app-common/src/components/state-chips/state-history-popover.tsx new file mode 100644 index 000000000000..b1668f636c24 --- /dev/null +++ b/web/libs/app-common/src/components/state-chips/state-history-popover.tsx @@ -0,0 +1,125 @@ +/** + * StateHistoryPopover component + * Displays the complete FSM state transition history for an entity + */ + +import type React from "react"; +import { Popover, Badge, Button, Typography } from "@humansignal/ui"; +import { IconSync, IconError, IconHistoryRewind } from "@humansignal/icons"; +import { useStateHistory, type StateHistoryItem } from "../../hooks/useStateHistory"; +import { getStateColorClass, formatStateName, formatTimestamp, formatUserName } from "./utils"; + +export interface StateHistoryPopoverProps { + trigger: React.ReactNode; + entityType: "task" | "annotation" | "project"; + entityId: number; + currentState: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +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 ( + +
e.stopPropagation()} + > + {/* 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/app-common/src/components/state-chips/state-registry.ts b/web/libs/app-common/src/components/state-chips/state-registry.ts new file mode 100644 index 000000000000..b2d622aa0f26 --- /dev/null +++ b/web/libs/app-common/src/components/state-chips/state-registry.ts @@ -0,0 +1,341 @@ +/** + * State Registry System for Label Studio + * + * This module provides an extensible state management system that allows + * Label Studio Enterprise to extend state definitions without modifying base code. + * + * Instead of mapping states directly to colors (CREATED → "grey"), we use semantic + * types that represent meaning (CREATED → StateType.INITIAL → neutral styling). + * + * This allows: + * - Clear intent when reading code (TERMINAL vs green) + * - Easy visual redesigns without touching logic + * - Consistent styling across similar state types + * - Entity-specific tooltips (same state, different descriptions) + * - LSE extension without modifying LSO code + */ + +/** + * Semantic state categories that define the meaning and visual representation of states. + * + * States are not just colors - they represent different phases in a workflow: + * - INITIAL: Starting point, newly created entities + * - IN_PROGRESS: Active work happening + * - ATTENTION: Requires intervention or review + * - TERMINAL: Completed, no further changes expected + */ +export enum StateType { + INITIAL = "initial", + IN_PROGRESS = "in_progress", + ATTENTION = "attention", + TERMINAL = "terminal", +} + +/** + * Entity types that can have states. + * Used for entity-specific tooltip lookup. + */ +export type EntityType = "task" | "annotation" | "project" | "annotationreview"; + +/** + * State metadata including type, label, and entity-specific tooltips. + */ +export interface StateMetadata { + /** Semantic state type determining visual styling */ + type: StateType; + + /** Human-readable label for display (defaults to formatted state name if not provided) */ + label?: string; + + /** Entity-specific tooltip descriptions */ + tooltips?: Partial>; +} + +/** + * Tailwind CSS classes for each state type. + * Using semantic design tokens for maintainable theming. + */ +const STATE_TYPE_STYLES: Record = { + [StateType.INITIAL]: "bg-neutral-emphasis border-neutral-border text-neutral-content", + [StateType.IN_PROGRESS]: "bg-primary-emphasis border-primary-border-subtlest text-primary-content", + [StateType.ATTENTION]: "bg-warning-emphasis border-warning-border-subtlest text-warning-content", + [StateType.TERMINAL]: "bg-positive-emphasis border-positive-border-subtlest text-positive-content", +}; + +/** + * Central registry for state definitions. + * + * This singleton class provides: + * - Registration of state metadata + * - Lookup of state types and tooltips + * - Extension mechanism for LSE + */ +class StateRegistry { + private states = new Map(); + + /** + * Register a state with its metadata. + * Can be called multiple times for the same state to update metadata. + * + * @param state - State constant (e.g., 'CREATED', 'IN_PROGRESS') + * @param metadata - State type, label, and tooltips + */ + register(state: string, metadata: StateMetadata): void { + this.states.set(state, metadata); + } + + /** + * Register multiple states at once. + * Useful for batch registration of related states. + * + * @param states - Map of state constants to metadata + */ + registerBatch(states: Record): void { + Object.entries(states).forEach(([state, metadata]) => { + this.register(state, metadata); + }); + } + + /** + * Get the semantic type of a state. + * Falls back to INITIAL if state is not registered. + * + * @param state - State constant + * @returns StateType enum value + */ + getType(state: string): StateType { + return this.states.get(state)?.type ?? StateType.INITIAL; + } + + /** + * Get the display label for a state. + * Falls back to formatted state name if no label is registered. + * + * @param state - State constant + * @returns Human-readable label + */ + getLabel(state: string): string { + const metadata = this.states.get(state); + return metadata?.label ?? this.formatStateName(state); + } + + /** + * Get the tooltip description for a state + entity combination. + * Falls back to generic description if no entity-specific tooltip exists. + * + * @param state - State constant + * @param entityType - Type of entity (task, project, etc.) + * @returns Tooltip text + */ + getTooltip(state: string, entityType: EntityType): string { + const metadata = this.states.get(state); + + if (!metadata?.tooltips) { + // No tooltips defined, return generic description + return `${this.getLabel(state)} state`; + } + + // Look up entity-specific tooltip, fall back to first available tooltip + const entityTooltip = metadata.tooltips[entityType]; + if (entityTooltip) { + return entityTooltip; + } + + // Fall back to any available tooltip + const firstTooltip = Object.values(metadata.tooltips)[0]; + return firstTooltip ?? `${this.getLabel(state)} state`; + } + + /** + * Get Tailwind CSS classes for a state's visual styling. + * + * @param state - State constant + * @returns Space-separated Tailwind class names + */ + getStyleClasses(state: string): string { + const stateType = this.getType(state); + return STATE_TYPE_STYLES[stateType]; + } + + /** + * Check if a state is registered. + * + * @param state - State constant + * @returns true if state is registered + */ + isRegistered(state: string): boolean { + return this.states.has(state); + } + + /** + * Get all registered states. + * Useful for debugging and testing. + * + * @returns Array of state constants + */ + getAllStates(): string[] { + return Array.from(this.states.keys()); + } + + /** + * Get states that apply to a specific entity type. + * A state applies to an entity if it has a tooltip defined for that entity type. + * + * @param entityType - Type of entity (task, annotation, project, annotationreview) + * @returns Array of state constants applicable to the entity type + */ + getStatesByEntityType(entityType: EntityType): string[] { + return Array.from(this.states.entries()) + .filter(([_, metadata]) => metadata.tooltips && entityType in metadata.tooltips) + .sort((a, b) => { + // Sort by logical workflow order: INITIAL → IN_PROGRESS → ATTENTION → TERMINAL + const workflowOrder = [StateType.INITIAL, StateType.IN_PROGRESS, StateType.ATTENTION, StateType.TERMINAL]; + const aType = this.getType(a[0]); + const bType = this.getType(b[0]); + const typeDiff = workflowOrder.indexOf(aType) - workflowOrder.indexOf(bType); + if (typeDiff !== 0) return typeDiff; + + // Within each type, sort by label alphabetically + // BUT: Special cases for specific states + const aState = a[0]; + const bState = b[0]; + + // Special case: IN_PROGRESS state should be first in its type group + if (aState === "IN_PROGRESS") return -1; + if (bState === "IN_PROGRESS") return 1; + + // Special case: COMPLETED (Done) should always be last + if (aState === "COMPLETED") return 1; + if (bState === "COMPLETED") return -1; + + // Otherwise sort by label alphabetically + const aLabel = this.getLabel(aState); + const bLabel = this.getLabel(bState); + return aLabel.localeCompare(bLabel); + }) + .map(([state]) => state); + } + + /** + * Format a state constant into a human-readable name. + * Converts SNAKE_CASE to Title Case. + * + * @param state - State constant (e.g., 'ANNOTATION_IN_PROGRESS') + * @returns Formatted name (e.g., 'Annotation In Progress') + */ + private formatStateName(state: string): string { + return state + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" "); + } +} + +/** + * Singleton instance of the state registry. + * Import this to register or query states. + */ +export const stateRegistry = new StateRegistry(); + +// ============================================================================ +// Core State Registrations (Label Studio Open Source) +// ============================================================================ + +/** + * Minimal LSO states - just the basics. + * All review, arbitration, and advanced workflow states are in LSE. + */ +stateRegistry.registerBatch({ + CREATED: { + type: StateType.INITIAL, + label: "Created", + tooltips: { + task: "Task has been created and is ready for annotation", + annotation: "Annotation has been created", + }, + }, + + ANNOTATION_IN_PROGRESS: { + type: StateType.IN_PROGRESS, + label: "Annotating", + tooltips: { + task: "Task is currently being annotated", + }, + }, + + COMPLETED: { + type: StateType.TERMINAL, + label: "Done", + tooltips: { + task: "Task is fully completed and no further work is needed", + annotation: "Annotation is completed and finalized", + project: "Project is completed - all tasks are done", + }, + }, +}); + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Get Tailwind CSS classes for a state's visual styling. + * + * @param state - State constant (e.g., 'CREATED', 'IN_PROGRESS') + * @returns Space-separated Tailwind class names + */ +export function getStateColorClass(state: string): string { + return stateRegistry.getStyleClasses(state); +} + +/** + * Format a state constant into a human-readable name. + * + * @param state - State constant (e.g., 'ANNOTATION_IN_PROGRESS') + * @returns Formatted name (e.g., 'Annotating') + */ +export function formatStateName(state: string): string { + return stateRegistry.getLabel(state); +} + +/** + * Get the tooltip description for a state + entity combination. + * + * @param state - State constant + * @param entityType - Type of entity (task, annotation, project, annotationreview) + * @returns Tooltip description text + */ +export function getStateDescription(state: string, entityType: EntityType = "task"): string { + return stateRegistry.getTooltip(state, entityType); +} + +/** + * Get the semantic type of a state. + * Useful for conditional logic based on state category. + * + * @param state - State constant + * @returns StateType enum value + */ +export function getStateType(state: string): StateType { + return stateRegistry.getType(state); +} + +/** + * Check if a state represents a terminal (completed) state. + * + * @param state - State constant + * @returns true if state is terminal + */ +export function isTerminalState(state: string): boolean { + return stateRegistry.getType(state) === StateType.TERMINAL; +} + +/** + * Check if a state requires attention/intervention. + * + * @param state - State constant + * @returns true if state requires attention + */ +export function requiresAttention(state: string): boolean { + return stateRegistry.getType(state) === StateType.ATTENTION; +} diff --git a/web/libs/app-common/src/components/state-chips/task-state-chip.tsx b/web/libs/app-common/src/components/state-chips/task-state-chip.tsx new file mode 100644 index 000000000000..4f102e2247cc --- /dev/null +++ b/web/libs/app-common/src/components/state-chips/task-state-chip.tsx @@ -0,0 +1,49 @@ +/** + * TaskStateChip - Task-specific state chip with history popover + */ + +import { useState } from "react"; +import { StateChip } from "@humansignal/ui"; +import { getStateColorClass, formatStateName, getStateDescription } from "./utils"; +import { StateHistoryPopoverContent } from "./state-history-popover-content"; + +export interface TaskStateChipProps { + /** + * Current state of the task + */ + state: string; + + /** + * Task ID for fetching state history + */ + taskId?: number; + + /** + * Whether the chip should be interactive (show history popover) + */ + interactive?: boolean; +} + +export function TaskStateChip({ state, taskId, interactive = true }: TaskStateChipProps) { + const [open, setOpen] = useState(false); + + const label = formatStateName(state); + const description = getStateDescription(state, "task"); + const colorClasses = getStateColorClass(state); + + const popoverContent = taskId ? ( + + ) : undefined; + + return ( + + ); +} diff --git a/web/libs/app-common/src/components/state-chips/utils.ts b/web/libs/app-common/src/components/state-chips/utils.ts new file mode 100644 index 000000000000..33c3e558f395 --- /dev/null +++ b/web/libs/app-common/src/components/state-chips/utils.ts @@ -0,0 +1,67 @@ +/** + * Shared utilities for state chip components + * + * This file re-exports the state registry functions for backward compatibility. + * The actual implementation is in state-registry.ts which provides an extensible + * semantic type system that LSE can extend. + */ + +export { + stateRegistry, + StateType, + getStateColorClass, + formatStateName, + getStateDescription, + getStateType, + isTerminalState, + requiresAttention, + type EntityType, + type StateMetadata, +} from "./state-registry"; + +/** + * Format timestamp to human-readable string + */ +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); + + 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", + }); +} + +/** + * Format user name from triggered_by object + */ +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; + + 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"; +} diff --git a/web/libs/app-common/src/hooks/useStateHistory.ts b/web/libs/app-common/src/hooks/useStateHistory.ts new file mode 100644 index 000000000000..c6e4b650e3d7 --- /dev/null +++ b/web/libs/app-common/src/hooks/useStateHistory.ts @@ -0,0 +1,62 @@ +/** + * 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(); + + // Use the API provider to make the request + const result = await api.invoke("fsmStateHistory", { entityType, entityId }); + + // Handle API errors + if (result?.error || !result?.$meta?.ok) { + throw new Error(result?.error || "Failed to fetch state history"); + } + + return result as StateHistoryResponse; + }, + 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/app-common/src/index.ts b/web/libs/app-common/src/index.ts index 2f6919eca752..18dc9eb084eb 100644 --- a/web/libs/app-common/src/index.ts +++ b/web/libs/app-common/src/index.ts @@ -1,3 +1,9 @@ import * as pages from "./pages"; export { pages }; + +// Hooks +export { useStateHistory, type StateHistoryItem, type StateHistoryResponse } from "./hooks/useStateHistory"; + +// Components +export * from "./components/state-chips"; diff --git a/web/libs/app-common/src/pages/AccountSettings/sections/Hotkeys/Help.tsx b/web/libs/app-common/src/pages/AccountSettings/sections/Hotkeys/Help.tsx index bed2f6ed4967..1fdf57507199 100644 --- a/web/libs/app-common/src/pages/AccountSettings/sections/Hotkeys/Help.tsx +++ b/web/libs/app-common/src/pages/AccountSettings/sections/Hotkeys/Help.tsx @@ -112,9 +112,7 @@ const HotkeyHelpModal = ({ sectionsToShow }: HotkeyHelpModalProps) => { {subgroups.map((subgroup) => (
{/* Subgroup Header */} {subgroup !== "default" && ( diff --git a/web/libs/datamanager/src/components/CellViews/TaskState.jsx b/web/libs/datamanager/src/components/CellViews/TaskState.jsx index 1ee4cb7c84ab..e010a7b7c479 100644 --- a/web/libs/datamanager/src/components/CellViews/TaskState.jsx +++ b/web/libs/datamanager/src/components/CellViews/TaskState.jsx @@ -1,81 +1,19 @@ import { isDefined } from "../../utils/utils"; -import { Badge } from "@humansignal/ui"; -import { Tooltip } from "@humansignal/ui"; - -// Map state values to human-readable labels -export 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 -export 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 -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", - green: "bg-positive-emphasis border-positive-border-subtlest text-positive-content", -}; +import { TaskStateChip } from "@humansignal/app-common"; export const TaskState = (cell) => { - const { value } = cell; + const { value, original } = 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]; + // Extract task ID from the original row data + const taskId = original?.id; return (
- - - {label} - - +
); }; diff --git a/web/libs/datamanager/src/components/Filters/types/TaskStateFilter.jsx b/web/libs/datamanager/src/components/Filters/types/TaskStateFilter.jsx index 4f9e9ca9f379..0a46c31ec777 100644 --- a/web/libs/datamanager/src/components/Filters/types/TaskStateFilter.jsx +++ b/web/libs/datamanager/src/components/Filters/types/TaskStateFilter.jsx @@ -1,17 +1,16 @@ import { observer } from "mobx-react"; import { Select, Badge } from "@humansignal/ui"; -import { stateLabels, STATE_COLORS, colorToClasses } from "../../CellViews/TaskState"; +import { stateRegistry, formatStateName, getStateColorClass } from "@humansignal/app-common"; 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 stateRegistry.getStatesByEntityType("task").map((state) => { + const textLabel = formatStateName(state); + const colorClasses = getStateColorClass(state); return { - value: key, + value: state, textLabel, label: {textLabel}, }; @@ -26,14 +25,13 @@ const BaseInput = observer(({ value, onChange, placeholder }) => { placeholder={placeholder} searchable={true} onSearch={(value) => { - // Search against textLabel which should match any of the stateLabels values + // Search against textLabel which should match any of the state labels return options.filter((option) => option.textLabel.toLowerCase().includes(value.toLowerCase())); }} selectedValueRenderer={(option) => { if (!option) return null; - const color = STATE_COLORS[option.value] || "grey"; - const colorClasses = colorToClasses[color]; + const colorClasses = getStateColorClass(option.value); return {option.textLabel}; }} diff --git a/web/libs/ui/src/index.ts b/web/libs/ui/src/index.ts index 273600e953dd..09a11613c5d7 100644 --- a/web/libs/ui/src/index.ts +++ b/web/libs/ui/src/index.ts @@ -16,6 +16,7 @@ export * from "./lib/enterprise-upgrade-overlay/enterprise-upgrade-overlay"; export * from "./lib/label/label"; export * from "./lib/select/select"; export * from "./lib/skeleton/skeleton"; +export * from "./lib/state-chip/state-chip"; export * from "./lib/toast/toast"; export * from "./lib/toggle/toggle"; export * from "./lib/typography/typography"; diff --git a/web/libs/ui/src/lib/state-chip/index.ts b/web/libs/ui/src/lib/state-chip/index.ts new file mode 100644 index 000000000000..67c3bf247152 --- /dev/null +++ b/web/libs/ui/src/lib/state-chip/index.ts @@ -0,0 +1 @@ +export { StateChip, type StateChipProps } from "./state-chip"; diff --git a/web/libs/ui/src/lib/state-chip/state-chip.tsx b/web/libs/ui/src/lib/state-chip/state-chip.tsx new file mode 100644 index 000000000000..90c90a2aa0a4 --- /dev/null +++ b/web/libs/ui/src/lib/state-chip/state-chip.tsx @@ -0,0 +1,95 @@ +/** + * StateChip - Base interactive state display component + * + * A reusable chip component that displays a state with optional popover interaction. + * This base component provides the visual display and interactive behavior, + * while entity-specific implementations handle state logic and history. + */ + +import { useState, type ReactNode } from "react"; +import { Badge, Tooltip, Popover } from "@humansignal/ui"; + +export interface StateChipProps { + /** + * Visual label to display in the chip + */ + label: string; + + /** + * Description for the tooltip when not interactive + */ + description?: string; + + /** + * Tailwind CSS classes for styling the chip + */ + className: string; + + /** + * Whether the chip should be interactive (clickable with popover) + */ + interactive?: boolean; + + /** + * Content to display in the popover when interactive + */ + popoverContent?: ReactNode; + + /** + * Controlled open state for the popover + */ + open?: boolean; + + /** + * Callback when popover open state changes + */ + onOpenChange?: (open: boolean) => void; +} + +export function StateChip({ + label, + description, + className, + interactive = false, + popoverContent, + open: controlledOpen, + onOpenChange, +}: StateChipProps) { + const [internalOpen, setInternalOpen] = useState(false); + + // Use controlled state if provided, otherwise use internal state + const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; + const setOpen = onOpenChange || setInternalOpen; + + // Non-interactive chip - just show the badge with tooltip + if (!interactive || !popoverContent) { + return ( + + + {label} + + + ); + } + + // Interactive chip with popover + const trigger = ( + + ); + + return ( + + {popoverContent} + + ); +}