Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions label_studio/projects/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion label_studio/projects/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
3 changes: 3 additions & 0 deletions label_studio/projects/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -249,6 +251,7 @@ class Meta:
'queue_total',
'queue_done',
'config_suitable_for_bulk_annotation',
'state',
]

def validate_label_config(self, value):
Expand Down
1 change: 1 addition & 0 deletions web/apps/labelstudio/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ registerAnalytics();

import "./app/App";
import "./utils/service-worker";
import "./utils/state-registry-lso";
6 changes: 4 additions & 2 deletions web/apps/labelstudio/src/pages/Home/HomePage.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -212,7 +212,9 @@ function ProjectSimpleCard({
style={{ borderLeftColor: color }}
>
<div className="flex flex-col gap-1">
<span className="text-neutral-content">{project.title}</span>
<Tooltip title={project.title}>
<span className="text-neutral-content truncate">{project.title}</span>
</Tooltip>
<div className="text-neutral-content-subtler text-sm">
{finished} of {total} Tasks ({total > 0 ? Math.round((finished / total) * 100) : 0}%)
</div>
Expand Down
1 change: 1 addition & 0 deletions web/apps/labelstudio/src/pages/Projects/Projects.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const ProjectsPage = () => {
"color",
"is_published",
"assignment_settings",
"state",
].join(",");

const data = await api.callApi("projects", {
Expand Down
19 changes: 18 additions & 1 deletion web/apps/labelstudio/src/pages/Projects/Projects.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 15 additions & 2 deletions web/apps/labelstudio/src/pages/Projects/ProjectsList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"];

Expand Down Expand Up @@ -80,7 +81,13 @@ const ProjectCard = ({ project }) => {
<div className={cn("project-card").mod({ colored: !!color }).toClassName()} style={projectColors}>
<div className={cn("project-card").elem("header").toClassName()}>
<div className={cn("project-card").elem("title").toClassName()}>
<div className={cn("project-card").elem("title-text").toClassName()}>{project.title ?? "New project"}</div>
<div className={cn("project-card").elem("title-text-wrapper").toClassName()}>
<Tooltip title={project.title ?? "New project"}>
<div className={cn("project-card").elem("title-text").toClassName()}>
{project.title ?? "New project"}
</div>
</Tooltip>
</div>

<div
className={cn("project-card").elem("menu").toClassName()}
Expand All @@ -102,6 +109,12 @@ const ProjectCard = ({ project }) => {
</Button>
</Dropdown.Trigger>
</div>

{project.state && (
<div className={cn("project-card").elem("state-chip").toClassName()}>
<ProjectStateChip state={project.state} projectId={project.id} interactive={false} />
</div>
)}
</div>
<div className={cn("project-card").elem("summary").toClassName()}>
<div className={cn("project-card").elem("annotation").toClassName()}>
Expand Down
84 changes: 84 additions & 0 deletions web/apps/labelstudio/src/utils/state-registry-lso.ts
Original file line number Diff line number Diff line change
@@ -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());
}
}
Loading