feat: organize skills with nested folders and My Skills (#9633)
## Thinking Path > - Paperclip is the open source control plane people use to organize and govern AI-agent companies > - Company skills are durable resources that users browse, import, assign, and maintain over time > - A flat skill list plus tags does not provide a stable location or hierarchy for personal, company, project-imported, and bundled skills > - Folder paths need to be canonical, company-scoped, safe to move, and preserved across re-imports without changing skill IDs > - The `/skills` UI also needs traversal, breadcrumbs, move/create flows, and a dedicated My Skills namespace that work on desktop and mobile > - This pull request adds the folder data model and APIs, reserved-root lifecycle, project import behavior, and the folder-first skills experience > - The benefit is a predictable filesystem-like organization model while tags remain available for cross-cutting classification ## Linked Issues or Issue Description Refs #9619 — the reviewed folder foundation was intentionally closed and folded into this combined feature PR. Refs #9026 — earlier flat-folder attempt superseded by this integrated implementation. Refs #3281 — related skill organization proposal; this PR uses canonical persisted folders rather than deriving groups from skill keys, and does not add hidden-skill behavior. **Feature request** - **Problem:** Skills currently lack a canonical hierarchical location, making personal skills, project imports, bundled skills, and company-authored skills difficult to traverse and manage at scale. - **Proposed behavior:** Add nested company-scoped folders with stable paths, reserved My/Projects/Bundled roots, subtree queries, safe move/create operations, and a folder-first `/skills` library UI. - **Import behavior:** New project scans file skills under `projects/<project-slug>`; later imports update content without overriding a user-selected folder. - **Alternatives considered:** Tags alone remain useful for cross-cutting classification, but they do not provide canonical location, nesting, reserved namespaces, or stable import placement. - **Roadmap alignment:** Extends the completed Skills Manager and Scheduled Routines capabilities without duplicating an active roadmap item. ## What Changed - Adds `folders` persistence for routine and skill folders, nested canonical paths, parent/slug/system-key fields, migration backfills, and reapply-safe migrations `0174`–`0175` after current master migrations. - Adds company-scoped folder CRUD, cycle/depth/namespace validation, reserved My/Projects/Bundled lifecycle, item moves, subtree filtering, and folder paths on skill results. - Preserves project-import placement: first import files into the project folder, while re-import keeps user-owned placement and stable skill IDs. - Adds the `/skills` folder tree rail, tags facet, breadcrumbs, subfolder browser, move/new-folder dialog, canonical detail location, inline tag editing, and folder-aware Studio creation. - Keeps bundled skills read-only even when their source metadata is incomplete by detecting the reserved Bundled folder and hiding selection/move actions. - Extends routine folder UI and OpenAPI coverage, and adds regression tests across migrations, services, routes, tree helpers, pages, and Studio creation. ## Verification - `pnpm exec vitest run packages/db/src/nested-skill-folders-migration.test.ts server/src/__tests__/folders-routes.test.ts server/src/__tests__/folders-service.test.ts server/src/__tests__/company-skills-service.test.ts server/src/__tests__/routines-service.test.ts ui/src/components/folders/FolderControls.test.tsx ui/src/components/folders/SkillFolderTree.test.tsx ui/src/components/folders/skill-folder-tree.test.ts ui/src/pages/CompanySkills.test.tsx ui/src/pages/Routines.test.tsx ui/src/pages/SkillStudio.test.tsx ui/src/lib/company-skill-routes.test.ts ui/src/lib/skill-create.test.ts` — 13 files, 192 tests passed. - `pnpm exec vitest run ui/src/pages/CompanySkills.test.tsx ui/src/components/folders/SkillFolderTree.test.tsx` — 2 files, 20 tests passed after preserving the existing PR's bundled-skill fixes. - `pnpm -r typecheck` — passed for all workspace packages. - `pnpm test:run` — passed in an isolated CI-like environment with inherited Paperclip runtime identity and static AWS credential variables removed. - `pnpm build` — production build passed for all workspace packages. - Greptile iteration 2 — 5/5 confidence with zero unresolved threads on commit `ff2d67aa71`. - Latest-head GitHub checks — all success, neutral, or skipped; PR is mergeable with a clean merge state. - `pnpm check:token-gates` — reports nine existing `#9627` comment false positives already present on `master`; this PR introduces no new token violation. ## Risks - **Migration/backfill:** `0174` creates the foundation and `0175` adds nested/reserved semantics. Both are ordered after current master migration `0173`, are covered by numbering/safety checks, and are designed to be reapply-safe. - **Reserved namespaces:** My, Projects, and Bundled roots are service-managed. Regression coverage prevents namespace squatting, cross-company folder use, bundled writes, cycles, and excessive depth. - **Behavioral change:** Project scans choose a project folder only on initial creation; existing skills deliberately retain their current folder during refresh. - **UI scope:** The folder rail applies to the Installed library; Catalog retains the discovery-oriented category sidebar. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI `gpt-5.5` in Codex CLI, medium reasoning mode; runtime did not expose a context-window value. Used repository/file tools, terminal execution, Git/GitHub operations, test execution, and code editing. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
1d2b6af5ac
commit
52aea90263
|
|
@ -0,0 +1,46 @@
|
|||
CREATE TABLE IF NOT EXISTS "folders" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"color" text,
|
||||
"position" integer DEFAULT 0 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "company_skills" ADD COLUMN IF NOT EXISTS "folder_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "routines" ADD COLUMN IF NOT EXISTS "folder_id" uuid;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'folders_company_id_companies_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "folders" ADD CONSTRAINT "folders_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'company_skills_folder_id_folders_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "company_skills" ADD CONSTRAINT "company_skills_folder_id_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."folders"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'routines_folder_id_folders_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "routines" ADD CONSTRAINT "routines_folder_id_folders_id_fk" FOREIGN KEY ("folder_id") REFERENCES "public"."folders"("id") ON DELETE set null ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "folders_company_kind_position_idx" ON "folders" USING btree ("company_id","kind","position","name");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "folders_company_kind_name_uq" ON "folders" USING btree ("company_id","kind","name");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "company_skills_company_folder_idx" ON "company_skills" USING btree ("company_id","folder_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "routines_company_folder_idx" ON "routines" USING btree ("company_id","folder_id");
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
ALTER TABLE "folders" ADD COLUMN IF NOT EXISTS "parent_id" uuid;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "folders" ADD COLUMN IF NOT EXISTS "slug" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "folders" ADD COLUMN IF NOT EXISTS "system_key" text;
|
||||
--> statement-breakpoint
|
||||
WITH normalized AS (
|
||||
SELECT
|
||||
"id",
|
||||
COALESCE(
|
||||
NULLIF(TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER("name"), '[^a-z0-9]+', '-', 'g')), ''),
|
||||
'folder'
|
||||
) AS base_slug,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY "company_id", "kind", COALESCE(
|
||||
NULLIF(TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER("name"), '[^a-z0-9]+', '-', 'g')), ''),
|
||||
'folder'
|
||||
)
|
||||
ORDER BY "id"
|
||||
) AS duplicate_number
|
||||
FROM "folders"
|
||||
WHERE "slug" IS NULL
|
||||
)
|
||||
UPDATE "folders" AS folder
|
||||
SET "slug" = CASE
|
||||
WHEN normalized.duplicate_number = 1 THEN normalized.base_slug
|
||||
ELSE normalized.base_slug || '-' || LEFT(folder."id"::text, 8)
|
||||
END
|
||||
FROM normalized
|
||||
WHERE folder."id" = normalized."id";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "folders" ALTER COLUMN "slug" SET NOT NULL;
|
||||
--> statement-breakpoint
|
||||
DROP INDEX IF EXISTS "folders_company_kind_name_uq";
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM "pg_constraint" WHERE "conname" = 'folders_parent_id_folders_id_fk'
|
||||
) THEN
|
||||
ALTER TABLE "folders" ADD CONSTRAINT "folders_parent_id_folders_id_fk"
|
||||
FOREIGN KEY ("parent_id") REFERENCES "public"."folders"("id") ON DELETE restrict ON UPDATE no action;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "folders" DROP CONSTRAINT IF EXISTS "folders_company_kind_parent_slug_uq";
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "folders_company_kind_root_slug_uq"
|
||||
ON "folders" USING btree ("company_id", "kind", "slug")
|
||||
WHERE "parent_id" IS NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "folders_company_kind_parent_slug_uq"
|
||||
ON "folders" USING btree ("company_id", "kind", "parent_id", "slug")
|
||||
WHERE "parent_id" IS NOT NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "folders_company_kind_system_key_uq"
|
||||
ON "folders" USING btree ("company_id", "kind", "system_key")
|
||||
WHERE "system_key" IS NOT NULL;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "folders_company_kind_parent_position_idx"
|
||||
ON "folders" USING btree ("company_id", "kind", "parent_id", "position", "name");
|
||||
--> statement-breakpoint
|
||||
UPDATE "folders" AS folder
|
||||
SET "slug" = 'bundled-' || LEFT(REPLACE(folder."id"::text, '-', ''), 8), "updated_at" = now()
|
||||
WHERE folder."kind" = 'skill'
|
||||
AND folder."parent_id" IS NULL
|
||||
AND folder."slug" = 'bundled'
|
||||
AND folder."system_key" IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "company_skills" AS skill
|
||||
WHERE skill."company_id" = folder."company_id"
|
||||
AND skill."folder_id" IS NULL
|
||||
AND (skill."key" LIKE 'paperclipai/bundled/%' OR skill."metadata"->>'sourceKind' = 'paperclip_bundled')
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "folders" ("company_id", "kind", "parent_id", "name", "slug", "system_key", "position")
|
||||
SELECT DISTINCT skill."company_id", 'skill', NULL::uuid, 'Bundled', 'bundled', 'bundled', 0
|
||||
FROM "company_skills" AS skill
|
||||
WHERE skill."folder_id" IS NULL
|
||||
AND (skill."key" LIKE 'paperclipai/bundled/%' OR skill."metadata"->>'sourceKind' = 'paperclip_bundled')
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "folders" AS folder
|
||||
WHERE folder."company_id" = skill."company_id"
|
||||
AND folder."kind" = 'skill'
|
||||
AND folder."system_key" = 'bundled'
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
WITH bundled_categories AS (
|
||||
SELECT DISTINCT ON (skill."company_id", category.slug)
|
||||
skill."company_id",
|
||||
category.name,
|
||||
category.slug
|
||||
FROM "company_skills" AS skill
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT
|
||||
COALESCE(NULLIF(SPLIT_PART(skill."key", '/', 3), ''), 'other') AS name,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER(COALESCE(NULLIF(SPLIT_PART(skill."key", '/', 3), ''), 'other')), '[^a-z0-9]+', '-', 'g')), ''),
|
||||
'other'
|
||||
) AS slug
|
||||
) AS category
|
||||
WHERE skill."folder_id" IS NULL
|
||||
AND (skill."key" LIKE 'paperclipai/bundled/%' OR skill."metadata"->>'sourceKind' = 'paperclip_bundled')
|
||||
ORDER BY skill."company_id", category.slug, category.name
|
||||
)
|
||||
INSERT INTO "folders" ("company_id", "kind", "parent_id", "name", "slug", "system_key", "position")
|
||||
SELECT category."company_id", 'skill', root."id", category."name", category."slug", 'bundled:' || category."slug", 0
|
||||
FROM bundled_categories AS category
|
||||
JOIN "folders" AS root
|
||||
ON root."company_id" = category."company_id"
|
||||
AND root."kind" = 'skill'
|
||||
AND root."system_key" = 'bundled'
|
||||
ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
UPDATE "company_skills" AS skill
|
||||
SET "folder_id" = category_folder."id", "updated_at" = now()
|
||||
FROM "folders" AS root
|
||||
JOIN "folders" AS category_folder
|
||||
ON category_folder."company_id" = root."company_id"
|
||||
AND category_folder."kind" = 'skill'
|
||||
AND category_folder."parent_id" = root."id"
|
||||
WHERE skill."company_id" = root."company_id"
|
||||
AND root."kind" = 'skill'
|
||||
AND root."system_key" = 'bundled'
|
||||
AND skill."folder_id" IS NULL
|
||||
AND (skill."key" LIKE 'paperclipai/bundled/%' OR skill."metadata"->>'sourceKind' = 'paperclip_bundled')
|
||||
AND category_folder."system_key" = 'bundled:' || COALESCE(
|
||||
NULLIF(TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER(COALESCE(NULLIF(SPLIT_PART(skill."key", '/', 3), ''), 'other')), '[^a-z0-9]+', '-', 'g')), ''),
|
||||
'other'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
UPDATE "folders" AS folder
|
||||
SET "slug" = 'projects-' || LEFT(REPLACE(folder."id"::text, '-', ''), 8), "updated_at" = now()
|
||||
WHERE folder."kind" = 'skill'
|
||||
AND folder."parent_id" IS NULL
|
||||
AND folder."slug" = 'projects'
|
||||
AND folder."system_key" IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM "company_skills" AS skill
|
||||
WHERE skill."company_id" = folder."company_id"
|
||||
AND skill."folder_id" IS NULL
|
||||
AND skill."metadata"->>'sourceKind' = 'project_scan'
|
||||
AND skill."metadata"->>'projectId' IS NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO "folders" ("company_id", "kind", "parent_id", "name", "slug", "system_key", "position")
|
||||
SELECT DISTINCT skill."company_id", 'skill', NULL::uuid, 'Projects', 'projects', 'projects', 1
|
||||
FROM "company_skills" AS skill
|
||||
WHERE skill."folder_id" IS NULL
|
||||
AND skill."metadata"->>'sourceKind' = 'project_scan'
|
||||
AND skill."metadata"->>'projectId' IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "folders" AS folder
|
||||
WHERE folder."company_id" = skill."company_id"
|
||||
AND folder."kind" = 'skill'
|
||||
AND folder."system_key" = 'projects'
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
WITH project_sources AS (
|
||||
SELECT DISTINCT ON (skill."company_id", skill."metadata"->>'projectId')
|
||||
skill."company_id",
|
||||
skill."metadata"->>'projectId' AS project_id,
|
||||
COALESCE(project."name", NULLIF(skill."metadata"->>'projectName', ''), 'Project') AS project_name
|
||||
FROM "company_skills" AS skill
|
||||
LEFT JOIN "projects" AS project
|
||||
ON project."id"::text = skill."metadata"->>'projectId'
|
||||
AND project."company_id" = skill."company_id"
|
||||
WHERE skill."folder_id" IS NULL
|
||||
AND skill."metadata"->>'sourceKind' = 'project_scan'
|
||||
AND skill."metadata"->>'projectId' IS NOT NULL
|
||||
ORDER BY skill."company_id", skill."metadata"->>'projectId', skill."id"
|
||||
), project_folders AS (
|
||||
SELECT
|
||||
source."company_id",
|
||||
source.project_id,
|
||||
source.project_name,
|
||||
container."id" AS parent_id,
|
||||
COALESCE(
|
||||
NULLIF(TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER(source.project_name), '[^a-z0-9]+', '-', 'g')), ''),
|
||||
'project'
|
||||
) AS base_slug
|
||||
FROM project_sources AS source
|
||||
JOIN "folders" AS container
|
||||
ON container."company_id" = source."company_id"
|
||||
AND container."kind" = 'skill'
|
||||
AND container."system_key" = 'projects'
|
||||
), ranked_project_folders AS (
|
||||
SELECT
|
||||
source.*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY source."company_id", source.parent_id, source.base_slug
|
||||
ORDER BY source.project_id
|
||||
) AS duplicate_number
|
||||
FROM project_folders AS source
|
||||
)
|
||||
INSERT INTO "folders" ("company_id", "kind", "parent_id", "name", "slug", "system_key", "position")
|
||||
SELECT
|
||||
source."company_id",
|
||||
'skill',
|
||||
source.parent_id,
|
||||
source.project_name,
|
||||
CASE
|
||||
WHEN sibling."id" IS NULL AND source.duplicate_number = 1 THEN source.base_slug
|
||||
ELSE source.base_slug || '-' || LEFT(source.project_id, 8)
|
||||
END,
|
||||
'project:' || source.project_id,
|
||||
0
|
||||
FROM ranked_project_folders AS source
|
||||
LEFT JOIN "folders" AS sibling
|
||||
ON sibling."company_id" = source."company_id"
|
||||
AND sibling."kind" = 'skill'
|
||||
AND sibling."parent_id" = source.parent_id
|
||||
AND sibling."slug" = source.base_slug
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM "folders" AS existing
|
||||
WHERE existing."company_id" = source."company_id"
|
||||
AND existing."kind" = 'skill'
|
||||
AND existing."system_key" = 'project:' || source.project_id
|
||||
)
|
||||
ON CONFLICT DO NOTHING;
|
||||
--> statement-breakpoint
|
||||
UPDATE "company_skills" AS skill
|
||||
SET "folder_id" = project_folder."id", "updated_at" = now()
|
||||
FROM "folders" AS project_folder
|
||||
WHERE project_folder."company_id" = skill."company_id"
|
||||
AND project_folder."kind" = 'skill'
|
||||
AND project_folder."system_key" = 'project:' || (skill."metadata"->>'projectId')
|
||||
AND skill."folder_id" IS NULL
|
||||
AND skill."metadata"->>'sourceKind' = 'project_scan';
|
||||
|
|
@ -1205,6 +1205,20 @@
|
|||
"when": 1784210753027,
|
||||
"tag": "0173_inbox_policy_agent_cleanup",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 174,
|
||||
"version": "7",
|
||||
"when": 1784210754027,
|
||||
"tag": "0174_folders",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 175,
|
||||
"version": "7",
|
||||
"when": 1784210755027,
|
||||
"tag": "0175_nested_skill_folders",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import { createHash, randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import postgres from "postgres";
|
||||
import { applyPendingMigrations } from "./client.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./test-embedded-postgres.js";
|
||||
|
||||
const MIGRATION_FILE = "0175_nested_skill_folders.sql";
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
async function migrationHash() {
|
||||
const content = await fs.promises.readFile(new URL(`./migrations/${MIGRATION_FILE}`, import.meta.url), "utf8");
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("nested skill folders migration", () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(cleanups.splice(0).map((cleanup) => cleanup()));
|
||||
});
|
||||
|
||||
it("backfills existing folders, bundled skills, and project scan skills", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("paperclip-nested-folders-migration-");
|
||||
cleanups.push(database.cleanup);
|
||||
const sql = postgres(database.connectionString, { max: 1 });
|
||||
cleanups.push(async () => sql.end());
|
||||
|
||||
await sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${await migrationHash()}`;
|
||||
await sql`DROP INDEX IF EXISTS "folders_company_kind_parent_position_idx"`;
|
||||
await sql`DROP INDEX IF EXISTS "folders_company_kind_system_key_uq"`;
|
||||
await sql`DROP INDEX IF EXISTS "folders_company_kind_root_slug_uq"`;
|
||||
await sql`DROP INDEX IF EXISTS "folders_company_kind_parent_slug_uq"`;
|
||||
await sql`ALTER TABLE "folders" DROP CONSTRAINT IF EXISTS "folders_company_kind_parent_slug_uq"`;
|
||||
await sql`ALTER TABLE "folders" DROP CONSTRAINT IF EXISTS "folders_parent_id_folders_id_fk"`;
|
||||
await sql`ALTER TABLE "folders" DROP COLUMN IF EXISTS "system_key"`;
|
||||
await sql`ALTER TABLE "folders" DROP COLUMN IF EXISTS "slug"`;
|
||||
await sql`ALTER TABLE "folders" DROP COLUMN IF EXISTS "parent_id"`;
|
||||
await sql`CREATE UNIQUE INDEX IF NOT EXISTS "folders_company_kind_name_uq" ON "folders" ("company_id", "kind", "name")`;
|
||||
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const existingFolderId = randomUUID();
|
||||
const squattedBundledId = randomUUID();
|
||||
const squattedProjectsId = randomUUID();
|
||||
const bundledSkillId = randomUUID();
|
||||
const projectSkillId = randomUUID();
|
||||
const unfiledSkillId = randomUUID();
|
||||
await sql`
|
||||
INSERT INTO "companies" ("id", "name", "issue_prefix")
|
||||
VALUES (${companyId}, 'Paperclip', 'PAP')
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO "projects" ("id", "company_id", "name")
|
||||
VALUES (${projectId}, ${companyId}, 'Agent Platform')
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO "folders" ("id", "company_id", "kind", "name", "position")
|
||||
VALUES
|
||||
(${existingFolderId}, ${companyId}, 'skill', 'Team Notes', 0),
|
||||
(${squattedBundledId}, ${companyId}, 'skill', 'Bundled', 1),
|
||||
(${squattedProjectsId}, ${companyId}, 'skill', 'Projects', 2)
|
||||
`;
|
||||
await sql`
|
||||
INSERT INTO "company_skills" ("id", "company_id", "key", "slug", "name", "markdown", "metadata")
|
||||
VALUES
|
||||
(${bundledSkillId}, ${companyId}, 'paperclipai/bundled/software-development/review', 'review', 'Review', '# Review', '{"sourceKind":"paperclip_bundled"}'::jsonb),
|
||||
(${projectSkillId}, ${companyId}, 'company/project-skill', 'project-skill', 'Project Skill', '# Project', ${sql.json({ sourceKind: "project_scan", projectId, projectName: "Agent Platform" })}),
|
||||
(${unfiledSkillId}, ${companyId}, 'company/unfiled', 'unfiled', 'Unfiled', '# Unfiled', '{}'::jsonb)
|
||||
`;
|
||||
|
||||
await applyPendingMigrations(database.connectionString);
|
||||
|
||||
const folderRows = await sql<{
|
||||
id: string;
|
||||
parent_id: string | null;
|
||||
slug: string;
|
||||
system_key: string | null;
|
||||
}[]>`
|
||||
SELECT "id", "parent_id", "slug", "system_key"
|
||||
FROM "folders"
|
||||
WHERE "company_id" = ${companyId}
|
||||
ORDER BY "slug"
|
||||
`;
|
||||
expect(folderRows).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ id: existingFolderId, slug: "team-notes", parent_id: null }),
|
||||
expect.objectContaining({ id: squattedBundledId, slug: `bundled-${squattedBundledId.replace(/-/g, "").slice(0, 8)}`, system_key: null }),
|
||||
expect.objectContaining({ id: squattedProjectsId, slug: `projects-${squattedProjectsId.replace(/-/g, "").slice(0, 8)}`, system_key: null }),
|
||||
expect.objectContaining({ slug: "bundled", system_key: "bundled", parent_id: null }),
|
||||
expect.objectContaining({ slug: "software-development", system_key: "bundled:software-development" }),
|
||||
expect.objectContaining({ slug: "projects", system_key: "projects", parent_id: null }),
|
||||
expect.objectContaining({ slug: "agent-platform", system_key: `project:${projectId}` }),
|
||||
]));
|
||||
|
||||
const skills = await sql<{ id: string; folder_id: string | null; folder_slug: string | null }[]>`
|
||||
SELECT skill."id", skill."folder_id", folder."slug" AS folder_slug
|
||||
FROM "company_skills" AS skill
|
||||
LEFT JOIN "folders" AS folder ON folder."id" = skill."folder_id"
|
||||
WHERE skill."company_id" = ${companyId}
|
||||
ORDER BY skill."id"
|
||||
`;
|
||||
expect(skills.find((skill) => skill.id === bundledSkillId)).toMatchObject({ folder_slug: "software-development" });
|
||||
expect(skills.find((skill) => skill.id === projectSkillId)).toMatchObject({ folder_slug: "agent-platform" });
|
||||
expect(skills.find((skill) => skill.id === unfiledSkillId)).toMatchObject({ folder_id: null, folder_slug: null });
|
||||
|
||||
const indexes = await sql<{ indexname: string; indexdef: string }[]>`
|
||||
SELECT "indexname", "indexdef"
|
||||
FROM "pg_indexes"
|
||||
WHERE "tablename" = 'folders'
|
||||
AND "indexname" IN ('folders_company_kind_root_slug_uq', 'folders_company_kind_parent_slug_uq')
|
||||
ORDER BY "indexname"
|
||||
`;
|
||||
expect(indexes).toHaveLength(2);
|
||||
expect(indexes.map((index) => index.indexdef).join("\n")).not.toContain("NULLS NOT DISTINCT");
|
||||
expect(indexes.find((index) => index.indexname === "folders_company_kind_root_slug_uq")?.indexdef)
|
||||
.toContain("WHERE (parent_id IS NULL)");
|
||||
expect(indexes.find((index) => index.indexname === "folders_company_kind_parent_slug_uq")?.indexdef)
|
||||
.toContain("WHERE (parent_id IS NOT NULL)");
|
||||
}, 30_000);
|
||||
});
|
||||
|
|
@ -13,12 +13,14 @@ import type { CompanySkillFileInventoryEntry, CompanySkillSharingScope } from "@
|
|||
import { agents } from "./agents.js";
|
||||
import { companies } from "./companies.js";
|
||||
import { issues } from "./issues.js";
|
||||
import { folders } from "./folders.js";
|
||||
|
||||
export const companySkills = pgTable(
|
||||
"company_skills",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id),
|
||||
folderId: uuid("folder_id").references(() => folders.id, { onDelete: "set null" }),
|
||||
key: text("key").notNull(),
|
||||
slug: text("slug").notNull(),
|
||||
name: text("name").notNull(),
|
||||
|
|
@ -51,6 +53,7 @@ export const companySkills = pgTable(
|
|||
(table) => ({
|
||||
companyKeyUniqueIdx: uniqueIndex("company_skills_company_key_idx").on(table.companyId, table.key),
|
||||
companyNameIdx: index("company_skills_company_name_idx").on(table.companyId, table.name),
|
||||
companyFolderIdx: index("company_skills_company_folder_idx").on(table.companyId, table.folderId),
|
||||
companyCategoriesIdx: index("company_skills_company_categories_idx").using("gin", table.categories),
|
||||
companySharingScopeIdx: index("company_skills_company_sharing_scope_idx").on(table.companyId, table.sharingScope),
|
||||
companyCurrentVersionIdx: index("company_skills_company_current_version_idx").on(table.companyId, table.currentVersionId),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import {
|
||||
type AnyPgColumn,
|
||||
index,
|
||||
integer,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { companies } from "./companies.js";
|
||||
import type { FolderKind } from "@paperclipai/shared";
|
||||
|
||||
export const folders = pgTable(
|
||||
"folders",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
kind: text("kind").$type<FolderKind>().notNull(),
|
||||
parentId: uuid("parent_id").references((): AnyPgColumn => folders.id, { onDelete: "restrict" }),
|
||||
name: text("name").notNull(),
|
||||
slug: text("slug").notNull(),
|
||||
systemKey: text("system_key"),
|
||||
color: text("color"),
|
||||
position: integer("position").notNull().default(0),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyKindPositionIdx: index("folders_company_kind_position_idx").on(
|
||||
table.companyId,
|
||||
table.kind,
|
||||
table.position,
|
||||
table.name,
|
||||
),
|
||||
companyKindRootSlugUniqueIdx: uniqueIndex("folders_company_kind_root_slug_uq")
|
||||
.on(table.companyId, table.kind, table.slug)
|
||||
.where(sql`${table.parentId} is null`),
|
||||
companyKindParentSlugUniqueIdx: uniqueIndex("folders_company_kind_parent_slug_uq")
|
||||
.on(table.companyId, table.kind, table.parentId, table.slug)
|
||||
.where(sql`${table.parentId} is not null`),
|
||||
companyKindSystemKeyUniqueIdx: uniqueIndex("folders_company_kind_system_key_uq")
|
||||
.on(table.companyId, table.kind, table.systemKey)
|
||||
.where(sql`${table.systemKey} is not null`),
|
||||
companyKindParentPositionIdx: index("folders_company_kind_parent_position_idx").on(
|
||||
table.companyId,
|
||||
table.kind,
|
||||
table.parentId,
|
||||
table.position,
|
||||
table.name,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
|
@ -35,6 +35,7 @@ export { workspaceOperations } from "./workspace_operations.js";
|
|||
export { workspaceRuntimeServices } from "./workspace_runtime_services.js";
|
||||
export { projectGoals } from "./project_goals.js";
|
||||
export { goals } from "./goals.js";
|
||||
export { folders } from "./folders.js";
|
||||
export { issues } from "./issues.js";
|
||||
export { issueWatchdogs } from "./issue_watchdogs.js";
|
||||
export { issuePlanDecompositions } from "./issue_plan_decompositions.js";
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { issues } from "./issues.js";
|
|||
import { projects } from "./projects.js";
|
||||
import { goals } from "./goals.js";
|
||||
import { heartbeatRuns } from "./heartbeat_runs.js";
|
||||
import { folders } from "./folders.js";
|
||||
import type { RoutineEnvConfig, RoutineRevisionSnapshotV1, RoutineVariable } from "@paperclipai/shared";
|
||||
|
||||
export const routines = pgTable(
|
||||
|
|
@ -25,6 +26,7 @@ export const routines = pgTable(
|
|||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
projectId: uuid("project_id").references(() => projects.id, { onDelete: "cascade" }),
|
||||
folderId: uuid("folder_id").references(() => folders.id, { onDelete: "set null" }),
|
||||
goalId: uuid("goal_id").references(() => goals.id, { onDelete: "set null" }),
|
||||
parentIssueId: uuid("parent_issue_id").references(() => issues.id, { onDelete: "set null" }),
|
||||
title: text("title").notNull(),
|
||||
|
|
@ -56,6 +58,7 @@ export const routines = pgTable(
|
|||
companyStatusIdx: index("routines_company_status_idx").on(table.companyId, table.status),
|
||||
companyAssigneeIdx: index("routines_company_assignee_idx").on(table.companyId, table.assigneeAgentId),
|
||||
companyProjectIdx: index("routines_company_project_idx").on(table.companyId, table.projectId),
|
||||
companyFolderIdx: index("routines_company_folder_idx").on(table.companyId, table.folderId),
|
||||
companyResponsibleUserIdx: index("routines_company_responsible_user_idx").on(table.companyId, table.responsibleUserId),
|
||||
companyOriginIdx: index("routines_company_origin_idx").on(table.companyId, table.originKind, table.originId),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ export const API_PREFIX = "/api";
|
|||
export const API = {
|
||||
health: `${API_PREFIX}/health`,
|
||||
companies: `${API_PREFIX}/companies`,
|
||||
companyFolders: `${API_PREFIX}/companies/:companyId/folders`,
|
||||
companyFolder: `${API_PREFIX}/companies/:companyId/folders/:folderId`,
|
||||
companyFolderMove: `${API_PREFIX}/companies/:companyId/folders/:folderId/move`,
|
||||
companyFolderItemMove: `${API_PREFIX}/companies/:companyId/folders/items/move`,
|
||||
agents: `${API_PREFIX}/agents`,
|
||||
projects: `${API_PREFIX}/projects`,
|
||||
environments: `${API_PREFIX}/environments`,
|
||||
|
|
|
|||
|
|
@ -2101,12 +2101,40 @@ export type {
|
|||
} from "./environment-support.js";
|
||||
|
||||
export type { AdapterRegistryEntry } from "./types/adapter-registry.js";
|
||||
export type {
|
||||
FolderKind,
|
||||
Folder,
|
||||
FolderListItem,
|
||||
FolderListResult,
|
||||
CreateFolderRequest,
|
||||
UpdateFolderRequest,
|
||||
MoveFolderRequest,
|
||||
MoveFolderItemRequest,
|
||||
EnsureMySkillFolderRequest,
|
||||
} from "./types/folder.js";
|
||||
|
||||
export {
|
||||
adapterRegistryEntrySchema,
|
||||
adapterRegistrySchema,
|
||||
type AdapterRegistryEntryParsed,
|
||||
} from "./validators/adapter-registry.js";
|
||||
export {
|
||||
folderKindSchema,
|
||||
folderSlugSchema,
|
||||
folderSchema,
|
||||
folderListItemSchema,
|
||||
folderListResultSchema,
|
||||
createFolderSchema,
|
||||
updateFolderSchema,
|
||||
moveFolderSchema,
|
||||
moveFolderItemSchema,
|
||||
ensureMySkillFolderSchema,
|
||||
type CreateFolder,
|
||||
type UpdateFolder,
|
||||
type MoveFolder,
|
||||
type MoveFolderItem,
|
||||
type EnsureMySkillFolder,
|
||||
} from "./validators/folder.js";
|
||||
|
||||
export {
|
||||
environmentCustomImageTemplateKindSchema,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ export interface CompanySkillVersionFileInventoryEntry extends CompanySkillFileI
|
|||
export interface CompanySkill {
|
||||
id: string;
|
||||
companyId: string;
|
||||
folderId?: string | null;
|
||||
folderPath?: string | null;
|
||||
key: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
|
|
@ -67,6 +69,8 @@ export interface CompanySkill {
|
|||
export interface CompanySkillListItem {
|
||||
id: string;
|
||||
companyId: string;
|
||||
folderId?: string | null;
|
||||
folderPath?: string | null;
|
||||
key: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
|
|
@ -140,6 +144,8 @@ export interface CompanySkillListQuery {
|
|||
categories?: string[];
|
||||
scope?: CompanySkillSharingScope;
|
||||
include?: CompanySkillListInclude[];
|
||||
folderId?: string;
|
||||
includeSubtree?: boolean;
|
||||
}
|
||||
|
||||
export interface CompanySkillCategoryCount {
|
||||
|
|
@ -374,6 +380,7 @@ export interface CompanySkillProjectScanResult {
|
|||
}
|
||||
|
||||
export interface CompanySkillCreateRequest {
|
||||
folderId?: string | null;
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
export type FolderKind = "routine" | "skill";
|
||||
|
||||
export interface Folder {
|
||||
id: string;
|
||||
companyId: string;
|
||||
kind: FolderKind;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
systemKey: string | null;
|
||||
path: string;
|
||||
depth: number;
|
||||
color: string | null;
|
||||
position: number;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface FolderListItem extends Folder {
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
export interface FolderListResult {
|
||||
kind: FolderKind;
|
||||
folders: FolderListItem[];
|
||||
allCount: number;
|
||||
unfiledCount: number;
|
||||
}
|
||||
|
||||
export interface CreateFolderRequest {
|
||||
kind: FolderKind;
|
||||
parentId?: string | null;
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
color?: string | null;
|
||||
position?: number | null;
|
||||
}
|
||||
|
||||
export interface UpdateFolderRequest {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
color?: string | null;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface MoveFolderRequest {
|
||||
parentId?: string | null;
|
||||
position: number;
|
||||
}
|
||||
|
||||
export interface EnsureMySkillFolderRequest {
|
||||
slug?: string | null;
|
||||
}
|
||||
|
||||
export interface MoveFolderItemRequest {
|
||||
kind: FolderKind;
|
||||
itemId: string;
|
||||
folderId?: string | null;
|
||||
}
|
||||
|
|
@ -163,6 +163,17 @@ export type {
|
|||
CompanySkillInstallCatalogRequest,
|
||||
CompanySkillInstallCatalogResult,
|
||||
} from "./company-skill.js";
|
||||
export type {
|
||||
FolderKind,
|
||||
Folder,
|
||||
FolderListItem,
|
||||
FolderListResult,
|
||||
CreateFolderRequest,
|
||||
UpdateFolderRequest,
|
||||
MoveFolderRequest,
|
||||
MoveFolderItemRequest,
|
||||
EnsureMySkillFolderRequest,
|
||||
} from "./folder.js";
|
||||
export type {
|
||||
CatalogTeamKind,
|
||||
CatalogTeamTrustLevel,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export interface Routine {
|
|||
id: string;
|
||||
companyId: string;
|
||||
projectId: string | null;
|
||||
folderId?: string | null;
|
||||
goalId: string | null;
|
||||
parentIssueId: string | null;
|
||||
title: string;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ export const companySkillVersionFileInventoryEntrySchema = companySkillFileInven
|
|||
export const companySkillSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
folderId: z.string().uuid().nullable().optional(),
|
||||
folderPath: z.string().nullable().optional(),
|
||||
key: z.string().min(1),
|
||||
slug: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
|
|
@ -106,6 +108,8 @@ export const companySkillListQuerySchema = z.object({
|
|||
categories: z.array(z.string().min(1)).optional(),
|
||||
scope: companySkillSharingScopeSchema.optional(),
|
||||
include: z.array(companySkillListIncludeSchema).optional(),
|
||||
folderId: z.string().uuid().optional(),
|
||||
includeSubtree: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const companySkillCategoryCountSchema = z.object({
|
||||
|
|
@ -320,6 +324,7 @@ export const companySkillProjectScanResultSchema = z.object({
|
|||
});
|
||||
|
||||
export const companySkillCreateSchema = z.object({
|
||||
folderId: z.string().uuid().nullable().optional(),
|
||||
name: z.string().min(1),
|
||||
slug: z.string().min(1).nullable().optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const folderKindSchema = z.enum(["routine", "skill"]);
|
||||
export const folderSlugSchema = z.string().trim().min(1).max(120).regex(
|
||||
/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
||||
"Folder slug must contain only lowercase letters, numbers, and single hyphens",
|
||||
);
|
||||
|
||||
export const folderSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
kind: folderKindSchema,
|
||||
parentId: z.string().uuid().nullable(),
|
||||
name: z.string().min(1),
|
||||
slug: folderSlugSchema,
|
||||
systemKey: z.string().nullable(),
|
||||
path: z.string().min(1),
|
||||
depth: z.number().int().min(1),
|
||||
color: z.string().nullable(),
|
||||
position: z.number().int(),
|
||||
createdAt: z.coerce.date(),
|
||||
updatedAt: z.coerce.date(),
|
||||
});
|
||||
|
||||
export const folderListItemSchema = folderSchema.extend({
|
||||
itemCount: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const folderListResultSchema = z.object({
|
||||
kind: folderKindSchema,
|
||||
folders: z.array(folderListItemSchema),
|
||||
allCount: z.number().int().nonnegative(),
|
||||
unfiledCount: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const createFolderSchema = z.object({
|
||||
kind: folderKindSchema,
|
||||
parentId: z.string().uuid().optional().nullable(),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
slug: folderSlugSchema.optional().nullable(),
|
||||
color: z.string().trim().min(1).max(80).optional().nullable(),
|
||||
position: z.number().int().min(0).optional().nullable(),
|
||||
});
|
||||
|
||||
export const updateFolderSchema = z.object({
|
||||
name: z.string().trim().min(1).max(120).optional(),
|
||||
slug: folderSlugSchema.optional(),
|
||||
color: z.string().trim().min(1).max(80).optional().nullable(),
|
||||
position: z.number().int().min(0).optional(),
|
||||
}).refine((value) => Object.keys(value).length > 0, {
|
||||
message: "At least one folder field is required",
|
||||
});
|
||||
|
||||
export const moveFolderSchema = z.object({
|
||||
parentId: z.string().uuid().optional().nullable(),
|
||||
position: z.number().int().min(0),
|
||||
});
|
||||
|
||||
export const ensureMySkillFolderSchema = z.object({
|
||||
slug: folderSlugSchema.optional().nullable(),
|
||||
}).default({});
|
||||
|
||||
export const moveFolderItemSchema = z.object({
|
||||
kind: folderKindSchema,
|
||||
itemId: z.string().uuid(),
|
||||
folderId: z.string().uuid().optional().nullable(),
|
||||
});
|
||||
|
||||
export type CreateFolder = z.infer<typeof createFolderSchema>;
|
||||
export type UpdateFolder = z.infer<typeof updateFolderSchema>;
|
||||
export type MoveFolder = z.infer<typeof moveFolderSchema>;
|
||||
export type MoveFolderItem = z.infer<typeof moveFolderItemSchema>;
|
||||
export type EnsureMySkillFolder = z.infer<typeof ensureMySkillFolderSchema>;
|
||||
|
|
@ -194,6 +194,23 @@ export {
|
|||
type CompanySkillInstallUpdate,
|
||||
type CompanySkillReset,
|
||||
} from "./company-skill.js";
|
||||
export {
|
||||
folderKindSchema,
|
||||
folderSlugSchema,
|
||||
folderSchema,
|
||||
folderListItemSchema,
|
||||
folderListResultSchema,
|
||||
createFolderSchema,
|
||||
updateFolderSchema,
|
||||
moveFolderSchema,
|
||||
moveFolderItemSchema,
|
||||
ensureMySkillFolderSchema,
|
||||
type CreateFolder,
|
||||
type UpdateFolder,
|
||||
type MoveFolder,
|
||||
type MoveFolderItem,
|
||||
type EnsureMySkillFolder,
|
||||
} from "./folder.js";
|
||||
export {
|
||||
catalogTeamKindSchema,
|
||||
catalogTeamTrustLevelSchema,
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ export const routineVariableSchema = z.object({
|
|||
|
||||
export const createRoutineSchema = z.object({
|
||||
projectId: z.string().uuid().optional().nullable(),
|
||||
folderId: z.string().uuid().optional().nullable(),
|
||||
goalId: z.string().uuid().optional().nullable(),
|
||||
parentIssueId: z.string().uuid().optional().nullable(),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
|
|
@ -85,6 +86,7 @@ export const routineRevisionSnapshotRoutineV1Schema = z.object({
|
|||
id: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
projectId: z.string().uuid().nullable(),
|
||||
folderId: z.string().uuid().nullable().optional(),
|
||||
goalId: z.string().uuid().nullable(),
|
||||
parentIssueId: z.string().uuid().nullable(),
|
||||
title: z.string().trim().min(1).max(200),
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import path from "node:path";
|
|||
import { promises as fs } from "node:fs";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { companies, companySkills, createDb } from "@paperclipai/db";
|
||||
import { companies, companySkills, createDb, folders } from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
|
|
@ -132,6 +132,7 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
|
|||
|
||||
afterEach(async () => {
|
||||
await db.delete(companySkills);
|
||||
await db.delete(folders);
|
||||
await db.delete(companies);
|
||||
await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
cleanupDirs.clear();
|
||||
|
|
@ -154,6 +155,7 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
|
|||
expect(result.action).toBe("created");
|
||||
expect(result.skill).toMatchObject({
|
||||
companyId,
|
||||
folderId: expect.any(String),
|
||||
key: sampleCatalogSkill.key,
|
||||
slug: sampleCatalogSkill.slug,
|
||||
sourceType: "catalog",
|
||||
|
|
@ -182,6 +184,37 @@ describeEmbeddedPostgres("companySkillService.installFromCatalog", () => {
|
|||
packageName: "@paperclipai/skills-catalog",
|
||||
packageVersion: "0.3.1",
|
||||
});
|
||||
const folder = await db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(eq(folders.id, result.skill.folderId!))
|
||||
.then((rows) => rows[0]);
|
||||
expect(folder).toMatchObject({
|
||||
name: "Software Development",
|
||||
slug: "software-development",
|
||||
systemKey: "bundled:software-development",
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs an existing unfiled Paperclip catalog skill during inventory refresh", async () => {
|
||||
const companyId = await createCompany();
|
||||
const installed = await svc.installFromCatalog(companyId, { catalogSkillId: sampleCatalogSkill.id });
|
||||
await db
|
||||
.update(companySkills)
|
||||
.set({ folderId: null })
|
||||
.where(eq(companySkills.id, installed.skill.id));
|
||||
|
||||
const listed = await svc.list(companyId);
|
||||
const repaired = listed.find((skill) => skill.id === installed.skill.id);
|
||||
const folder = repaired?.folderId
|
||||
? await db.select().from(folders).where(eq(folders.id, repaired.folderId)).then((rows) => rows[0])
|
||||
: null;
|
||||
|
||||
expect(repaired?.folderId).toEqual(expect.any(String));
|
||||
expect(folder).toMatchObject({
|
||||
name: "Software Development",
|
||||
systemKey: "bundled:software-development",
|
||||
});
|
||||
});
|
||||
|
||||
it("materializes catalog asset files without UTF-8 rewriting", async () => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
companySkillVersions,
|
||||
companySkills,
|
||||
createDb,
|
||||
folders,
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
} from "@paperclipai/db";
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { companySkillService } from "../services/company-skills.ts";
|
||||
import { folderService } from "../services/folders.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
|
@ -63,6 +65,7 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
|||
await db.delete(companySkills);
|
||||
await db.delete(projectWorkspaces);
|
||||
await db.delete(projects);
|
||||
await db.delete(folders);
|
||||
await db.delete(companies);
|
||||
await db.delete(authUsers);
|
||||
await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true })));
|
||||
|
|
@ -310,6 +313,13 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
|||
const bundledSkill = initialList.find((skill) => skill.key.startsWith("paperclipai/paperclip/"));
|
||||
expect(bundledSkill).toBeDefined();
|
||||
if (!bundledSkill) throw new Error("Expected bundled Paperclip skills fixture");
|
||||
const bundledFolder = bundledSkill.folderId
|
||||
? await db.select().from(folders).where(eq(folders.id, bundledSkill.folderId)).then((rows) => rows[0])
|
||||
: null;
|
||||
expect(bundledFolder).toMatchObject({
|
||||
name: "Paperclip Core",
|
||||
systemKey: "bundled:paperclip-core",
|
||||
});
|
||||
|
||||
const preservedUpdatedAt = new Date("2026-01-01T00:00:00.000Z");
|
||||
await db
|
||||
|
|
@ -323,6 +333,34 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
|||
expect(refreshedSkill?.updatedAt.toISOString()).toBe(preservedUpdatedAt.toISOString());
|
||||
});
|
||||
|
||||
it("repairs a squatted bundled root during bundled-skill list refresh", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
const [squatted] = await db.insert(folders).values({
|
||||
companyId,
|
||||
kind: "skill",
|
||||
parentId: null,
|
||||
name: "User Bundled",
|
||||
slug: "bundled",
|
||||
position: 0,
|
||||
}).returning();
|
||||
|
||||
const listed = await svc.list(companyId);
|
||||
const folderRows = await db.select().from(folders).where(eq(folders.companyId, companyId));
|
||||
const bundledRoot = folderRows.find((folder) => folder.systemKey === "bundled");
|
||||
const repairedSquat = folderRows.find((folder) => folder.id === squatted!.id);
|
||||
|
||||
expect(listed.some((skill) => skill.key.startsWith("paperclipai/paperclip/"))).toBe(true);
|
||||
expect(bundledRoot).toMatchObject({ slug: "bundled", parentId: null, systemKey: "bundled" });
|
||||
expect(repairedSquat).toMatchObject({ name: "User Bundled", systemKey: null });
|
||||
expect(repairedSquat?.slug).toMatch(/^bundled-[a-f0-9]{8}$/);
|
||||
});
|
||||
|
||||
it("does not retouch bundled skills with stale missing-source metadata during list refresh", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
|
|
@ -686,6 +724,97 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
|||
await expect(svc.categoryCounts(companyId)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("filters by folder subtree, keeps search global, and returns canonical folder paths", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
const folderSvc = folderService(db);
|
||||
const engineering = await folderSvc.create(companyId, { kind: "skill", name: "Engineering" });
|
||||
const reviews = await folderSvc.create(companyId, { kind: "skill", parentId: engineering.id, name: "Reviews" });
|
||||
const operations = await folderSvc.create(companyId, { kind: "skill", name: "Operations" });
|
||||
|
||||
const reviewDir = await createManagedSkillDir(companyId, "review-");
|
||||
const deployDir = await createManagedSkillDir(companyId, "deploy-");
|
||||
await fs.writeFile(path.join(reviewDir, "SKILL.md"), "# Review\n", "utf8");
|
||||
await fs.writeFile(path.join(deployDir, "SKILL.md"), "# Deploy\n", "utf8");
|
||||
await db.insert(companySkills).values([
|
||||
{
|
||||
companyId,
|
||||
folderId: reviews.id,
|
||||
key: `company/${companyId}/review`,
|
||||
slug: "review",
|
||||
name: "Review",
|
||||
markdown: "# Review",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: reviewDir,
|
||||
categories: ["engineering"],
|
||||
},
|
||||
{
|
||||
companyId,
|
||||
folderId: operations.id,
|
||||
key: `company/${companyId}/deploy`,
|
||||
slug: "deploy",
|
||||
name: "Deploy",
|
||||
markdown: "# Deploy",
|
||||
sourceType: "local_path",
|
||||
sourceLocator: deployDir,
|
||||
categories: ["operations"],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(svc.list(companyId, {
|
||||
folderId: engineering.id,
|
||||
includeSubtree: true,
|
||||
categories: ["engineering"],
|
||||
})).resolves.toEqual([
|
||||
expect.objectContaining({ name: "Review", folderPath: "engineering/reviews" }),
|
||||
]);
|
||||
await expect(svc.list(companyId, { folderId: engineering.id })).resolves.toEqual([]);
|
||||
await expect(svc.list(companyId, { folderId: engineering.id, q: "deploy" })).resolves.toEqual([
|
||||
expect.objectContaining({ name: "Deploy", folderPath: "operations" }),
|
||||
]);
|
||||
const review = (await svc.list(companyId)).find((skill) => skill.name === "Review");
|
||||
await expect(svc.getById(companyId, review!.id)).resolves.toMatchObject({
|
||||
name: "Review",
|
||||
folderPath: "engineering/reviews",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates skills in same-company folders and rejects cross-company folder references", async () => {
|
||||
const companyId = randomUUID();
|
||||
const otherCompanyId = randomUUID();
|
||||
await db.insert(companies).values([
|
||||
{
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
},
|
||||
{
|
||||
id: otherCompanyId,
|
||||
name: "Other",
|
||||
issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
},
|
||||
]);
|
||||
const folderSvc = folderService(db);
|
||||
const folder = await folderSvc.create(companyId, { kind: "skill", name: "Personal" });
|
||||
const otherFolder = await folderSvc.create(otherCompanyId, { kind: "skill", name: "Private" });
|
||||
|
||||
await expect(svc.createLocalSkill(companyId, {
|
||||
name: "Filed Skill",
|
||||
folderId: folder.id,
|
||||
})).resolves.toMatchObject({ folderId: folder.id });
|
||||
await expect(svc.createLocalSkill(companyId, {
|
||||
name: "Cross Company Skill",
|
||||
folderId: otherFolder.id,
|
||||
})).rejects.toMatchObject({ status: 404, message: "Skill folder not found" });
|
||||
});
|
||||
|
||||
it("resolves detail by unique skill slug for Studio deep links", async () => {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
|
|
@ -2327,4 +2456,60 @@ describeEmbeddedPostgres("companySkillService.list", () => {
|
|||
const persisted = await db.select().from(companySkills).where(eq(companySkills.companyId, companyId));
|
||||
expect(persisted.filter((skill) => skill.metadata?.sourceKind === "project_scan")).toEqual([]);
|
||||
});
|
||||
|
||||
it("files new project imports without moving them back on re-import", async () => {
|
||||
const companyId = randomUUID();
|
||||
const projectId = randomUUID();
|
||||
const workspaceId = randomUUID();
|
||||
const folderSvc = folderService(db);
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skill-project-folder-"));
|
||||
cleanupDirs.add(workspaceDir);
|
||||
const skillDir = path.join(workspaceDir, "skills", "project-skill");
|
||||
const skillFile = path.join(skillDir, "SKILL.md");
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.writeFile(skillFile, "---\nname: Project Skill\n---\n\nInitial content.\n", "utf8");
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(projects).values({ id: projectId, companyId, name: "Skills Project" });
|
||||
await db.insert(projectWorkspaces).values({
|
||||
id: workspaceId,
|
||||
companyId,
|
||||
projectId,
|
||||
name: "Primary",
|
||||
cwd: workspaceDir,
|
||||
isPrimary: true,
|
||||
});
|
||||
|
||||
const firstImport = await svc.scanProjectWorkspaces(companyId, { projectIds: [projectId] });
|
||||
|
||||
expect(firstImport.imported).toHaveLength(1);
|
||||
const importedSkill = firstImport.imported[0]!;
|
||||
const projectFolder = await folderSvc.getFolder(companyId, importedSkill.folderId!);
|
||||
expect(projectFolder).toMatchObject({
|
||||
path: "projects/skills-project",
|
||||
systemKey: `project:${projectId}`,
|
||||
});
|
||||
|
||||
const personalFolder = await folderSvc.create(companyId, { kind: "skill", name: "Personal" });
|
||||
await folderSvc.moveItem(companyId, {
|
||||
kind: "skill",
|
||||
itemId: importedSkill.id,
|
||||
folderId: personalFolder.id,
|
||||
});
|
||||
await fs.writeFile(skillFile, "---\nname: Project Skill\n---\n\nUpdated content.\n", "utf8");
|
||||
|
||||
const reimport = await svc.scanProjectWorkspaces(companyId, { projectIds: [projectId] });
|
||||
|
||||
expect(reimport.updated).toHaveLength(1);
|
||||
expect(reimport.updated[0]).toMatchObject({
|
||||
id: importedSkill.id,
|
||||
folderId: personalFolder.id,
|
||||
markdown: expect.stringContaining("Updated content."),
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockFolderService = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
moveFolder: vi.fn(),
|
||||
moveItem: vi.fn(),
|
||||
deleteFolder: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
folderService: () => mockFolderService,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
async function createApp() {
|
||||
vi.resetModules();
|
||||
const [{ errorHandler }, { folderRoutes }] = await Promise.all([
|
||||
import("../middleware/index.js") as Promise<typeof import("../middleware/index.js")>,
|
||||
import("../routes/folders.js") as Promise<typeof import("../routes/folders.js")>,
|
||||
]);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
companyIds: ["company-1"],
|
||||
source: "session",
|
||||
isInstanceAdmin: false,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", folderRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe("folder routes", () => {
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mockFolderService)) mock.mockReset();
|
||||
mockLogActivity.mockReset();
|
||||
});
|
||||
|
||||
it("routes item moves to the item move handler before the folder reorder route", async () => {
|
||||
mockFolderService.moveItem.mockResolvedValue({
|
||||
kind: "routine",
|
||||
itemId: "11111111-1111-4111-8111-111111111111",
|
||||
folderId: "22222222-2222-4222-8222-222222222222",
|
||||
});
|
||||
|
||||
const app = await createApp();
|
||||
const res = await request(app)
|
||||
.post("/api/companies/company-1/folders/items/move")
|
||||
.send({
|
||||
kind: "routine",
|
||||
itemId: "11111111-1111-4111-8111-111111111111",
|
||||
folderId: "22222222-2222-4222-8222-222222222222",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockFolderService.moveItem).toHaveBeenCalledWith("company-1", {
|
||||
kind: "routine",
|
||||
itemId: "11111111-1111-4111-8111-111111111111",
|
||||
folderId: "22222222-2222-4222-8222-222222222222",
|
||||
});
|
||||
expect(mockFolderService.moveFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,447 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
folderSlugSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import {
|
||||
companies,
|
||||
companySkills,
|
||||
createDb,
|
||||
folders,
|
||||
routines,
|
||||
} from "@paperclipai/db";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
import { folderService } from "../services/folders.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
describeEmbeddedPostgres("folder service", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-folders-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(companySkills);
|
||||
await db.delete(routines);
|
||||
await db.delete(folders);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seedCompany() {
|
||||
const companyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
defaultResponsibleUserId: "responsible-user",
|
||||
});
|
||||
return companyId;
|
||||
}
|
||||
|
||||
async function seedRoutine(companyId: string, title: string, folderId?: string | null) {
|
||||
const [routine] = await db
|
||||
.insert(routines)
|
||||
.values({
|
||||
companyId,
|
||||
title,
|
||||
folderId: folderId ?? null,
|
||||
responsibleUserId: "responsible-user",
|
||||
})
|
||||
.returning();
|
||||
return routine!;
|
||||
}
|
||||
|
||||
async function seedSkill(companyId: string, slug: string, folderId?: string | null) {
|
||||
const [skill] = await db
|
||||
.insert(companySkills)
|
||||
.values({
|
||||
companyId,
|
||||
folderId: folderId ?? null,
|
||||
key: `company/${companyId}/${slug}`,
|
||||
slug,
|
||||
name: slug,
|
||||
markdown: `# ${slug}`,
|
||||
})
|
||||
.returning();
|
||||
return skill!;
|
||||
}
|
||||
|
||||
it("creates, updates, reorders, and lists routine folders with counts", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
|
||||
const reporting = await svc.create(companyId, {
|
||||
kind: "routine",
|
||||
name: "Reporting",
|
||||
color: "green",
|
||||
});
|
||||
const cleanup = await svc.create(companyId, {
|
||||
kind: "routine",
|
||||
name: "Cleanup",
|
||||
color: null,
|
||||
});
|
||||
await seedRoutine(companyId, "Filed", reporting.id);
|
||||
await seedRoutine(companyId, "Unfiled");
|
||||
|
||||
const renamed = await svc.update(companyId, cleanup.id, { name: "Ops", color: "cyan" });
|
||||
expect(renamed).toMatchObject({ id: cleanup.id, name: "Ops", color: "cyan" });
|
||||
const cleared = await svc.update(companyId, cleanup.id, { color: null });
|
||||
expect(cleared).toMatchObject({ id: cleanup.id, color: null });
|
||||
|
||||
const movedFolder = await svc.moveFolder(companyId, reporting.id, { position: 10 });
|
||||
expect(movedFolder).toMatchObject({ id: reporting.id, position: 10 });
|
||||
|
||||
const listed = await svc.list(companyId, "routine");
|
||||
expect(listed.allCount).toBe(2);
|
||||
expect(listed.unfiledCount).toBe(1);
|
||||
expect(listed.folders).toEqual([
|
||||
expect.objectContaining({ id: cleanup.id, name: "Ops", itemCount: 0 }),
|
||||
expect.objectContaining({ id: reporting.id, name: "Reporting", itemCount: 1 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("moves routines and skills to folders and back to virtual Unfiled", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
const routineFolder = await svc.create(companyId, { kind: "routine", name: "Reports" });
|
||||
const skillFolder = await svc.create(companyId, { kind: "skill", name: "Runtime" });
|
||||
const routine = await seedRoutine(companyId, "Daily report");
|
||||
const skill = await seedSkill(companyId, "review");
|
||||
|
||||
await expect(svc.moveItem(companyId, {
|
||||
kind: "routine",
|
||||
itemId: routine.id,
|
||||
folderId: routineFolder.id,
|
||||
})).resolves.toEqual({ kind: "routine", itemId: routine.id, folderId: routineFolder.id });
|
||||
await expect(svc.moveItem(companyId, {
|
||||
kind: "skill",
|
||||
itemId: skill.id,
|
||||
folderId: skillFolder.id,
|
||||
})).resolves.toEqual({ kind: "skill", itemId: skill.id, folderId: skillFolder.id });
|
||||
|
||||
await expect(svc.moveItem(companyId, {
|
||||
kind: "routine",
|
||||
itemId: routine.id,
|
||||
folderId: null,
|
||||
})).resolves.toEqual({ kind: "routine", itemId: routine.id, folderId: null });
|
||||
|
||||
const [updatedRoutine] = await db.select().from(routines).where(eq(routines.id, routine.id));
|
||||
const [updatedSkill] = await db.select().from(companySkills).where(eq(companySkills.id, skill.id));
|
||||
expect(updatedRoutine?.folderId).toBeNull();
|
||||
expect(updatedSkill?.folderId).toBe(skillFolder.id);
|
||||
});
|
||||
|
||||
it("rejects moving an item into a folder of the wrong kind", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
const skillFolder = await svc.create(companyId, { kind: "skill", name: "Runtime" });
|
||||
const routine = await seedRoutine(companyId, "Daily report");
|
||||
|
||||
await expect(svc.moveItem(companyId, {
|
||||
kind: "routine",
|
||||
itemId: routine.id,
|
||||
folderId: skillFolder.id,
|
||||
})).rejects.toMatchObject({
|
||||
status: 422,
|
||||
message: "Folder kind must match item kind",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes folders without deleting contents by moving items to Unfiled", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
const folder = await svc.create(companyId, { kind: "routine", name: "Reports" });
|
||||
const routine = await seedRoutine(companyId, "Daily report", folder.id);
|
||||
|
||||
const deleted = await svc.deleteFolder(companyId, folder.id);
|
||||
expect(deleted).toMatchObject({ id: folder.id, name: "Reports" });
|
||||
|
||||
const [updatedRoutine] = await db.select().from(routines).where(eq(routines.id, routine.id));
|
||||
expect(updatedRoutine?.folderId).toBeNull();
|
||||
expect(await db.select().from(folders).where(eq(folders.id, folder.id))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("computes canonical paths and updates descendant paths after rename and move", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
const root = await svc.create(companyId, { kind: "skill", name: "Engineering" });
|
||||
const child = await svc.create(companyId, { kind: "skill", parentId: root.id, name: "Code Review" });
|
||||
const destination = await svc.create(companyId, { kind: "skill", name: "Operations" });
|
||||
|
||||
expect(child).toMatchObject({ path: "engineering/code-review", depth: 2 });
|
||||
await svc.update(companyId, root.id, { name: "Product Engineering" });
|
||||
expect(await svc.getFolder(companyId, child.id)).toMatchObject({
|
||||
path: "product-engineering/code-review",
|
||||
depth: 2,
|
||||
});
|
||||
|
||||
await svc.moveFolder(companyId, child.id, { parentId: destination.id, position: 0 });
|
||||
expect(await svc.getFolder(companyId, child.id)).toMatchObject({
|
||||
parentId: destination.id,
|
||||
path: "operations/code-review",
|
||||
depth: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects invalid slugs, cycles, and folders deeper than four levels", async () => {
|
||||
expect(folderSlugSchema.safeParse("../escape").success).toBe(false);
|
||||
expect(folderSlugSchema.safeParse("Valid Slug").success).toBe(false);
|
||||
expect(folderSlugSchema.safeParse("valid-slug-2").success).toBe(true);
|
||||
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
const level1 = await svc.create(companyId, { kind: "skill", name: "Level 1" });
|
||||
const level2 = await svc.create(companyId, { kind: "skill", parentId: level1.id, name: "Level 2" });
|
||||
const level3 = await svc.create(companyId, { kind: "skill", parentId: level2.id, name: "Level 3" });
|
||||
const level4 = await svc.create(companyId, { kind: "skill", parentId: level3.id, name: "Level 4" });
|
||||
|
||||
await expect(svc.create(companyId, {
|
||||
kind: "skill",
|
||||
parentId: level4.id,
|
||||
name: "Level 5",
|
||||
})).rejects.toMatchObject({ status: 422, message: "Folder depth cannot exceed 4" });
|
||||
await expect(svc.moveFolder(companyId, level1.id, {
|
||||
parentId: level3.id,
|
||||
position: 0,
|
||||
})).rejects.toMatchObject({ status: 422, message: "A folder cannot be moved into its own subtree" });
|
||||
});
|
||||
|
||||
it("creates stable personal roots and protects bundled folders", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
const personal = await svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace");
|
||||
const repeated = await svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace");
|
||||
const bundled = await svc.ensureBundledCategory(companyId, "software-development");
|
||||
|
||||
expect(repeated.id).toBe(personal.id);
|
||||
expect(personal).toMatchObject({ systemKey: "my:user-1", path: "my/ada-lovelace", depth: 2 });
|
||||
expect(bundled.path).toBe("bundled/software-development");
|
||||
await expect(svc.create(companyId, {
|
||||
kind: "skill",
|
||||
parentId: bundled.id,
|
||||
name: "Nested",
|
||||
})).rejects.toMatchObject({ status: 403, message: "Bundled folders are read-only" });
|
||||
await expect(svc.update(companyId, bundled.id, { name: "Changed" })).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it("heals legacy bundled category names without changing folder identity", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
const legacy = await svc.ensureBundledCategory(companyId, "software-development");
|
||||
|
||||
const reconciled = await svc.ensureBundledCategory(companyId, "Software Development");
|
||||
|
||||
expect(reconciled).toMatchObject({
|
||||
id: legacy.id,
|
||||
name: "Software Development",
|
||||
path: "bundled/software-development",
|
||||
systemKey: "bundled:software-development",
|
||||
});
|
||||
});
|
||||
|
||||
it("creates reserved folders idempotently under concurrent requests", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
|
||||
const [personalA, personalB] = await Promise.all([
|
||||
svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace"),
|
||||
svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace"),
|
||||
]);
|
||||
const [projectA, projectB] = await Promise.all([
|
||||
svc.ensureProjectFolder(companyId, "project-1", "Core App"),
|
||||
svc.ensureProjectFolder(companyId, "project-1", "Core App"),
|
||||
]);
|
||||
const [bundledA, bundledB] = await Promise.all([
|
||||
svc.ensureBundledCategory(companyId, "software-development"),
|
||||
svc.ensureBundledCategory(companyId, "software-development"),
|
||||
]);
|
||||
|
||||
expect(personalA.id).toBe(personalB.id);
|
||||
expect(projectA.id).toBe(projectB.id);
|
||||
expect(bundledA.id).toBe(bundledB.id);
|
||||
});
|
||||
|
||||
it("reserves system skill roots from manual create, update, and move", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
|
||||
for (const slug of ["bundled", "my", "projects"]) {
|
||||
await expect(svc.create(companyId, {
|
||||
kind: "skill",
|
||||
name: slug,
|
||||
slug,
|
||||
})).rejects.toMatchObject({ status: 403, message: "Reserved skill folders are system-managed" });
|
||||
}
|
||||
|
||||
const editable = await svc.create(companyId, { kind: "skill", name: "Editable" });
|
||||
await expect(svc.update(companyId, editable.id, { slug: "bundled" })).rejects.toMatchObject({ status: 403 });
|
||||
|
||||
const parent = await svc.create(companyId, { kind: "skill", name: "Parent" });
|
||||
const nestedReserved = await svc.create(companyId, { kind: "skill", parentId: parent.id, name: "Projects" });
|
||||
await expect(svc.moveFolder(companyId, nestedReserved.id, { parentId: null, position: 0 })).rejects.toMatchObject({
|
||||
status: 403,
|
||||
message: "Reserved skill folders are system-managed",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows only system helpers to create children under personal and project roots", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
const personal = await svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace");
|
||||
const project = await svc.ensureProjectFolder(companyId, "project-1", "Core App");
|
||||
const myRoot = await svc.getFolder(companyId, personal.parentId!);
|
||||
const projectsRoot = await svc.getFolder(companyId, project.parentId!);
|
||||
const movable = await svc.create(companyId, { kind: "skill", name: "Movable" });
|
||||
|
||||
expect(myRoot?.systemKey).toBe("my");
|
||||
expect(projectsRoot?.systemKey).toBe("projects");
|
||||
await expect(svc.create(companyId, {
|
||||
kind: "skill",
|
||||
parentId: myRoot!.id,
|
||||
name: "Spoofed User",
|
||||
})).rejects.toMatchObject({ status: 403 });
|
||||
await expect(svc.moveFolder(companyId, movable.id, {
|
||||
parentId: projectsRoot!.id,
|
||||
position: 0,
|
||||
})).rejects.toMatchObject({ status: 403 });
|
||||
});
|
||||
|
||||
it("moves squatted roots aside instead of adopting them as system containers", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
const [squattedMy, squattedProjects] = await db.insert(folders).values([
|
||||
{ companyId, kind: "skill", parentId: null, name: "Attacker My", slug: "my", position: 0 },
|
||||
{ companyId, kind: "skill", parentId: null, name: "Attacker Projects", slug: "projects", position: 1 },
|
||||
]).returning();
|
||||
|
||||
const personal = await svc.ensureMyFolder(companyId, "user-1", "Ada Lovelace");
|
||||
const project = await svc.ensureProjectFolder(companyId, "project-1", "Core App");
|
||||
const myRoot = await svc.getFolder(companyId, personal.parentId!);
|
||||
const projectsRoot = await svc.getFolder(companyId, project.parentId!);
|
||||
const repairedMy = await svc.getFolder(companyId, squattedMy!.id);
|
||||
const repairedProjects = await svc.getFolder(companyId, squattedProjects!.id);
|
||||
|
||||
expect(myRoot).toMatchObject({ slug: "my", systemKey: "my" });
|
||||
expect(projectsRoot).toMatchObject({ slug: "projects", systemKey: "projects" });
|
||||
expect(repairedMy).toMatchObject({ name: "Attacker My", systemKey: null });
|
||||
expect(repairedMy?.slug).toMatch(/^my-[a-f0-9]{8}$/);
|
||||
expect(repairedProjects).toMatchObject({ name: "Attacker Projects", systemKey: null });
|
||||
expect(repairedProjects?.slug).toMatch(/^projects-[a-f0-9]{8}$/);
|
||||
});
|
||||
|
||||
it("suffixes system children when legacy rows squat personal and project slugs", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
const initialPersonal = await svc.ensureMyFolder(companyId, "seed-user", "Seed User");
|
||||
const initialProject = await svc.ensureProjectFolder(companyId, "seed-project", "Seed Project");
|
||||
const myRootId = initialPersonal.parentId!;
|
||||
const projectsRootId = initialProject.parentId!;
|
||||
await db.insert(folders).values([
|
||||
{ companyId, kind: "skill", parentId: myRootId, name: "Ada Squat", slug: "ada-lovelace", position: 1 },
|
||||
{ companyId, kind: "skill", parentId: projectsRootId, name: "Core Squat", slug: "core-app", position: 1 },
|
||||
]);
|
||||
|
||||
const personal = await svc.ensureMyFolder(companyId, "user-12345678", "Ada Lovelace");
|
||||
const project = await svc.ensureProjectFolder(companyId, "project-12345678", "Core App");
|
||||
|
||||
expect(personal).toMatchObject({ path: "my/ada-lovelace-user-12345678", systemKey: "my:user-12345678" });
|
||||
expect(project).toMatchObject({ path: "projects/core-app-project-12345678", systemKey: "project:project-12345678" });
|
||||
});
|
||||
|
||||
it("does not adopt a legacy category row under the bundled root", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
const initialCategory = await svc.ensureBundledCategory(companyId, "initial");
|
||||
const bundledRootId = initialCategory.parentId!;
|
||||
const [squatted] = await db.insert(folders).values({
|
||||
companyId,
|
||||
kind: "skill",
|
||||
parentId: bundledRootId,
|
||||
name: "User Software Development",
|
||||
slug: "software-development",
|
||||
position: 1,
|
||||
}).returning();
|
||||
|
||||
const category = await svc.ensureBundledCategory(companyId, "software-development");
|
||||
|
||||
expect(category).toMatchObject({
|
||||
path: "bundled/software-development-bundled",
|
||||
systemKey: "bundled:software-development",
|
||||
});
|
||||
expect(await svc.getFolder(companyId, squatted!.id)).toMatchObject({
|
||||
path: "bundled/software-development",
|
||||
systemKey: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes concurrent system folder ensures", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
await db.insert(folders).values({
|
||||
companyId,
|
||||
kind: "skill",
|
||||
name: "Squatted My",
|
||||
slug: "my",
|
||||
position: 0,
|
||||
});
|
||||
|
||||
const personalFolders = await Promise.all(
|
||||
Array.from({ length: 8 }, () => svc.ensureMyFolder(companyId, "user-123", "Ada Lovelace")),
|
||||
);
|
||||
|
||||
expect(new Set(personalFolders.map((folder) => folder.id)).size).toBe(1);
|
||||
const rows = await db.select().from(folders).where(eq(folders.companyId, companyId));
|
||||
expect(rows.filter((row) => row.systemKey === "my")).toHaveLength(1);
|
||||
expect(rows.filter((row) => row.systemKey === "my:user-123")).toHaveLength(1);
|
||||
expect(rows.find((row) => row.systemKey === null)).toMatchObject({ name: "Squatted My" });
|
||||
});
|
||||
|
||||
it("rechecks nested folders after waiting for the company mutation lock", async () => {
|
||||
const companyId = await seedCompany();
|
||||
const svc = folderService(db);
|
||||
const parent = await svc.create(companyId, { kind: "routine", name: "Parent" });
|
||||
let releaseLock!: () => void;
|
||||
let markLockAcquired!: () => void;
|
||||
const lockAcquired = new Promise<void>((resolve) => { markLockAcquired = resolve; });
|
||||
const holdLock = new Promise<void>((resolve) => { releaseLock = resolve; });
|
||||
const lockKey = `paperclip:folders:${companyId}`;
|
||||
const blocker = db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${lockKey}, 0))`);
|
||||
markLockAcquired();
|
||||
await holdLock;
|
||||
});
|
||||
await lockAcquired;
|
||||
|
||||
const deletion = svc.deleteFolder(companyId, parent.id);
|
||||
await db.insert(folders).values({
|
||||
companyId,
|
||||
kind: "routine",
|
||||
parentId: parent.id,
|
||||
name: "Child",
|
||||
slug: "child",
|
||||
position: 0,
|
||||
});
|
||||
releaseLock();
|
||||
await blocker;
|
||||
|
||||
await expect(deletion).rejects.toMatchObject({
|
||||
status: 409,
|
||||
message: "Move or delete nested folders first",
|
||||
});
|
||||
await expect(svc.getFolder(companyId, parent.id)).resolves.toMatchObject({ id: parent.id });
|
||||
});
|
||||
});
|
||||
|
|
@ -30,6 +30,7 @@ const apiPrefixes: Record<string, string> = {
|
|||
"environments.ts": "/api",
|
||||
"execution-workspaces.ts": "/api",
|
||||
"file-resources.ts": "/api",
|
||||
"folders.ts": "/api",
|
||||
"goals.ts": "/api",
|
||||
"health.ts": "/api/health",
|
||||
"inbox-dismissals.ts": "/api",
|
||||
|
|
@ -181,6 +182,10 @@ describe("openapi routes", () => {
|
|||
name: { type: "string" },
|
||||
},
|
||||
});
|
||||
expect(res.body.paths["/api/companies/{companyId}/folders"].post.responses["201"]).toBeDefined();
|
||||
expect(res.body.paths["/api/companies/{companyId}/folders/items/move"].post.summary).toBe(
|
||||
"Move an item into or out of a folder",
|
||||
);
|
||||
expect(JSON.stringify(res.body.paths["/api/tool-gateway/tools"].get)).not.toContain("sessionToken");
|
||||
expect(JSON.stringify(res.body.paths["/api/tool-gateway/tools/call"].post)).not.toContain("sessionToken");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
documentRevisions,
|
||||
documents,
|
||||
executionWorkspaces,
|
||||
folders,
|
||||
heartbeatRuns,
|
||||
instanceSettings,
|
||||
issueInboxArchives,
|
||||
|
|
@ -68,6 +69,7 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
|||
await db.delete(routineRuns);
|
||||
await db.delete(routineTriggers);
|
||||
await db.delete(routines);
|
||||
await db.delete(folders);
|
||||
await db.delete(routineDocuments);
|
||||
await db.delete(documents);
|
||||
await db.delete(documentRevisions);
|
||||
|
|
@ -271,6 +273,39 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
|||
expect(allRoutines.map((entry) => entry.id)).toEqual(expect.arrayContaining([routine.id, otherRoutine.id]));
|
||||
});
|
||||
|
||||
it("does not reveal folders owned by another company", async () => {
|
||||
const { companyId, agentId, projectId, svc } = await seedFixture();
|
||||
const otherCompanyId = randomUUID();
|
||||
await db.insert(companies).values({
|
||||
id: otherCompanyId,
|
||||
name: "Other company",
|
||||
issuePrefix: `T${otherCompanyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
defaultResponsibleUserId: randomUUID(),
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
const [otherFolder] = await db.insert(folders).values({
|
||||
companyId: otherCompanyId,
|
||||
kind: "routine",
|
||||
name: "Private folder",
|
||||
slug: "private-folder",
|
||||
position: 0,
|
||||
}).returning();
|
||||
|
||||
await expect(svc.create(companyId, {
|
||||
projectId,
|
||||
folderId: otherFolder!.id,
|
||||
goalId: null,
|
||||
parentIssueId: null,
|
||||
title: "cross-company folder probe",
|
||||
description: null,
|
||||
assigneeAgentId: agentId,
|
||||
priority: "medium",
|
||||
status: "active",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
catchUpPolicy: "skip_missed",
|
||||
}, {})).rejects.toMatchObject({ status: 404, message: "Folder not found" });
|
||||
});
|
||||
|
||||
it("defaults activity gates to always at company scope", async () => {
|
||||
const { routine } = await seedFixture();
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { companyRoutes } from "./routes/companies.js";
|
|||
import { companySkillRoutes } from "./routes/company-skills.js";
|
||||
import { companySkillPolicyRoutes } from "./routes/company-skill-policy.js";
|
||||
import { builtInAgentRoutes } from "./routes/built-in-agents.js";
|
||||
import { folderRoutes } from "./routes/folders.js";
|
||||
import { teamsCatalogRoutes } from "./routes/teams-catalog.js";
|
||||
import { agentRoutes } from "./routes/agents.js";
|
||||
import { projectRoutes } from "./routes/projects.js";
|
||||
|
|
@ -235,6 +236,7 @@ export async function createApp(
|
|||
api.use(openApiRoutes());
|
||||
api.use("/companies", companyRoutes(db, opts.storageService));
|
||||
api.use(llmRoutes(db));
|
||||
api.use(folderRoutes(db));
|
||||
api.use(companySkillRoutes(db));
|
||||
api.use(companySkillPolicyRoutes(db));
|
||||
api.use(builtInAgentRoutes(db));
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ import {
|
|||
listCatalogSkillsOrEmpty,
|
||||
readCatalogSkillFile,
|
||||
} from "../services/skills-catalog.js";
|
||||
import { forbidden, unauthorized } from "../errors.js";
|
||||
import { badRequest, forbidden, unauthorized } from "../errors.js";
|
||||
import { assertAuthenticated, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { getTelemetryClient } from "../telemetry.js";
|
||||
import {
|
||||
|
|
@ -115,6 +115,14 @@ export function companySkillRoutes(db: Db) {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function optionalQueryBoolean(value: unknown) {
|
||||
const parsed = firstQueryString(value);
|
||||
if (parsed === undefined) return undefined;
|
||||
if (parsed === "true") return true;
|
||||
if (parsed === "false") return false;
|
||||
throw badRequest("Boolean query parameters must be true or false");
|
||||
}
|
||||
|
||||
function queryStringArray(value: unknown): string[] {
|
||||
if (typeof value === "string") return [value];
|
||||
if (Array.isArray(value)) return value.filter((entry): entry is string => typeof entry === "string");
|
||||
|
|
@ -305,12 +313,17 @@ export function companySkillRoutes(db: Db) {
|
|||
...queryStringArray(req.query.category),
|
||||
...queryStringArray(req.query.categories),
|
||||
...queryStringArray(req.query["categories[]"]),
|
||||
...queryStringArray(req.query.tag),
|
||||
...queryStringArray(req.query.tags),
|
||||
...queryStringArray(req.query["tags[]"]),
|
||||
],
|
||||
scope: firstQueryString(req.query.scope),
|
||||
include: [
|
||||
...queryStringArray(req.query.include),
|
||||
...queryStringArray(req.query["include[]"]),
|
||||
],
|
||||
folderId: firstQueryString(req.query.folderId),
|
||||
includeSubtree: optionalQueryBoolean(req.query.includeSubtree),
|
||||
}));
|
||||
res.json(result);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
import { Router } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
createFolderSchema,
|
||||
ensureMySkillFolderSchema,
|
||||
folderKindSchema,
|
||||
moveFolderItemSchema,
|
||||
moveFolderSchema,
|
||||
updateFolderSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { badRequest, forbidden } from "../errors.js";
|
||||
import { folderService, logActivity } from "../services/index.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
export function folderRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = folderService(db);
|
||||
|
||||
function parseKind(value: unknown) {
|
||||
const result = folderKindSchema.safeParse(value);
|
||||
if (!result.success) throw badRequest("Folder kind query parameter is required");
|
||||
return result.data;
|
||||
}
|
||||
|
||||
router.get("/companies/:companyId/folders", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
res.json(await svc.list(companyId, parseKind(req.query.kind)));
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/folders", validate(createFolderSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const created = await svc.create(companyId, req.body);
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "folder.created",
|
||||
entityType: "folder",
|
||||
entityId: created.id,
|
||||
details: { kind: created.kind, name: created.name, path: created.path, parentId: created.parentId, position: created.position },
|
||||
});
|
||||
res.status(201).json(created);
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/companies/:companyId/folders/ensure-my",
|
||||
validate(ensureMySkillFolderSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
if (req.actor.type !== "board" || !req.actor.userId) {
|
||||
throw forbidden("A signed-in board user is required to create a personal skill folder");
|
||||
}
|
||||
const folder = await svc.ensureMyFolder(companyId, req.actor.userId, req.actor.userName ?? null, req.body.slug);
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "folder.personal_ensured",
|
||||
entityType: "folder",
|
||||
entityId: folder.id,
|
||||
details: { path: folder.path, systemKey: folder.systemKey },
|
||||
});
|
||||
res.json(folder);
|
||||
},
|
||||
);
|
||||
|
||||
router.patch("/companies/:companyId/folders/:folderId", validate(updateFolderSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const folderId = req.params.folderId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const updated = await svc.update(companyId, folderId, req.body);
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "Folder not found" });
|
||||
return;
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "folder.updated",
|
||||
entityType: "folder",
|
||||
entityId: updated.id,
|
||||
details: { kind: updated.kind, name: updated.name, path: updated.path, position: updated.position },
|
||||
});
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/folders/items/move", validate(moveFolderItemSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const moved = await svc.moveItem(companyId, req.body);
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "folder.item_moved",
|
||||
entityType: req.body.kind === "routine" ? "routine" : "company_skill",
|
||||
entityId: moved.itemId,
|
||||
details: { kind: moved.kind, folderId: moved.folderId },
|
||||
});
|
||||
res.json(moved);
|
||||
});
|
||||
|
||||
router.post("/companies/:companyId/folders/:folderId/move", validate(moveFolderSchema), async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const folderId = req.params.folderId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const updated = await svc.moveFolder(companyId, folderId, req.body);
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: "Folder not found" });
|
||||
return;
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "folder.moved",
|
||||
entityType: "folder",
|
||||
entityId: updated.id,
|
||||
details: { kind: updated.kind, parentId: updated.parentId, path: updated.path, position: updated.position },
|
||||
});
|
||||
res.json(updated);
|
||||
});
|
||||
|
||||
router.delete("/companies/:companyId/folders/:folderId", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const folderId = req.params.folderId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const deleted = await svc.deleteFolder(companyId, folderId);
|
||||
if (!deleted) {
|
||||
res.status(404).json({ error: "Folder not found" });
|
||||
return;
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "folder.deleted",
|
||||
entityType: "folder",
|
||||
entityId: deleted.id,
|
||||
details: { kind: deleted.kind, name: deleted.name },
|
||||
});
|
||||
res.json({ deleted });
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ export { companyRoutes } from "./companies.js";
|
|||
export { companySkillRoutes } from "./company-skills.js";
|
||||
export { companySkillPolicyRoutes } from "./company-skill-policy.js";
|
||||
export { builtInAgentRoutes } from "./built-in-agents.js";
|
||||
export { folderRoutes } from "./folders.js";
|
||||
export { teamsCatalogRoutes } from "./teams-catalog.js";
|
||||
export { agentRoutes } from "./agents.js";
|
||||
export { projectRoutes } from "./projects.js";
|
||||
|
|
|
|||
|
|
@ -47,6 +47,13 @@ import {
|
|||
updateRoutineTriggerSchema,
|
||||
rotateRoutineTriggerSecretSchema,
|
||||
runRoutineSchema,
|
||||
// Folders
|
||||
createFolderSchema,
|
||||
ensureMySkillFolderSchema,
|
||||
folderKindSchema,
|
||||
moveFolderItemSchema,
|
||||
moveFolderSchema,
|
||||
updateFolderSchema,
|
||||
// Goal
|
||||
createGoalSchema,
|
||||
updateGoalSchema,
|
||||
|
|
@ -862,6 +869,8 @@ const CREATED_OPERATIONS = new Set([
|
|||
"POST /api/companies/{companyId}/projects",
|
||||
"POST /api/projects/{id}/workspaces",
|
||||
"POST /api/companies/{companyId}/routines",
|
||||
"POST /api/companies/{companyId}/folders",
|
||||
"POST /api/companies/{companyId}/folders/ensure-my",
|
||||
"POST /api/routines/{id}/triggers",
|
||||
"POST /api/companies/{companyId}/secrets",
|
||||
"POST /api/companies/{companyId}/user-secret-definitions",
|
||||
|
|
@ -5168,6 +5177,61 @@ for (const route of [
|
|||
});
|
||||
}
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/folders",
|
||||
tags: ["folders"],
|
||||
summary: "List folders for a company item kind",
|
||||
query: z.object({ kind: folderKindSchema }),
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/folders",
|
||||
tags: ["folders"],
|
||||
summary: "Create a folder",
|
||||
body: createFolderSchema,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/folders/ensure-my",
|
||||
tags: ["folders"],
|
||||
summary: "Ensure the current user's personal skill folder exists",
|
||||
body: ensureMySkillFolderSchema,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "patch",
|
||||
path: "/api/companies/{companyId}/folders/{folderId}",
|
||||
tags: ["folders"],
|
||||
summary: "Update a folder",
|
||||
body: updateFolderSchema,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/folders/items/move",
|
||||
tags: ["folders"],
|
||||
summary: "Move an item into or out of a folder",
|
||||
body: moveFolderItemSchema,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "post",
|
||||
path: "/api/companies/{companyId}/folders/{folderId}/move",
|
||||
tags: ["folders"],
|
||||
summary: "Move or reorder a folder",
|
||||
body: moveFolderSchema,
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "delete",
|
||||
path: "/api/companies/{companyId}/folders/{folderId}",
|
||||
tags: ["folders"],
|
||||
summary: "Delete a folder",
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
method: "get",
|
||||
path: "/api/issues/{id}/cost-summary",
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ import { issueDocumentSelect, mapIssueDocumentRow } from "./documents.js";
|
|||
import { toIssueWorkProduct } from "./work-products.js";
|
||||
import { projectService } from "./projects.js";
|
||||
import { normalizePortablePath } from "./portable-path.js";
|
||||
import { folderService } from "./folders.js";
|
||||
import {
|
||||
copyCatalogSkillFile,
|
||||
getCatalogPackageMetadata,
|
||||
|
|
@ -119,6 +120,7 @@ type CompanySkillListDbRow = Pick<
|
|||
CompanySkillRow,
|
||||
| "id"
|
||||
| "companyId"
|
||||
| "folderId"
|
||||
| "key"
|
||||
| "slug"
|
||||
| "name"
|
||||
|
|
@ -151,6 +153,8 @@ type CompanySkillListRow = Pick<
|
|||
CompanySkill,
|
||||
| "id"
|
||||
| "companyId"
|
||||
| "folderId"
|
||||
| "folderPath"
|
||||
| "key"
|
||||
| "slug"
|
||||
| "name"
|
||||
|
|
@ -213,6 +217,7 @@ type ImportedSkill = {
|
|||
type ImportedSkillPersistValues = Pick<
|
||||
CompanySkill,
|
||||
| "companyId"
|
||||
| "folderId"
|
||||
| "key"
|
||||
| "slug"
|
||||
| "name"
|
||||
|
|
@ -376,6 +381,7 @@ function selectCompanySkillColumns() {
|
|||
return {
|
||||
id: companySkills.id,
|
||||
companyId: companySkills.companyId,
|
||||
folderId: companySkills.folderId,
|
||||
key: companySkills.key,
|
||||
slug: companySkills.slug,
|
||||
name: companySkills.name,
|
||||
|
|
@ -1208,6 +1214,26 @@ function isPaperclipBundledSkillKey(key: string) {
|
|||
return key.startsWith("paperclipai/paperclip/");
|
||||
}
|
||||
|
||||
function paperclipBundledFolderCategory(key: string, metadata?: unknown) {
|
||||
const keyParts = key.split("/");
|
||||
if (keyParts[0] === "paperclipai" && keyParts[1] === "bundled" && keyParts[2]) {
|
||||
return keyParts[2];
|
||||
}
|
||||
if (isPaperclipBundledSkillKey(key)) return "paperclip-core";
|
||||
if (isPlainRecord(metadata) && asString(metadata.sourceKind) === "paperclip_bundled") {
|
||||
return "paperclip-core";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function bundledFolderLabel(category: string) {
|
||||
return category
|
||||
.split(/[-_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function stripDerivedPaperclipBundledMetadata(key: string, metadata: unknown): unknown {
|
||||
if (metadata === null || metadata === undefined) return {};
|
||||
const comparable = stableJsonComparable(metadata);
|
||||
|
|
@ -1239,6 +1265,7 @@ function importedSkillPersistValuesMatchExisting(
|
|||
values: ImportedSkillPersistValues,
|
||||
) {
|
||||
return existing.companyId === values.companyId
|
||||
&& existing.folderId === values.folderId
|
||||
&& existing.key === values.key
|
||||
&& existing.slug === values.slug
|
||||
&& existing.name === values.name
|
||||
|
|
@ -2643,6 +2670,8 @@ function toCompanySkillListItem(skill: CompanySkillListRow, attachedAgentCount:
|
|||
return {
|
||||
id: skill.id,
|
||||
companyId: skill.companyId,
|
||||
folderId: skill.folderId,
|
||||
folderPath: skill.folderPath ?? null,
|
||||
key: skill.key,
|
||||
slug: skill.slug,
|
||||
name: skill.name,
|
||||
|
|
@ -2743,6 +2772,7 @@ async function listLastEditorsBySkillId(
|
|||
}
|
||||
|
||||
export function companySkillService(db: Db) {
|
||||
const folderSvc = folderService(db);
|
||||
const agents = agentService(db);
|
||||
const projects = projectService(db);
|
||||
|
||||
|
|
@ -2798,6 +2828,39 @@ export function companySkillService(db: Db) {
|
|||
return [];
|
||||
}
|
||||
|
||||
async function reconcilePaperclipSkillFolders(companyId: string) {
|
||||
const shippedSkills = await db
|
||||
.select({
|
||||
id: companySkills.id,
|
||||
key: companySkills.key,
|
||||
folderId: companySkills.folderId,
|
||||
metadata: companySkills.metadata,
|
||||
})
|
||||
.from(companySkills)
|
||||
.where(eq(companySkills.companyId, companyId))
|
||||
.then((rows) => rows.flatMap((skill) => {
|
||||
const category = paperclipBundledFolderCategory(skill.key, skill.metadata);
|
||||
return category ? [{ ...skill, category }] : [];
|
||||
}));
|
||||
const foldersByCategory = new Map<string, Awaited<ReturnType<typeof folderSvc.ensureBundledCategory>>>();
|
||||
for (const skill of shippedSkills) {
|
||||
let folder = foldersByCategory.get(skill.category);
|
||||
if (!folder) {
|
||||
folder = await folderSvc.ensureBundledCategory(companyId, bundledFolderLabel(skill.category));
|
||||
foldersByCategory.set(skill.category, folder);
|
||||
}
|
||||
if (skill.folderId === folder.id) continue;
|
||||
await db
|
||||
.update(companySkills)
|
||||
.set({ folderId: folder.id, updatedAt: new Date() })
|
||||
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, skill.id)));
|
||||
}
|
||||
await folderSvc.pruneEmptyBundledCategories(
|
||||
companyId,
|
||||
Array.from(foldersByCategory.keys(), bundledFolderLabel),
|
||||
);
|
||||
}
|
||||
|
||||
async function reconcileLocalPathSkillSources(companyId: string) {
|
||||
const rows = await db
|
||||
.select({
|
||||
|
|
@ -2889,6 +2952,7 @@ export function companySkillService(db: Db) {
|
|||
throw notFound("Company not found");
|
||||
}
|
||||
await ensureBundledSkills(companyId);
|
||||
await reconcilePaperclipSkillFolders(companyId);
|
||||
await reconcileLocalPathSkillSources(companyId);
|
||||
})();
|
||||
|
||||
|
|
@ -2904,10 +2968,11 @@ export function companySkillService(db: Db) {
|
|||
|
||||
async function list(companyId: string, query: CompanySkillListQuery = {}): Promise<CompanySkillListItem[]> {
|
||||
await ensureSkillInventoryCurrent(companyId);
|
||||
const rows = await db
|
||||
const [dbRows, folderListing] = await Promise.all([db
|
||||
.select({
|
||||
id: companySkills.id,
|
||||
companyId: companySkills.companyId,
|
||||
folderId: companySkills.folderId,
|
||||
key: companySkills.key,
|
||||
slug: companySkills.slug,
|
||||
name: companySkills.name,
|
||||
|
|
@ -2939,7 +3004,21 @@ export function companySkillService(db: Db) {
|
|||
.from(companySkills)
|
||||
.where(eq(companySkills.companyId, companyId))
|
||||
.orderBy(asc(companySkills.name), asc(companySkills.key))
|
||||
.then((entries) => entries.map((entry) => toCompanySkillListRow(entry as CompanySkillListDbRow)));
|
||||
.then((entries) => entries.map((entry) => toCompanySkillListRow(entry as CompanySkillListDbRow))),
|
||||
folderSvc.list(companyId, "skill"),
|
||||
]);
|
||||
const folderPaths = new Map(folderListing.folders.map((folder) => [folder.id, folder.path]));
|
||||
const rows = dbRows.map((skill) => ({
|
||||
...skill,
|
||||
folderPath: skill.folderId ? folderPaths.get(skill.folderId) ?? null : null,
|
||||
}));
|
||||
let selectedFolderIds: Set<string> | null = null;
|
||||
if (query.folderId) {
|
||||
await folderSvc.validateSkillFolder(companyId, query.folderId, { allowBundled: true });
|
||||
selectedFolderIds = query.includeSubtree
|
||||
? await folderSvc.descendantIds(companyId, "skill", query.folderId)
|
||||
: new Set([query.folderId]);
|
||||
}
|
||||
const agentRows = await agents.list(companyId);
|
||||
const q = query.q?.trim().toLowerCase() ?? "";
|
||||
const categories = new Set(
|
||||
|
|
@ -2950,6 +3029,7 @@ export function companySkillService(db: Db) {
|
|||
);
|
||||
const filtered = rows.filter((skill) => {
|
||||
if (query.scope && skill.sharingScope !== query.scope) return false;
|
||||
if (!q && selectedFolderIds && (!skill.folderId || !selectedFolderIds.has(skill.folderId))) return false;
|
||||
if (categories.size > 0 && !skill.categories.some((category) => categories.has(categoryLookupKey(category)))) return false;
|
||||
if (q) {
|
||||
const haystack = [
|
||||
|
|
@ -3032,7 +3112,7 @@ export function companySkillService(db: Db) {
|
|||
.from(companySkills)
|
||||
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, id)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toCompanySkill(row) : null;
|
||||
return row ? enrichFolderPath(companyId, toCompanySkill(row)) : null;
|
||||
}
|
||||
|
||||
async function getByKey(companyId: string, key: string) {
|
||||
|
|
@ -3041,7 +3121,7 @@ export function companySkillService(db: Db) {
|
|||
.from(companySkills)
|
||||
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.key, key)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return row ? toCompanySkill(row) : null;
|
||||
return row ? enrichFolderPath(companyId, toCompanySkill(row)) : null;
|
||||
}
|
||||
|
||||
async function getBySlugIfUnique(companyId: string, slug: string) {
|
||||
|
|
@ -3049,7 +3129,13 @@ export function companySkillService(db: Db) {
|
|||
.select(selectCompanySkillColumns())
|
||||
.from(companySkills)
|
||||
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.slug, slug)));
|
||||
return rows.length === 1 ? toCompanySkill(rows[0]!) : null;
|
||||
return rows.length === 1 ? enrichFolderPath(companyId, toCompanySkill(rows[0]!)) : null;
|
||||
}
|
||||
|
||||
async function enrichFolderPath(companyId: string, skill: CompanySkill): Promise<CompanySkill> {
|
||||
if (!skill.folderId) return { ...skill, folderPath: null };
|
||||
const folder = await folderSvc.getFolder(companyId, skill.folderId);
|
||||
return { ...skill, folderPath: folder?.kind === "skill" ? folder.path : null };
|
||||
}
|
||||
|
||||
async function getByRouteRef(companyId: string, ref: string) {
|
||||
|
|
@ -3927,6 +4013,7 @@ export function companySkillService(db: Db) {
|
|||
input: CompanySkillCreateRequest,
|
||||
actor: SkillActor | null = null,
|
||||
): Promise<CompanySkill> {
|
||||
if (input.folderId) await folderSvc.validateSkillFolder(companyId, input.folderId);
|
||||
const slug = normalizeSkillSlug(input.slug ?? input.name) ?? "skill";
|
||||
const key = `company/${companyId}/${slug}`;
|
||||
const existing = await getByKey(companyId, key);
|
||||
|
|
@ -4013,6 +4100,7 @@ export function companySkillService(db: Db) {
|
|||
authorName: normalizeStoreText(input.authorName, 200) ?? forkSource?.authorName ?? created.authorName,
|
||||
homepageUrl: normalizeStoreText(input.homepageUrl, 2000) ?? forkSource?.homepageUrl ?? created.homepageUrl,
|
||||
categories: input.categories ? normalizeCategoryList(input.categories) : forkSource?.categories ?? created.categories,
|
||||
folderId: input.folderId ?? null,
|
||||
sharingScope,
|
||||
forkedFromSkillId: forkSource?.id ?? null,
|
||||
forkedFromCompanyId: forkSource?.companyId ?? null,
|
||||
|
|
@ -4557,6 +4645,15 @@ export function companySkillService(db: Db) {
|
|||
existingSkillId: existingBySource.id,
|
||||
reason: "This skill is already installed from the same path.",
|
||||
});
|
||||
if (mode === "preview" || !selected) continue;
|
||||
const persisted = (await upsertImportedSkills(companyId, [{
|
||||
...nextSkill,
|
||||
key: existingBySource.key,
|
||||
slug: existingBySource.slug,
|
||||
}]))[0];
|
||||
if (!persisted) continue;
|
||||
updated.push(persisted);
|
||||
upsertAcceptedSkill(persisted);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -5050,6 +5147,10 @@ export function companySkillService(db: Db) {
|
|||
}
|
||||
const markdown = await fs.readFile(path.join(originSnapshotLocator, catalogSkill.entrypoint), "utf8");
|
||||
const metadata = buildCatalogSkillMetadata(catalogSkill, existingByKey, originSnapshotLocator);
|
||||
const bundledCategory = paperclipBundledFolderCategory(catalogSkill.key, metadata);
|
||||
const bundledFolder = bundledCategory
|
||||
? await folderSvc.ensureBundledCategory(companyId, bundledFolderLabel(bundledCategory))
|
||||
: null;
|
||||
const parsed = parseFrontmatterMarkdown(markdown);
|
||||
const storeMetadata = readSkillStoreMetadata(parsed.frontmatter, {
|
||||
...metadata,
|
||||
|
|
@ -5057,6 +5158,7 @@ export function companySkillService(db: Db) {
|
|||
});
|
||||
const values = {
|
||||
companyId,
|
||||
folderId: bundledFolder?.id ?? existingByKey?.folderId ?? null,
|
||||
key: catalogSkill.key,
|
||||
slug,
|
||||
name: catalogSkill.name,
|
||||
|
|
@ -5446,8 +5548,18 @@ export function companySkillService(db: Db) {
|
|||
};
|
||||
const parsed = parseFrontmatterMarkdown(skill.markdown);
|
||||
const storeMetadata = readSkillStoreMetadata(parsed.frontmatter, metadata);
|
||||
const bundledCategory = paperclipBundledFolderCategory(skill.key, incomingMeta);
|
||||
const bundledFolder = bundledCategory
|
||||
? await folderSvc.ensureBundledCategory(companyId, bundledFolderLabel(bundledCategory))
|
||||
: null;
|
||||
const projectId = asString(incomingMeta.projectId);
|
||||
const projectName = asString(incomingMeta.projectName);
|
||||
const projectFolder = !existing && incomingKind === "project_scan" && projectId && projectName
|
||||
? await folderSvc.ensureProjectFolder(companyId, projectId, projectName)
|
||||
: null;
|
||||
const values: ImportedSkillPersistValues = {
|
||||
companyId,
|
||||
folderId: bundledFolder?.id ?? projectFolder?.id ?? existing?.folderId ?? null,
|
||||
key: skill.key,
|
||||
slug: skill.slug,
|
||||
name: skill.name,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,619 @@
|
|||
import { and, asc, eq, max, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { companySkills, folders, routines } from "@paperclipai/db";
|
||||
import type {
|
||||
CreateFolder,
|
||||
Folder,
|
||||
FolderKind,
|
||||
FolderListResult,
|
||||
MoveFolder,
|
||||
MoveFolderItem,
|
||||
UpdateFolder,
|
||||
} from "@paperclipai/shared";
|
||||
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
|
||||
|
||||
const MAX_FOLDER_DEPTH = 4;
|
||||
const RESERVED_ROOT_SLUGS = new Set(["bundled", "my", "projects"]);
|
||||
const RESERVED_CHILD_ROOT_SYSTEM_KEYS = new Set(["my", "projects"]);
|
||||
|
||||
type FolderRow = typeof folders.$inferSelect;
|
||||
|
||||
function isPostgresError(error: unknown, code: string) {
|
||||
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
||||
}
|
||||
|
||||
function normalizeName(name: string) {
|
||||
return name.trim();
|
||||
}
|
||||
|
||||
function normalizeColor(color: string | null | undefined) {
|
||||
if (color === undefined) return undefined;
|
||||
const trimmed = color?.trim() ?? "";
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
export function normalizeFolderSlug(value: string) {
|
||||
const slug = value
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.replace(/-+/g, "-");
|
||||
return slug || "folder";
|
||||
}
|
||||
|
||||
function buildFolderViews(rows: FolderRow[]) {
|
||||
const byId = new Map(rows.map((row) => [row.id, row]));
|
||||
const views = new Map<string, Folder>();
|
||||
const visiting = new Set<string>();
|
||||
|
||||
function resolve(row: FolderRow): Folder {
|
||||
const existing = views.get(row.id);
|
||||
if (existing) return existing;
|
||||
if (visiting.has(row.id)) throw unprocessable("Folder hierarchy contains a cycle");
|
||||
visiting.add(row.id);
|
||||
const parent = row.parentId ? byId.get(row.parentId) : null;
|
||||
if (row.parentId && !parent) throw unprocessable("Folder hierarchy contains an invalid parent");
|
||||
const parentView = parent ? resolve(parent) : null;
|
||||
const view: Folder = {
|
||||
...row,
|
||||
parentId: row.parentId ?? null,
|
||||
systemKey: row.systemKey ?? null,
|
||||
color: row.color ?? null,
|
||||
path: parentView ? `${parentView.path}/${row.slug}` : row.slug,
|
||||
depth: (parentView?.depth ?? 0) + 1,
|
||||
};
|
||||
visiting.delete(row.id);
|
||||
views.set(row.id, view);
|
||||
return view;
|
||||
}
|
||||
|
||||
for (const row of rows) resolve(row);
|
||||
return views;
|
||||
}
|
||||
|
||||
export function folderService(db: Db, mutationLockHeld = false) {
|
||||
async function withCompanyFolderLock<T>(companyId: string, operation: (lockedDb: Db) => Promise<T>) {
|
||||
if (mutationLockHeld) return operation(db);
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`paperclip:folders:${companyId}`}, 0))`);
|
||||
return operation(tx as unknown as Db);
|
||||
});
|
||||
}
|
||||
|
||||
async function getRows(companyId: string, kind: FolderKind) {
|
||||
return db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.companyId, companyId), eq(folders.kind, kind)))
|
||||
.orderBy(asc(folders.position), asc(folders.name), asc(folders.id));
|
||||
}
|
||||
|
||||
async function getFolderRow(companyId: string, folderId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getFolder(companyId: string, folderId: string) {
|
||||
const row = await getFolderRow(companyId, folderId);
|
||||
if (!row) return null;
|
||||
const views = buildFolderViews(await getRows(companyId, row.kind));
|
||||
return views.get(row.id) ?? null;
|
||||
}
|
||||
|
||||
async function assertNoSlugConflict(
|
||||
companyId: string,
|
||||
kind: FolderKind,
|
||||
parentId: string | null,
|
||||
slug: string,
|
||||
excludeFolderId?: string,
|
||||
) {
|
||||
const existing = await db
|
||||
.select({ id: folders.id })
|
||||
.from(folders)
|
||||
.where(and(
|
||||
eq(folders.companyId, companyId),
|
||||
eq(folders.kind, kind),
|
||||
parentId === null ? sql`${folders.parentId} is null` : eq(folders.parentId, parentId),
|
||||
eq(folders.slug, slug),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existing && existing.id !== excludeFolderId) {
|
||||
throw conflict("Folder slug already exists under this parent");
|
||||
}
|
||||
}
|
||||
|
||||
async function nextPosition(companyId: string, kind: FolderKind, parentId: string | null) {
|
||||
const row = await db
|
||||
.select({ value: max(folders.position) })
|
||||
.from(folders)
|
||||
.where(and(
|
||||
eq(folders.companyId, companyId),
|
||||
eq(folders.kind, kind),
|
||||
parentId === null ? sql`${folders.parentId} is null` : eq(folders.parentId, parentId),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return Number(row?.value ?? -1) + 1;
|
||||
}
|
||||
|
||||
async function routineCounts(companyId: string) {
|
||||
return db
|
||||
.select({ folderId: routines.folderId, count: sql<number>`count(*)::int` })
|
||||
.from(routines)
|
||||
.where(eq(routines.companyId, companyId))
|
||||
.groupBy(routines.folderId);
|
||||
}
|
||||
|
||||
async function skillCounts(companyId: string) {
|
||||
return db
|
||||
.select({ folderId: companySkills.folderId, count: sql<number>`count(*)::int` })
|
||||
.from(companySkills)
|
||||
.where(eq(companySkills.companyId, companyId))
|
||||
.groupBy(companySkills.folderId);
|
||||
}
|
||||
|
||||
async function list(companyId: string, kind: FolderKind): Promise<FolderListResult> {
|
||||
const [folderRows, countRows] = await Promise.all([
|
||||
getRows(companyId, kind),
|
||||
kind === "routine" ? routineCounts(companyId) : skillCounts(companyId),
|
||||
]);
|
||||
const views = buildFolderViews(folderRows);
|
||||
const countsByFolderId = new Map<string | null, number>();
|
||||
for (const row of countRows) countsByFolderId.set(row.folderId ?? null, Number(row.count ?? 0));
|
||||
return {
|
||||
kind,
|
||||
folders: folderRows.map((row) => ({
|
||||
...views.get(row.id)!,
|
||||
itemCount: countsByFolderId.get(row.id) ?? 0,
|
||||
})),
|
||||
allCount: Array.from(countsByFolderId.values()).reduce((sum, count) => sum + count, 0),
|
||||
unfiledCount: countsByFolderId.get(null) ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function isReservedRootSlug(kind: FolderKind, parentId: string | null, slug: string) {
|
||||
return kind === "skill" && parentId === null && RESERVED_ROOT_SLUGS.has(slug);
|
||||
}
|
||||
|
||||
async function isBundledFolder(companyId: string, folderId: string) {
|
||||
let current = await getFolder(companyId, folderId);
|
||||
const visited = new Set<string>();
|
||||
while (current) {
|
||||
if (current.systemKey === "bundled") return true;
|
||||
if (!current.parentId || visited.has(current.id)) return false;
|
||||
visited.add(current.id);
|
||||
current = await getFolder(companyId, current.parentId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function assertMutableFolder(companyId: string, folder: Folder) {
|
||||
if (folder.systemKey || await isBundledFolder(companyId, folder.id)) {
|
||||
throw forbidden("System-managed folders cannot be changed");
|
||||
}
|
||||
}
|
||||
|
||||
async function validateParent(companyId: string, kind: FolderKind, parentId: string | null) {
|
||||
if (!parentId) return null;
|
||||
const parent = await getFolder(companyId, parentId);
|
||||
if (!parent || parent.kind !== kind) throw notFound("Parent folder not found");
|
||||
if (await isBundledFolder(companyId, parent.id)) throw forbidden("Bundled folders are read-only");
|
||||
if (
|
||||
parent.kind === "skill"
|
||||
&& parent.parentId === null
|
||||
&& (RESERVED_CHILD_ROOT_SYSTEM_KEYS.has(parent.systemKey ?? "") || RESERVED_CHILD_ROOT_SYSTEM_KEYS.has(parent.slug))
|
||||
) {
|
||||
throw forbidden("Reserved skill folders are system-managed");
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
async function create(companyId: string, input: CreateFolder): Promise<Folder> {
|
||||
if (!mutationLockHeld) {
|
||||
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).create(companyId, input));
|
||||
}
|
||||
const parentId = input.parentId ?? null;
|
||||
const parent = await validateParent(companyId, input.kind, parentId);
|
||||
if ((parent?.depth ?? 0) + 1 > MAX_FOLDER_DEPTH) {
|
||||
throw unprocessable(`Folder depth cannot exceed ${MAX_FOLDER_DEPTH}`);
|
||||
}
|
||||
const name = normalizeName(input.name);
|
||||
const slug = input.slug ?? normalizeFolderSlug(name);
|
||||
if (isReservedRootSlug(input.kind, parentId, slug)) {
|
||||
throw forbidden("Reserved skill folders are system-managed");
|
||||
}
|
||||
await assertNoSlugConflict(companyId, input.kind, parentId, slug);
|
||||
const position = input.position ?? await nextPosition(companyId, input.kind, parentId);
|
||||
let row: FolderRow;
|
||||
try {
|
||||
row = await db
|
||||
.insert(folders)
|
||||
.values({ companyId, kind: input.kind, parentId, name, slug, color: normalizeColor(input.color) ?? null, position })
|
||||
.returning()
|
||||
.then((rows) => rows[0]!);
|
||||
} catch (error) {
|
||||
if (isPostgresError(error, "23505")) throw conflict("Folder slug already exists under this parent");
|
||||
throw error;
|
||||
}
|
||||
return (await getFolder(companyId, row.id))!;
|
||||
}
|
||||
|
||||
async function update(companyId: string, folderId: string, patch: UpdateFolder): Promise<Folder | null> {
|
||||
if (!mutationLockHeld) {
|
||||
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).update(companyId, folderId, patch));
|
||||
}
|
||||
const existing = await getFolder(companyId, folderId);
|
||||
if (!existing) return null;
|
||||
await assertMutableFolder(companyId, existing);
|
||||
const name = patch.name === undefined ? existing.name : normalizeName(patch.name);
|
||||
const slug = patch.slug ?? (patch.name === undefined ? existing.slug : normalizeFolderSlug(name));
|
||||
if (isReservedRootSlug(existing.kind, existing.parentId, slug)) {
|
||||
throw forbidden("Reserved skill folders are system-managed");
|
||||
}
|
||||
await assertNoSlugConflict(companyId, existing.kind, existing.parentId, slug, existing.id);
|
||||
try {
|
||||
await db
|
||||
.update(folders)
|
||||
.set({
|
||||
name,
|
||||
slug,
|
||||
color: patch.color === undefined ? existing.color : normalizeColor(patch.color),
|
||||
position: patch.position ?? existing.position,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)));
|
||||
} catch (error) {
|
||||
if (isPostgresError(error, "23505")) throw conflict("Folder slug already exists under this parent");
|
||||
throw error;
|
||||
}
|
||||
return getFolder(companyId, folderId);
|
||||
}
|
||||
|
||||
function descendantIdsFromRows(rows: FolderRow[], folderId: string) {
|
||||
if (!rows.some((row) => row.id === folderId)) throw notFound("Folder not found");
|
||||
const children = new Map<string, string[]>();
|
||||
for (const row of rows) {
|
||||
if (!row.parentId) continue;
|
||||
children.set(row.parentId, [...(children.get(row.parentId) ?? []), row.id]);
|
||||
}
|
||||
const result = new Set([folderId]);
|
||||
const queue = [folderId];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
for (const childId of children.get(current) ?? []) {
|
||||
if (result.has(childId)) throw unprocessable("Folder hierarchy contains a cycle");
|
||||
result.add(childId);
|
||||
queue.push(childId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function descendantIds(companyId: string, kind: FolderKind, folderId: string) {
|
||||
return descendantIdsFromRows(await getRows(companyId, kind), folderId);
|
||||
}
|
||||
|
||||
async function moveFolder(companyId: string, folderId: string, input: MoveFolder): Promise<Folder | null> {
|
||||
if (!mutationLockHeld) {
|
||||
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).moveFolder(companyId, folderId, input));
|
||||
}
|
||||
const existing = await getFolder(companyId, folderId);
|
||||
if (!existing) return null;
|
||||
await assertMutableFolder(companyId, existing);
|
||||
const parentId = input.parentId === undefined ? existing.parentId : input.parentId;
|
||||
if (parentId === existing.id) throw unprocessable("A folder cannot be its own parent");
|
||||
const rows = await getRows(companyId, existing.kind);
|
||||
const descendants = descendantIdsFromRows(rows, existing.id);
|
||||
if (parentId && descendants.has(parentId)) throw unprocessable("A folder cannot be moved into its own subtree");
|
||||
const parent = await validateParent(companyId, existing.kind, parentId);
|
||||
const views = buildFolderViews(rows);
|
||||
const relativeDepth = Math.max(...Array.from(descendants).map((id) => views.get(id)!.depth - existing.depth + 1));
|
||||
if ((parent?.depth ?? 0) + relativeDepth > MAX_FOLDER_DEPTH) {
|
||||
throw unprocessable(`Folder depth cannot exceed ${MAX_FOLDER_DEPTH}`);
|
||||
}
|
||||
if (isReservedRootSlug(existing.kind, parentId, existing.slug)) {
|
||||
throw forbidden("Reserved skill folders are system-managed");
|
||||
}
|
||||
await assertNoSlugConflict(companyId, existing.kind, parentId, existing.slug, existing.id);
|
||||
try {
|
||||
await db
|
||||
.update(folders)
|
||||
.set({ parentId, position: input.position, updatedAt: new Date() })
|
||||
.where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)));
|
||||
} catch (error) {
|
||||
if (isPostgresError(error, "23505")) throw conflict("Folder slug already exists under this parent");
|
||||
if (isPostgresError(error, "23503")) throw conflict("Parent folder changed during move");
|
||||
throw error;
|
||||
}
|
||||
return getFolder(companyId, folderId);
|
||||
}
|
||||
|
||||
async function deleteFolder(companyId: string, folderId: string): Promise<Folder | null> {
|
||||
if (!mutationLockHeld) {
|
||||
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).deleteFolder(companyId, folderId));
|
||||
}
|
||||
const existing = await getFolder(companyId, folderId);
|
||||
if (!existing) return null;
|
||||
await assertMutableFolder(companyId, existing);
|
||||
const child = await db
|
||||
.select({ id: folders.id })
|
||||
.from(folders)
|
||||
.where(and(eq(folders.companyId, companyId), eq(folders.parentId, folderId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (child) throw conflict("Move or delete nested folders first");
|
||||
try {
|
||||
await db.delete(folders).where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)));
|
||||
} catch (error) {
|
||||
if (isPostgresError(error, "23503")) throw conflict("Move or delete nested folders first");
|
||||
throw error;
|
||||
}
|
||||
return existing;
|
||||
}
|
||||
|
||||
async function validateSkillFolder(companyId: string, folderId: string, options?: { allowBundled?: boolean }) {
|
||||
const folder = await getFolder(companyId, folderId);
|
||||
if (!folder || folder.kind !== "skill") throw notFound("Skill folder not found");
|
||||
if (!options?.allowBundled && await isBundledFolder(companyId, folder.id)) {
|
||||
throw forbidden("Bundled folders are read-only");
|
||||
}
|
||||
return folder;
|
||||
}
|
||||
|
||||
async function moveItem(companyId: string, input: MoveFolderItem) {
|
||||
if (input.folderId) {
|
||||
const target = await getFolder(companyId, input.folderId);
|
||||
if (!target) throw notFound("Folder not found");
|
||||
if (target.kind !== input.kind) throw unprocessable("Folder kind must match item kind");
|
||||
if (await isBundledFolder(companyId, target.id)) throw forbidden("Bundled folders are read-only");
|
||||
}
|
||||
if (input.kind === "routine") {
|
||||
const row = await db
|
||||
.update(routines)
|
||||
.set({ folderId: input.folderId ?? null, updatedAt: new Date() })
|
||||
.where(and(eq(routines.companyId, companyId), eq(routines.id, input.itemId)))
|
||||
.returning({ id: routines.id, folderId: routines.folderId })
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!row) throw notFound("Routine not found");
|
||||
return { kind: input.kind, itemId: row.id, folderId: row.folderId ?? null };
|
||||
}
|
||||
const existing = await db
|
||||
.select({ id: companySkills.id, folderId: companySkills.folderId })
|
||||
.from(companySkills)
|
||||
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, input.itemId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!existing) throw notFound("Skill not found");
|
||||
if (existing.folderId && await isBundledFolder(companyId, existing.folderId)) {
|
||||
throw forbidden("Bundled skills cannot be moved");
|
||||
}
|
||||
const row = await db
|
||||
.update(companySkills)
|
||||
.set({ folderId: input.folderId ?? null, updatedAt: new Date() })
|
||||
.where(and(eq(companySkills.companyId, companyId), eq(companySkills.id, input.itemId)))
|
||||
.returning({ id: companySkills.id, folderId: companySkills.folderId })
|
||||
.then((rows) => rows[0]!);
|
||||
return { kind: input.kind, itemId: row.id, folderId: row.folderId ?? null };
|
||||
}
|
||||
|
||||
async function uniqueSiblingSlug(companyId: string, parentId: string | null, baseSlug: string, stableSuffix: string) {
|
||||
const siblingSlugs = new Set(await db
|
||||
.select({ slug: folders.slug })
|
||||
.from(folders)
|
||||
.where(and(
|
||||
eq(folders.companyId, companyId),
|
||||
eq(folders.kind, "skill"),
|
||||
parentId === null ? sql`${folders.parentId} is null` : eq(folders.parentId, parentId),
|
||||
))
|
||||
.then((rows) => rows.map((row) => row.slug)));
|
||||
if (!siblingSlugs.has(baseSlug)) return baseSlug;
|
||||
const suffix = normalizeFolderSlug(stableSuffix).slice(0, 24);
|
||||
let candidate = `${baseSlug}-${suffix}`;
|
||||
let duplicateNumber = 2;
|
||||
while (siblingSlugs.has(candidate)) {
|
||||
candidate = `${baseSlug}-${suffix}-${duplicateNumber}`;
|
||||
duplicateNumber += 1;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function findSystemFolder(companyId: string, systemKey: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(
|
||||
eq(folders.companyId, companyId),
|
||||
eq(folders.kind, "skill"),
|
||||
eq(folders.systemKey, systemKey),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function insertSystemFolder(input: {
|
||||
companyId: string;
|
||||
parentId: string | null;
|
||||
name: string;
|
||||
slug: string;
|
||||
systemKey: string;
|
||||
}) {
|
||||
const inserted = await db
|
||||
.insert(folders)
|
||||
.values({
|
||||
companyId: input.companyId,
|
||||
kind: "skill",
|
||||
parentId: input.parentId,
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
systemKey: input.systemKey,
|
||||
position: await nextPosition(input.companyId, "skill", input.parentId),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: folders.id })
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (inserted) return (await getFolder(input.companyId, inserted.id))!;
|
||||
const existing = await findSystemFolder(input.companyId, input.systemKey);
|
||||
return existing ? (await getFolder(input.companyId, existing.id))! : null;
|
||||
}
|
||||
|
||||
async function ensureContainer(companyId: string, slug: "bundled" | "my" | "projects", name: string) {
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const existingSystem = await findSystemFolder(companyId, slug);
|
||||
if (existingSystem) return (await getFolder(companyId, existingSystem.id))!;
|
||||
const squatted = await db
|
||||
.select({ id: folders.id })
|
||||
.from(folders)
|
||||
.where(and(
|
||||
eq(folders.companyId, companyId),
|
||||
eq(folders.kind, "skill"),
|
||||
sql`${folders.parentId} is null`,
|
||||
eq(folders.slug, slug),
|
||||
))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (squatted) {
|
||||
await db
|
||||
.update(folders)
|
||||
.set({ slug: await uniqueSiblingSlug(companyId, null, slug, squatted.id.slice(0, 8)), updatedAt: new Date() })
|
||||
.where(and(eq(folders.companyId, companyId), eq(folders.id, squatted.id)));
|
||||
}
|
||||
const created = await insertSystemFolder({ companyId, parentId: null, name, slug, systemKey: slug });
|
||||
if (created) return created;
|
||||
}
|
||||
throw conflict(`Could not create ${name} folder`);
|
||||
}
|
||||
|
||||
async function uniqueSystemSlug(
|
||||
companyId: string,
|
||||
parentId: string,
|
||||
baseSlug: string,
|
||||
systemKey: string,
|
||||
stableSuffix = systemKey.split(":").at(-1) ?? systemKey,
|
||||
) {
|
||||
const existingSystem = await db
|
||||
.select({ id: folders.id })
|
||||
.from(folders)
|
||||
.where(and(eq(folders.companyId, companyId), eq(folders.kind, "skill"), eq(folders.systemKey, systemKey)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (existingSystem) return { id: existingSystem.id, slug: null };
|
||||
return {
|
||||
id: null,
|
||||
slug: await uniqueSiblingSlug(companyId, parentId, baseSlug, stableSuffix),
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureMyFolder(companyId: string, userId: string, userName: string | null, requestedSlug?: string | null): Promise<Folder> {
|
||||
if (!mutationLockHeld) {
|
||||
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).ensureMyFolder(companyId, userId, userName, requestedSlug));
|
||||
}
|
||||
const parent = await ensureContainer(companyId, "my", "My Skills");
|
||||
const systemKey = `my:${userId}`;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const resolved = await uniqueSystemSlug(companyId, parent.id, requestedSlug ?? normalizeFolderSlug(userName ?? userId), systemKey);
|
||||
if (resolved.id) return (await getFolder(companyId, resolved.id))!;
|
||||
const created = await insertSystemFolder({
|
||||
companyId,
|
||||
parentId: parent.id,
|
||||
name: userName?.trim() || "My Skills",
|
||||
slug: resolved.slug!,
|
||||
systemKey,
|
||||
});
|
||||
if (created) return created;
|
||||
}
|
||||
throw conflict("Could not create personal skill folder");
|
||||
}
|
||||
|
||||
async function ensureProjectFolder(companyId: string, projectId: string, projectName: string): Promise<Folder> {
|
||||
if (!mutationLockHeld) {
|
||||
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).ensureProjectFolder(companyId, projectId, projectName));
|
||||
}
|
||||
const parent = await ensureContainer(companyId, "projects", "Projects");
|
||||
const systemKey = `project:${projectId}`;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const resolved = await uniqueSystemSlug(companyId, parent.id, normalizeFolderSlug(projectName), systemKey);
|
||||
if (resolved.id) return (await getFolder(companyId, resolved.id))!;
|
||||
const created = await insertSystemFolder({
|
||||
companyId,
|
||||
parentId: parent.id,
|
||||
name: projectName,
|
||||
slug: resolved.slug!,
|
||||
systemKey,
|
||||
});
|
||||
if (created) return created;
|
||||
}
|
||||
throw conflict("Could not create project skill folder");
|
||||
}
|
||||
|
||||
async function ensureBundledCategory(companyId: string, category: string): Promise<Folder> {
|
||||
if (!mutationLockHeld) {
|
||||
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).ensureBundledCategory(companyId, category));
|
||||
}
|
||||
const root = await ensureContainer(companyId, "bundled", "Bundled");
|
||||
const name = normalizeName(category);
|
||||
const slug = normalizeFolderSlug(category);
|
||||
const systemKey = `bundled:${slug}`;
|
||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||
const resolved = await uniqueSystemSlug(companyId, root.id, slug, systemKey, "bundled");
|
||||
if (resolved.id) {
|
||||
const existing = await getFolder(companyId, resolved.id);
|
||||
if (!existing) continue;
|
||||
if (existing.name === name) return existing;
|
||||
await db
|
||||
.update(folders)
|
||||
.set({ name, updatedAt: new Date() })
|
||||
.where(and(eq(folders.companyId, companyId), eq(folders.id, existing.id)));
|
||||
return (await getFolder(companyId, existing.id))!;
|
||||
}
|
||||
const created = await insertSystemFolder({
|
||||
companyId,
|
||||
parentId: root.id,
|
||||
name,
|
||||
slug: resolved.slug!,
|
||||
systemKey,
|
||||
});
|
||||
if (created) return created;
|
||||
}
|
||||
throw conflict("Could not create bundled skill folder");
|
||||
}
|
||||
|
||||
async function pruneEmptyBundledCategories(companyId: string, retainedCategories: string[]): Promise<void> {
|
||||
if (!mutationLockHeld) {
|
||||
return withCompanyFolderLock(companyId, (lockedDb) => folderService(lockedDb, true).pruneEmptyBundledCategories(companyId, retainedCategories));
|
||||
}
|
||||
const root = await findSystemFolder(companyId, "bundled");
|
||||
if (!root) return;
|
||||
const rows = await getRows(companyId, "skill");
|
||||
const retainedSystemKeys = new Set(
|
||||
retainedCategories.map((category) => `bundled:${normalizeFolderSlug(category)}`),
|
||||
);
|
||||
const usedFolderIds = new Set(
|
||||
await db
|
||||
.select({ folderId: companySkills.folderId })
|
||||
.from(companySkills)
|
||||
.where(eq(companySkills.companyId, companyId))
|
||||
.then((skills) => skills.flatMap((skill) => skill.folderId ? [skill.folderId] : [])),
|
||||
);
|
||||
const parentIds = new Set(rows.flatMap((row) => row.parentId ? [row.parentId] : []));
|
||||
for (const row of rows) {
|
||||
if (row.parentId !== root.id || !row.systemKey?.startsWith("bundled:")) continue;
|
||||
if (retainedSystemKeys.has(row.systemKey) || usedFolderIds.has(row.id) || parentIds.has(row.id)) continue;
|
||||
await db.delete(folders).where(and(eq(folders.companyId, companyId), eq(folders.id, row.id)));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
create,
|
||||
update,
|
||||
moveFolder,
|
||||
deleteFolder,
|
||||
moveItem,
|
||||
getFolder,
|
||||
descendantIds,
|
||||
validateSkillFolder,
|
||||
ensureMyFolder,
|
||||
ensureProjectFolder,
|
||||
ensureBundledCategory,
|
||||
pruneEmptyBundledCategories,
|
||||
};
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ export { companySearchExtractService } from "./company-search-extract.js";
|
|||
export { feedbackService } from "./feedback.js";
|
||||
export { companySkillService } from "./company-skills.js";
|
||||
export { companySkillPolicyService, normalizeSkillPolicySourceType } from "./company-skill-policy.js";
|
||||
export { folderService } from "./folders.js";
|
||||
export { agentService, deduplicateAgentName } from "./agents.js";
|
||||
export {
|
||||
builtInAgentService,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
documentRevisions,
|
||||
documents,
|
||||
executionWorkspaces,
|
||||
folders,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
issueInboxArchives,
|
||||
|
|
@ -974,6 +975,17 @@ export function routineService(
|
|||
if (parentIssue.companyId !== companyId) throw unprocessable("Parent issue must belong to same company");
|
||||
}
|
||||
|
||||
async function assertRoutineFolder(companyId: string, folderId: string | null | undefined) {
|
||||
if (!folderId) return;
|
||||
const folder = await db
|
||||
.select({ id: folders.id, kind: folders.kind })
|
||||
.from(folders)
|
||||
.where(and(eq(folders.companyId, companyId), eq(folders.id, folderId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!folder) throw notFound("Folder not found");
|
||||
if (folder.kind !== "routine") throw unprocessable("Folder kind must match routine");
|
||||
}
|
||||
|
||||
async function listTriggersForRoutineIds(companyId: string, routineIds: string[]) {
|
||||
if (routineIds.length === 0) return new Map<string, RoutineTrigger[]>();
|
||||
const rows = await db
|
||||
|
|
@ -2066,6 +2078,7 @@ export function routineService(
|
|||
|
||||
create: async (companyId: string, input: CreateRoutine, actor: Actor): Promise<Routine> => {
|
||||
await assertProject(companyId, input.projectId ?? null);
|
||||
await assertRoutineFolder(companyId, input.folderId ?? null);
|
||||
await assertAssignableAgent(db, companyId, input.assigneeAgentId ?? null, { kind: "routine" });
|
||||
if (input.goalId) await assertGoal(companyId, input.goalId);
|
||||
if (input.parentIssueId) await assertParentIssue(companyId, input.parentIssueId);
|
||||
|
|
@ -2092,6 +2105,7 @@ export function routineService(
|
|||
.values({
|
||||
companyId,
|
||||
projectId: input.projectId ?? null,
|
||||
folderId: input.folderId ?? null,
|
||||
goalId: input.goalId ?? null,
|
||||
parentIssueId: input.parentIssueId ?? null,
|
||||
title: input.title,
|
||||
|
|
@ -2130,6 +2144,7 @@ export function routineService(
|
|||
const existing = await getRoutineById(id);
|
||||
if (!existing) return null;
|
||||
const nextProjectId = patch.projectId === undefined ? existing.projectId : patch.projectId;
|
||||
const nextFolderId = patch.folderId === undefined ? existing.folderId : patch.folderId;
|
||||
const nextAssigneeAgentId = patch.assigneeAgentId === undefined ? existing.assigneeAgentId : patch.assigneeAgentId;
|
||||
const nextTitle = patch.title ?? existing.title;
|
||||
const nextDescription = patch.description === undefined ? existing.description : patch.description;
|
||||
|
|
@ -2153,6 +2168,7 @@ export function routineService(
|
|||
patch.variables === undefined ? existing.variables : sanitizeRoutineVariableInputs(patch.variables),
|
||||
);
|
||||
if (patch.projectId !== undefined) await assertProject(existing.companyId, nextProjectId);
|
||||
if (patch.folderId !== undefined) await assertRoutineFolder(existing.companyId, nextFolderId);
|
||||
if (patch.assigneeAgentId !== undefined || patch.status === "active") {
|
||||
await assertAssignableAgent(db, existing.companyId, nextAssigneeAgentId, { kind: "routine" });
|
||||
}
|
||||
|
|
@ -2202,6 +2218,7 @@ export function routineService(
|
|||
const candidate: RoutineRow = {
|
||||
...locked,
|
||||
projectId: nextProjectId,
|
||||
folderId: nextFolderId,
|
||||
goalId: patch.goalId === undefined ? locked.goalId : patch.goalId,
|
||||
parentIssueId: patch.parentIssueId === undefined ? locked.parentIssueId : patch.parentIssueId,
|
||||
title: nextTitle,
|
||||
|
|
@ -2218,8 +2235,20 @@ export function routineService(
|
|||
updatedByUserId: actor.userId ?? null,
|
||||
};
|
||||
|
||||
const folderChanged = patch.folderId !== undefined && locked.folderId !== candidate.folderId;
|
||||
if (locked.latestRevisionId && routineCurrentFieldsMatch(locked, candidate)) {
|
||||
return locked;
|
||||
if (!folderChanged) return locked;
|
||||
const [updated] = await txDb
|
||||
.update(routines)
|
||||
.set({
|
||||
folderId: candidate.folderId,
|
||||
updatedByAgentId: actor.agentId ?? null,
|
||||
updatedByUserId: actor.userId ?? null,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(routines.id, id))
|
||||
.returning();
|
||||
return updated ?? locked;
|
||||
}
|
||||
|
||||
const nextSnapshot = await buildRoutineRevisionSnapshot(txDb, candidate);
|
||||
|
|
@ -2252,6 +2281,7 @@ export function routineService(
|
|||
.update(routines)
|
||||
.set({
|
||||
projectId: candidate.projectId,
|
||||
folderId: candidate.folderId,
|
||||
goalId: candidate.goalId,
|
||||
parentIssueId: candidate.parentIssueId,
|
||||
title: candidate.title,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
import type {
|
||||
CreateFolderRequest,
|
||||
EnsureMySkillFolderRequest,
|
||||
Folder,
|
||||
FolderKind,
|
||||
FolderListResult,
|
||||
MoveFolderItemRequest,
|
||||
MoveFolderRequest,
|
||||
UpdateFolderRequest,
|
||||
} from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const foldersApi = {
|
||||
list: (companyId: string, kind: FolderKind) =>
|
||||
api.get<FolderListResult>(`/companies/${encodeURIComponent(companyId)}/folders?kind=${kind}`),
|
||||
create: (companyId: string, payload: CreateFolderRequest) =>
|
||||
api.post<Folder>(`/companies/${encodeURIComponent(companyId)}/folders`, payload),
|
||||
ensureMy: (companyId: string, payload: EnsureMySkillFolderRequest = {}) =>
|
||||
api.post<Folder>(`/companies/${encodeURIComponent(companyId)}/folders/ensure-my`, payload),
|
||||
update: (companyId: string, folderId: string, payload: UpdateFolderRequest) =>
|
||||
api.patch<Folder>(
|
||||
`/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}`,
|
||||
payload,
|
||||
),
|
||||
moveFolder: (companyId: string, folderId: string, payload: MoveFolderRequest) =>
|
||||
api.post<Folder>(
|
||||
`/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}/move`,
|
||||
payload,
|
||||
),
|
||||
moveItem: (companyId: string, payload: MoveFolderItemRequest) =>
|
||||
api.post<MoveFolderItemRequest>(
|
||||
`/companies/${encodeURIComponent(companyId)}/folders/items/move`,
|
||||
payload,
|
||||
),
|
||||
delete: (companyId: string, folderId: string) =>
|
||||
api.delete<{ deleted: Folder }>(
|
||||
`/companies/${encodeURIComponent(companyId)}/folders/${encodeURIComponent(folderId)}`,
|
||||
),
|
||||
};
|
||||
|
|
@ -64,6 +64,10 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
|
|||
disableToggle = false,
|
||||
hideArchiveAction = false,
|
||||
divider = true,
|
||||
selected = false,
|
||||
selectMode = false,
|
||||
extraMenuItems,
|
||||
onSelectChange,
|
||||
onRunNow,
|
||||
onToggleEnabled,
|
||||
onToggleArchived,
|
||||
|
|
@ -83,6 +87,10 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
|
|||
hideArchiveAction?: boolean;
|
||||
/** Render a bottom divider between consecutive rows. Off when the group is its own card. */
|
||||
divider?: boolean;
|
||||
selected?: boolean;
|
||||
selectMode?: boolean;
|
||||
extraMenuItems?: ReactNode;
|
||||
onSelectChange?: (routine: TRoutine, selected: boolean) => void;
|
||||
onRunNow: (routine: TRoutine) => void;
|
||||
onToggleEnabled: (routine: TRoutine, enabled: boolean) => void;
|
||||
onToggleArchived?: (routine: TRoutine) => void;
|
||||
|
|
@ -102,6 +110,23 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
|
|||
divider ? " border-b border-border last:border-b-0" : ""
|
||||
}`}
|
||||
>
|
||||
{selectMode ? (
|
||||
<div
|
||||
className="flex items-start pt-0.5 sm:pt-1"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-border"
|
||||
checked={selected}
|
||||
aria-label={`Select ${routine.title}`}
|
||||
onChange={(event) => onSelectChange?.(routine, event.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{routine.title}</span>
|
||||
|
|
@ -178,6 +203,12 @@ export function RoutineListRow<TRoutine extends RoutineListRowItem>({
|
|||
>
|
||||
{runningRoutineId === routine.id ? "Running..." : "Run now"}
|
||||
</DropdownMenuItem>
|
||||
{extraMenuItems ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
{extraMenuItems}
|
||||
</>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onToggleEnabled(routine, enabled)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,480 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FolderListResult } from "@paperclipai/shared";
|
||||
import {
|
||||
AllUnfiledBanner,
|
||||
BulkBar,
|
||||
DeleteFolderDialog,
|
||||
FolderFormDialog,
|
||||
FolderRail,
|
||||
MobileFolderSheet,
|
||||
MoveToMenu,
|
||||
folderSearchValue,
|
||||
normalizeFolderSelection,
|
||||
} from "./FolderControls";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
if (!globalThis.PointerEvent) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).PointerEvent = MouseEvent;
|
||||
}
|
||||
if (!Element.prototype.hasPointerCapture) {
|
||||
Element.prototype.hasPointerCapture = () => false;
|
||||
}
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => undefined;
|
||||
}
|
||||
|
||||
function act(callback: () => void | Promise<void>) {
|
||||
let result: void | Promise<void> | undefined;
|
||||
flushSync(() => {
|
||||
result = callback();
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
const folderResult: FolderListResult = {
|
||||
kind: "routine",
|
||||
allCount: 4,
|
||||
unfiledCount: 1,
|
||||
folders: [
|
||||
{
|
||||
id: "folder-reporting",
|
||||
companyId: "company-1",
|
||||
kind: "routine",
|
||||
parentId: null,
|
||||
name: "Reporting",
|
||||
slug: "reporting",
|
||||
systemKey: null,
|
||||
path: "reporting",
|
||||
depth: 1,
|
||||
color: "indigo",
|
||||
position: 0,
|
||||
itemCount: 3,
|
||||
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const skillFolderResult: FolderListResult = {
|
||||
kind: "skill",
|
||||
allCount: 8,
|
||||
unfiledCount: 1,
|
||||
folders: [
|
||||
{
|
||||
...folderResult.folders[0]!,
|
||||
id: "my",
|
||||
kind: "skill",
|
||||
name: "my",
|
||||
slug: "my",
|
||||
systemKey: "my",
|
||||
path: "my",
|
||||
itemCount: 1,
|
||||
},
|
||||
{
|
||||
...folderResult.folders[0]!,
|
||||
id: "engineering",
|
||||
kind: "skill",
|
||||
name: "Engineering",
|
||||
slug: "engineering",
|
||||
path: "engineering",
|
||||
itemCount: 3,
|
||||
},
|
||||
{
|
||||
...folderResult.folders[0]!,
|
||||
id: "code-review",
|
||||
kind: "skill",
|
||||
parentId: "engineering",
|
||||
name: "Code Review",
|
||||
slug: "code-review",
|
||||
path: "engineering/code-review",
|
||||
depth: 2,
|
||||
itemCount: 2,
|
||||
},
|
||||
{
|
||||
...folderResult.folders[0]!,
|
||||
id: "projects",
|
||||
kind: "skill",
|
||||
name: "projects",
|
||||
slug: "projects",
|
||||
systemKey: "projects",
|
||||
path: "projects",
|
||||
itemCount: 2,
|
||||
},
|
||||
{
|
||||
...folderResult.folders[0]!,
|
||||
id: "bundled",
|
||||
kind: "skill",
|
||||
name: "bundled",
|
||||
slug: "bundled",
|
||||
systemKey: "bundled",
|
||||
path: "bundled",
|
||||
itemCount: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("FolderControls", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
}
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("normalizes URL selection values for folder persistence", () => {
|
||||
expect(normalizeFolderSelection(null)).toBe("all");
|
||||
expect(normalizeFolderSelection("unfiled")).toBe("unfiled");
|
||||
expect(normalizeFolderSelection("folder-reporting")).toBe("folder-reporting");
|
||||
expect(folderSearchValue("all")).toBe("");
|
||||
expect(folderSearchValue("unfiled")).toBe("unfiled");
|
||||
expect(folderSearchValue("folder-reporting")).toBe("folder-reporting");
|
||||
});
|
||||
|
||||
it("renders All, user folders, and Unfiled with counts and selection callbacks", () => {
|
||||
const onSelect = vi.fn();
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
<FolderRail
|
||||
result={folderResult}
|
||||
selection="all"
|
||||
itemLabelPlural="routines"
|
||||
allLabel="All routines"
|
||||
onSelect={onSelect}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("All routines");
|
||||
expect(container.textContent).toContain("Reporting");
|
||||
expect(container.textContent).toContain("Unfiled");
|
||||
expect(container.textContent).toContain("4");
|
||||
expect(container.textContent).toContain("3");
|
||||
expect(container.textContent).toContain("1");
|
||||
|
||||
const reportingButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Reporting"),
|
||||
);
|
||||
expect(reportingButton).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
reportingButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("folder-reporting");
|
||||
});
|
||||
|
||||
it("marks the active row with aria-current, including virtual Unfiled", () => {
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<FolderRail
|
||||
result={folderResult}
|
||||
selection="unfiled"
|
||||
itemLabelPlural="routines"
|
||||
allLabel="All routines"
|
||||
onSelect={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={vi.fn()}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const current = container.querySelector('[aria-current="page"]');
|
||||
expect(current?.textContent).toContain("Unfiled");
|
||||
});
|
||||
|
||||
it("renames a folder inline via double-click and Enter", () => {
|
||||
const onRename = vi.fn();
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<FolderRail
|
||||
result={folderResult}
|
||||
selection="all"
|
||||
itemLabelPlural="routines"
|
||||
allLabel="All routines"
|
||||
onSelect={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
onRename={onRename}
|
||||
onEdit={vi.fn()}
|
||||
onDelete={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const nameButton = Array.from(container.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("Reporting"),
|
||||
);
|
||||
act(() => {
|
||||
nameButton?.dispatchEvent(new MouseEvent("dblclick", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
const input = container.querySelector<HTMLInputElement>("input");
|
||||
expect(input).toBeTruthy();
|
||||
expect(input?.value).toBe("Reporting");
|
||||
|
||||
act(() => {
|
||||
if (!input) return;
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(input, "Monthly reports");
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
act(() => {
|
||||
input?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onRename).toHaveBeenCalledWith(folderResult.folders[0], "Monthly reports");
|
||||
});
|
||||
|
||||
it("moves via MoveToMenu: Unfiled, a folder, and new-folder chaining", () => {
|
||||
const onMove = vi.fn();
|
||||
const onCreateAndMove = vi.fn();
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<DropdownMenu open modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button">Row actions</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<MoveToMenu
|
||||
folders={folderResult.folders}
|
||||
currentFolderId={null}
|
||||
onMove={onMove}
|
||||
onCreateAndMove={onCreateAndMove}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>,
|
||||
);
|
||||
});
|
||||
|
||||
const subTrigger = Array.from(document.querySelectorAll("[data-radix-collection-item]")).find(
|
||||
(element) => element.textContent?.includes("Move to"),
|
||||
);
|
||||
expect(subTrigger).toBeTruthy();
|
||||
act(() => {
|
||||
subTrigger?.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true }));
|
||||
});
|
||||
|
||||
const menuItems = Array.from(document.querySelectorAll('[role="menuitem"]'));
|
||||
const unfiledItem = menuItems.find((element) => element.textContent?.includes("Unfiled"));
|
||||
const folderItem = menuItems.find((element) => element.textContent?.includes("Reporting"));
|
||||
const newFolderItem = menuItems.find((element) => element.textContent?.includes("New folder"));
|
||||
expect(unfiledItem).toBeTruthy();
|
||||
expect(folderItem).toBeTruthy();
|
||||
expect(newFolderItem).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
unfiledItem?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(onMove).toHaveBeenCalledWith(null);
|
||||
|
||||
act(() => {
|
||||
folderItem?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(onMove).toHaveBeenCalledWith("folder-reporting");
|
||||
|
||||
act(() => {
|
||||
newFolderItem?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(onCreateAndMove).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates a folder through FolderFormDialog and disables submit on empty name", () => {
|
||||
const onSubmit = vi.fn();
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<FolderFormDialog
|
||||
open
|
||||
kind="routine"
|
||||
folder={null}
|
||||
onOpenChange={vi.fn()}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const submit = Array.from(document.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Create folder",
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(submit).toBeTruthy();
|
||||
expect(submit?.disabled).toBe(true);
|
||||
|
||||
const nameInput = document.querySelector<HTMLInputElement>("#folder-name");
|
||||
act(() => {
|
||||
if (!nameInput) return;
|
||||
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
setter?.call(nameInput, "Reporting");
|
||||
nameInput.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(submit?.disabled).toBe(false);
|
||||
act(() => {
|
||||
submit?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: "Reporting", color: expect.any(String) });
|
||||
});
|
||||
|
||||
it("states the forgiving delete behavior and confirms", () => {
|
||||
const onConfirm = vi.fn();
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<DeleteFolderDialog
|
||||
open
|
||||
folder={folderResult.folders[0]!}
|
||||
itemLabelPlural="routines"
|
||||
onOpenChange={vi.fn()}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("3 routines in this folder won't be deleted");
|
||||
expect(document.body.textContent).toContain("They'll move to Unfiled");
|
||||
|
||||
const confirmButton = Array.from(document.querySelectorAll("button")).find(
|
||||
(button) => button.textContent === "Delete folder",
|
||||
);
|
||||
act(() => {
|
||||
confirmButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
expect(onConfirm).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("selects a folder from the mobile sheet and dismisses", () => {
|
||||
const onSelect = vi.fn();
|
||||
const onOpenChange = vi.fn();
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<MobileFolderSheet
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
result={folderResult}
|
||||
selection="all"
|
||||
allLabel="All routines"
|
||||
itemLabelPlural="Routines"
|
||||
onSelect={onSelect}
|
||||
onCreate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(document.body.textContent).toContain("All routines");
|
||||
expect(document.body.textContent).toContain("Unfiled");
|
||||
|
||||
const reportingRow = Array.from(document.querySelectorAll("button")).find(
|
||||
(button) => button.textContent?.includes("Reporting"),
|
||||
);
|
||||
act(() => {
|
||||
reportingRow?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("folder-reporting");
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("preserves skill root grouping and child hierarchy in the mobile sheet", () => {
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<MobileFolderSheet
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
result={skillFolderResult}
|
||||
selection="all"
|
||||
allLabel="All skills"
|
||||
itemLabelPlural="Skills"
|
||||
onSelect={vi.fn()}
|
||||
onCreate={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
const body = document.body.textContent ?? "";
|
||||
expect(body.indexOf("My Skills")).toBeLessThan(body.indexOf("Company"));
|
||||
expect(body.indexOf("Company")).toBeLessThan(body.indexOf("Engineering"));
|
||||
expect(body.indexOf("Engineering")).toBeLessThan(body.indexOf("Code Review"));
|
||||
expect(body.indexOf("Code Review")).toBeLessThan(body.indexOf("Projects"));
|
||||
expect(body.indexOf("Projects")).toBeLessThan(body.indexOf("Bundled"));
|
||||
expect(document.querySelector('[data-folder-id="engineering"] > .pl-3 [data-folder-id="code-review"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("persists AllUnfiledBanner dismissal across mounts", () => {
|
||||
const storageKey = "paperclip:test-folder-nudge";
|
||||
window.localStorage.removeItem(storageKey);
|
||||
const onCreateFolder = vi.fn();
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<AllUnfiledBanner
|
||||
storageKey={storageKey}
|
||||
itemLabelPlural="routines"
|
||||
onCreateFolder={onCreateFolder}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain("Create your first folder");
|
||||
|
||||
const dismissButton = container.querySelector<HTMLButtonElement>(
|
||||
'button[aria-label="Dismiss folder suggestion"]',
|
||||
);
|
||||
act(() => {
|
||||
dismissButton?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
||||
});
|
||||
|
||||
expect(container.textContent).not.toContain("Create your first folder");
|
||||
expect(window.localStorage.getItem(storageKey)).toBe("1");
|
||||
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
root = createRoot(container);
|
||||
act(() => {
|
||||
root?.render(
|
||||
<AllUnfiledBanner
|
||||
storageKey={storageKey}
|
||||
itemLabelPlural="routines"
|
||||
onCreateFolder={onCreateFolder}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
expect(container.textContent).not.toContain("Create your first folder");
|
||||
window.localStorage.removeItem(storageKey);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,776 @@
|
|||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
Folder as FolderIcon,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { FolderKind, FolderListItem, FolderListResult } from "@paperclipai/shared";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
reservedRootLabel,
|
||||
treeFromResult,
|
||||
type FolderTreeNode,
|
||||
} from "./skill-folder-tree";
|
||||
|
||||
export type FolderSelection = "all" | "unfiled" | string;
|
||||
|
||||
export const FOLDER_COLORS = [
|
||||
"indigo",
|
||||
"violet",
|
||||
"emerald",
|
||||
"cyan",
|
||||
"amber",
|
||||
"slate",
|
||||
];
|
||||
|
||||
const FOLDER_COLOR_VALUES: Record<(typeof FOLDER_COLORS)[number], string> = {
|
||||
indigo: "var(--folder-color-indigo)",
|
||||
violet: "var(--folder-color-violet)",
|
||||
emerald: "var(--folder-color-emerald)",
|
||||
cyan: "var(--folder-color-cyan)",
|
||||
amber: "var(--folder-color-amber)",
|
||||
slate: "var(--folder-color-slate)",
|
||||
};
|
||||
|
||||
export function normalizeFolderSelection(value: string | null | undefined): FolderSelection {
|
||||
if (!value) return "all";
|
||||
if (value === "unfiled") return "unfiled";
|
||||
return value;
|
||||
}
|
||||
|
||||
export function folderSearchValue(selection: FolderSelection): string {
|
||||
return selection === "all" ? "" : selection === "unfiled" ? "unfiled" : selection;
|
||||
}
|
||||
|
||||
export function selectedFolderFromList(
|
||||
folders: FolderListItem[],
|
||||
selection: FolderSelection,
|
||||
): FolderListItem | null {
|
||||
if (selection === "all" || selection === "unfiled") return null;
|
||||
return folders.find((folder) => folder.id === selection) ?? null;
|
||||
}
|
||||
|
||||
export function FolderSwatch({
|
||||
color,
|
||||
className,
|
||||
}: {
|
||||
color: string | null | undefined;
|
||||
className?: string;
|
||||
}) {
|
||||
const backgroundColor = color
|
||||
? (FOLDER_COLOR_VALUES[color] ?? color)
|
||||
: "var(--folder-color-slate)";
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("h-2.5 w-2.5 shrink-0 rounded-sm border border-border/40", className)}
|
||||
style={{ backgroundColor }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function selectionLabel({
|
||||
folders,
|
||||
selection,
|
||||
allLabel,
|
||||
}: {
|
||||
folders: FolderListItem[];
|
||||
selection: FolderSelection;
|
||||
allLabel: string;
|
||||
}) {
|
||||
if (selection === "all") return allLabel;
|
||||
if (selection === "unfiled") return "Unfiled";
|
||||
return folders.find((folder) => folder.id === selection)?.name ?? allLabel;
|
||||
}
|
||||
|
||||
function selectionCount(result: FolderListResult | null | undefined, selection: FolderSelection) {
|
||||
if (!result) return 0;
|
||||
if (selection === "all") return result.allCount;
|
||||
if (selection === "unfiled") return result.unfiledCount;
|
||||
return result.folders.find((folder) => folder.id === selection)?.itemCount ?? 0;
|
||||
}
|
||||
|
||||
export function FolderChip({
|
||||
result,
|
||||
selection,
|
||||
allLabel,
|
||||
onClick,
|
||||
}: {
|
||||
result: FolderListResult | null | undefined;
|
||||
selection: FolderSelection;
|
||||
allLabel: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const folder = result ? selectedFolderFromList(result.folders, selection) : null;
|
||||
return (
|
||||
<Button variant="outline" size="sm" className="max-w-full justify-start" onClick={onClick}>
|
||||
{selection === "all" ? <FolderIcon className="mr-2 h-3.5 w-3.5" /> : <FolderSwatch color={folder?.color} className="mr-2" />}
|
||||
<span className="truncate">{selectionLabel({ folders: result?.folders ?? [], selection, allLabel })}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{selectionCount(result, selection)}</span>
|
||||
<ChevronDown className="ml-1 h-3.5 w-3.5 shrink-0" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderRail({
|
||||
result,
|
||||
selection,
|
||||
itemLabelPlural,
|
||||
allLabel,
|
||||
loading = false,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onRename,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
result: FolderListResult | null | undefined;
|
||||
selection: FolderSelection;
|
||||
itemLabelPlural: string;
|
||||
allLabel: string;
|
||||
loading?: boolean;
|
||||
onSelect: (selection: FolderSelection) => void;
|
||||
onCreate: () => void;
|
||||
onRename: (folder: FolderListItem, name: string) => void;
|
||||
onEdit: (folder: FolderListItem) => void;
|
||||
onDelete: (folder: FolderListItem) => void;
|
||||
}) {
|
||||
const folders = result?.folders ?? [];
|
||||
const [renamingFolderId, setRenamingFolderId] = useState<string | null>(null);
|
||||
const [renameDraft, setRenameDraft] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!renamingFolderId) return;
|
||||
const folder = folders.find((entry) => entry.id === renamingFolderId);
|
||||
if (!folder) setRenamingFolderId(null);
|
||||
}, [folders, renamingFolderId]);
|
||||
|
||||
function startRename(folder: FolderListItem) {
|
||||
setRenamingFolderId(folder.id);
|
||||
setRenameDraft(folder.name);
|
||||
}
|
||||
|
||||
function commitRename(folder: FolderListItem) {
|
||||
const name = renameDraft.trim();
|
||||
if (name && name !== folder.name) onRename(folder, name);
|
||||
setRenamingFolderId(null);
|
||||
}
|
||||
|
||||
function renderVirtualRow(
|
||||
key: FolderSelection,
|
||||
label: string,
|
||||
count: number,
|
||||
icon: ReactNode,
|
||||
) {
|
||||
const active = selection === key;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"grid w-full grid-cols-(--gtc-folder-row) items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/40",
|
||||
active ? "bg-accent/60 text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={() => onSelect(key)}
|
||||
>
|
||||
<span className="h-4 w-4">{icon}</span>
|
||||
<span className="truncate">{label}</span>
|
||||
<span className="text-xs text-muted-foreground">{count}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav aria-label={`${itemLabelPlural} folders`} className="hidden w-(--sz-folder-rail) shrink-0 border-r border-border pr-3 md:block">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">Folders</div>
|
||||
<Button variant="ghost" size="icon-sm" title="New folder" onClick={onCreate}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="space-y-2">
|
||||
<div className="h-7 rounded-md bg-muted/60" />
|
||||
<div className="h-7 rounded-md bg-muted/40" />
|
||||
<div className="h-7 rounded-md bg-muted/30" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{renderVirtualRow("all", allLabel, result?.allCount ?? 0, <FolderIcon className="h-3.5 w-3.5" />)}
|
||||
{folders.map((folder) => (
|
||||
<FolderRailItem
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
active={selection === folder.id}
|
||||
renaming={renamingFolderId === folder.id}
|
||||
renameDraft={renameDraft}
|
||||
onRenameDraftChange={setRenameDraft}
|
||||
onRenameCommit={() => commitRename(folder)}
|
||||
onRenameCancel={() => setRenamingFolderId(null)}
|
||||
onSelect={() => onSelect(folder.id)}
|
||||
onStartRename={() => startRename(folder)}
|
||||
onEdit={() => onEdit(folder)}
|
||||
onDelete={() => onDelete(folder)}
|
||||
/>
|
||||
))}
|
||||
<div className="px-2 pb-1 pt-3 text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
|
||||
System
|
||||
</div>
|
||||
{renderVirtualRow("unfiled", "Unfiled", result?.unfiledCount ?? 0, <FolderSwatch color={null} className="mt-0.5" />)}
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One selectable rail row. The leading 1rem grid column is the reserved
|
||||
* disclosure/indent slot so nested folders can slot in later without relayout
|
||||
* (ux-spec §3.1/§3.2 "nesting-ready").
|
||||
*/
|
||||
export function FolderRailItem({
|
||||
folder,
|
||||
active,
|
||||
renaming,
|
||||
renameDraft,
|
||||
onRenameDraftChange,
|
||||
onRenameCommit,
|
||||
onRenameCancel,
|
||||
onSelect,
|
||||
onStartRename,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
folder: FolderListItem;
|
||||
active: boolean;
|
||||
renaming: boolean;
|
||||
renameDraft: string;
|
||||
onRenameDraftChange: (value: string) => void;
|
||||
onRenameCommit: () => void;
|
||||
onRenameCancel: () => void;
|
||||
onSelect: () => void;
|
||||
onStartRename: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group grid grid-cols-(--gtc-folder-row-actions) items-center gap-2 rounded-md px-2 py-1 text-sm transition-colors hover:bg-accent/40",
|
||||
active ? "bg-accent/60 text-foreground" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="h-4 w-4" />
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 items-center gap-2 text-left"
|
||||
aria-current={active ? "page" : undefined}
|
||||
onClick={onSelect}
|
||||
onDoubleClick={onStartRename}
|
||||
>
|
||||
<FolderSwatch color={folder.color} />
|
||||
{renaming ? (
|
||||
<input
|
||||
value={renameDraft}
|
||||
onChange={(event) => onRenameDraftChange(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") onRenameCommit();
|
||||
if (event.key === "Escape") onRenameCancel();
|
||||
}}
|
||||
onBlur={onRenameCommit}
|
||||
className="h-6 min-w-0 flex-1 rounded-sm border border-border bg-background px-1 text-sm outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<span className="truncate">{folder.name}</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">{folder.itemCount}</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100"
|
||||
aria-label={`Folder actions for ${folder.name}`}
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={onStartRename}>Rename</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={onEdit}>Edit color</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onSelect={onDelete}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismissible nudge shown when items exist but no folders do (ux-spec §6.3).
|
||||
* Dismissal persists per storage key.
|
||||
*/
|
||||
export function AllUnfiledBanner({
|
||||
storageKey,
|
||||
itemLabelPlural,
|
||||
onCreateFolder,
|
||||
}: {
|
||||
storageKey: string;
|
||||
itemLabelPlural: string;
|
||||
onCreateFolder: () => void;
|
||||
}) {
|
||||
const [dismissed, setDismissed] = useState(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
function dismiss() {
|
||||
setDismissed(true);
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, "1");
|
||||
} catch {
|
||||
// Ignore storage failures; the banner just reappears next visit.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm">
|
||||
<FolderIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 text-muted-foreground">
|
||||
Group these {itemLabelPlural} into folders to keep things tidy.
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={onCreateFolder}>
|
||||
Create your first folder
|
||||
</Button>
|
||||
<Button size="icon-sm" variant="ghost" aria-label="Dismiss folder suggestion" onClick={dismiss}>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileFolderSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
result,
|
||||
selection,
|
||||
allLabel,
|
||||
itemLabelPlural,
|
||||
onSelect,
|
||||
onCreate,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
result: FolderListResult | null | undefined;
|
||||
selection: FolderSelection;
|
||||
allLabel: string;
|
||||
itemLabelPlural: string;
|
||||
onSelect: (selection: FolderSelection) => void;
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
function select(next: FolderSelection) {
|
||||
onSelect(next);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
const model = useMemo(() => treeFromResult(result), [result]);
|
||||
|
||||
function renderBranch(node: FolderTreeNode, rootLabel?: string) {
|
||||
return (
|
||||
<div key={node.folder.id} data-folder-id={node.folder.id}>
|
||||
<MobileFolderRow
|
||||
id={node.folder.id}
|
||||
label={rootLabel ?? node.folder.name}
|
||||
count={node.folder.itemCount}
|
||||
color={node.folder.color}
|
||||
selected={selection === node.folder.id}
|
||||
onSelect={select}
|
||||
/>
|
||||
{node.children.length > 0 ? (
|
||||
<div className="pl-3">
|
||||
{node.children.map((child) => renderBranch(child))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="bottom" className="max-h-(--sz-folder-sheet-max) rounded-t-lg pb-4">
|
||||
<SheetHeader className="border-b border-border px-4 py-3">
|
||||
<SheetTitle>{itemLabelPlural} folders</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="overflow-y-auto px-3">
|
||||
<MobileFolderRow
|
||||
id="all"
|
||||
label={allLabel}
|
||||
count={result?.allCount ?? 0}
|
||||
selected={selection === "all"}
|
||||
onSelect={select}
|
||||
all
|
||||
/>
|
||||
{result?.kind === "skill" ? (
|
||||
<>
|
||||
{model.my ? renderBranch(model.my, "My Skills") : null}
|
||||
<div className="px-2 pb-0.5 pt-2 text-(length:--text-micro) font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Company
|
||||
</div>
|
||||
{model.company.map((node) => renderBranch(node))}
|
||||
{model.projects ? renderBranch(model.projects, "Projects") : null}
|
||||
{model.bundled ? renderBranch(model.bundled, "Bundled") : null}
|
||||
</>
|
||||
) : (
|
||||
model.roots.map((node) => renderBranch(node, reservedRootLabel(node.folder)))
|
||||
)}
|
||||
<MobileFolderRow
|
||||
id="unfiled"
|
||||
label="Unfiled"
|
||||
count={result?.unfiledCount ?? 0}
|
||||
selected={selection === "unfiled"}
|
||||
onSelect={select}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-border px-4 pt-3">
|
||||
<Button size="sm" variant="outline" className="w-full" onClick={onCreate}>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
New folder
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileFolderRow({
|
||||
id,
|
||||
label,
|
||||
count,
|
||||
color,
|
||||
selected,
|
||||
all = false,
|
||||
onSelect,
|
||||
}: {
|
||||
id: FolderSelection;
|
||||
label: string;
|
||||
count: number;
|
||||
color?: string | null;
|
||||
selected: boolean;
|
||||
all?: boolean;
|
||||
onSelect: (selection: FolderSelection) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm hover:bg-accent/50"
|
||||
onClick={() => onSelect(id)}
|
||||
>
|
||||
{all ? <FolderIcon className="h-3.5 w-3.5 text-muted-foreground" /> : <FolderSwatch color={color} />}
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
<span className="text-xs text-muted-foreground">{count}</span>
|
||||
{selected ? <Check className="h-3.5 w-3.5" /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoveToMenu({
|
||||
folders,
|
||||
currentFolderId,
|
||||
onMove,
|
||||
onCreateAndMove,
|
||||
}: {
|
||||
folders: FolderListItem[];
|
||||
currentFolderId: string | null | undefined;
|
||||
onMove: (folderId: string | null) => void;
|
||||
onCreateAndMove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>Move to...</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-56">
|
||||
<MoveToMenuItems
|
||||
folders={folders}
|
||||
currentFolderId={currentFolderId}
|
||||
onMove={onMove}
|
||||
onCreateAndMove={onCreateAndMove}
|
||||
/>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveToMenuItems({
|
||||
folders,
|
||||
currentFolderId,
|
||||
onMove,
|
||||
onCreateAndMove,
|
||||
}: {
|
||||
folders: FolderListItem[];
|
||||
currentFolderId: string | null | undefined;
|
||||
onMove: (folderId: string | null) => void;
|
||||
onCreateAndMove: () => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const visibleFolders = useMemo(() => {
|
||||
const lowered = query.trim().toLowerCase();
|
||||
if (!lowered) return folders;
|
||||
return folders.filter((folder) => folder.name.toLowerCase().includes(lowered));
|
||||
}, [folders, query]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
||||
<Search className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
placeholder="Search folders"
|
||||
className="h-7 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={() => onMove(null)}>
|
||||
<FolderSwatch color={null} />
|
||||
Unfiled
|
||||
{currentFolderId == null ? <Check className="ml-auto h-3.5 w-3.5" /> : null}
|
||||
</DropdownMenuItem>
|
||||
{visibleFolders.map((folder) => (
|
||||
<DropdownMenuItem key={folder.id} onSelect={() => onMove(folder.id)}>
|
||||
<FolderSwatch color={folder.color} />
|
||||
<span className="min-w-0 flex-1 truncate">{folder.name}</span>
|
||||
{currentFolderId === folder.id ? <Check className="ml-auto h-3.5 w-3.5" /> : null}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{visibleFolders.length === 0 ? (
|
||||
<div className="px-2 py-2 text-xs text-muted-foreground">No folders match.</div>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onSelect={onCreateAndMove}>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
New folder...
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FolderFormDialog({
|
||||
open,
|
||||
kind,
|
||||
folder,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
pending = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
kind: FolderKind;
|
||||
folder: FolderListItem | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (payload: { name: string; color: string | null }) => void;
|
||||
pending?: boolean;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState<string | null>(FOLDER_COLORS[0] ?? null);
|
||||
const isEdit = Boolean(folder);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(folder?.name ?? "");
|
||||
setColor(folder?.color ?? FOLDER_COLORS[0] ?? null);
|
||||
}, [folder, open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? "Edit folder" : "Create folder"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{kind === "routine" ? "Organize routines in this company." : "Organize installed company skills."}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium" htmlFor="folder-name">Name</label>
|
||||
<Input
|
||||
id="folder-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
autoFocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && name.trim()) onSubmit({ name: name.trim(), color });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Color</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{FOLDER_COLORS.map((swatch) => (
|
||||
<button
|
||||
key={swatch}
|
||||
type="button"
|
||||
aria-label={`Use folder color ${swatch}`}
|
||||
className={cn(
|
||||
"h-7 w-7 rounded-md border border-border",
|
||||
color === swatch && "ring-2 ring-ring ring-offset-2 ring-offset-background",
|
||||
)}
|
||||
style={{ backgroundColor: FOLDER_COLOR_VALUES[swatch] }}
|
||||
onClick={() => setColor(swatch)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"h-7 rounded-md border border-border px-2 text-xs text-muted-foreground",
|
||||
color == null && "ring-2 ring-ring ring-offset-2 ring-offset-background",
|
||||
)}
|
||||
onClick={() => setColor(null)}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={() => onSubmit({ name: name.trim(), color })} disabled={pending || !name.trim()}>
|
||||
{pending ? "Saving..." : isEdit ? "Save" : "Create folder"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeleteFolderDialog({
|
||||
open,
|
||||
folder,
|
||||
itemLabelPlural,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
pending = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
folder: FolderListItem | null;
|
||||
itemLabelPlural: string;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
pending?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete folder</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The {folder?.itemCount ?? 0} {itemLabelPlural} in this folder won't be deleted. They'll move to Unfiled.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={pending}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
disabled={pending || !folder}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onConfirm();
|
||||
}}
|
||||
>
|
||||
{pending ? "Deleting..." : "Delete folder"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function BulkBar({
|
||||
selectedCount,
|
||||
folders,
|
||||
onMove,
|
||||
onCreateAndMove,
|
||||
onClear,
|
||||
onDone,
|
||||
}: {
|
||||
selectedCount: number;
|
||||
folders: FolderListItem[];
|
||||
onMove: (folderId: string | null) => void;
|
||||
onCreateAndMove: () => void;
|
||||
onClear: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
if (selectedCount === 0) return null;
|
||||
return (
|
||||
<div className="sticky top-2 z-10 flex flex-wrap items-center gap-2 rounded-md border border-border bg-background/95 px-3 py-2 shadow-sm backdrop-blur">
|
||||
<span className="mr-auto text-sm text-muted-foreground">{selectedCount} selected</span>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="outline">Move to...</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<MoveToMenuItems
|
||||
folders={folders}
|
||||
currentFolderId={undefined}
|
||||
onMove={onMove}
|
||||
onCreateAndMove={onCreateAndMove}
|
||||
/>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button size="sm" variant="ghost" onClick={onClear}>Deselect all</Button>
|
||||
<Button size="sm" onClick={onDone}>Done</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { FolderListResult } from "@paperclipai/shared";
|
||||
import { SkillFolderRail } from "./SkillFolderTree";
|
||||
|
||||
function pointerEvent(type: string, clientX: number) {
|
||||
const event = new MouseEvent(type, { bubbles: true, clientX });
|
||||
Object.defineProperty(event, "pointerId", { value: 1 });
|
||||
return event;
|
||||
}
|
||||
|
||||
const result: FolderListResult = {
|
||||
kind: "skill",
|
||||
allCount: 12,
|
||||
unfiledCount: 2,
|
||||
folders: [
|
||||
{
|
||||
id: "my-root",
|
||||
companyId: "company-1",
|
||||
kind: "skill",
|
||||
parentId: null,
|
||||
name: "My Skills",
|
||||
slug: "my",
|
||||
systemKey: "my",
|
||||
path: "my",
|
||||
depth: 1,
|
||||
color: null,
|
||||
position: 0,
|
||||
itemCount: 4,
|
||||
createdAt: new Date("2026-07-16T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-16T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "personal-root",
|
||||
companyId: "company-1",
|
||||
kind: "skill",
|
||||
parentId: "my-root",
|
||||
name: "Ada",
|
||||
slug: "ada",
|
||||
systemKey: "my:user-1",
|
||||
path: "my/ada",
|
||||
depth: 2,
|
||||
color: null,
|
||||
position: 0,
|
||||
itemCount: 4,
|
||||
createdAt: new Date("2026-07-16T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-16T00:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("SkillFolderRail", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
const onSelect = vi.fn<(selection: string) => void>();
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
onSelect.mockClear();
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<SkillFolderRail
|
||||
result={result}
|
||||
selection="all"
|
||||
tags={[]}
|
||||
activeTag={null}
|
||||
onSelect={onSelect}
|
||||
onSelectTag={vi.fn()}
|
||||
onCreateFolder={vi.fn()}
|
||||
onRenameFolder={vi.fn()}
|
||||
onEditFolder={vi.fn()}
|
||||
onMoveFolder={vi.fn()}
|
||||
onDeleteFolder={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root.unmount());
|
||||
container.remove();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it("uses the wider default and persists drag resizing", () => {
|
||||
const rail = container.firstElementChild as HTMLDivElement;
|
||||
const separator = container.querySelector('[role="separator"]') as HTMLDivElement;
|
||||
expect(rail.style.width).toBe("288px");
|
||||
separator.setPointerCapture = vi.fn();
|
||||
|
||||
flushSync(() => {
|
||||
separator.dispatchEvent(pointerEvent("pointerdown", 288));
|
||||
separator.dispatchEvent(pointerEvent("pointermove", 320));
|
||||
separator.dispatchEvent(pointerEvent("pointerup", 320));
|
||||
});
|
||||
|
||||
expect(rail.style.width).toBe("320px");
|
||||
expect(window.localStorage.getItem("paperclip.skills.folderRail.width")).toBe("320");
|
||||
});
|
||||
|
||||
it("keeps virtual and folder counts on the same grid column", () => {
|
||||
const allRow = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("All skills"));
|
||||
const myLabel = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("My Skills"));
|
||||
const myRow = myLabel?.parentElement;
|
||||
|
||||
expect(allRow?.className).toContain("grid-cols-(--gtc-folder-row-actions)");
|
||||
expect(myRow?.className).toContain("grid-cols-(--gtc-folder-row-actions)");
|
||||
expect(allRow?.querySelector(".tabular-nums")?.textContent).toBe("12");
|
||||
expect(myRow?.querySelector(".tabular-nums")?.textContent).toBe("4");
|
||||
});
|
||||
|
||||
it("selects and toggles a folder when its row label is clicked", () => {
|
||||
const myLabel = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.includes("My Skills"));
|
||||
|
||||
expect(container.textContent).not.toContain("Ada");
|
||||
flushSync(() => myLabel?.click());
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith("my-root");
|
||||
expect(container.textContent).toContain("Ada");
|
||||
expect(container.querySelector('[aria-label="Collapse folder"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,118 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { FolderListItem } from "@paperclipai/shared";
|
||||
import {
|
||||
buildSkillFolderTree,
|
||||
folderBreadcrumbTrail,
|
||||
isBundledFolder,
|
||||
isProjectsFolder,
|
||||
reservedRootLabel,
|
||||
skillFolderDisplayPath,
|
||||
skillFolderPathDisplayFallback,
|
||||
subtreeFolderIds,
|
||||
} from "./skill-folder-tree";
|
||||
|
||||
function folder(partial: Partial<FolderListItem> & { id: string; slug: string; path: string }): FolderListItem {
|
||||
return {
|
||||
companyId: "co",
|
||||
kind: "skill",
|
||||
parentId: null,
|
||||
name: partial.slug,
|
||||
systemKey: null,
|
||||
depth: 1,
|
||||
color: null,
|
||||
position: 0,
|
||||
itemCount: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
const folders: FolderListItem[] = [
|
||||
folder({ id: "my", slug: "my", path: "my", systemKey: "my", name: "my", position: 0 }),
|
||||
folder({ id: "mine", slug: "dotta", path: "my/dotta", parentId: "my", systemKey: "my:u1", depth: 2, position: 0 }),
|
||||
folder({ id: "eng", slug: "engineering", path: "engineering", name: "Engineering", position: 1 }),
|
||||
folder({ id: "eng-review", slug: "review", path: "engineering/review", parentId: "eng", depth: 2, name: "Review", position: 0 }),
|
||||
folder({ id: "proj", slug: "projects", path: "projects", systemKey: "projects", name: "projects", position: 2 }),
|
||||
folder({ id: "proj-a", slug: "acme", path: "projects/acme", parentId: "proj", systemKey: "project:a", depth: 2 }),
|
||||
folder({ id: "bundled", slug: "bundled", path: "bundled", systemKey: "bundled", name: "bundled", position: 3 }),
|
||||
folder({ id: "bundled-git", slug: "git", path: "bundled/git", parentId: "bundled", systemKey: "bundled:git", depth: 2 }),
|
||||
];
|
||||
|
||||
describe("buildSkillFolderTree", () => {
|
||||
it("groups reserved roots and company folders in order", () => {
|
||||
const model = buildSkillFolderTree(folders);
|
||||
expect(model.my?.folder.id).toBe("my");
|
||||
expect(model.projects?.folder.id).toBe("proj");
|
||||
expect(model.bundled?.folder.id).toBe("bundled");
|
||||
expect(model.company.map((n) => n.folder.id)).toEqual(["eng"]);
|
||||
// Ordered roots: My → company → Projects → Bundled.
|
||||
expect(model.roots.map((n) => n.folder.id)).toEqual(["my", "eng", "proj", "bundled"]);
|
||||
});
|
||||
|
||||
it("nests children under their parent", () => {
|
||||
const model = buildSkillFolderTree(folders);
|
||||
expect(model.my?.children.map((n) => n.folder.id)).toEqual(["mine"]);
|
||||
expect(model.company[0]?.children.map((n) => n.folder.id)).toEqual(["eng-review"]);
|
||||
expect(model.childrenById.get("bundled")?.map((n) => n.folder.id)).toEqual(["bundled-git"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subtreeFolderIds", () => {
|
||||
it("includes the folder and all descendants", () => {
|
||||
const model = buildSkillFolderTree(folders);
|
||||
expect([...subtreeFolderIds(model, "eng")].sort()).toEqual(["eng", "eng-review"]);
|
||||
expect([...subtreeFolderIds(model, "my")].sort()).toEqual(["mine", "my"]);
|
||||
expect([...subtreeFolderIds(model, "eng-review")]).toEqual(["eng-review"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("folderBreadcrumbTrail", () => {
|
||||
it("walks from top-level root down to the target", () => {
|
||||
const model = buildSkillFolderTree(folders);
|
||||
expect(folderBreadcrumbTrail(model, "eng-review").map((f) => f.id)).toEqual(["eng", "eng-review"]);
|
||||
expect(folderBreadcrumbTrail(model, "mine").map((f) => f.id)).toEqual(["my", "mine"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reserved subtree detection", () => {
|
||||
it("flags bundled root and descendants", () => {
|
||||
expect(isBundledFolder({ path: "bundled", systemKey: "bundled" })).toBe(true);
|
||||
expect(isBundledFolder({ path: "bundled/git", systemKey: "bundled:git" })).toBe(true);
|
||||
expect(isBundledFolder({ path: "engineering", systemKey: null })).toBe(false);
|
||||
});
|
||||
|
||||
it("flags projects root and descendants", () => {
|
||||
expect(isProjectsFolder({ path: "projects", systemKey: "projects" })).toBe(true);
|
||||
expect(isProjectsFolder({ path: "projects/acme", systemKey: "project:a" })).toBe(true);
|
||||
expect(isProjectsFolder({ path: "my/dotta", systemKey: "my:u1" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reservedRootLabel", () => {
|
||||
it("renames reserved roots and passes through others", () => {
|
||||
expect(reservedRootLabel({ systemKey: "my", name: "my" })).toBe("My Skills");
|
||||
expect(reservedRootLabel({ systemKey: "projects", name: "projects" })).toBe("Projects");
|
||||
expect(reservedRootLabel({ systemKey: "bundled", name: "bundled" })).toBe("Bundled");
|
||||
expect(reservedRootLabel({ systemKey: null, name: "Engineering" })).toBe("Engineering");
|
||||
});
|
||||
});
|
||||
|
||||
describe("skillFolderDisplayPath", () => {
|
||||
it("prefixes company folders and preserves reserved-root labels", () => {
|
||||
const model = buildSkillFolderTree(folders);
|
||||
expect(skillFolderDisplayPath(model, "eng-review")).toBe("Company / Engineering / Review");
|
||||
expect(skillFolderDisplayPath(model, "mine")).toBe("My Skills / dotta");
|
||||
expect(skillFolderDisplayPath(model, "bundled-git")).toBe("Bundled / git");
|
||||
expect(skillFolderDisplayPath(model, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("skillFolderPathDisplayFallback", () => {
|
||||
it("humanizes cold detail paths before folder metadata loads", () => {
|
||||
expect(skillFolderPathDisplayFallback("engineering/code-review")).toBe("Company / Engineering / Code Review");
|
||||
expect(skillFolderPathDisplayFallback("my/local-board/drafts")).toBe("My Skills / Local Board / Drafts");
|
||||
expect(skillFolderPathDisplayFallback("bundled/review-pr")).toBe("Bundled / Review Pr");
|
||||
expect(skillFolderPathDisplayFallback(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
import type { FolderListItem, FolderListResult } from "@paperclipai/shared";
|
||||
|
||||
/**
|
||||
* Pure tree helpers for the skill folder browser (Idea A, PAP-14038).
|
||||
*
|
||||
* The server returns a *flat* {@link FolderListResult}; every folder carries a
|
||||
* `parentId`, a canonical slug `path` (root = slug, children = `parent/slug`),
|
||||
* a `depth`, and a `systemKey`. Reserved roots have a stable `systemKey`:
|
||||
* - `my` → "My Skills" (personal folders live beneath it as `my:<userId>`)
|
||||
* - `projects` → "Projects" (auto-created project folders `project:<id>`)
|
||||
* - `bundled` → "Bundled" (read-only; categories `bundled:<slug>`)
|
||||
* Everything else at the top level is a plain company folder and is grouped
|
||||
* under the virtual "Company" heading in the rail.
|
||||
*/
|
||||
|
||||
export interface FolderTreeNode {
|
||||
folder: FolderListItem;
|
||||
children: FolderTreeNode[];
|
||||
}
|
||||
|
||||
export type ReservedRootKey = "my" | "projects" | "bundled";
|
||||
|
||||
export interface SkillFolderTreeModel {
|
||||
/** "My Skills" reserved root, or null when it hasn't been provisioned yet. */
|
||||
my: FolderTreeNode | null;
|
||||
/** Non-reserved top-level folders, shown under the "Company" heading. */
|
||||
company: FolderTreeNode[];
|
||||
/** "Projects" reserved root. */
|
||||
projects: FolderTreeNode | null;
|
||||
/** "Bundled" reserved root (read-only subtree). */
|
||||
bundled: FolderTreeNode | null;
|
||||
/** Every folder by id, for O(1) lookups. */
|
||||
byId: Map<string, FolderListItem>;
|
||||
/** Direct child nodes for a given folder id. */
|
||||
childrenById: Map<string, FolderTreeNode[]>;
|
||||
/** All top-level nodes in reserved-then-company order (for move pickers). */
|
||||
roots: FolderTreeNode[];
|
||||
}
|
||||
|
||||
const RESERVED_ROOT_SYSTEM_KEYS = new Set<string>(["my", "projects", "bundled"]);
|
||||
|
||||
export function isReservedRootSystemKey(systemKey: string | null | undefined): boolean {
|
||||
return Boolean(systemKey && RESERVED_ROOT_SYSTEM_KEYS.has(systemKey));
|
||||
}
|
||||
|
||||
/** True for the Bundled root or anything nested inside it (read-only subtree). */
|
||||
export function isBundledFolder(folder: Pick<FolderListItem, "path" | "systemKey">): boolean {
|
||||
if (folder.systemKey === "bundled" || folder.systemKey?.startsWith("bundled:")) return true;
|
||||
return folder.path === "bundled" || folder.path.startsWith("bundled/");
|
||||
}
|
||||
|
||||
/** True for the Projects root or anything nested inside it (auto-managed subtree). */
|
||||
export function isProjectsFolder(folder: Pick<FolderListItem, "path" | "systemKey">): boolean {
|
||||
if (folder.systemKey === "projects" || folder.systemKey?.startsWith("project:")) return true;
|
||||
return folder.path === "projects" || folder.path.startsWith("projects/");
|
||||
}
|
||||
|
||||
function sortNodes(nodes: FolderTreeNode[]): void {
|
||||
nodes.sort(
|
||||
(a, b) => a.folder.position - b.folder.position || a.folder.name.localeCompare(b.folder.name),
|
||||
);
|
||||
for (const node of nodes) sortNodes(node.children);
|
||||
}
|
||||
|
||||
export function buildSkillFolderTree(folders: FolderListItem[]): SkillFolderTreeModel {
|
||||
const byId = new Map<string, FolderListItem>();
|
||||
const nodeById = new Map<string, FolderTreeNode>();
|
||||
for (const folder of folders) {
|
||||
byId.set(folder.id, folder);
|
||||
nodeById.set(folder.id, { folder, children: [] });
|
||||
}
|
||||
|
||||
const roots: FolderTreeNode[] = [];
|
||||
for (const folder of folders) {
|
||||
const node = nodeById.get(folder.id)!;
|
||||
const parent = folder.parentId ? nodeById.get(folder.parentId) : null;
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
}
|
||||
sortNodes(roots);
|
||||
|
||||
let my: FolderTreeNode | null = null;
|
||||
let projects: FolderTreeNode | null = null;
|
||||
let bundled: FolderTreeNode | null = null;
|
||||
const company: FolderTreeNode[] = [];
|
||||
for (const node of roots) {
|
||||
switch (node.folder.systemKey) {
|
||||
case "my":
|
||||
my = node;
|
||||
break;
|
||||
case "projects":
|
||||
projects = node;
|
||||
break;
|
||||
case "bundled":
|
||||
bundled = node;
|
||||
break;
|
||||
default:
|
||||
company.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const childrenById = new Map<string, FolderTreeNode[]>();
|
||||
for (const [id, node] of nodeById) childrenById.set(id, node.children);
|
||||
|
||||
// Ordered roots: reserved first (My → Projects → Bundled), then company folders.
|
||||
const orderedRoots: FolderTreeNode[] = [];
|
||||
if (my) orderedRoots.push(my);
|
||||
orderedRoots.push(...company);
|
||||
if (projects) orderedRoots.push(projects);
|
||||
if (bundled) orderedRoots.push(bundled);
|
||||
|
||||
return { my, company, projects, bundled, byId, childrenById, roots: orderedRoots };
|
||||
}
|
||||
|
||||
/** Folder id + every descendant id (the subtree rooted at `folderId`). */
|
||||
export function subtreeFolderIds(model: SkillFolderTreeModel, folderId: string): Set<string> {
|
||||
const out = new Set<string>([folderId]);
|
||||
const queue = [folderId];
|
||||
while (queue.length > 0) {
|
||||
const id = queue.pop()!;
|
||||
for (const child of model.childrenById.get(id) ?? []) {
|
||||
if (!out.has(child.folder.id)) {
|
||||
out.add(child.folder.id);
|
||||
queue.push(child.folder.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The chain of folders from the top-level root down to `folderId` (inclusive). */
|
||||
export function folderBreadcrumbTrail(
|
||||
model: SkillFolderTreeModel,
|
||||
folderId: string,
|
||||
): FolderListItem[] {
|
||||
const trail: FolderListItem[] = [];
|
||||
let current: FolderListItem | undefined = model.byId.get(folderId);
|
||||
const guard = new Set<string>();
|
||||
while (current && !guard.has(current.id)) {
|
||||
guard.add(current.id);
|
||||
trail.unshift(current);
|
||||
current = current.parentId ? model.byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return trail;
|
||||
}
|
||||
|
||||
/** Human label for a reserved root, used when composing breadcrumb prefixes. */
|
||||
export function reservedRootLabel(folder: Pick<FolderListItem, "systemKey" | "name">): string {
|
||||
switch (folder.systemKey) {
|
||||
case "my":
|
||||
return "My Skills";
|
||||
case "projects":
|
||||
return "Projects";
|
||||
case "bundled":
|
||||
return "Bundled";
|
||||
default:
|
||||
return folder.name;
|
||||
}
|
||||
}
|
||||
|
||||
/** Human-readable canonical path used by detail surfaces and breadcrumbs. */
|
||||
export function skillFolderDisplayPath(
|
||||
model: SkillFolderTreeModel,
|
||||
folderId: string | null | undefined,
|
||||
): string | null {
|
||||
if (!folderId) return null;
|
||||
const trail = folderBreadcrumbTrail(model, folderId);
|
||||
if (trail.length === 0) return null;
|
||||
const labels = trail.map((folder) => reservedRootLabel(folder));
|
||||
if (!trail[0]?.systemKey) labels.unshift("Company");
|
||||
return labels.join(" / ");
|
||||
}
|
||||
|
||||
function humanizeFolderPathSegment(segment: string): string {
|
||||
return segment
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\b\w/g, (character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
export function skillFolderPathDisplayFallback(folderPath: string | null | undefined): string | null {
|
||||
if (!folderPath) return null;
|
||||
if (folderPath.includes(" / ")) return folderPath;
|
||||
|
||||
const segments = folderPath.split("/").filter(Boolean);
|
||||
if (segments.length === 0) return null;
|
||||
|
||||
const root = segments[0]?.toLowerCase();
|
||||
const labels = segments.map(humanizeFolderPathSegment);
|
||||
if (root === "my") labels[0] = "My Skills";
|
||||
else if (root === "projects") labels[0] = "Projects";
|
||||
else if (root === "bundled") labels[0] = "Bundled";
|
||||
else labels.unshift("Company");
|
||||
return labels.join(" / ");
|
||||
}
|
||||
|
||||
export function emptySkillFolderTree(): SkillFolderTreeModel {
|
||||
return buildSkillFolderTree([]);
|
||||
}
|
||||
|
||||
export function treeFromResult(result: FolderListResult | null | undefined): SkillFolderTreeModel {
|
||||
return buildSkillFolderTree(result?.folders ?? []);
|
||||
}
|
||||
|
|
@ -182,6 +182,17 @@
|
|||
--status-task-icon-blocked: var(--status-task-blocked); /* #dc2626 both modes */
|
||||
--status-task-icon-cancelled: #52585d;
|
||||
--status-task-icon-in_queue: var(--status-task-in_progress); /* blocked shape, blue */
|
||||
|
||||
--folder-color-indigo: #6366f1;
|
||||
--folder-color-violet: #8b5cf6;
|
||||
--folder-color-emerald: #10b981;
|
||||
--folder-color-cyan: #06b6d4;
|
||||
--folder-color-amber: #f59e0b;
|
||||
--folder-color-slate: #64748b;
|
||||
--gtc-folder-row: 1rem minmax(0, 1fr) auto;
|
||||
--gtc-folder-row-actions: 1rem minmax(0, 1fr) auto auto;
|
||||
--sz-folder-rail: 13.25rem;
|
||||
--sz-folder-sheet-max: 80dvh;
|
||||
}
|
||||
|
||||
.dark {
|
||||
|
|
|
|||
|
|
@ -84,5 +84,11 @@ describe("company skill routes", () => {
|
|||
expect(skillStudioNewRoute("skill/with spaces")).toBe(
|
||||
"/skills/studio/new?forkFrom=skill%2Fwith%20spaces",
|
||||
);
|
||||
expect(skillStudioNewRoute(null, "folder/with spaces")).toBe(
|
||||
"/skills/studio/new?folderId=folder%2Fwith%20spaces",
|
||||
);
|
||||
expect(skillStudioNewRoute("skill 1", "folder 1")).toBe(
|
||||
"/skills/studio/new?forkFrom=skill%201&folderId=folder%201",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -191,9 +191,13 @@ export function skillStudioRoute(skillId: string) {
|
|||
return `/skills/studio/${encodeURIComponent(skillId)}`;
|
||||
}
|
||||
|
||||
export function skillStudioNewRoute(forkFromSkillId?: string | null) {
|
||||
export function skillStudioNewRoute(forkFromSkillId?: string | null, folderId?: string | null) {
|
||||
const basePath = "/skills/studio/new";
|
||||
return forkFromSkillId ? `${basePath}?forkFrom=${encodeURIComponent(forkFromSkillId)}` : basePath;
|
||||
const params: string[] = [];
|
||||
if (forkFromSkillId) params.push(`forkFrom=${encodeURIComponent(forkFromSkillId)}`);
|
||||
if (folderId) params.push(`folderId=${encodeURIComponent(folderId)}`);
|
||||
const query = params.join("&");
|
||||
return query ? `${basePath}?${query}` : basePath;
|
||||
}
|
||||
|
||||
export function withRouteSkill(
|
||||
|
|
|
|||
|
|
@ -193,6 +193,9 @@ export const queryKeys = {
|
|||
documentAnnotations: (routineId: string, key: "description", status: "open" | "resolved" | "all" = "all") =>
|
||||
["routines", "document-annotations", routineId, key, status] as const,
|
||||
},
|
||||
folders: {
|
||||
list: (companyId: string, kind: string) => ["folders", companyId, kind] as const,
|
||||
},
|
||||
pipelines: {
|
||||
list: (companyId: string) => ["pipelines", companyId] as const,
|
||||
detail: (pipelineId: string) => ["pipelines", "detail", pipelineId] as const,
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ describe("skill create helpers", () => {
|
|||
});
|
||||
|
||||
it("builds fork drafts from the source skill metadata", () => {
|
||||
const draft = buildForkSkillDraft(skill({ color: "#123456" }));
|
||||
const draft = buildForkSkillDraft(skill({ color: "#123456", folderId: "bundled-folder" }));
|
||||
|
||||
expect(draft.name).toBe("Demo Skill Fork");
|
||||
expect(draft.slug).toBe("demo-skill-fork");
|
||||
|
|
@ -91,6 +91,7 @@ describe("skill create helpers", () => {
|
|||
expect(draft.categories).toEqual(["engineering", "review"]);
|
||||
expect(draft.forkedFromSkillId).toBe("skill-1");
|
||||
expect(draft.forkedFromName).toBe("Demo Skill");
|
||||
expect(draft.folderId).toBeNull();
|
||||
expect(draft.markdown).toContain("name: Demo Skill Fork");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ export type SkillCreateDraft = {
|
|||
sharingScope: Exclude<CompanySkillSharingScope, "public_link">;
|
||||
forkedFromSkillId: string | null;
|
||||
forkedFromName: string | null;
|
||||
/** Destination folder for the new skill (null = Unfiled / top level). */
|
||||
folderId: string | null;
|
||||
};
|
||||
|
||||
export function normalizeSkillDraftSlug(value: string) {
|
||||
|
|
@ -92,6 +94,7 @@ export function buildBlankSkillDraft(): SkillCreateDraft {
|
|||
sharingScope: "company",
|
||||
forkedFromSkillId: null,
|
||||
forkedFromName: null,
|
||||
folderId: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -109,6 +112,7 @@ export function buildForkSkillDraft(skill: CompanySkillDetail): SkillCreateDraft
|
|||
sharingScope: "company",
|
||||
forkedFromSkillId: skill.id,
|
||||
forkedFromName: skill.name,
|
||||
folderId: null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -128,5 +132,6 @@ export function skillCreateDraftToPayload(draft: SkillCreateDraft): CompanySkill
|
|||
categories: draft.categories,
|
||||
sharingScope: draft.sharingScope,
|
||||
forkedFromSkillId: draft.forkedFromSkillId,
|
||||
folderId: draft.folderId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,16 @@
|
|||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import type { CompanySkillDetail, CompanySkillVersion } from "@paperclipai/shared";
|
||||
import type { CompanySkillDetail, CompanySkillVersion, FolderListResult } from "@paperclipai/shared";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DiscoveryGrid, SkillDetailPage, getSkillVersionDiffSelection } from "./CompanySkills";
|
||||
import {
|
||||
DiscoveryGrid,
|
||||
SkillDetailPage,
|
||||
getSkillVersionDiffSelection,
|
||||
resolveDiscoveryTab,
|
||||
withDiscoveryTab,
|
||||
skillDetailBreadcrumbs,
|
||||
} from "./CompanySkills";
|
||||
import { skillStudioNewRoute } from "../lib/company-skill-routes";
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
|
|
@ -51,6 +58,9 @@ vi.mock("@/components/ui/dropdown-menu", () => ({
|
|||
<button type="button" onClick={onSelect}>{children}</button>
|
||||
),
|
||||
DropdownMenuSeparator: () => <hr />,
|
||||
DropdownMenuSub: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuSubContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DropdownMenuSubTrigger: ({ children }: { children: ReactNode }) => <button type="button">{children}</button>,
|
||||
DropdownMenuTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
|
|
@ -327,6 +337,171 @@ describe("DiscoveryGrid Studio entry points", () => {
|
|||
|
||||
expect(onCreate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not open a skill when keyboard-activating its actions button", async () => {
|
||||
const onOpenCard = vi.fn();
|
||||
const card = {
|
||||
key: "demo-skill",
|
||||
skillId: "skill-1",
|
||||
folderId: null,
|
||||
catalogRef: null,
|
||||
name: "Demo Skill",
|
||||
slug: "demo-skill",
|
||||
author: "Paperclip",
|
||||
version: null,
|
||||
tagline: null,
|
||||
description: null,
|
||||
categories: [],
|
||||
iconUrl: null,
|
||||
color: null,
|
||||
starCount: 0,
|
||||
agentCount: 0,
|
||||
forkCount: 0,
|
||||
installed: true,
|
||||
required: false,
|
||||
forkedFrom: false,
|
||||
updatedAt: 0,
|
||||
};
|
||||
const node = await renderDiscoveryGrid({
|
||||
cards: [card],
|
||||
totalCount: 1,
|
||||
onOpenCard,
|
||||
folderResult: { kind: "skill", folders: [], allCount: 1, unfiledCount: 1 },
|
||||
onMoveCard: vi.fn(),
|
||||
onCreateFolderAndMoveCard: vi.fn(),
|
||||
});
|
||||
const actionsButton = node.querySelector<HTMLButtonElement>('[aria-label="More actions for Demo Skill"]');
|
||||
|
||||
await act(async () => {
|
||||
actionsButton?.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
|
||||
expect(onOpenCard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not offer move actions for skills in the bundled folder", async () => {
|
||||
const card = {
|
||||
key: "bundled-skill",
|
||||
skillId: "skill-1",
|
||||
folderId: "bundled-folder",
|
||||
catalogRef: null,
|
||||
name: "Bundled Skill",
|
||||
slug: "bundled-skill",
|
||||
author: "Paperclip",
|
||||
version: null,
|
||||
tagline: null,
|
||||
description: null,
|
||||
categories: [],
|
||||
iconUrl: null,
|
||||
color: null,
|
||||
starCount: 0,
|
||||
agentCount: 0,
|
||||
forkCount: 0,
|
||||
installed: true,
|
||||
required: false,
|
||||
forkedFrom: false,
|
||||
updatedAt: 0,
|
||||
};
|
||||
const node = await renderDiscoveryGrid({
|
||||
cards: [card],
|
||||
totalCount: 1,
|
||||
selectMode: true,
|
||||
folderResult: {
|
||||
kind: "skill",
|
||||
folders: [{
|
||||
id: "bundled-folder",
|
||||
companyId: "company-1",
|
||||
kind: "skill",
|
||||
parentId: null,
|
||||
name: "Bundled",
|
||||
slug: "bundled",
|
||||
systemKey: "bundled",
|
||||
path: "bundled",
|
||||
depth: 1,
|
||||
color: null,
|
||||
position: 0,
|
||||
createdAt: new Date("2026-01-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
itemCount: 1,
|
||||
}],
|
||||
allCount: 1,
|
||||
unfiledCount: 0,
|
||||
},
|
||||
onMoveCard: vi.fn(),
|
||||
onCreateFolderAndMoveCard: vi.fn(),
|
||||
onOpenMoveCard: vi.fn(),
|
||||
});
|
||||
|
||||
expect(node.querySelector('[aria-label="More actions for Bundled Skill"]')).toBeNull();
|
||||
expect(node.querySelector('input[type="checkbox"]')).toBeNull();
|
||||
expect(node.textContent).not.toContain("Move to folder");
|
||||
});
|
||||
});
|
||||
|
||||
describe("skills discovery tab routing", () => {
|
||||
it("opens the folder-first installed view when the URL has no tab", () => {
|
||||
expect(resolveDiscoveryTab(null)).toBe("installed");
|
||||
expect(resolveDiscoveryTab("all")).toBe("all");
|
||||
});
|
||||
|
||||
it("keeps All explicit and makes Installed the canonical default URL", () => {
|
||||
const allParams = withDiscoveryTab(new URLSearchParams("folder=my&category=writing"), "all");
|
||||
expect(allParams.toString()).toBe("tab=all");
|
||||
|
||||
const installedParams = withDiscoveryTab(new URLSearchParams("tab=all&folder=my"), "installed");
|
||||
expect(installedParams.toString()).toBe("folder=my");
|
||||
});
|
||||
});
|
||||
|
||||
describe("skill detail breadcrumbs", () => {
|
||||
it("links each folder ancestor back to the installed folder view", () => {
|
||||
const folders: FolderListResult = {
|
||||
kind: "skill",
|
||||
allCount: 1,
|
||||
unfiledCount: 0,
|
||||
folders: [
|
||||
{
|
||||
id: "my-root",
|
||||
companyId: "company-1",
|
||||
kind: "skill",
|
||||
parentId: null,
|
||||
name: "My Skills",
|
||||
slug: "my",
|
||||
systemKey: "my",
|
||||
path: "my",
|
||||
depth: 1,
|
||||
color: null,
|
||||
position: 0,
|
||||
itemCount: 1,
|
||||
createdAt: new Date("2026-07-16T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-16T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "review-folder",
|
||||
companyId: "company-1",
|
||||
kind: "skill",
|
||||
parentId: "my-root",
|
||||
name: "Review",
|
||||
slug: "review",
|
||||
systemKey: null,
|
||||
path: "my/review",
|
||||
depth: 2,
|
||||
color: null,
|
||||
position: 0,
|
||||
itemCount: 1,
|
||||
createdAt: new Date("2026-07-16T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-16T00:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(skillDetailBreadcrumbs({ name: "Deal with PR", folderId: "review-folder" }, folders)).toEqual([
|
||||
{ label: "Skills", href: "/skills" },
|
||||
{ label: "My Skills", href: "/skills?folder=my-root" },
|
||||
{ label: "Review", href: "/skills?folder=review-folder" },
|
||||
{ label: "Deal with PR" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("skillStudioNewRoute", () => {
|
||||
|
|
@ -366,6 +541,16 @@ describe("SkillDetailPage versions tab", () => {
|
|||
});
|
||||
|
||||
describe("SkillDetailPage settings", () => {
|
||||
it("humanizes the server folder path on a cold detail render", async () => {
|
||||
const v1 = makeVersion(1, "# Demo Skill");
|
||||
const node = await renderSkillDetail([v1], {
|
||||
activeTab: "overview",
|
||||
detail: makeDetail(v1, { folderPath: "engineering/code-review" }),
|
||||
});
|
||||
|
||||
expect(node.textContent).toContain("Company / Engineering / Code Review");
|
||||
});
|
||||
|
||||
it("shows a direct fork action for read-only skills", async () => {
|
||||
const v1 = makeVersion(1, "# Demo Skill");
|
||||
const onFork = vi.fn();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -4,7 +4,7 @@ import type { AnchorHTMLAttributes, ReactNode } from "react";
|
|||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue, RoutineListItem } from "@paperclipai/shared";
|
||||
import type { FolderListResult, Issue, RoutineListItem } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Routines, buildRoutineGroups, buildRoutineSections, sortRoutines } from "./Routines";
|
||||
|
||||
|
|
@ -20,6 +20,7 @@ async function act(callback: () => void | Promise<void>) {
|
|||
|
||||
const navigateMock = vi.fn();
|
||||
const routinesListMock = vi.fn<(companyId: string) => Promise<RoutineListItem[]>>();
|
||||
const foldersListMock = vi.fn<(companyId: string, kind: string) => Promise<FolderListResult>>();
|
||||
const issuesListMock = vi.fn<(companyId: string, filters?: Record<string, unknown>) => Promise<Issue[]>>();
|
||||
const markdownEditorRenderMock = vi.fn((props: { mentions?: Array<{ id: string; name: string }> }) => props);
|
||||
const issuesListRenderMock = vi.fn(({ issues }: { issues: Issue[] }) => (
|
||||
|
|
@ -58,6 +59,16 @@ vi.mock("../api/routines", () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock("../api/folders", () => ({
|
||||
foldersApi: {
|
||||
list: (companyId: string, kind: string) => foldersListMock(companyId, kind),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
moveItem: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../api/issues", () => ({
|
||||
issuesApi: {
|
||||
list: (companyId: string, filters?: Record<string, unknown>) => issuesListMock(companyId, filters),
|
||||
|
|
@ -275,6 +286,7 @@ function createRoutine(overrides: Partial<RoutineListItem>): RoutineListItem {
|
|||
triggers: [],
|
||||
lastRun: null,
|
||||
activeIssue: null,
|
||||
folderId: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
|
@ -343,6 +355,13 @@ describe("Routines page", () => {
|
|||
currentSearch = "";
|
||||
navigateMock.mockReset();
|
||||
routinesListMock.mockReset();
|
||||
foldersListMock.mockReset();
|
||||
foldersListMock.mockResolvedValue({
|
||||
kind: "routine",
|
||||
folders: [],
|
||||
allCount: 0,
|
||||
unfiledCount: 0,
|
||||
});
|
||||
issuesListMock.mockReset();
|
||||
markdownEditorRenderMock.mockClear();
|
||||
issuesListRenderMock.mockClear();
|
||||
|
|
@ -398,6 +417,22 @@ describe("Routines page", () => {
|
|||
expect(groups[1]?.items.map((item) => item.title)).toEqual(["Reflection review"]);
|
||||
});
|
||||
|
||||
it("uses a flat group when Folder grouping is active", () => {
|
||||
const routines = [
|
||||
createRoutine({ id: "routine-1", title: "Morning sync", projectId: "project-1" }),
|
||||
createRoutine({ id: "routine-2", title: "Weekly digest", projectId: "project-2" }),
|
||||
];
|
||||
|
||||
const groups = buildRoutineGroups(
|
||||
routines,
|
||||
"folder",
|
||||
new Map(),
|
||||
new Map(),
|
||||
);
|
||||
|
||||
expect(groups).toEqual([{ key: "__all", label: null, items: routines }]);
|
||||
});
|
||||
|
||||
it("sorts routines by selected field and direction without mutating the source list", () => {
|
||||
const routines = [
|
||||
createRoutine({
|
||||
|
|
@ -489,7 +524,7 @@ describe("Routines page", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("defaults the routines list to project groups sorted by title", async () => {
|
||||
it("defaults the routines list to folder mode without rendering project groups", async () => {
|
||||
routinesListMock.mockResolvedValue([
|
||||
createRoutine({ id: "routine-1", title: "Weekly digest", projectId: "project-1" }),
|
||||
createRoutine({ id: "routine-2", title: "Morning sync", projectId: "project-1" }),
|
||||
|
|
@ -513,17 +548,15 @@ describe("Routines page", () => {
|
|||
await flush();
|
||||
});
|
||||
|
||||
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Project Alpha"); attempts += 1) {
|
||||
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Morning sync"); attempts += 1) {
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Project Beta"));
|
||||
expect(text.indexOf("Morning sync")).toBeLessThan(text.indexOf("Weekly digest"));
|
||||
expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Morning sync"));
|
||||
expect(text.indexOf("Weekly digest")).toBeLessThan(text.indexOf("Project Beta"));
|
||||
expect(text).toContain("New folder");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
@ -546,7 +579,6 @@ describe("Routines page", () => {
|
|||
}),
|
||||
]);
|
||||
issuesListMock.mockResolvedValue([]);
|
||||
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
|
@ -570,7 +602,6 @@ describe("Routines page", () => {
|
|||
}
|
||||
|
||||
const text = container.textContent ?? "";
|
||||
expect(text.indexOf("Project Alpha")).toBeLessThan(text.indexOf("Morning sync"));
|
||||
expect(text.indexOf("Morning sync")).toBeLessThan(text.indexOf("Built-in routines"));
|
||||
expect(text.indexOf("Built-in routines")).toBeLessThan(text.indexOf("Reflection review"));
|
||||
|
||||
|
|
@ -579,6 +610,109 @@ describe("Routines page", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("filters to Unfiled and shows the empty-folder state with a create CTA", async () => {
|
||||
foldersListMock.mockResolvedValue({
|
||||
kind: "routine",
|
||||
allCount: 2,
|
||||
unfiledCount: 1,
|
||||
folders: [
|
||||
{
|
||||
id: "folder-reporting",
|
||||
companyId: "company-1",
|
||||
kind: "routine",
|
||||
parentId: null,
|
||||
name: "Reporting",
|
||||
slug: "reporting",
|
||||
systemKey: null,
|
||||
path: "reporting",
|
||||
depth: 1,
|
||||
color: "#6366f1",
|
||||
position: 0,
|
||||
itemCount: 1,
|
||||
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
id: "folder-empty",
|
||||
companyId: "company-1",
|
||||
kind: "routine",
|
||||
parentId: null,
|
||||
name: "Empty folder",
|
||||
slug: "empty-folder",
|
||||
systemKey: null,
|
||||
path: "empty-folder",
|
||||
depth: 1,
|
||||
color: null,
|
||||
position: 1,
|
||||
itemCount: 0,
|
||||
createdAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
routinesListMock.mockResolvedValue([
|
||||
createRoutine({ id: "routine-1", title: "Filed digest", folderId: "folder-reporting" }),
|
||||
createRoutine({ id: "routine-2", title: "Loose routine", folderId: null }),
|
||||
]);
|
||||
issuesListMock.mockResolvedValue([]);
|
||||
|
||||
currentSearch = "folder=unfiled";
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routines />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("Loose routine"); attempts += 1) {
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
|
||||
// Unfiled filter: only the folderless routine renders; the rail still lists folders.
|
||||
expect(container.textContent).toContain("Loose routine");
|
||||
expect(container.textContent).not.toContain("Filed digest");
|
||||
expect(container.textContent).toContain("Reporting");
|
||||
expect(container.textContent).toContain("Empty folder");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
|
||||
// Remount filtered to the empty folder: empty state + create-into-folder CTA.
|
||||
currentSearch = "folder=folder-empty";
|
||||
const secondRoot = createRoot(container);
|
||||
await act(async () => {
|
||||
secondRoot.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Routines />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await flush();
|
||||
});
|
||||
for (let attempts = 0; attempts < 5 && !container.textContent?.includes("This folder is empty"); attempts += 1) {
|
||||
await act(async () => {
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
|
||||
expect(container.textContent).toContain("This folder is empty");
|
||||
expect(container.textContent).toContain("New routine in this folder");
|
||||
|
||||
await act(async () => {
|
||||
secondRoot.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("hides archived routines from the routines list", async () => {
|
||||
routinesListMock.mockResolvedValue([
|
||||
createRoutine({ id: "routine-1", title: "Morning sync", status: "active" }),
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||
import { Link, useNavigate, useSearchParams } from "@/lib/router";
|
||||
import { ArrowUpDown, Check, ChevronDown, ChevronRight, Layers, Plus, Repeat } from "lucide-react";
|
||||
import { routinesApi } from "../api/routines";
|
||||
import { foldersApi } from "../api/folders";
|
||||
import { agentsApi } from "../api/agents";
|
||||
import { projectsApi } from "../api/projects";
|
||||
import { issuesApi } from "../api/issues";
|
||||
|
|
@ -12,6 +13,7 @@ import { useCompany } from "../context/CompanyContext";
|
|||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useToastActions } from "../context/ToastContext";
|
||||
import { buildMarkdownMentionOptions } from "../lib/company-members";
|
||||
import { cn } from "../lib/utils";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { groupBy } from "../lib/groupBy";
|
||||
import { createIssueDetailLocationState } from "../lib/issueDetailBreadcrumb";
|
||||
|
|
@ -46,6 +48,22 @@ import {
|
|||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import type { RoutineListItem, RoutineVariable } from "@paperclipai/shared";
|
||||
import type { FolderListItem } from "@paperclipai/shared";
|
||||
import {
|
||||
AllUnfiledBanner,
|
||||
BulkBar,
|
||||
DeleteFolderDialog,
|
||||
FolderChip,
|
||||
FolderFormDialog,
|
||||
FolderRail,
|
||||
FolderSwatch,
|
||||
MobileFolderSheet,
|
||||
MoveToMenu,
|
||||
folderSearchValue,
|
||||
normalizeFolderSelection,
|
||||
selectedFolderFromList,
|
||||
type FolderSelection,
|
||||
} from "../components/folders/FolderControls";
|
||||
|
||||
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
||||
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
|
||||
|
|
@ -66,7 +84,7 @@ function autoResizeTextarea(element: HTMLTextAreaElement | null) {
|
|||
}
|
||||
|
||||
type RoutinesTab = "routines" | "runs";
|
||||
type RoutineGroupBy = "none" | "project" | "assignee";
|
||||
type RoutineGroupBy = "folder" | "none" | "project" | "assignee";
|
||||
type RoutineSortField = "updated" | "created" | "title" | "lastRun";
|
||||
type RoutineSortDir = "asc" | "desc";
|
||||
|
||||
|
|
@ -88,7 +106,7 @@ const builtInRoutineGroupKey = "__built_in_routines";
|
|||
const defaultRoutineViewState: RoutineViewState = {
|
||||
sortField: "title",
|
||||
sortDir: "asc",
|
||||
groupBy: "project",
|
||||
groupBy: "folder",
|
||||
collapsedGroups: [],
|
||||
};
|
||||
|
||||
|
|
@ -120,6 +138,7 @@ function buildRoutineMutationPayload(input: {
|
|||
title: string;
|
||||
description: string;
|
||||
projectId: string;
|
||||
folderId: string | null;
|
||||
assigneeAgentId: string;
|
||||
priority: string;
|
||||
concurrencyPolicy: string;
|
||||
|
|
@ -130,6 +149,7 @@ function buildRoutineMutationPayload(input: {
|
|||
...input,
|
||||
description: input.description.trim() || null,
|
||||
projectId: input.projectId || null,
|
||||
folderId: input.folderId || null,
|
||||
assigneeAgentId: input.assigneeAgentId || null,
|
||||
};
|
||||
}
|
||||
|
|
@ -140,7 +160,7 @@ export function buildRoutineGroups(
|
|||
projectById: Map<string, { name: string }>,
|
||||
agentById: Map<string, { name: string }>,
|
||||
): RoutineGroup[] {
|
||||
if (groupByValue === "none") {
|
||||
if (groupByValue === "none" || groupByValue === "folder") {
|
||||
return [{ key: "__all", label: null, items: routines }];
|
||||
}
|
||||
|
||||
|
|
@ -267,12 +287,19 @@ export function Routines() {
|
|||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { pushToast } = useToastActions();
|
||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const assigneeSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const projectSelectorRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [folderDialogOpen, setFolderDialogOpen] = useState(false);
|
||||
const [folderDialogTarget, setFolderDialogTarget] = useState<FolderListItem | null>(null);
|
||||
const [deleteFolderTarget, setDeleteFolderTarget] = useState<FolderListItem | null>(null);
|
||||
const [mobileFoldersOpen, setMobileFoldersOpen] = useState(false);
|
||||
const [selectMode, setSelectMode] = useState(false);
|
||||
const [selectedRoutineIds, setSelectedRoutineIds] = useState<string[]>([]);
|
||||
const [moveAfterCreateIds, setMoveAfterCreateIds] = useState<string[]>([]);
|
||||
const [runningRoutineId, setRunningRoutineId] = useState<string | null>(null);
|
||||
const [statusMutationRoutineId, setStatusMutationRoutineId] = useState<string | null>(null);
|
||||
const [runDialogRoutine, setRunDialogRoutine] = useState<RoutineListItem | null>(null);
|
||||
|
|
@ -283,6 +310,7 @@ export function Routines() {
|
|||
title: string;
|
||||
description: string;
|
||||
projectId: string;
|
||||
folderId: string | null;
|
||||
assigneeAgentId: string;
|
||||
priority: string;
|
||||
concurrencyPolicy: string;
|
||||
|
|
@ -292,6 +320,7 @@ export function Routines() {
|
|||
title: "",
|
||||
description: "",
|
||||
projectId: "",
|
||||
folderId: null,
|
||||
assigneeAgentId: "",
|
||||
priority: "medium",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
|
|
@ -302,6 +331,7 @@ export function Routines() {
|
|||
? `paperclip:routines-view:${selectedCompanyId}`
|
||||
: "paperclip:routines-view";
|
||||
const [routineViewState, setRoutineViewState] = useState<RoutineViewState>(() => getRoutineViewState(routineViewStateKey));
|
||||
const folderSelection = normalizeFolderSelection(searchParams.get("folder"));
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Routines" }]);
|
||||
|
|
@ -316,6 +346,11 @@ export function Routines() {
|
|||
queryFn: () => routinesApi.list(selectedCompanyId!),
|
||||
enabled: !!selectedCompanyId,
|
||||
});
|
||||
const { data: routineFolders, isLoading: foldersLoading } = useQuery({
|
||||
queryKey: queryKeys.folders.list(selectedCompanyId!, "routine"),
|
||||
queryFn: () => foldersApi.list(selectedCompanyId!, "routine"),
|
||||
enabled: !!selectedCompanyId && activeTab === "routines",
|
||||
});
|
||||
const { data: agents } = useQuery({
|
||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||
|
|
@ -374,6 +409,7 @@ export function Routines() {
|
|||
title: "",
|
||||
description: "",
|
||||
projectId: "",
|
||||
folderId: null,
|
||||
assigneeAgentId: "",
|
||||
priority: "medium",
|
||||
concurrencyPolicy: "coalesce_if_active",
|
||||
|
|
@ -393,6 +429,97 @@ export function Routines() {
|
|||
navigate(`/routines/${routine.id}?tab=triggers`);
|
||||
},
|
||||
});
|
||||
const createFolder = useMutation({
|
||||
mutationFn: (payload: { name: string; color: string | null }) =>
|
||||
foldersApi.create(selectedCompanyId!, { kind: "routine", ...payload }),
|
||||
onSuccess: async (folder) => {
|
||||
setFolderDialogOpen(false);
|
||||
setFolderDialogTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") });
|
||||
if (moveAfterCreateIds.length > 0) {
|
||||
const ids = moveAfterCreateIds;
|
||||
setMoveAfterCreateIds([]);
|
||||
try {
|
||||
await Promise.all(ids.map((itemId) =>
|
||||
foldersApi.moveItem(selectedCompanyId!, { kind: "routine", itemId, folderId: folder.id })
|
||||
));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
|
||||
]);
|
||||
} catch (moveError) {
|
||||
pushToast({
|
||||
title: "Folder created, move failed",
|
||||
body: moveError instanceof Error ? moveError.message : "Paperclip could not move the selected routines.",
|
||||
tone: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
setFolderSelection(folder.id);
|
||||
}
|
||||
pushToast({ title: "Folder created", body: folder.name, tone: "success" });
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
pushToast({
|
||||
title: "Failed to save folder",
|
||||
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not save the folder.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
const updateFolder = useMutation({
|
||||
mutationFn: ({ folderId, payload }: { folderId: string; payload: { name?: string; color?: string | null } }) =>
|
||||
foldersApi.update(selectedCompanyId!, folderId, payload),
|
||||
onSuccess: async () => {
|
||||
setFolderDialogOpen(false);
|
||||
setFolderDialogTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") });
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
pushToast({
|
||||
title: "Folder save failed",
|
||||
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not update the folder.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
const deleteFolder = useMutation({
|
||||
mutationFn: (folderId: string) => foldersApi.delete(selectedCompanyId!, folderId),
|
||||
onSuccess: async (_, folderId) => {
|
||||
if (folderSelection === folderId) setFolderSelection("all");
|
||||
setDeleteFolderTarget(null);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
|
||||
]);
|
||||
pushToast({ title: "Folder deleted", body: "Items moved to Unfiled.", tone: "success" });
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
pushToast({
|
||||
title: "Folder delete failed",
|
||||
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not delete the folder.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
const moveRoutineToFolder = useMutation({
|
||||
mutationFn: ({ itemId, folderId }: { itemId: string; folderId: string | null }) =>
|
||||
foldersApi.moveItem(selectedCompanyId!, { kind: "routine", itemId, folderId }),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
|
||||
]);
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
pushToast({
|
||||
title: "Move failed",
|
||||
body: mutationError instanceof Error ? mutationError.message : "Paperclip could not move the routine.",
|
||||
tone: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
const updateIssue = useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
|
||||
issuesApi.update(id, data),
|
||||
|
|
@ -495,9 +622,35 @@ export function Routines() {
|
|||
() => (routines ?? []).filter((routine) => routine.status !== "archived"),
|
||||
[routines],
|
||||
);
|
||||
const folderFilteredRoutines = useMemo(() => {
|
||||
if (routineViewState.groupBy !== "folder") return visibleRoutines;
|
||||
if (folderSelection === "all") return visibleRoutines;
|
||||
if (folderSelection === "unfiled") return visibleRoutines.filter((routine) => !routine.folderId);
|
||||
return visibleRoutines.filter((routine) => routine.folderId === folderSelection);
|
||||
}, [folderSelection, routineViewState.groupBy, visibleRoutines]);
|
||||
// Rail counts reflect the page's visible scope (archived hidden), not raw DB
|
||||
// counts (ux-spec §5.3).
|
||||
const railFolderResult = useMemo(() => {
|
||||
if (!routineFolders) return routineFolders;
|
||||
const counts = new Map<string, number>();
|
||||
let unfiled = 0;
|
||||
for (const routine of visibleRoutines) {
|
||||
if (routine.folderId) counts.set(routine.folderId, (counts.get(routine.folderId) ?? 0) + 1);
|
||||
else unfiled += 1;
|
||||
}
|
||||
return {
|
||||
...routineFolders,
|
||||
allCount: visibleRoutines.length,
|
||||
unfiledCount: unfiled,
|
||||
folders: routineFolders.folders.map((folder) => ({
|
||||
...folder,
|
||||
itemCount: counts.get(folder.id) ?? 0,
|
||||
})),
|
||||
};
|
||||
}, [routineFolders, visibleRoutines]);
|
||||
const sortedRoutines = useMemo(
|
||||
() => sortRoutines(visibleRoutines, routineViewState.sortField, routineViewState.sortDir),
|
||||
[routineViewState.sortDir, routineViewState.sortField, visibleRoutines],
|
||||
() => sortRoutines(folderFilteredRoutines, routineViewState.sortField, routineViewState.sortDir),
|
||||
[folderFilteredRoutines, routineViewState.sortDir, routineViewState.sortField],
|
||||
);
|
||||
const routineSections = useMemo(
|
||||
() => buildRoutineSections(sortedRoutines, routineViewState.groupBy, projectById, agentById),
|
||||
|
|
@ -514,6 +667,9 @@ export function Routines() {
|
|||
);
|
||||
const currentAssignee = draft.assigneeAgentId ? agentById.get(draft.assigneeAgentId) ?? null : null;
|
||||
const currentProject = draft.projectId ? projectById.get(draft.projectId) ?? null : null;
|
||||
const activeFolder = selectedFolderFromList(routineFolders?.folders ?? [], folderSelection);
|
||||
const hasRoutineFolders = (routineFolders?.folders.length ?? 0) > 0;
|
||||
const showFolderRail = activeTab === "routines" && routineViewState.groupBy === "folder" && hasRoutineFolders;
|
||||
|
||||
function updateRoutineView(patch: Partial<RoutineViewState>) {
|
||||
setRoutineViewState((current) => {
|
||||
|
|
@ -530,6 +686,51 @@ export function Routines() {
|
|||
});
|
||||
}
|
||||
|
||||
function setFolderSelection(selection: FolderSelection) {
|
||||
setSearchParams((current) => {
|
||||
const params = new URLSearchParams(current);
|
||||
const value = folderSearchValue(selection);
|
||||
if (value) params.set("folder", value);
|
||||
else params.delete("folder");
|
||||
return params;
|
||||
});
|
||||
}
|
||||
|
||||
function openCreateFolder(moveItemIds: string[] = []) {
|
||||
setMoveAfterCreateIds(moveItemIds);
|
||||
setFolderDialogTarget(null);
|
||||
setFolderDialogOpen(true);
|
||||
}
|
||||
|
||||
function openCreateRoutine() {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
folderId: folderSelection === "all" || folderSelection === "unfiled" ? null : folderSelection,
|
||||
}));
|
||||
setComposerOpen(true);
|
||||
}
|
||||
|
||||
async function moveSelectedRoutines(folderId: string | null) {
|
||||
const ids = selectedRoutineIds;
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
await Promise.all(ids.map((itemId) => foldersApi.moveItem(selectedCompanyId!, { kind: "routine", itemId, folderId })));
|
||||
setSelectedRoutineIds([]);
|
||||
setSelectMode(false);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.folders.list(selectedCompanyId!, "routine") }),
|
||||
]);
|
||||
pushToast({ title: "Routines moved", body: `${ids.length} routine${ids.length === 1 ? "" : "s"} filed.`, tone: "success" });
|
||||
} catch (moveError) {
|
||||
pushToast({
|
||||
title: "Failed to move routines",
|
||||
body: moveError instanceof Error ? moveError.message : "Paperclip could not move the selected routines.",
|
||||
tone: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleRunNow(routine: RoutineListItem) {
|
||||
setRunDialogRoutine(routine);
|
||||
}
|
||||
|
|
@ -575,7 +776,7 @@ export function Routines() {
|
|||
Recurring work definitions that materialize into auditable execution tasks.
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setComposerOpen(true)}>
|
||||
<Button onClick={openCreateRoutine}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create routine
|
||||
</Button>
|
||||
|
|
@ -648,6 +849,7 @@ export function Routines() {
|
|||
<PopoverContent align="end" className="w-44 p-0">
|
||||
<div className="p-2 space-y-0.5">
|
||||
{([
|
||||
["folder", "Folder"],
|
||||
["project", "Project"],
|
||||
["assignee", "Agent"],
|
||||
["none", "None"],
|
||||
|
|
@ -668,8 +870,29 @@ export function Routines() {
|
|||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{routineViewState.groupBy === "folder" && !hasRoutineFolders ? (
|
||||
<Button variant="outline" size="sm" onClick={() => openCreateFolder()}>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
New folder
|
||||
</Button>
|
||||
) : null}
|
||||
{showFolderRail ? (
|
||||
<Button variant="ghost" size="sm" className="text-xs" onClick={() => setSelectMode((current) => !current)}>
|
||||
{selectMode ? "Done" : "Select"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{routineViewState.groupBy === "folder" ? (
|
||||
<div className="md:hidden">
|
||||
<FolderChip
|
||||
result={railFolderResult}
|
||||
selection={folderSelection}
|
||||
allLabel="All routines"
|
||||
onClick={() => setMobileFoldersOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="runs">
|
||||
<IssuesList
|
||||
|
|
@ -844,6 +1067,26 @@ export function Routines() {
|
|||
);
|
||||
}}
|
||||
/>
|
||||
<span>filed in</span>
|
||||
<Select
|
||||
value={draft.folderId ?? "__unfiled"}
|
||||
onValueChange={(value) => setDraft((current) => ({
|
||||
...current,
|
||||
folderId: value === "__unfiled" ? null : value,
|
||||
}))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-auto min-w-32 border-0 bg-muted/50 px-2 shadow-none">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__unfiled">Unfiled</SelectItem>
|
||||
{(routineFolders?.folders ?? []).map((folder) => (
|
||||
<SelectItem key={folder.id} value={folder.id}>
|
||||
{folder.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -950,7 +1193,56 @@ export function Routines() {
|
|||
) : null}
|
||||
|
||||
{activeTab === "routines" ? (
|
||||
<div>
|
||||
<div className={cn(showFolderRail && "flex gap-4")}>
|
||||
{showFolderRail ? (
|
||||
<FolderRail
|
||||
result={railFolderResult}
|
||||
selection={folderSelection}
|
||||
allLabel="All routines"
|
||||
itemLabelPlural="routines"
|
||||
loading={foldersLoading}
|
||||
onSelect={setFolderSelection}
|
||||
onCreate={() => openCreateFolder()}
|
||||
onRename={(folder, name) => updateFolder.mutate({ folderId: folder.id, payload: { name } })}
|
||||
onEdit={(folder) => {
|
||||
setFolderDialogTarget(folder);
|
||||
setFolderDialogOpen(true);
|
||||
}}
|
||||
onDelete={setDeleteFolderTarget}
|
||||
/>
|
||||
) : null}
|
||||
<div className="min-w-0 flex-1">
|
||||
{routineViewState.groupBy === "folder" && hasRoutineFolders ? (
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
{folderSelection === "all" ? <FolderIconHeader label="All routines" count={sortedRoutines.length} /> : (
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<FolderSwatch color={activeFolder?.color} />
|
||||
<span className="truncate font-medium">{folderSelection === "unfiled" ? "Unfiled" : activeFolder?.name ?? "Folder"}</span>
|
||||
<span className="text-muted-foreground">{sortedRoutines.length} routine{sortedRoutines.length === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{routineViewState.groupBy === "folder" && !hasRoutineFolders && !foldersLoading && visibleRoutines.length > 0 ? (
|
||||
<AllUnfiledBanner
|
||||
storageKey={`paperclip:routines-folder-nudge:${selectedCompanyId ?? "none"}`}
|
||||
itemLabelPlural="routines"
|
||||
onCreateFolder={() => openCreateFolder()}
|
||||
/>
|
||||
) : null}
|
||||
{selectMode ? (
|
||||
<BulkBar
|
||||
selectedCount={selectedRoutineIds.length}
|
||||
folders={routineFolders?.folders ?? []}
|
||||
onMove={(folderId) => void moveSelectedRoutines(folderId)}
|
||||
onCreateAndMove={() => openCreateFolder(selectedRoutineIds)}
|
||||
onClear={() => setSelectedRoutineIds([])}
|
||||
onDone={() => {
|
||||
setSelectMode(false);
|
||||
setSelectedRoutineIds([]);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{visibleRoutines.length === 0 ? (
|
||||
<div className="py-12">
|
||||
<EmptyState
|
||||
|
|
@ -958,6 +1250,21 @@ export function Routines() {
|
|||
message="No active routines. Use Create routine to define the first recurring workflow."
|
||||
/>
|
||||
</div>
|
||||
) : sortedRoutines.length === 0 ? (
|
||||
<div className="py-12">
|
||||
<EmptyState
|
||||
icon={Repeat}
|
||||
message={folderSelection === "all" ? "No routines match this view." : "This folder is empty."}
|
||||
/>
|
||||
{folderSelection !== "all" ? (
|
||||
<div className="mt-3 flex justify-center">
|
||||
<Button size="sm" onClick={openCreateRoutine}>
|
||||
<Plus className="mr-2 h-3.5 w-3.5" />
|
||||
New routine in this folder
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{routineSections.map((group) => {
|
||||
|
|
@ -996,6 +1303,37 @@ export function Routines() {
|
|||
onRunNow={handleRunNow}
|
||||
onToggleEnabled={handleToggleEnabled}
|
||||
onToggleArchived={handleToggleArchived}
|
||||
selectMode={selectMode}
|
||||
selected={selectedRoutineIds.includes(routine.id)}
|
||||
onSelectChange={(selectedRoutine, selected) => {
|
||||
setSelectedRoutineIds((current) =>
|
||||
selected
|
||||
? Array.from(new Set([...current, selectedRoutine.id]))
|
||||
: current.filter((id) => id !== selectedRoutine.id)
|
||||
);
|
||||
}}
|
||||
extraMenuItems={
|
||||
<MoveToMenu
|
||||
folders={routineFolders?.folders ?? []}
|
||||
currentFolderId={routine.folderId ?? null}
|
||||
onMove={(folderId) => {
|
||||
const previousFolderId = routine.folderId ?? null;
|
||||
moveRoutineToFolder.mutate({ itemId: routine.id, folderId });
|
||||
pushToast({
|
||||
title: "Routine moved",
|
||||
body: folderId
|
||||
? `Moved "${routine.title}" to ${routineFolders?.folders.find((folder) => folder.id === folderId)?.name ?? "folder"}.`
|
||||
: `Moved "${routine.title}" to Unfiled.`,
|
||||
tone: "success",
|
||||
action: {
|
||||
label: "Undo",
|
||||
onClick: () => moveRoutineToFolder.mutate({ itemId: routine.id, folderId: previousFolderId }),
|
||||
},
|
||||
});
|
||||
}}
|
||||
onCreateAndMove={() => openCreateFolder([routine.id])}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
|
|
@ -1004,9 +1342,44 @@ export function Routines() {
|
|||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<FolderFormDialog
|
||||
open={folderDialogOpen}
|
||||
kind="routine"
|
||||
folder={folderDialogTarget}
|
||||
pending={createFolder.isPending || updateFolder.isPending}
|
||||
onOpenChange={setFolderDialogOpen}
|
||||
onSubmit={(payload) => {
|
||||
if (folderDialogTarget) updateFolder.mutate({ folderId: folderDialogTarget.id, payload });
|
||||
else createFolder.mutate(payload);
|
||||
}}
|
||||
/>
|
||||
<DeleteFolderDialog
|
||||
open={deleteFolderTarget !== null}
|
||||
folder={deleteFolderTarget}
|
||||
itemLabelPlural="routines"
|
||||
pending={deleteFolder.isPending}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteFolderTarget(null);
|
||||
}}
|
||||
onConfirm={() => {
|
||||
if (deleteFolderTarget) deleteFolder.mutate(deleteFolderTarget.id);
|
||||
}}
|
||||
/>
|
||||
<MobileFolderSheet
|
||||
open={mobileFoldersOpen}
|
||||
onOpenChange={setMobileFoldersOpen}
|
||||
result={railFolderResult}
|
||||
selection={folderSelection}
|
||||
allLabel="All routines"
|
||||
itemLabelPlural="Routines"
|
||||
onSelect={setFolderSelection}
|
||||
onCreate={() => openCreateFolder()}
|
||||
/>
|
||||
|
||||
<RoutineRunVariablesDialog
|
||||
open={runDialogRoutine !== null}
|
||||
onOpenChange={(next) => {
|
||||
|
|
@ -1028,3 +1401,13 @@ export function Routines() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderIconHeader({ label, count }: { label: string; count: number }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<Repeat className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="truncate font-medium">{label}</span>
|
||||
<span className="text-muted-foreground">{count} routine{count === 1 ? "" : "s"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -368,6 +368,26 @@ describe("SkillStudio create mode", () => {
|
|||
await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/skills/studio/created-skill"));
|
||||
});
|
||||
|
||||
it("forwards the folderId query param so the new skill is filed there (PAP-14086)", async () => {
|
||||
routeState.search = "?folderId=folder-my-skills";
|
||||
|
||||
const node = await renderStudio();
|
||||
|
||||
await waitFor(() => expect(node.querySelector("#skill-name")).toBeTruthy());
|
||||
await inputValue(node.querySelector("#skill-name") as HTMLInputElement, "Code Review");
|
||||
await click(buttonsNamed(node, "Create skill")[0] as HTMLButtonElement);
|
||||
|
||||
await waitFor(() => expect(mockCompanySkillsApi.create).toHaveBeenCalled());
|
||||
|
||||
expect(mockCompanySkillsApi.create).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
expect.objectContaining({
|
||||
name: "Code Review",
|
||||
folderId: "folder-my-skills",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps category commas and spaces editable while creating a skill", async () => {
|
||||
const node = await renderStudio();
|
||||
|
||||
|
|
|
|||
|
|
@ -277,6 +277,10 @@ export function SkillStudio() {
|
|||
const companyId = selectedCompanyId ?? "";
|
||||
const isCreateMode = location.pathname.replace(/\/+$/, "").endsWith("/skills/studio/new");
|
||||
const forkFromSkillId = isCreateMode ? searchParams.get("forkFrom")?.trim() || null : null;
|
||||
// New skills created from a folder context (e.g. My Skills) carry their
|
||||
// destination folder through this query param; without it the created skill
|
||||
// silently lands in Unfiled (PAP-14086).
|
||||
const newSkillFolderId = isCreateMode ? searchParams.get("folderId")?.trim() || null : null;
|
||||
|
||||
const skillsQuery = useQuery({
|
||||
queryKey: queryKeys.companySkills.list(companyId),
|
||||
|
|
@ -333,6 +337,7 @@ export function SkillStudio() {
|
|||
skills={skillsQuery.data ?? []}
|
||||
skillsLoading={skillsQuery.isLoading}
|
||||
forkFromSkillId={forkFromSkillId}
|
||||
folderId={newSkillFolderId}
|
||||
forkSkill={forkDetailQuery.data ?? null}
|
||||
forkLoading={forkDetailQuery.isLoading}
|
||||
forkError={forkDetailQuery.isError}
|
||||
|
|
@ -373,6 +378,7 @@ function StudioCreateMode({
|
|||
skills,
|
||||
skillsLoading,
|
||||
forkFromSkillId,
|
||||
folderId,
|
||||
forkSkill,
|
||||
forkLoading,
|
||||
forkError,
|
||||
|
|
@ -382,6 +388,7 @@ function StudioCreateMode({
|
|||
skills: CompanySkillListItem[];
|
||||
skillsLoading: boolean;
|
||||
forkFromSkillId: string | null;
|
||||
folderId: string | null;
|
||||
forkSkill: CompanySkillDetail | null;
|
||||
forkLoading: boolean;
|
||||
forkError: boolean;
|
||||
|
|
@ -403,6 +410,7 @@ function StudioCreateMode({
|
|||
<StudioNewSkillPanel
|
||||
companyId={companyId}
|
||||
forkFromSkillId={forkFromSkillId}
|
||||
folderId={folderId}
|
||||
forkSkill={forkSkill}
|
||||
forkLoading={forkLoading}
|
||||
forkError={forkError}
|
||||
|
|
@ -416,12 +424,14 @@ function StudioCreateMode({
|
|||
function StudioNewSkillPanel({
|
||||
companyId,
|
||||
forkFromSkillId,
|
||||
folderId,
|
||||
forkSkill,
|
||||
forkLoading,
|
||||
forkError,
|
||||
}: {
|
||||
companyId: string;
|
||||
forkFromSkillId: string | null;
|
||||
folderId: string | null;
|
||||
forkSkill: CompanySkillDetail | null;
|
||||
forkLoading: boolean;
|
||||
forkError: boolean;
|
||||
|
|
@ -429,10 +439,12 @@ function StudioNewSkillPanel({
|
|||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const toast = useOptionalToastActions();
|
||||
const initialDraft = useMemo(
|
||||
() => (forkSkill ? buildForkSkillDraft(forkSkill) : buildBlankSkillDraft()),
|
||||
[forkSkill],
|
||||
);
|
||||
const initialDraft = useMemo(() => {
|
||||
const base = forkSkill ? buildForkSkillDraft(forkSkill) : buildBlankSkillDraft();
|
||||
// An explicit folder context from the URL wins over a fork source's folder
|
||||
// so the new skill is filed where the user launched creation (PAP-14086).
|
||||
return folderId ? { ...base, folderId } : base;
|
||||
}, [forkSkill, folderId]);
|
||||
const [draft, setDraft] = useState<SkillCreateDraft>(initialDraft);
|
||||
const [slugDirty, setSlugDirty] = useState(initialDraft.slug.trim().length > 0);
|
||||
const [categoryDraft, setCategoryDraft] = useState(initialDraft.categories.join(", "));
|
||||
|
|
|
|||
Loading…
Reference in New Issue