diff --git a/.changeset/11553-studio-package-less-flows.md b/.changeset/11553-studio-package-less-flows.md new file mode 100644 index 0000000000..d5ef6c0df1 --- /dev/null +++ b/.changeset/11553-studio-package-less-flows.md @@ -0,0 +1,14 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/console': patch +--- + +Studio reaches the organization's own flows that belong to no package (objectui#11553). + +A clone of a packaged flow is, by ADR-0126 §7.1, an ordinary org-owned flow, and the clone door stores it with no package on purpose. Studio was routed and listed per package, so the clone matched no route and no rail: the Studio home said "No writable packages yet", the read-only package's Automations rail listed only the packaged flows, and a deep link naming the clone opened another flow. + +- **A package-less scope, `/studio/~org/automations`.** The same Studio design surface with no package under it. Its Automations rail lists every flow that belongs to no package (the unscoped flow list, narrowed by each item's owning-package field, plus package-less drafts), and opens each one editable. Edits save as drafts bound to no package; the header's count, the pending-changes sheet and Publish cover package-less flow drafts only, and Publish promotes each one by reference, since the package batch door cannot reach a draft bound to no package. The scope offers no other pillar, no "New" flow, no Create app and no package copilot. `~` is outside every package-id alphabet, so the segment can never name a package. +- **Reachable from Studio's home and from the package switcher**, whether or not a writable package exists. +- **A deep link names the flow it opens.** A `?surface=flow:` link naming a flow the rail does not hold no longer opens the rail's first flow in its place: from a package's pillar, a package-less flow is found and opened in the package-less scope; a flow found nowhere is reported on the canvas. +- A read-only package's flows stay read-only, as before. +- The console's `/studio` routes gain the scope's bare leg, `/studio/~org`, which lands on its one pillar. diff --git a/apps/console/src/components/StudioRoute.test.tsx b/apps/console/src/components/StudioRoute.test.tsx index 06c24c6248..666286b9bb 100644 --- a/apps/console/src/components/StudioRoute.test.tsx +++ b/apps/console/src/components/StudioRoute.test.tsx @@ -336,6 +336,25 @@ describe('/studio/* — the entry decision, both ways', () => { ); }); + it('the package-less scope\'s bare leg lands on its one pillar, the builder mounted (objectui#11553)', async () => { + // `~org` is the reserved segment for the organization's own, package-less + // flows. Without its own leg it falls to `:packageId`, whose redirect + // targets a Data pillar that scope does not have. + answerWith(OPERATOR_CAPS); + renderStudioDeepLink('/studio/~org'); + + await waitFor(() => expect(pathname()).toBe('/studio/~org/automations')); + await waitFor(() => expect(screen.getByTestId('studio-pillar-builder')).toBeInTheDocument()); + expect(designSurface).toHaveBeenCalled(); + }); + + it('the package-less scope is behind the same entry gate (objectui#11553)', async () => { + renderStudioDeepLink('/studio/~org/automations'); + + await waitFor(() => expect(pathname()).toBe('/home')); + expect(designSurface).not.toHaveBeenCalled(); + }); + it('NEGATIVE CONTROL: the holder is answered ONCE for the whole subtree', async () => { answerWith(OPERATOR_CAPS); renderStudioDeepLink('/studio/hotcrm/data'); diff --git a/apps/console/src/components/StudioRoute.tsx b/apps/console/src/components/StudioRoute.tsx index 49449c46f7..72fcc402ed 100644 --- a/apps/console/src/components/StudioRoute.tsx +++ b/apps/console/src/components/StudioRoute.tsx @@ -40,6 +40,8 @@ import { BuilderLanding, LoadingFallback, LoadingScreen, + STUDIO_ORG_SCOPE_PILLAR, + STUDIO_ORG_SCOPE_SEGMENT, StudioDesignSurface, getProductName, useHomePath, @@ -102,7 +104,8 @@ export function StudioRoute() { } /** - * The `/studio` front door: pick or create a writable package. + * The `/studio` front door: pick or create a writable package, or open the + * organization's package-less flows. * * Standalone frame — the landing must never be a navigation dead end, so the * wordmark walks back to the platform Home. @@ -150,12 +153,25 @@ function StudioLanding() { * agree with itself while `App.tsx` quietly mounted the builder ungated. * * `/studio` front door (pick / create a writable package) + * `/studio/~org` the package-less scope lands on its one pillar * `/studio/:packageId` a package lands on its Data pillar * `/studio/:packageId/:tab` the pillar builder + * + * `~org` is the reserved segment for the organization's own, package-less + * flows (objectui#11553; `studioScope.ts` in app-shell says why `~` can never + * name a package). Its pillar URL is served by the generic `:packageId/:tab` + * route — the SAME `StudioDesignSurface`, which reads the segment as "no + * package" — so only its bare leg needs a route of its own: a static segment + * outranks `:packageId`, and the generic leg would send it to a Data pillar the + * scope does not have. */ export const studioRoutes = ( }> } /> + } + /> } /> } /> diff --git a/content/docs/guide/console.md b/content/docs/guide/console.md index 2114983436..bc6a054dbc 100644 --- a/content/docs/guide/console.md +++ b/content/docs/guide/console.md @@ -29,6 +29,7 @@ The console opens at **http://localhost:5180** (the port is fixed in `apps/conso | **Branding** | Per-app colors, favicons, and logos via `AppShell` branding. | | **Command Palette** | `⌘+K` opens a searchable command bar for quick navigation. | | **Studio Package Scope** | Studio home, metadata counts, quick-create links, and diagnostics follow the selected package. | +| **Package-less Flows in Studio** | Flows that belong to no package — such as a clone of a packaged flow, made from Setup › Packaged automation — are listed and edited at `/studio/~org/automations`, reached from the Studio home ("Not in a package") or the package switcher. They open editable, edits save as drafts, and Publish promotes those drafts. | | **Design in Studio** | Workspace admins get a top-bar entry inside a running app that opens its owning package on the Studio design surface. On an interface route — a dashboard, page, or report — it deep-links straight to that surface's design page in the Interfaces pillar (`/studio/:packageId/interfaces?surface=:`, e.g. `surface=page:showcase_crm_workbench`); elsewhere (objects, the app root) it opens the package's Data tab (`/studio/:packageId/data`). These interfaces are authored in Studio — there is no in-page edit panel. | | **Dashboard Refresh** | A dashboard page shows a **Refresh All** button above its widgets, and a dashboard that sets `refreshIntervalSeconds` (Studio's auto-refresh field) re-reads its widgets' data every that many seconds. Widgets re-read in place, so they are not remounted. `0` or no value means no automatic refresh. Known gap: a dataset-bound single-value (KPI) tile does not refresh yet. | | **App Creation Wizard** | 4-step wizard (Basic Info → Objects → Navigation → Branding) to create or edit apps. | diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md index 12b9a3465a..37e2530193 100644 --- a/packages/app-shell/README.md +++ b/packages/app-shell/README.md @@ -374,6 +374,22 @@ enable, or disable packages; direct `/metadata/package` links redirect there. The Studio sidebar also flattens the root Overview group so Home and package navigation sit directly under the package selector. +### Package-less flows (`/studio/~org/automations`) + +A flow that belongs to no package — a clone of a packaged flow is one, by +ADR-0126 §7.1 — matches no package scope, so Studio gives it one scope of its +own: `/studio/~org/automations` (`studioOrgScopePath()`, segment +`STUDIO_ORG_SCOPE_SEGMENT`). It is the same `StudioDesignSurface` with no +package under it, reached from the Studio home and from the package switcher. +Its Automations rail lists every flow whose served `_packageId` names no +package, opens each one editable, and saves package-less drafts; its Publish +promotes those drafts one by one through the single-item publish door, because +the package batch publish cannot reach a draft bound to no package. It offers +no other pillar and no "New" flow. `~` is outside every package-id alphabet, +so the segment can never name a package. A `?surface=flow:` deep link that +names a flow the open rail does not hold opens no other flow in its place; +from a package's pillar, a package-less flow is opened in this scope instead. + ### Access matrix (package-scoped) The Access pillar's permission matrix follows the active package (ADR-0086 P0). diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index e6c25b6fa7..6246d77840 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -451,6 +451,14 @@ export type { // The builder's front door: pick/create a writable package → pillar builder. // Standalone at `/studio` and embedded via the `studio:builder` component ref. export { BuilderLanding } from './views/studio-design/BuilderLanding.js'; +// The one Studio scope that is not a package (objectui#11553): the +// organization's own package-less flows, at `/studio/~org/automations`. A host +// that declares the `/studio` routes reads the reserved segment from here. +export { + STUDIO_ORG_SCOPE_SEGMENT, + STUDIO_ORG_SCOPE_PILLAR, + studioOrgScopePath, +} from './views/studio-design/studioScope.js'; // Setup › Packaged automation (ADR-0126 §7.4) — on/off + clone for the flows // installed packages ship. Reached through the `automation:packaged` component diff --git a/packages/app-shell/src/preview/DraftChangesPanel.tsx b/packages/app-shell/src/preview/DraftChangesPanel.tsx index 2eeeb4a051..56532f29f8 100644 --- a/packages/app-shell/src/preview/DraftChangesPanel.tsx +++ b/packages/app-shell/src/preview/DraftChangesPanel.tsx @@ -337,6 +337,14 @@ export interface DraftChangesPanelProps { onOpenChange: (open: boolean) => void; /** When set, list only pending drafts belonging to this package (Studio is package-scoped). */ packageId?: string | null; + /** + * Narrows the listed drafts further, for a host whose scope the `_drafts` + * query cannot express on its own. Studio's package-less scope passes its + * own predicate (package-less FLOW drafts, objectui#11553) so the sheet + * reviews exactly what that scope's Publish ships. Applied to the folded + * entries, before anything is classified or linted. + */ + include?: (entry: DraftChangeEntry) => boolean; /** * When provided, the panel renders a confirm footer whose button invokes * this — turning the panel into the review-then-publish step. The caller @@ -351,6 +359,7 @@ export function DraftChangesPanel({ open, onOpenChange, packageId, + include, onPublish, publishing = false, }: DraftChangesPanelProps) { @@ -386,7 +395,8 @@ export function DraftChangesPanel({ setError(null); setProblems([]); try { - const drafts = await listPendingDrafts(packageId); + const listed = await listPendingDrafts(packageId); + const drafts = include ? listed.filter(include) : listed; setEntries(drafts); // Mirror the publish door's own security-posture rule over these drafts. // Deliberately not awaited with the classification below: a finding is @@ -428,7 +438,7 @@ export function DraftChangesPanel({ } catch (e) { setError((e as Error).message); } - }, [packageId]); + }, [packageId, include]); useEffect(() => { if (open) void load(); diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 4026b9aa61..3ab83b8986 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -2776,6 +2776,14 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.landing.dupGo': 'Duplicate and open the builder', 'engine.studio.landing.dupCreated': 'Duplicated into writable package “{name}”', 'engine.studio.landing.installedHeading': 'Installed (read-only · browsable)', + // objectui#11553 — the package-less scope: the organization's own flows. + 'engine.studio.landing.orgHeading': 'Not in a package', + 'engine.studio.org.name': 'Organization flows', + 'engine.studio.org.hint': 'Flows that belong to no package', + 'engine.studio.org.description': 'The organization’s own flows that belong to no package, such as a clone of a packaged flow. They open editable.', + 'engine.studio.org.none': 'No flows outside a package yet. Clone a packaged flow in Setup › Packaged automation to edit the copy here.', + 'engine.studio.org.published': 'Published the package-less flow drafts', + 'engine.studio.auto.deepLinkMissing': 'The link names flow “{name}”, which is not here.', 'engine.studio.designer.search': 'Search…', 'engine.studio.designer.select': 'Select…', 'engine.studio.designer.pickDate': 'Pick a date…', @@ -5649,6 +5657,14 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.landing.dupGo': '复制并进入构建器', 'engine.studio.landing.dupCreated': '已复制为可写软件包「{name}」', 'engine.studio.landing.installedHeading': '已安装(只读 · 可浏览)', + // objectui#11553 — the package-less scope: the organization's own flows. + 'engine.studio.landing.orgHeading': '不属于软件包', + 'engine.studio.org.name': '组织流程', + 'engine.studio.org.hint': '不属于任何软件包的流程', + 'engine.studio.org.description': '本组织自有、不属于任何软件包的流程,例如软件包流程的克隆副本。可直接编辑。', + 'engine.studio.org.none': '还没有不属于软件包的流程。在 设置 › 打包自动化 中克隆一个软件包流程,即可在这里编辑副本。', + 'engine.studio.org.published': '已发布不属于软件包的流程草稿', + 'engine.studio.auto.deepLinkMissing': '链接指向的流程「{name}」不在这里。', 'engine.studio.designer.search': '搜索…', 'engine.studio.designer.select': '请选择…', 'engine.studio.designer.pickDate': '选择日期…', diff --git a/packages/app-shell/src/views/studio-design/BuilderLanding.orgScope-11553.test.tsx b/packages/app-shell/src/views/studio-design/BuilderLanding.orgScope-11553.test.tsx new file mode 100644 index 0000000000..6d147f5baf --- /dev/null +++ b/packages/app-shell/src/views/studio-design/BuilderLanding.orgScope-11553.test.tsx @@ -0,0 +1,78 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11553 — Studio's home reaches the organization's package-less flows. + * + * Measured on the stock showcase: the home said "No writable packages yet" and + * its only entry was the read-only showcase package, whose Automations rail + * lists packaged flows and not the clone. A package-less flow matches no + * package card, so the home carries an entry of its own for them, and it is + * there whether or not a writable package exists. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; + +const packages = vi.hoisted(() => ({ list: [] as Array> })); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => packages.list) }; +}); + +// The create dialog is closed throughout; its form stack is not under test. +vi.mock('../metadata-admin/PackageFormDialog', () => ({ PackageFormDialog: () => null })); + +import { BuilderLanding } from './BuilderLanding'; + +afterEach(() => { + cleanup(); + packages.list = []; +}); + +function LocationProbe() { + const { pathname } = useLocation(); + return
{pathname}
; +} + +function renderLanding() { + return render( + + + + } /> + } /> + + , + ); +} + +describe('Studio home → the package-less scope (objectui#11553)', () => { + it('offers the entry on the measured shape: no writable package, one read-only package', async () => { + packages.list = [{ id: 'com.example.showcase', name: 'Showcase', writable: false, namespace: 'showcase' }]; + renderLanding(); + + await screen.findByText('No writable packages yet — create one to start.'); + const entry = screen.getByTestId('studio-landing-org-scope'); + expect(entry).toHaveTextContent('Organization flows'); + expect(screen.getByText('Not in a package')).toBeInTheDocument(); + + fireEvent.click(entry); + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/studio/~org/automations')); + expect(screen.getByTestId('pillar-builder')).toBeInTheDocument(); + }); + + it('offers it beside writable packages too, and a package card still opens that package', async () => { + packages.list = [{ id: 'com.acme.app', name: 'Acme', writable: true, namespace: 'acme' }]; + renderLanding(); + + await screen.findByText('Acme'); + expect(screen.getByTestId('studio-landing-org-scope')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('Acme')); + await waitFor(() => expect(screen.getByTestId('location')).toHaveTextContent('/studio/com.acme.app/data')); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/BuilderLanding.tsx b/packages/app-shell/src/views/studio-design/BuilderLanding.tsx index c8df436859..11335a6a50 100644 --- a/packages/app-shell/src/views/studio-design/BuilderLanding.tsx +++ b/packages/app-shell/src/views/studio-design/BuilderLanding.tsx @@ -9,19 +9,21 @@ * (`/studio/:packageId/:tab`). Also served standalone at bare `/studio` so the * builder is bookmarkable. * - * Writable bases (where authoring happens) lead; read-only code packages are - * listed secondary for browsing. Writability is the shared display heuristic + * Writable bases (where authoring happens) lead; the organization's own + * package-less flows have one entry of their own (objectui#11553); read-only + * code packages are listed secondary for browsing. Writability is the shared display heuristic * from packages-io — the ADR-0070 D4 gate stays the server-side authority. */ import * as React from 'react'; import { useNavigate } from 'react-router-dom'; -import { Boxes, Hammer, Lock, Plus, Loader2, Copy } from 'lucide-react'; +import { Boxes, Building2, Hammer, Lock, Plus, Loader2, Copy } from 'lucide-react'; import { toast } from 'sonner'; import { t, tFormat, useMetadataLocale } from '../metadata-admin/i18n.js'; import { PackageFormDialog } from '../metadata-admin/PackageFormDialog.js'; import { fetchPackages, duplicatePackage, PACKAGE_ID_RE, type PkgEntry } from './packages-io.js'; import { PackageIdInput } from './PackageIdInput.js'; +import { studioOrgScopePath } from './studioScope.js'; export function BuilderLanding(): React.ReactElement { const navigate = useNavigate(); @@ -203,6 +205,32 @@ export function BuilderLanding(): React.ReactElement { onSaved={(r) => open(r.id)} /> + {/* objectui#11553 — the organization's own flows, which belong to no + * package (a clone of a packaged flow is one, by ADR-0126 §7.1). They + * match no package card above, so they get their own entry, shown + * whether or not any writable package exists. */} +

+ {t('engine.studio.landing.orgHeading', locale)} +

+
+ +
+ {readonly.length > 0 && ( <>

diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.packageLessFlows-11553.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.packageLessFlows-11553.test.tsx new file mode 100644 index 0000000000..3b9bd32cfe --- /dev/null +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.packageLessFlows-11553.test.tsx @@ -0,0 +1,381 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11553 — a package-less flow (a clone of a packaged flow, which + * ADR-0126 §7.1 makes "an ordinary org/install-owned flow") is reachable in + * Studio, editable, and its deep link opens it and no other flow. + * + * Measured on objectstack 17.6.0 with the console at the pin: the clone's row + * names no package; the package-scoped flow list omits it and the bare list + * includes it; Studio's every route was keyed by a package, its Automations + * rail read the package-scoped list, and a deep link naming the clone as the + * surface opened the package's first flow instead. + * + * Rendered through the REAL `StudioDesignSurface` on the REAL route shape, with + * the real registered `FlowPreview` / `FlowInspector`, so "opens editable" is + * what the canvas and the switch actually offer. The server double answers the + * metadata reads the way the platform does: a `packageId` list keeps only rows + * whose `_packageId` equals it, and the unscoped list returns every row. Each + * package-less case has its packaged control (the read-only package below + * stays read-only, as objectui#11124 pins at the pillar). + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; + +const SHOWCASE = 'com.example.showcase'; +const NODES = [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'end', type: 'end', label: 'End' }, +]; +const EDGES = [{ id: 'e1', source: 'start', target: 'end' }]; +const flow = (name: string, label: string, packageId?: string) => ({ + name, + label, + type: 'autolaunched', + status: 'active', + nodes: NODES, + edges: EDGES, + ...(packageId ? { _packageId: packageId } : {}), +}); + +const server = vi.hoisted(() => ({ + active: new Map>(), + drafts: [] as Array<{ type: string; name: string; packageId: string | null }>, + saves: [] as Array<{ type: string; name: string; options: Record | undefined; body: Record }>, + publishedByRef: [] as Array<{ type: string; name: string }>, + packageBatches: [] as string[], +})); + +const mockClient = vi.hoisted(() => ({ + list: vi.fn(async (type: string, options?: { packageId?: string }) => + [...server.active.values()] + .filter((row) => row.__type === type) + .filter((row) => !options?.packageId || row._packageId === options.packageId) + .map(({ __type: _t, ...row }) => row), + ), + listDrafts: vi.fn(async (options: { packageId?: string; type?: string } = {}) => + server.drafts.filter( + (d) => (!options.type || d.type === options.type) && (!options.packageId || d.packageId === options.packageId), + ), + ), + listTypes: vi.fn(async () => ({ entries: [] })), + get: vi.fn(async () => null), + references: vi.fn(async () => []), + layered: vi.fn(async (type: string, name: string) => { + const row = server.active.get(`${type}/${name}`); + const { __type: _t, ...eff } = row ?? {}; + return { code: null, overlay: row ? eff : null, overlayScope: row ? 'env' : null, effective: row ? eff : null }; + }), + getDraft: vi.fn(async (type: string, name: string) => { + throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 }); + }), + save: vi.fn(async (type: string, name: string, item: unknown, options?: Record) => { + server.saves.push({ type, name, options, body: JSON.parse(JSON.stringify(item)) as Record }); + return { type, name, item }; + }), + publishDraft: vi.fn(async (type: string, name: string) => { + server.publishedByRef.push({ type, name }); + return { success: true }; + }), + publishPackageDrafts: vi.fn(async (packageId: string) => { + server.packageBatches.push(packageId); + return { success: true, failed: [] }; + }), + publish: vi.fn(async () => ({ success: true })), + reset: vi.fn(async () => ({})), +})); + +/** What the surface handed the pending-changes sheet, last render. */ +const panel = vi.hoisted(() => ({ props: null as null | Record })); +/** Read through a call, so a reset between renders does not narrow it away. */ +const panelProps = (): Record | null => panel.props; +const chatDock = vi.hoisted(() => ({ mounts: 0 })); + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + // The stock showcase: its one installed package is read-only. + fetchPackages: vi.fn(async () => [{ id: SHOWCASE, name: 'Showcase', writable: false, namespace: 'showcase' }]), + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => dataSource }; +}); + +vi.mock('./StudioAiCopilot', () => ({ + StudioChatDock: () => { + chatDock.mounts += 1; + return null; + }, +})); + +vi.mock('../../preview/DraftChangesPanel', () => ({ + DraftChangesPanel: (props: Record) => { + panel.props = props; + return null; + }, +})); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } })); + +import { StudioDesignSurface } from './StudioDesignSurface'; +import { createEmptyDataSource, failOnAbsorbedFetchError } from './__tests__/emptyDataSource'; +import { registerMetadataPreview } from '../metadata-admin/preview-registry'; +import { registerMetadataInspector } from '../metadata-admin/inspector-registry'; +import { FlowPreview } from '../metadata-admin/previews/FlowPreview'; +import { FlowInspector } from '../metadata-admin/inspectors/FlowInspector'; + +const dataSource = createEmptyDataSource(); +failOnAbsorbedFetchError(); + +// The two global-`fetch` reads on this surface: the pending-drafts feed +// (`/meta/_drafts`, the header count and the package-less publish) and the +// rail's `/automation/_status` probe (answered "absent", so no dots). +vi.stubGlobal( + 'fetch', + vi.fn(async (input: unknown) => { + const url = String(input); + if (url.startsWith('/api/v1/meta/_drafts')) { + const qs = new URL(url, 'http://x').searchParams.get('packageId'); + const drafts = server.drafts.filter((d) => !qs || d.packageId === qs); + return new Response(JSON.stringify({ drafts }), { status: 200, headers: { 'content-type': 'application/json' } }); + } + return new Response('null', { status: 404, headers: { 'content-type': 'application/json' } }); + }), +); + +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; +(globalThis as { ResizeObserver?: unknown }).ResizeObserver = + (globalThis as { ResizeObserver?: unknown }).ResizeObserver ?? + class { + observe() {} + unobserve() {} + disconnect() {} + }; + +registerMetadataPreview('flow', FlowPreview); +registerMetadataInspector('flow', FlowInspector); + +function seed(row: Record) { + server.active.set(`flow/${String(row.name)}`, { __type: 'flow', ...row }); +} + +beforeEach(() => { + server.active.clear(); + server.drafts.length = 0; + server.saves.length = 0; + server.publishedByRef.length = 0; + server.packageBatches.length = 0; + panel.props = null; + chatDock.mounts = 0; + for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear(); + // Two packaged flows on the read-only showcase, and the clone, which names no package. + seed(flow('showcase_urgent_task_alert', 'Urgent task alert', SHOWCASE)); + seed(flow('showcase_daily_digest', 'Daily digest', SHOWCASE)); + seed(flow('qa_urgent_alert_clone', 'QA urgent alert clone')); +}); + +afterEach(cleanup); + +function LocationProbe() { + const { pathname, search } = useLocation(); + return
{`${pathname}${search}`}
; +} + +function renderAt(url: string) { + return render( + + + + } /> + + , + ); +} + +const location = () => screen.getByTestId('location').textContent ?? ''; + +/** The canvas's own status pill, which reads the open flow's `draft.status`. */ +function canvasStatus(): string { + return screen.getByText('Status:').nextElementSibling?.textContent ?? ''; +} + +async function openedFlow(name: string): Promise { + await screen.findByText(`flow · ${name}`, undefined, { timeout: 8000 }); + await waitFor(() => expect(canvasStatus()).toBe('active'), { timeout: 8000 }); +} + +describe('the package-less scope lists a package-less flow and opens it editable (objectui#11553)', () => { + it('lists the clone and none of the packaged flows', async () => { + renderAt('/studio/~org/automations'); + await openedFlow('qa_urgent_alert_clone'); + + expect(screen.getByRole('button', { name: /QA urgent alert clone/ })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Urgent task alert/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Daily digest/ })).not.toBeInTheDocument(); + // The unscoped reads: a package-scoped one cannot see a package-less row. + expect(mockClient.list).toHaveBeenCalledWith('flow'); + expect(mockClient.list).not.toHaveBeenCalledWith('flow', expect.objectContaining({ packageId: expect.anything() })); + }); + + it('opens it editable, and an edit saves a draft bound to no package', async () => { + renderAt('/studio/~org/automations'); + await openedFlow('qa_urgent_alert_clone'); + + const toggle = screen.getByRole('switch'); + expect(toggle).toBeEnabled(); + expect(screen.getAllByRole('button', { name: 'Add connected node' }).length).toBeGreaterThan(0); + + fireEvent.click(toggle); + await waitFor(() => expect(server.saves).toHaveLength(1), { timeout: 8000 }); + const [saved] = server.saves; + expect(saved!.name).toBe('qa_urgent_alert_clone'); + expect(saved!.body.status).toBe('obsolete'); + expect(saved!.options?.mode).toBe('draft'); + expect(saved!.options?.packageId).toBeUndefined(); + }); + + it('offers its one pillar, no Create app, no package copilot, and names itself in the switcher', async () => { + renderAt('/studio/~org/automations'); + await openedFlow('qa_urgent_alert_clone'); + + expect(screen.getByRole('link', { name: /Automations/ })).toHaveAttribute('href', '/studio/~org/automations'); + expect(screen.queryByRole('link', { name: /^Data$/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /Interfaces/ })).not.toBeInTheDocument(); + expect(screen.queryByTestId('studio-nav-more')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Create app/ })).not.toBeInTheDocument(); + expect(chatDock.mounts).toBe(0); + expect(screen.getByTitle('Switch / create package')).toHaveTextContent('Organization flows'); + // No "New" flow here: authoring stays package-first. + expect(screen.queryByTitle('New automation')).not.toBeInTheDocument(); + }); + + it('a package-less scope URL on another pillar lands on its one pillar', async () => { + renderAt('/studio/~org/data'); + await waitFor(() => expect(location()).toBe('/studio/~org/automations')); + await openedFlow('qa_urgent_alert_clone'); + }); +}); + +describe('the deep link to a package-less flow opens that flow, not another (objectui#11553)', () => { + it('in the package-less scope: the named flow, though another is listed first', async () => { + seed(flow('aa_first_org_flow', 'AA first org flow')); + renderAt('/studio/~org/automations?surface=flow%3Aqa_urgent_alert_clone'); + + await openedFlow('qa_urgent_alert_clone'); + expect(screen.queryByText('flow · aa_first_org_flow')).not.toBeInTheDocument(); + expect(location()).toBe('/studio/~org/automations?surface=flow%3Aqa_urgent_alert_clone'); + }); + + it('the measured link, through the showcase pillar: lands on the package-less scope, the clone open and editable', async () => { + renderAt(`/studio/${SHOWCASE}/automations?surface=flow%3Aqa_urgent_alert_clone`); + + await waitFor(() => expect(location()).toBe('/studio/~org/automations?surface=flow%3Aqa_urgent_alert_clone'), { + timeout: 8000, + }); + await openedFlow('qa_urgent_alert_clone'); + expect(screen.getByRole('switch')).toBeEnabled(); + // The defect's own signature: the package's first flow was opened instead. + expect(mockClient.layered).not.toHaveBeenCalledWith('flow', 'showcase_urgent_task_alert'); + }); + + it('a link naming a flow no rail holds opens no other flow, and says so', async () => { + renderAt(`/studio/${SHOWCASE}/automations?surface=flow%3Anobody_has_this`); + + await screen.findByText('The link names flow “nobody_has_this”, which is not here.', undefined, { timeout: 8000 }); + expect(location()).toBe(`/studio/${SHOWCASE}/automations?surface=flow%3Anobody_has_this`); + expect(mockClient.layered).not.toHaveBeenCalled(); + // The rail still lists the package's flows to pick from. + expect(screen.getByRole('button', { name: /Urgent task alert/ })).toBeInTheDocument(); + }); +}); + +describe('the packaged control: a read-only package stays read-only (objectui#11124)', () => { + it('lists the package’s flows, not the clone, and offers no edit', async () => { + renderAt(`/studio/${SHOWCASE}/automations`); + await openedFlow('showcase_urgent_task_alert'); + + expect(screen.queryByRole('button', { name: /QA urgent alert clone/ })).not.toBeInTheDocument(); + await waitFor(() => expect(screen.getByRole('switch')).toBeDisabled()); + expect(screen.queryAllByRole('button', { name: 'Add connected node' })).toHaveLength(0); + }); + + it('from that read-only package, the switcher’s entry opens the package-less scope editable', async () => { + // The surface stays mounted across the switch, so the read-only answer the + // showcase got is still in hand when the package-less scope renders. + renderAt(`/studio/${SHOWCASE}/automations`); + await openedFlow('showcase_urgent_task_alert'); + await waitFor(() => expect(screen.getByRole('switch')).toBeDisabled()); + + fireEvent.click(screen.getByTitle('Switch / create package')); + fireEvent.click(await screen.findByTestId('studio-org-scope-entry')); + + await waitFor(() => expect(location()).toBe('/studio/~org/automations')); + await openedFlow('qa_urgent_alert_clone'); + expect(screen.getByRole('switch')).toBeEnabled(); + }); +}); + +describe('the package-less scope reviews and publishes exactly its own drafts (objectui#11553)', () => { + beforeEach(() => { + server.drafts.push( + { type: 'flow', name: 'qa_urgent_alert_clone', packageId: null }, + { type: 'object', name: 'org_loose_object', packageId: null }, + { type: 'flow', name: 'showcase_daily_digest', packageId: SHOWCASE }, + ); + }); + + it('counts only package-less flow drafts', async () => { + renderAt('/studio/~org/automations'); + await openedFlow('qa_urgent_alert_clone'); + await waitFor(() => expect(screen.getByRole('button', { name: /Changes · 1$/ })).toBeInTheDocument()); + }); + + it('hands the sheet a filter that keeps only package-less flow drafts', async () => { + renderAt('/studio/~org/automations'); + await openedFlow('qa_urgent_alert_clone'); + + const include = panelProps()?.include as ((e: { type: string; name: string; packageId: string | null }) => boolean) | undefined; + expect(include).toBeTypeOf('function'); + expect(server.drafts.filter((d) => include!(d)).map((d) => d.name)).toEqual(['qa_urgent_alert_clone']); + // Control: a package's sheet is not narrowed. + cleanup(); + panel.props = null; + renderAt(`/studio/${SHOWCASE}/automations`); + await openedFlow('showcase_urgent_task_alert'); + expect(panelProps()?.include).toBeUndefined(); + }); + + it('publishes each package-less flow draft by reference, and nothing through the package batch door', async () => { + renderAt('/studio/~org/automations'); + await openedFlow('qa_urgent_alert_clone'); + + const onPublish = panelProps()?.onPublish as (() => Promise) | undefined; + expect(onPublish).toBeTypeOf('function'); + await onPublish!(); + + expect(server.publishedByRef).toEqual([{ type: 'flow', name: 'qa_urgent_alert_clone' }]); + expect(server.packageBatches).toEqual([]); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index d9379c63fe..ea43b010c9 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -15,7 +15,7 @@ */ import * as React from 'react'; -import { useParams, useNavigate, Link } from 'react-router-dom'; +import { useParams, useNavigate, Link, Navigate } from 'react-router-dom'; import { useAdapter, SchemaRendererProvider } from '@object-ui/react'; // The ONE draft-envelope reader (objectui#8181): unwrap AND strip the // framework's read decorations in one place. This file used to carry its own @@ -50,6 +50,7 @@ import { AlertTriangle, Ban, Boxes, + Building2, FileText, Database, LayoutDashboard, @@ -102,11 +103,17 @@ import { } from '../metadata-admin/nav-selection.js'; import { useNavSelDeepLink } from '../metadata-admin/useNavSelDeepLink.js'; import { SourcePageEditor } from '../metadata-admin/previews/SourcePageEditor.js'; -import { usePendingDrafts } from '../../preview/usePendingDrafts.js'; +import { fetchPendingDrafts, usePendingDrafts } from '../../preview/usePendingDrafts.js'; import { emitMetadataRefresh, subscribeMetadataRefresh } from '../../assistant/assistantBus.js'; import { formatPublishFailures, type PublishFailure } from './metadataError.js'; import { readEnvelopeFailureText } from '../../utils/apiErrorEnvelope.js'; -import { loadPackageSurfaces } from './packageSurfaces.js'; +import { loadPackageLessSurfaces, loadPackageSurfaces } from './packageSurfaces.js'; +import { + STUDIO_ORG_SCOPE_PILLAR, + isOrgScopeDraft, + isStudioOrgScope, + studioOrgScopePath, +} from './studioScope.js'; import { useMetadataRefreshNonce } from './useMetadataRefreshNonce.js'; import { useHomePath } from '../../hooks/useHomePath.js'; import { resolveSurface, findSurfaceInTree, type NavNode, type Surface } from './navSurface.js'; @@ -407,7 +414,8 @@ function PackageSwitcher({ tab, beforeNavigate, }: { - packageId: string; + /** The open package, or `null` in the package-less scope (objectui#11553). */ + packageId: string | null; tab: string; /** objectui#2600 — veto hook for package-switch navigation: return false to * stay put (the surface prompts about unsaved pillar edits). Not consulted @@ -791,7 +799,17 @@ function PackageSwitcher({ className="flex items-center gap-1.5 whitespace-nowrap rounded-md px-1.5 py-0.5 text-[13px] font-medium hover:bg-muted" title={t('engine.studio.pkg.switchTitle', locale)} > - {current?.name ?? packageId} + {/* objectui#11553 — the package-less scope names itself; there is + no package id to show as the diagnostic handle. */} + {packageId === null ? ( + <> + {t('engine.studio.org.name', locale)} + + ) : ( + <> + {current?.name ?? packageId} + + )} {/* ⛔ Not a replacement for the id (objectui#7368): the id is the one diagnostic handle the author has, so the states are told apart by what stands NEXT to it, never by swapping it for prose. */} @@ -877,11 +895,39 @@ function PackageSwitcher({ ))} + {/* objectui#11553 — the package-less scope, reachable from every + package as it is from the Studio home. It lands on its one + pillar whatever pillar is open here. */} +
+ +
+
{current && (
· {/* Package-level draft review + one atomic publish (replaces per-item Publish) */}
{/* objectui#5800 — the Open app teleport is retired: the canvas's Run mode IS the way to try the app without leaving the - workbench. The published-app state needs no chrome at all. */} - {packageApp ? null : appDraftPending ? ( + workbench. The published-app state needs no chrome at all. + objectui#11553 — the package-less scope has no app to create. */} + {packageId === null || packageApp ? null : appDraftPending ? ( + ) : ( + + ) + ) : tab === 'data' ? ( )} + {/* objectui#11553 — the package-less scope reviews exactly what its + * Publish ships: package-less flow drafts, read off the env-wide + * feed (`packageId` null) and narrowed by `include`. */} @@ -4391,7 +4530,12 @@ export function AutomationsPillar({ onDraftSaved, readOnly = false, }: { - packageId: string; + /** + * The package whose flows the rail lists, or `null` for the package-less + * scope (objectui#11553, `studioScope.ts`): every flow that belongs to no + * package, saved as package-less drafts. + */ + packageId: string | null; publishNonce?: number; onDraftSaved?: () => void; /** Courtesy gate: hide/disable flow-authoring affordances. */ @@ -4399,6 +4543,11 @@ export function AutomationsPillar({ }): React.ReactElement { const client = useMetadataClient(); const locale = useMetadataLocale(); + const navigate = useNavigate(); + // The draft writes' package binding (objectui#11553): none in the + // package-less scope, so the draft row stays as package-less as the flow it + // edits. The client sends no `package=` for an absent id. + const draftPackageId = packageId ?? undefined; // See DataPillar's rail — same mobile-overlay treatment for the flow list. const isMobile = useIsMobile(); const [railOpen, setRailOpen] = React.useState(false); @@ -4411,6 +4560,9 @@ export function AutomationsPillar({ // useSurfaceDeepLink). No producer emits this link yet; honoring it keeps // the pillars uniform so a future "design this flow" bridge just works. const initialSurface = useSurfaceDeepLink(current); + // objectui#11553 — the package-less probe for a deep-linked flow this + // package does not hold runs once per mount, not on every list re-read. + const deepLinkProbedRef = React.useRef(false); const [draft, setDraft] = React.useState>({}); // objectui#11272 — the flow `draft` was loaded for, `flow:NAME`: written // where the load below installs it, and nowhere else. @@ -4423,6 +4575,11 @@ export function AutomationsPillar({ // Tells "still fetching the list" apart from "fetched, package has no flows" // — without it the empty rail showed an endless "Loading…" for a fresh package. const [listed, setListed] = React.useState(false); + // The flow a deep link named that this rail, once listed, does not hold. + const missingFlow = + listed && initialSurface?.type === 'flow' && !flows.some((f) => f.name === initialSurface.name) + ? initialSurface.name + : null; // Inline create — a fresh package starts with zero flows, so the pillar must // offer a way to author the first one (mirrors the object/app creators). const [creating, setCreating] = React.useState(false); @@ -4477,11 +4634,29 @@ export function AutomationsPillar({ // draft exists. Mirrors the Data / Interfaces / Access pillars, which all // merge their drafts. Keyed on `publishNonce` too so drafts that go live // collapse back into the published rail after a package publish. - const items = await loadPackageSurfaces(client, 'flow', packageId); + const items = + packageId === null + ? await loadPackageLessSurfaces(client, 'flow') + : await loadPackageSurfaces(client, 'flow', packageId); if (cancelled) return; setFlows(items); const deepLinked = resolveSurfaceDeepLink(items, initialSurface, 'flow'); - setCurrent((c) => c ?? deepLinked ?? items[0] ?? null); + // objectui#11553 — a deep link that NAMES a flow this rail does not + // hold opens no other flow in its place: the first flow under the name + // of the one asked for is how the clone's link "redirected to another + // flow". A package-less flow named from a package's pillar is found in + // the package-less scope and opened there, once per mount. + const named = initialSurface?.type === 'flow' ? initialSurface.name : null; + if (named && !deepLinked && packageId !== null && !deepLinkProbedRef.current) { + deepLinkProbedRef.current = true; + const packageLess = await loadPackageLessSurfaces(client, 'flow').catch(() => []); + if (cancelled) return; + if (packageLess.some((f) => f.name === named)) { + navigate(studioOrgScopePath({ type: 'flow', name: named }), { replace: true }); + return; + } + } + setCurrent((c) => c ?? deepLinked ?? (named ? null : items[0]) ?? null); } catch (e) { if (!cancelled) setError(formatMetadataError(e)); } finally { @@ -4506,7 +4681,7 @@ export function AutomationsPillar({ t('engine.studio.auto.nodeStart', locale), t('engine.studio.auto.nodeEnd', locale), ); - await client.save('flow', name, skeleton, { mode: 'draft', packageId }); + await client.save('flow', name, skeleton, { mode: 'draft', packageId: draftPackageId }); const item: Surface = { type: 'flow', name, label }; setFlows((fs) => [...fs.filter((f) => f.name !== name), item]); setCurrent(item); @@ -4520,7 +4695,7 @@ export function AutomationsPillar({ setCreateBusy(false); } }, - [client, packageId, onDraftSaved, locale], + [client, draftPackageId, onDraftSaved, locale], ); React.useEffect(() => { @@ -4577,7 +4752,7 @@ export function AutomationsPillar({ setSaving('draft'); setError(null); try { - await client.save('flow', current.name, draft, { mode: 'draft', packageId }); + await client.save('flow', current.name, draft, { mode: 'draft', packageId: draftPackageId }); setHasDraft(true); // objectui#11204 — clean only if nothing was edited while it was in flight. if (sent.unmoved()) setAutoDirty(false); @@ -4587,7 +4762,7 @@ export function AutomationsPillar({ } finally { setSaving(false); } - }, [client, current, draft, onDraftSaved]); + }, [client, current, draft, draftPackageId, onDraftSaved]); const { sending: sendingFlowDraft, loaded: flowLoaded } = useDraftAutoSave({ // objectui#11232 — the flow `doSave` addresses. target: `flow:${current?.name ?? ''}`, @@ -4626,7 +4801,7 @@ export function AutomationsPillar({ setSaving('draft'); setError(null); try { - await client.save('flow', flowName, nextDraft, { mode: 'draft', packageId }); + await client.save('flow', flowName, nextDraft, { mode: 'draft', packageId: draftPackageId }); setHasDraft(true); onDraftSaved?.(); toast.success(next ? t('engine.studio.auto.enabledToast', locale) : t('engine.studio.auto.disabledToast', locale)); @@ -4645,7 +4820,7 @@ export function AutomationsPillar({ } finally { setSaving(false); } - }, [client, current, draft, packageId, onDraftSaved, locale, readOnly, sendingFlowDraft]); + }, [client, current, draft, draftPackageId, onDraftSaved, locale, readOnly, sendingFlowDraft]); return (
@@ -4716,7 +4891,10 @@ export function AutomationsPillar({ >

{t('engine.studio.auto.heading', locale)}

- {!readOnly && ( + {/* objectui#11553 — no "New" in the package-less scope: new + authoring stays package-first, and this scope reaches flows + that already exist without a package. */} + {!readOnly && packageId !== null && (