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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/11553-studio-package-less-flows.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions apps/console/src/components/StudioRoute.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
18 changes: 17 additions & 1 deletion apps/console/src/components/StudioRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
BuilderLanding,
LoadingFallback,
LoadingScreen,
STUDIO_ORG_SCOPE_PILLAR,
STUDIO_ORG_SCOPE_SEGMENT,
StudioDesignSurface,
getProductName,
useHomePath,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 = (
<Route path="/studio" element={<StudioRoute />}>
<Route index element={<StudioLanding />} />
<Route
path={STUDIO_ORG_SCOPE_SEGMENT}
element={<Navigate to={STUDIO_ORG_SCOPE_PILLAR} replace />}
/>
<Route path=":packageId" element={<Navigate to="data" replace />} />
<Route path=":packageId/:tab" element={<StudioDesignSurface />} />
</Route>
Expand Down
1 change: 1 addition & 0 deletions content/docs/guide/console.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<type>:<name>`, 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. |
Expand Down
16 changes: 16 additions & 0 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
8 changes: 8 additions & 0 deletions packages/app-shell/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions packages/app-shell/src/preview/DraftChangesPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -351,6 +359,7 @@ export function DraftChangesPanel({
open,
onOpenChange,
packageId,
include,
onPublish,
publishing = false,
}: DraftChangesPanelProps) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -428,7 +438,7 @@ export function DraftChangesPanel({
} catch (e) {
setError((e as Error).message);
}
}, [packageId]);
}, [packageId, include]);

useEffect(() => {
if (open) void load();
Expand Down
16 changes: 16 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2776,6 +2776,14 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'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…',
Expand Down Expand Up @@ -5649,6 +5657,14 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'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': '选择日期…',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>> }));

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
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 <div data-testid="location">{pathname}</div>;
}

function renderLanding() {
return render(
<MemoryRouter initialEntries={['/studio']}>
<LocationProbe />
<Routes>
<Route path="/studio" element={<BuilderLanding />} />
<Route path="/studio/:packageId/:tab" element={<div data-testid="pillar-builder" />} />
</Routes>
</MemoryRouter>,
);
}

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'));
});
});
34 changes: 31 additions & 3 deletions packages/app-shell/src/views/studio-design/BuilderLanding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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. */}
<h2 className="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{t('engine.studio.landing.orgHeading', locale)}
</h2>
<div className="mb-6 grid grid-cols-1 gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
<button
type="button"
data-testid="studio-landing-org-scope"
onClick={() => navigate(studioOrgScopePath())}
className="flex items-center gap-2.5 rounded-lg border bg-background px-3 py-2.5 text-left hover:bg-muted/40"
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Building2 className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium">{t('engine.studio.org.name', locale)}</span>
<span className="block text-[10px] leading-4 text-muted-foreground">
{t('engine.studio.org.description', locale)}
</span>
</span>
</button>
</div>

{readonly.length > 0 && (
<>
<h2 className="mb-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Expand Down
Loading
Loading