Skip to content

Commit ca39fa8

Browse files
strukalexclaude
andcommitted
workflow slug: backfill for existing rows + frontend surfacing
Migration backfill ------------------ The slug migration previously assumed `workflow_lineages` was empty (`NOT NULL DEFAULT ''` then drop default). On any populated environment that would either fail when adding the unique index, or collapse every row to `slug = ''`. Rewrote the in-place migration to: 1. Add `slug TEXT` (nullable). 2. Backfill in SQL: kebab-case the `name` column (lower, replace `[^a-z0-9]+` with `-`, trim leading/trailing `-`). Rows whose name normalizes to an empty string fall back to `workflow`. 3. Deduplicate within `group_id` using `row_number()` ordered by `created_at, id` -- earliest creator keeps the bare slug, others get `-2`, `-3`, ... 4. SET NOT NULL, then add the unique index `(group_id, slug)`. Verified locally against synthetic rows: 3 collisions of "Invoice Workflow" -> {invoice-workflow, invoice-workflow-2, invoice-workflow-3}; "Invoice Workflow!!! (v2)" -> invoice-workflow-v2; all-symbol name " --- " -> workflow. Frontend slug surfacing ----------------------- New shared chip component (Mantine): src/components/workflow/SlugChip.tsx `<SlugChip slug="..." />` renders the slug as a `<Code>` element with a `CopyButton` tooltip ("Copy slug" -> "Copied" on click). Surfaced in three places: * WorkflowListPage: new "Slug" column between Name and Description. * WorkflowEditPage: slug shown under the title (slugs are immutable once a lineage exists, so it's read-only here -- only a copy affordance, no edit). * WorkflowPage (create): a small live "API handle (slug): <preview>" description under the Name input. `previewSlug()` mirrors the backend `slugifyName` so the user can anticipate what they'll get; server still has the final say on dedup. Type plumbing ------------- `WorkflowInfo` (and the duplicate definition in `features/benchmarking/hooks/useWorkflows.ts`) gain a `slug: string` field to match the backend response shape. Test summary ------------ - Frontend: tsc clean, biome clean, existing test suites unchanged. - Backend: full Jest run -- 2065 tests pass, 0 regressions. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2bcabd2 commit ca39fa8

7 files changed

Lines changed: 122 additions & 3 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { ActionIcon, Code, CopyButton, Group, Tooltip } from "@mantine/core";
2+
import { IconCheck, IconCopy } from "@tabler/icons-react";
3+
4+
type Size = "xs" | "sm";
5+
6+
interface SlugChipProps {
7+
slug: string;
8+
/** Visual scale of the rendered chip. Defaults to "sm". */
9+
size?: Size;
10+
}
11+
12+
/**
13+
* Renders a workflow's slug as a copyable code chip.
14+
*
15+
* The slug is the stable, URL/CLI-friendly handle used in upload requests
16+
* (`workflow_slug`) -- exposed here so operators can copy it without digging
17+
* through the API response.
18+
*/
19+
export function SlugChip({ slug, size = "sm" }: SlugChipProps) {
20+
return (
21+
<Group gap={4} wrap="nowrap" align="center">
22+
<Code data-testid="workflow-slug">{slug}</Code>
23+
<CopyButton value={slug} timeout={1500}>
24+
{({ copied, copy }) => (
25+
<Tooltip label={copied ? "Copied" : "Copy slug"} withArrow>
26+
<ActionIcon
27+
size={size}
28+
variant="subtle"
29+
color={copied ? "green" : "gray"}
30+
onClick={copy}
31+
aria-label="Copy workflow slug"
32+
>
33+
{copied ? <IconCheck size={14} /> : <IconCopy size={14} />}
34+
</ActionIcon>
35+
</Tooltip>
36+
)}
37+
</CopyButton>
38+
</Group>
39+
);
40+
}

apps/frontend/src/data/hooks/useWorkflows.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { apiService } from "../services/api.service";
66
export interface WorkflowInfo {
77
id: string;
88
workflowVersionId: string;
9+
/** Stable, URL/CLI-friendly handle. Unique within a group. */
10+
slug: string;
911
name: string;
1012
description: string | null;
1113
actorId: string;

apps/frontend/src/features/benchmarking/hooks/useWorkflows.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { apiService } from "@/data/services/api.service";
55
interface WorkflowInfo {
66
id: string;
77
workflowVersionId: string;
8+
slug: string;
89
name: string;
910
version: number;
1011
description: string | null;

apps/frontend/src/pages/WorkflowEditPage.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { notifications } from "@mantine/notifications";
1717
import { IconArrowLeft, IconCheck } from "@tabler/icons-react";
1818
import { useEffect, useState } from "react";
19+
import { SlugChip } from "../components/workflow/SlugChip";
1920
import { WorkflowVisualization } from "../components/workflow/WorkflowVisualization";
2021
import { useUpdateWorkflow, useWorkflow } from "../data/hooks/useWorkflows";
2122
import { useTemplateModels } from "../features/annotation/template-models/hooks/useTemplateModels";
@@ -334,8 +335,14 @@ export function WorkflowEditPage({
334335
return (
335336
<Stack gap="lg">
336337
<Group justify="space-between">
337-
<Stack gap={2}>
338+
<Stack gap={4}>
338339
<Title order={2}>Edit Workflow</Title>
340+
<Group gap="xs">
341+
<Text c="dimmed" size="sm">
342+
Slug:
343+
</Text>
344+
<SlugChip slug={workflow.slug} />
345+
</Group>
339346
<Text c="dimmed" size="sm">
340347
Modify workflow configuration and parameters
341348
</Text>

apps/frontend/src/pages/WorkflowListPage.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { notifications } from "@mantine/notifications";
1616
import { IconEdit, IconFlask, IconPlus, IconTrash } from "@tabler/icons-react";
1717
import { type ReactNode, useState } from "react";
1818
import { useNavigate } from "react-router-dom";
19+
import { SlugChip } from "../components/workflow/SlugChip";
1920
import { useDeleteWorkflow, useWorkflows } from "../data/hooks/useWorkflows";
2021

2122
export function WorkflowListPage() {
@@ -161,6 +162,7 @@ export function WorkflowListPage() {
161162
<Table.Thead>
162163
<Table.Tr>
163164
<Table.Th>Name</Table.Th>
165+
<Table.Th>Slug</Table.Th>
164166
<Table.Th>Description</Table.Th>
165167
<Table.Th>Version</Table.Th>
166168
<Table.Th>Schema</Table.Th>
@@ -175,6 +177,9 @@ export function WorkflowListPage() {
175177
<Table.Td>
176178
<Text fw={500}>{workflow.name}</Text>
177179
</Table.Td>
180+
<Table.Td>
181+
<SlugChip slug={workflow.slug} />
182+
</Table.Td>
178183
<Table.Td>
179184
<Text c="dimmed" size="sm" lineClamp={1}>
180185
{workflow.description || "—"}

apps/frontend/src/pages/WorkflowPage.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
Badge,
33
Button,
4+
Code,
45
Grid,
56
Group,
67
NumberInput,
@@ -20,6 +21,21 @@ import { useCreateWorkflow } from "../data/hooks/useWorkflows";
2021
import { useTemplateModels } from "../features/annotation/template-models/hooks/useTemplateModels";
2122
import type { WorkflowStepsConfig } from "../types/workflow";
2223

24+
/**
25+
* Mirrors the backend `slugifyName` so the create form can preview the
26+
* handle that will be generated. The server still has the last word --
27+
* it deduplicates within the group with `-2`, `-3`, ... suffixes.
28+
*/
29+
function previewSlug(name: string): string {
30+
const base = name
31+
.toLowerCase()
32+
.normalize("NFKD")
33+
.replace(/[̀-ͯ]/g, "")
34+
.replace(/[^a-z0-9]+/g, "-")
35+
.replace(/^-+|-+$/g, "");
36+
return base.length > 0 ? base : "workflow";
37+
}
38+
2339
interface WorkflowStepConfig {
2440
enabled: boolean;
2541
parameters?: Record<string, unknown>;
@@ -272,6 +288,19 @@ export function WorkflowPage() {
272288
<TextInput
273289
label="Workflow Name"
274290
placeholder="e.g., High-Confidence OCR Workflow"
291+
description={
292+
workflowName.trim().length > 0 ? (
293+
<Text size="xs" component="span">
294+
API handle (slug):{" "}
295+
<Code>{previewSlug(workflowName)}</Code>
296+
<Text size="xs" c="dimmed" component="span">
297+
{" "}
298+
— server picks the final value, deduping within the
299+
group if needed.
300+
</Text>
301+
</Text>
302+
) : undefined
303+
}
275304
value={workflowName}
276305
onChange={(e) => setWorkflowName(e.target.value)}
277306
required

apps/shared/prisma/migrations/20260513174924_add_workflow_slug_and_ocr_content/migration.sql

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,41 @@
22
ALTER TABLE "ocr_results" ADD COLUMN "content" JSONB;
33

44
-- Add stable, human-friendly slug to workflow lineages, unique within a group.
5-
ALTER TABLE "workflow_lineages" ADD COLUMN "slug" TEXT NOT NULL DEFAULT '';
6-
ALTER TABLE "workflow_lineages" ALTER COLUMN "slug" DROP DEFAULT;
5+
-- Created as nullable, backfilled from `name` (kebab-case, deduped within group),
6+
-- then made NOT NULL and uniquely indexed.
7+
ALTER TABLE "workflow_lineages" ADD COLUMN "slug" TEXT;
8+
9+
WITH base AS (
10+
SELECT
11+
id,
12+
group_id,
13+
CASE
14+
WHEN length(trim(both '-' from
15+
regexp_replace(lower(name), '[^a-z0-9]+', '-', 'g'))) > 0
16+
THEN trim(both '-' from
17+
regexp_replace(lower(name), '[^a-z0-9]+', '-', 'g'))
18+
ELSE 'workflow'
19+
END AS base_slug,
20+
created_at
21+
FROM "workflow_lineages"
22+
),
23+
ranked AS (
24+
SELECT
25+
id,
26+
base_slug,
27+
row_number() OVER (
28+
PARTITION BY group_id, base_slug
29+
ORDER BY created_at, id
30+
) AS rn
31+
FROM base
32+
)
33+
UPDATE "workflow_lineages" wl
34+
SET slug = CASE
35+
WHEN r.rn = 1 THEN r.base_slug
36+
ELSE r.base_slug || '-' || r.rn
37+
END
38+
FROM ranked r
39+
WHERE r.id = wl.id;
40+
41+
ALTER TABLE "workflow_lineages" ALTER COLUMN "slug" SET NOT NULL;
742
CREATE UNIQUE INDEX "workflow_lineages_group_id_slug_key" ON "workflow_lineages"("group_id", "slug");

0 commit comments

Comments
 (0)