diff --git a/label_studio/projects/api.py b/label_studio/projects/api.py index fb642411fa07..65dca4c7dc98 100644 --- a/label_studio/projects/api.py +++ b/label_studio/projects/api.py @@ -184,7 +184,11 @@ def get_queryset(self): ) if filter in ['pinned_only', 'exclude_pinned']: projects = projects.filter(pinned_at__isnull=filter == 'exclude_pinned') - return ProjectManager.with_counts_annotate(projects, fields=fields).prefetch_related('members', 'created_by') + return ( + ProjectManager.with_counts_annotate(projects, fields=fields) + .annotate_fsm_state() + .prefetch_related('members', 'created_by') + ) def get_serializer_context(self): context = super(ProjectListAPI, self).get_serializer_context() @@ -238,7 +242,11 @@ def get_queryset(self): serializer = GetFieldsSerializer(data=self.request.query_params) serializer.is_valid(raise_exception=True) fields = serializer.validated_data.get('include') - return Project.objects.with_counts(fields=fields).filter(organization=self.request.user.active_organization) + return ( + Project.objects.with_counts(fields=fields) + .annotate_fsm_state() + .filter(organization=self.request.user.active_organization) + ) @method_decorator( @@ -345,7 +353,7 @@ def get_queryset(self): ) class ProjectAPI(generics.RetrieveUpdateDestroyAPIView): parser_classes = (JSONParser, FormParser, MultiPartParser) - queryset = Project.objects.with_counts() + queryset = Project.objects.with_counts().annotate_fsm_state() permission_required = ViewClassPermission( GET=all_permissions.projects_view, DELETE=all_permissions.projects_delete, @@ -362,7 +370,11 @@ def get_queryset(self): serializer = GetFieldsSerializer(data=self.request.query_params) serializer.is_valid(raise_exception=True) fields = serializer.validated_data.get('include') - return Project.objects.with_counts(fields=fields).filter(organization=self.request.user.active_organization) + return ( + Project.objects.with_counts(fields=fields) + .annotate_fsm_state() + .filter(organization=self.request.user.active_organization) + ) def get(self, request, *args, **kwargs): return super(ProjectAPI, self).get(request, *args, **kwargs) diff --git a/label_studio/projects/models.py b/label_studio/projects/models.py index fc9d5790ad52..da46a19fa37e 100644 --- a/label_studio/projects/models.py +++ b/label_studio/projects/models.py @@ -121,7 +121,7 @@ def with_state(self): Example: projects = Project.objects.with_state().filter(organization=org) for project in projects: - print(project.current_state) # No N+1 queries! + print(project.state) # No N+1 queries! """ return self.get_queryset().annotate_fsm_state() diff --git a/label_studio/projects/serializers.py b/label_studio/projects/serializers.py index 31429773c764..a0da9d1cb150 100644 --- a/label_studio/projects/serializers.py +++ b/label_studio/projects/serializers.py @@ -3,6 +3,7 @@ import bleach from constants import SAFE_HTML_ATTRIBUTES, SAFE_HTML_TAGS from django.db.models import Q +from fsm.serializer_fields import FSMStateField from label_studio_sdk.label_interface import LabelInterface from label_studio_sdk.label_interface.control_tags import ( BrushLabelsTag, @@ -97,6 +98,7 @@ class ProjectSerializer(FlexFieldsModelSerializer): queue_total = serializers.SerializerMethodField() queue_done = serializers.SerializerMethodField() + state = FSMStateField(read_only=True) # FSM state - automatically uses annotation if present @property def user_id(self): @@ -249,6 +251,7 @@ class Meta: 'queue_total', 'queue_done', 'config_suitable_for_bulk_annotation', + 'state', ] def validate_label_config(self, value): diff --git a/web/apps/labelstudio/src/main.tsx b/web/apps/labelstudio/src/main.tsx index c7449ae3c2f2..c5431c75f6c2 100644 --- a/web/apps/labelstudio/src/main.tsx +++ b/web/apps/labelstudio/src/main.tsx @@ -3,3 +3,4 @@ registerAnalytics(); import "./app/App"; import "./utils/service-worker"; +import "./utils/state-registry-lso"; diff --git a/web/apps/labelstudio/src/pages/Home/HomePage.tsx b/web/apps/labelstudio/src/pages/Home/HomePage.tsx index 54f81f9ee6ee..054afdc6efaf 100644 --- a/web/apps/labelstudio/src/pages/Home/HomePage.tsx +++ b/web/apps/labelstudio/src/pages/Home/HomePage.tsx @@ -1,5 +1,5 @@ import { IconExternal, IconFolderAdd, IconHumanSignal, IconUserAdd, IconFolderOpen } from "@humansignal/icons"; -import { Button, SimpleCard, Spinner, Typography } from "@humansignal/ui"; +import { Button, SimpleCard, Spinner, Tooltip, Typography } from "@humansignal/ui"; import { useQuery } from "@tanstack/react-query"; import { useState } from "react"; import { Link } from "react-router-dom"; @@ -212,7 +212,9 @@ function ProjectSimpleCard({ style={{ borderLeftColor: color }} >
- {project.title} + + {project.title} +
{finished} of {total} Tasks ({total > 0 ? Math.round((finished / total) * 100) : 0}%)
diff --git a/web/apps/labelstudio/src/pages/Projects/Projects.jsx b/web/apps/labelstudio/src/pages/Projects/Projects.jsx index 6df47b083d47..2218c5f67abc 100644 --- a/web/apps/labelstudio/src/pages/Projects/Projects.jsx +++ b/web/apps/labelstudio/src/pages/Projects/Projects.jsx @@ -52,6 +52,7 @@ export const ProjectsPage = () => { "color", "is_published", "assignment_settings", + "state", ].join(","); const data = await api.callApi("projects", { diff --git a/web/apps/labelstudio/src/pages/Projects/Projects.scss b/web/apps/labelstudio/src/pages/Projects/Projects.scss index faca43cccba5..c545b28314b1 100644 --- a/web/apps/labelstudio/src/pages/Projects/Projects.scss +++ b/web/apps/labelstudio/src/pages/Projects/Projects.scss @@ -121,20 +121,37 @@ &__title { display: flex; + flex-wrap: wrap; font-size: 16px; font-weight: 500; margin-bottom: 0.25rem; line-height: 22px; align-items: center; + row-gap: 0.25rem; + + &-text-wrapper { + flex: 1; + min-width: 0; + display: flex; + } &-text { - max-width: 250px; + flex: 1; + min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } } + &__state-chip { + width: 100%; + display: flex; + justify-content: flex-end; + align-items: center; + white-space: nowrap; + } + &__summary { flex: 1; font-size: 14px; diff --git a/web/apps/labelstudio/src/pages/Projects/ProjectsList.jsx b/web/apps/labelstudio/src/pages/Projects/ProjectsList.jsx index 7900c660b9bf..c491a95aac26 100644 --- a/web/apps/labelstudio/src/pages/Projects/ProjectsList.jsx +++ b/web/apps/labelstudio/src/pages/Projects/ProjectsList.jsx @@ -3,10 +3,11 @@ import { format } from "date-fns"; import { useMemo } from "react"; import { NavLink } from "react-router-dom"; import { IconCheck, IconEllipsis, IconMinus, IconSparks } from "@humansignal/icons"; -import { Userpic, Button, Dropdown } from "@humansignal/ui"; +import { Userpic, Button, Dropdown, Tooltip } from "@humansignal/ui"; import { Menu, Pagination } from "../../components"; import { cn } from "../../utils/bem"; import { absoluteURL } from "../../utils/helpers"; +import { ProjectStateChip } from "@humansignal/app-common"; const DEFAULT_CARD_COLORS = ["#FFFFFF", "#FDFDFC"]; @@ -80,7 +81,13 @@ const ProjectCard = ({ project }) => {
-
{project.title ?? "New project"}
+
+ +
+ {project.title ?? "New project"} +
+
+
{
+ + {project.state && ( +
+ +
+ )}
diff --git a/web/apps/labelstudio/src/utils/state-registry-lso.ts b/web/apps/labelstudio/src/utils/state-registry-lso.ts new file mode 100644 index 000000000000..aed0c8f915d0 --- /dev/null +++ b/web/apps/labelstudio/src/utils/state-registry-lso.ts @@ -0,0 +1,84 @@ +/** + * Label Studio Open Source - State Registry + * + * This file registers core LSO project states with the state registry from app-common. + * + * The base registry (imported from @humansignal/app-common) provides: + * - StateRegistry class and singleton instance + * - StateType enum (INITIAL, IN_PROGRESS, ATTENTION, TERMINAL) + * - Helper functions (getStateColorClass, formatStateName, etc.) + * + * LSO supports a simplified state model with 3 core project states: + * - CREATED (initial state) + * - ANNOTATION_IN_PROGRESS (work in progress) + * - COMPLETED (terminal state) + * + * IMPORTANT: Import this file in your main.tsx or app initialization: + * ```typescript + * import './utils/state-registry-lso'; + * ``` + */ + +import { stateRegistry, StateType } from "@humansignal/app-common"; + +// ============================================================================ +// Project States (LSO Core) +// ============================================================================ + +/** + * LSO has a simplified state model with 3 core states. + * LSE extends this with additional workflow states (review, arbitration, etc.) + */ +stateRegistry.registerBatch({ + CREATED: { + type: StateType.INITIAL, + label: "Created", + tooltips: { + project: "Project has been created and is ready for configuration", + }, + }, + + ANNOTATION_IN_PROGRESS: { + type: StateType.IN_PROGRESS, + label: "In Progress", + tooltips: { + project: "Annotation work is in progress on this project", + task: "Task is being annotated", + }, + }, + + COMPLETED: { + type: StateType.TERMINAL, + label: "Completed", + tooltips: { + project: "All work on this project is completed", + task: "Task has been completed", + }, + }, +}); + +// ============================================================================ +// Development Validation +// ============================================================================ + +/** + * In development mode, verify all expected LSO states are properly registered. + * This helps catch configuration issues early. + */ +if (process.env.NODE_ENV === "development") { + const lsoStates = ["CREATED", "ANNOTATION_IN_PROGRESS", "COMPLETED"]; + + const missingStates = lsoStates.filter((state) => !stateRegistry.isRegistered(state)); + + if (missingStates.length > 0) { + console.error("[LSO State Registry] Missing state registrations:", missingStates); + } else { + console.log("[LSO State Registry] ✅ All LSO states registered successfully"); + console.log(`[LSO State Registry] Registered ${lsoStates.length} LSO states`); + } + + // Log all registered states for debugging + if (process.env.DEBUG_STATE_REGISTRY) { + console.log("[LSO State Registry] All registered states:", stateRegistry.getAllStates()); + } +}