diff --git a/.changeset/11863-studio-header.md b/.changeset/11863-studio-header.md index f4af4f3ad5..89353c2031 100644 --- a/.changeset/11863-studio-header.md +++ b/.changeset/11863-studio-header.md @@ -6,6 +6,8 @@ The Studio landing (`/studio`) gets the console's own header (objectui#11863). Its old header held one control, the product wordmark linking back to Home. It now mounts `AppHeader` with `variant="studio"`: the brand logo, then a fixed "Studio" crumb, then the header's right-hand cluster as on Home and the Workspaces page (the inbox, help, and the account menu with profile, theme, language and sign-out). The brand links to the declared landing (`useHomePath()`), as the wordmark did, so the landing and the Studio builder's Home button still name one home. No command palette is mounted on `/studio`, so the header shows no search trigger there. +**Note added 2026-10-09 (objectui#11863 Q2, PR objectui#12044):** the last sentence above no longer holds. From objectui#11863 Q2 the landing mounts the command palette in its `studio` scope, so the header draws the search trigger and `⌘K` opens the palette; see `.changeset/11863-studio-search.md`. + **Clause-②: yes (widening; and one narrowing in `@object-ui/i18n`).** `AppHeader`'s `variant` prop accepts a fourth member, `'studio'`, beside `'app'`, `'home'` and `'orgs'`. It draws the brand and a fixed crumb, as `'orgs'` does. The union is not exported by name (the package entry exports `AppHeader` only), so a host meets it as the prop's type: a host that switches exhaustively over that type must handle `'studio'`. A call that passes no `variant` resolves it as before (`'app'` with an `appName`, `'home'` without). No export is added or removed. `@object-ui/i18n`: each of the ten packs gains `console.studio.title` ("Studio", a product name every pack writes as is), the crumb's label, and drops `console.studio.backToHome`, the retired wordmark's tooltip, which nothing reads any more. diff --git a/.changeset/11863-studio-search.md b/.changeset/11863-studio-search.md new file mode 100644 index 0000000000..fe09688442 --- /dev/null +++ b/.changeset/11863-studio-search.md @@ -0,0 +1,17 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/i18n': patch +'@object-ui/console': patch +--- + +The Studio landing (`/studio`) gets search (objectui#11863). It mounts the command palette, so its header shows the "Search ⌘K" trigger and `Ctrl+K` / `⌘K` opens the palette. Until now only the frame inside an app mounted one, so the landing's header drew no trigger (objectui#11912's rule). The entry for the landing's new header says it shows no search trigger; with this change it does. + +On `/studio` no app is active, so the palette leaves out everything that belongs to an app: the app's objects, dashboards, pages and reports, record search, app switching, and the "Open Full Search Page" command, whose link starts with `/apps/APP` (mounted without an app it would have gone to `/apps/undefined/search`). It lists the Studio instead, in three groups: + +- **Packages**: the packages the Studio landing lists (kernel packages left out). Each opens its Data pillar, `/studio/PKG/data`. +- **Objects**: the objects of those packages, each opened in its package's Data pillar on that object (`/studio/PKG/data?surface=object:NAME`). +- **Flows**: the flows of those packages, each opened in its package's Automations pillar on that flow (`/studio/PKG/automations?surface=flow:NAME`), and the flows that belong to no package, opened in the package-less scope (`/studio/~org/automations?surface=flow:NAME`). + +An entry matches the query by its label or its machine name, as the palette's other entries do; an object or a packaged flow shows its package's name beside it. Nothing is read until the palette opens. Objects and flows are the published ones from the metadata cache, so an item that is still only a draft is not listed. The theme commands and the recently viewed records stay. + +**Clause-②: yes (widening).** `CommandPalette`'s props gain a second form, `scope="studio"`, which takes no other prop. The props a host passes inside an app (`apps`, `activeApp`, `objects`, `onAppChange`, `dataSource`) are unchanged and still required there; that form leaves `scope` out. The props type is not exported by name (the package entry exports `CommandPalette` only), so a host meets it as the component's props. No export is added or removed. `@object-ui/i18n` gains two keys in all ten packs, `console.commandPalette.packages` and `console.commandPalette.flows`, the headings of two of the new groups; the objects group reuses `console.commandPalette.objects`. diff --git a/apps/console/src/components/StudioRoute.searchPalette-11863.test.tsx b/apps/console/src/components/StudioRoute.searchPalette-11863.test.tsx new file mode 100644 index 0000000000..02f3dbce4e --- /dev/null +++ b/apps/console/src/components/StudioRoute.searchPalette-11863.test.tsx @@ -0,0 +1,177 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11863 (Q2) — the `/studio` landing mounts the command palette, in + * its `studio` scope, so the header's search trigger appears there and opens a + * palette that lists the Studio. + * + * Before it, only `ConsoleLayout` (inside an app) mounted a + * `CommandPaletteProvider`, and `AppHeader` draws its "Search ⌘K" trigger only + * under one (objectui#11912), so the landing's `studio` header had no search at + * all and `Ctrl+K` opened nothing. Pinned through the REAL `studioRoutes` tree, + * the real `AppHeader`, `CommandPaletteProvider` and `CommandPalette`, and the + * real `fetchPackages` over a stubbed `fetch`: + * + * - the header draws both triggers (desktop and compact) on `/studio`; + * - the desktop trigger and `Ctrl+K` each open the palette (`?palette=1`); + * - the palette lists the Studio's packages, and picking one opens its pillar + * builder through the real route tree. + * + * `t` answers in keys, as in `StudioRoute.landingI18n.test.tsx`, so a heading + * is read off the key it asks for. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; + +/** Auth facts. This file only ever drives the holder path. */ +const auth = { isAuthenticated: true, isLoading: false, user: { id: 'u1' } as unknown }; + +function jsonResponse(body: unknown, status = 200): Response { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + json: async () => body, + } as unknown as Response; +} + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => auth, + createAuthenticatedFetch: () => async () => + jsonResponse({ + authenticated: true, + userId: 'u1', + systemPermissions: ['studio.access'], + objects: {}, + fields: {}, + }), +})); + +// `AuthGuard` reaches `useAuth` through the auth package's OWN module graph, +// not through its entry point — the same seam `StudioRoute.test.tsx` documents. +vi.mock('../../../../packages/auth/src/useAuth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => auth, +})); + +// The same barrel seam as `StudioRoute.landingI18n.test.tsx`: `chrome/` is +// pulled whole, so `CommandPalette` is the real one. +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + ...(await vi.importActual>( + '../../../../packages/app-shell/src/chrome/index' + )), + ConnectedShell: ({ children }: { children?: React.ReactNode }) => <>{children}, + RequireOrganization: ({ children }: { children?: React.ReactNode }) => <>{children}, + LoadingFallback: () =>
, + LoadingScreen: () =>
, + getProductName: () => 'ObjectOS', + BuilderLanding: () =>
pick a package
, + StudioDesignSurface: () =>
, +})); + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + t: (key: string) => `«${key}»`, + }), +})); + +import { studioRoutes } from './StudioRoute'; + +const DESKTOP = 'action:command-palette:open'; +const MOBILE = 'action:command-palette:open-mobile'; +const OVERLAY = 'overlay:command-palette'; + +function LocationProbe() { + const location = useLocation(); + return
; +} + +function renderStudio(at = '/studio') { + return render( + + + {studioRoutes} + home
} /> + login
} /> + + + , + ); +} + +function locationNow(): string { + return screen.getByTestId('location').getAttribute('data-path') ?? ''; +} + +beforeEach(() => { + vi.clearAllMocks(); + // The package list answers two packages; every other read (the header's + // feeds, the AI agent catalogue) answers empty, so no request leaves the + // process. + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => + String(url).endsWith('/api/v1/packages') + ? jsonResponse({ + data: [ + { manifest: { id: 'com.acme.crm', name: 'Acme CRM' }, writable: true }, + { manifest: { id: 'com.acme.hr', name: 'People' }, writable: true }, + ], + }) + : jsonResponse({ data: [] }), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('the /studio landing mounts the command palette (objectui#11863, Q2)', () => { + it('draws the header search trigger, desktop and compact', async () => { + renderStudio(); + await screen.findByTestId('studio-front-door'); + + expect(screen.getByTestId(DESKTOP)).toBeInTheDocument(); + expect(screen.getByTestId(MOBILE)).toBeInTheDocument(); + // Closed until asked. + expect(screen.queryByTestId(OVERLAY)).not.toBeInTheDocument(); + }); + + it('opens the palette from the trigger, and it lists the Studio packages', async () => { + renderStudio(); + await screen.findByTestId('studio-front-door'); + + fireEvent.click(screen.getByTestId(DESKTOP)); + expect(await screen.findByTestId(OVERLAY)).toBeInTheDocument(); + expect(locationNow()).toBe('/studio?palette=1'); + + expect(await screen.findByText('«console.commandPalette.packages»')).toBeInTheDocument(); + expect(screen.getByText('Acme CRM')).toBeInTheDocument(); + expect(screen.getByText('People')).toBeInTheDocument(); + }); + + it('opens the palette on Ctrl+K', async () => { + renderStudio(); + await screen.findByTestId('studio-front-door'); + + fireEvent.keyDown(document, { key: 'k', ctrlKey: true }); + expect(await screen.findByTestId(OVERLAY)).toBeInTheDocument(); + expect(locationNow()).toBe('/studio?palette=1'); + }); + + it("picking a package opens that package's pillar builder", async () => { + renderStudio('/studio?palette=1'); + fireEvent.click(await screen.findByText('People')); + + expect(await screen.findByTestId('studio-pillar-builder')).toBeInTheDocument(); + expect(locationNow()).toBe('/studio/com.acme.hr/data'); + }); +}); diff --git a/apps/console/src/components/StudioRoute.tsx b/apps/console/src/components/StudioRoute.tsx index 9e2098ca83..b314334d2a 100644 --- a/apps/console/src/components/StudioRoute.tsx +++ b/apps/console/src/components/StudioRoute.tsx @@ -38,6 +38,8 @@ import { lazy, Suspense, useEffect, type ReactNode } from 'react'; import { Navigate, Outlet, Route, useParams } from 'react-router-dom'; import { AppHeader, + CommandPalette, + CommandPaletteProvider, LoadingFallback, LoadingScreen, STUDIO_ORG_SCOPE_PILLAR, @@ -151,22 +153,30 @@ export function StudioBuilderLoading() { * and help, and the brand, then a fixed Studio crumb. The brand links to * `useHomePath()`, so the landing is never a navigation dead end, and it names * the same home as `StudioDesignSurface`'s Home button one route apart - * (objectui#7256, objectui#7373). No command palette is mounted here, so the - * header draws no search trigger (objectui#11912). + * (objectui#7256, objectui#7373). + * + * The frame mounts the command palette in its `studio` scope (objectui#11863): + * no app is active here, so it lists the Studio's packages, objects and flows + * instead of an app's entries. Under its `CommandPaletteProvider` the header + * draws the search trigger, as it does wherever a palette is mounted + * (objectui#11912), and `⌘K` opens it. */ function StudioLanding() { return ( -
-
- -
-
- {/* The frame above stays painted while the landing's chunk loads. */} - }> - - + +
+
+ +
+ +
+ {/* The frame above stays painted while the landing's chunk loads. */} + }> + + +
-
+ ); } diff --git a/content/docs/guide/console.md b/content/docs/guide/console.md index f11b546cbc..2639a2828b 100644 --- a/content/docs/guide/console.md +++ b/content/docs/guide/console.md @@ -28,6 +28,7 @@ The console opens at **http://localhost:5180** (the port is fixed in `apps/conso | **Expression Visibility** | Show/hide navigation items using `visible: "${data.role === 'admin'}"`. | | **Branding** | Per-app colors, favicons, and logos via `AppShell` branding. | | **Command Palette** | `⌘+K` opens a searchable command bar for quick navigation. | +| **Search in Studio** | On the Studio landing (`/studio`) the header's search, or `⌘+K`, opens the command palette over the Studio rather than an app: its packages, their objects and their flows, matched by label or machine name. A package opens its Data pillar, an object opens in its package's Data pillar, a flow in its package's Automations pillar, and a flow that belongs to no package in `/studio/~org/automations`. App entries, record search and the full-search page are not offered there, since no app is open. | | **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. | | **Studio Automations Rail** | The Automations pillar's flow rail has a search box that matches a flow's label or its machine name, case-insensitively. A long flow name wraps instead of being cut off. | diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md index 7b83d142e4..46e1e9529b 100644 --- a/packages/app-shell/README.md +++ b/packages/app-shell/README.md @@ -1037,6 +1037,16 @@ record search. Its open state and the command that opens it are provided by `CommandPaletteProvider` (wired in by `ConsoleLayout`) and exposed via `useCommandPalette()`. +`CommandPalette` has two scopes. Inside an app it takes `apps`, `activeApp`, +`objects`, `onAppChange` and an optional `dataSource`, and searches that app. +`` takes no other prop: it is the palette of the +`/studio` landing, a frame outside every app, which the console mounts under its +own `CommandPaletteProvider`. It leaves out every app-scoped group and the +full-search command (their links start with `/apps/APP`), and lists the Studio's +packages, their objects and their flows, each opening its Studio page. The +header's search trigger is drawn wherever a provider is mounted, so `AppHeader` +with `variant="studio"` shows it there. + ```tsx import { useCommandPalette } from '@object-ui/app-shell'; diff --git a/packages/app-shell/src/chrome/CommandPalette.studioScope-11863.test.tsx b/packages/app-shell/src/chrome/CommandPalette.studioScope-11863.test.tsx new file mode 100644 index 0000000000..7bb1cda2ac --- /dev/null +++ b/packages/app-shell/src/chrome/CommandPalette.studioScope-11863.test.tsx @@ -0,0 +1,299 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ⌘K palette's `studio` scope (objectui#11863, Q2): the palette the + * `/studio` landing mounts, a frame outside every app. + * + * Before it, the palette was mounted only inside an app, and every link it + * built started with `/apps/APP`: mounted with no active app, its full-search + * command went to `/apps/undefined/search`. In the `studio` scope it has no + * app-scoped group and no full-search command, and it lists the Studio's + * packages, objects and flows, each opening its Studio page. Pinned here: + * + * - the three groups, under their pack headings, and what each one leaves + * out: a kernel package (the package list drops it), an object or flow + * whose package is not in that list, and an object with no package; + * - each entry's target: a package's Data pillar, an object in its package's + * Data pillar and a flow in its package's Automations pillar (both through + * the pillar's `?surface=` deep link), and a package-less flow in the + * package-less scope (objectui#11553); + * - the query matches an entry's label or machine name, as elsewhere in the + * palette (objectui#11812); + * - no rendered entry is app-scoped and none leads under `/apps/`: what the + * scope renders is its Studio entries and the theme commands. CONTROL: the + * app scope, on the same query, renders its full-search command; + * - nothing is read until the palette opens. CONTROL: opened, it reads. + * + * Real subjects: `CommandPalette`, the real `CommandPaletteProvider` (opened by + * its `?palette=1` deep link), the real `fetchPackages` over a stubbed `fetch`, + * and a real `I18nProvider` in `en`, so a heading is the pack's row. The + * metadata cache is a hand-rolled `MetadataCtx` value that counts its reads. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, fireEvent, waitFor, cleanup, screen } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; +import { MetadataCtx, type MetadataContextValue } from '@object-ui/react'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1' }, activeOrganization: null }), +})); + +import { CommandPalette } from './CommandPalette'; +import { CommandPaletteProvider } from '../context/CommandPaletteProvider'; + +/** `GET /api/v1/packages`: two app packages and a kernel one, in both served shapes. */ +const PACKAGES_PAYLOAD = { + data: [ + { manifest: { id: 'com.acme.crm', name: 'Acme CRM' }, writable: true }, + { id: 'com.acme.hr', name: 'People', writable: false }, + { manifest: { id: 'com.objectstack.platform', name: 'Platform', scope: 'system' }, writable: false }, + ], +}; + +/** The cached `object` list: one per package, one in the kernel package, one in none. */ +const OBJECTS = [ + { name: 'crm_account', label: 'Account', _packageId: 'com.acme.crm' }, + { name: 'hr_employee', label: { key: 'hr.employee', defaultValue: 'Employee' }, _packageId: 'com.acme.hr' }, + { name: 'sys_user', label: 'User', _packageId: 'com.objectstack.platform' }, + { name: 'loose_thing', label: 'Loose Thing' }, +]; + +/** The cached `flow` list, read unscoped: a packaged flow, a package-less one, a kernel one. */ +const FLOWS = [ + { name: 'crm_onboard', label: 'Onboard Account', _packageId: 'com.acme.crm' }, + { name: 'org_cleanup', label: 'Nightly Cleanup' }, + { name: 'sys_audit_sweep', label: 'Audit Sweep', _packageId: 'com.objectstack.platform' }, +]; + +/** A metadata cache that answers the lists above and counts what was read. */ +function metadataCache() { + const reads: string[] = []; + const value: MetadataContextValue = { + apps: [], + get objects() { + reads.push('object'); + return OBJECTS; + }, + dashboards: [], + reports: [], + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: (type: string) => { + reads.push(type); + return type === 'flow' ? FLOWS : []; + }, + getTypeStatus: () => 'ready', + }; + return { value, reads }; +} + +function LocationProbe() { + const location = useLocation(); + return
; +} + +let fetchMock: ReturnType; + +beforeEach(() => { + fetchMock = vi.fn(async (url: string) => { + if (String(url).endsWith('/api/v1/packages')) { + return { ok: true, status: 200, json: async () => PACKAGES_PAYLOAD }; + } + throw new Error(`unexpected request: ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +function mount(palette: React.ReactNode, at: string) { + const cache = metadataCache(); + render( + + + + + {palette} + + + + + , + ); + return cache; +} + +function mountStudio(at = '/studio?palette=1') { + return mount(, at); +} + +function type(query: string) { + const input = document.querySelector('[cmdk-input]'); + if (!input) throw new Error('the palette did not open'); + fireEvent.change(input, { target: { value: query } }); +} + +/** The visible text of every rendered item whose cmdk value starts with `kind `. */ +function shown(kind: string): string[] { + return Array.from(document.querySelectorAll(`[cmdk-item][data-value^="${kind} "]`)).map((el) => + (el.textContent ?? '').trim(), + ); +} + +/** The cmdk value of every rendered item. */ +function renderedValues(): string[] { + return Array.from(document.querySelectorAll('[cmdk-item]')).map((el) => el.getAttribute('data-value') ?? ''); +} + +/** The rendered group headings, in order. */ +function headings(): string[] { + return Array.from(document.querySelectorAll('[cmdk-group-heading]')).map((el) => (el.textContent ?? '').trim()); +} + +function locationNow(): string { + return screen.getByTestId('location').getAttribute('data-path') ?? ''; +} + +async function studioListed() { + await waitFor(() => expect(shown('studio-package').length).toBeGreaterThan(0)); +} + +/** Pick the rendered item whose cmdk value starts with `prefix`. */ +function pick(prefix: string) { + const item = document.querySelector(`[cmdk-item][data-value^="${prefix}"]`); + if (!item) throw new Error(`no rendered item starts with "${prefix}"`); + fireEvent.click(item); +} + +describe('the palette in the `studio` scope lists the Studio, not an app (objectui#11863)', () => { + it('lists packages, objects and flows under the pack headings, leaving out what Studio cannot open', async () => { + mountStudio(); + await studioListed(); + + expect(headings()).toEqual(['Packages', 'Objects', 'Flows', 'Preferences']); + expect(shown('studio-package')).toEqual(['Acme CRM', 'People']); + // Each object and packaged flow names its package on the right. + expect(shown('studio-object')).toEqual(['AccountAcme CRM', 'EmployeePeople']); + expect(shown('studio-flow')).toEqual(['Onboard AccountAcme CRM', 'Nightly Cleanup']); + }); + + it('opens a package on its Data pillar', async () => { + mountStudio(); + await studioListed(); + pick('studio-package People '); + await waitFor(() => expect(locationNow()).toBe('/studio/com.acme.hr/data')); + }); + + it("opens an object in its package's Data pillar, on that object", async () => { + mountStudio(); + await studioListed(); + pick('studio-object Account '); + await waitFor(() => expect(locationNow()).toBe('/studio/com.acme.crm/data?surface=object%3Acrm_account')); + }); + + it("opens a packaged flow in its package's Automations pillar, on that flow", async () => { + mountStudio(); + await studioListed(); + pick('studio-flow Onboard Account '); + await waitFor(() => + expect(locationNow()).toBe('/studio/com.acme.crm/automations?surface=flow%3Acrm_onboard'), + ); + }); + + it('opens a package-less flow in the package-less scope (objectui#11553)', async () => { + mountStudio(); + await studioListed(); + pick('studio-flow Nightly Cleanup '); + await waitFor(() => expect(locationNow()).toBe('/studio/~org/automations?surface=flow%3Aorg_cleanup')); + }); + + it('matches an entry by its label or its machine name', async () => { + mountStudio(); + await studioListed(); + + type('employee'); + expect(shown('studio-object')).toEqual(['EmployeePeople']); + expect(shown('studio-package')).toEqual([]); + expect(shown('studio-flow')).toEqual([]); + + type('com.acme.hr'); + expect(shown('studio-package')).toEqual(['People']); + expect(shown('studio-object')).toEqual([]); + }); + + it('renders no app-scoped entry and nothing that leads under /apps/, on an empty query and on a search query', async () => { + mountStudio(); + await studioListed(); + + const studioOrTheme = (value: string) => /^(studio-(package|object|flow)|theme) /.test(value); + expect(renderedValues().filter((value) => !studioOrTheme(value))).toEqual([]); + + type('search'); + await waitFor(() => expect(document.querySelector('[cmdk-empty]')).not.toBeNull()); + expect(renderedValues()).toEqual([]); + expect(locationNow()).toBe('/studio?palette=1'); + }); + + it('CONTROL — the app scope renders its full-search command for the same query', async () => { + mount( + {}} + />, + '/apps/crm_app?palette=1', + ); + type('search'); + expect(renderedValues()).toEqual(['search all results full page']); + pick('search all results full page'); + await waitFor(() => expect(locationNow()).toBe('/apps/crm_app/search')); + // The app scope reads no Studio list. + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('reads nothing until the palette opens', async () => { + const cache = mountStudio('/studio'); + // The palette is mounted and closed; let any effect run. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(document.querySelector('[cmdk-input]')).toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(cache.reads).toEqual([]); + }); + + it('CONTROL — opened, it reads the package list and the object and flow lists', async () => { + const cache = mountStudio(); + await studioListed(); + expect(fetchMock).toHaveBeenCalledWith('/api/v1/packages', expect.anything()); + expect(cache.reads).toContain('object'); + expect(cache.reads).toContain('flow'); + }); +}); + +/** + * The scope is declared, not inferred: the `studio` palette takes no app prop, + * so a palette cannot be half in an app. Checked by `tsc -p tsconfig.test.json` + * (the package's `type-check`), never run. + */ +export function studioScopeTakesNoAppProps() { + return ( + <> + {/* @ts-expect-error — the `studio` scope has no active app to take */} + + {/* @ts-expect-error — an app palette needs its app props */} + + + ); +} diff --git a/packages/app-shell/src/chrome/CommandPalette.tsx b/packages/app-shell/src/chrome/CommandPalette.tsx index 77b8589756..0564316405 100644 --- a/packages/app-shell/src/chrome/CommandPalette.tsx +++ b/packages/app-shell/src/chrome/CommandPalette.tsx @@ -8,6 +8,12 @@ * matches its navigation entries and built-in commands itself, on word * prefixes and contiguous substrings (`matchesPaletteQuery`, objectui#11812), * and renders only those that match; record hits come from the server search. + * + * Two scopes (objectui#11863). Inside an app (`scope` omitted) it searches that + * app. On `/studio` (`scope="studio"`), a frame outside every app, it has no + * app-scoped group and no full-search command, whose links all start with + * `/apps/APP`; it lists the Studio's packages, objects and flows instead, each + * opening its Studio page. */ import { useEffect, useState, useCallback, useMemo } from 'react'; @@ -29,6 +35,10 @@ import { Sun, Monitor, Search, + Boxes, + Database, + Workflow, + type LucideIcon, } from 'lucide-react'; import { useRecordSearch } from '@object-ui/react'; import { usePermissions } from '@object-ui/permissions'; @@ -40,13 +50,20 @@ import { getIcon } from '../utils/getIcon.js'; import { useRecentItems } from '../context/RecentItemsProvider.js'; import { useRecentItemLabel } from '../hooks/useRecentItemLabel.js'; import { useCommandPalette } from '../context/CommandPaletteProvider.js'; +import { useMetadata } from '../providers/MetadataProvider.js'; import { resolveHref, resolveNavItemLabel } from '@object-ui/layout'; import type { NavigationItem } from '@object-ui/types'; import { useNavTargetLabel } from '../hooks/useNavTargetLabel.js'; import { useAuth } from '@object-ui/auth'; import { matchesPaletteQuery } from './paletteMatch.js'; +import { fetchPackages, type PkgEntry } from '../views/studio-design/packages-io.js'; +import { isPackageLessItem, studioOrgScopePath } from '../views/studio-design/studioScope.js'; +import { DESIGNER_SURFACE_PARAM, formatSurfaceParam } from '../views/metadata-admin/nav-selection.js'; -interface CommandPaletteProps { +/** The palette of the active app: the frame `ConsoleLayout` draws for `/apps/APP`. */ +interface AppCommandPaletteProps { + /** Omitted inside an app; `'studio'` is the other scope. */ + scope?: undefined; apps: any[]; activeApp: any; objects: any[]; @@ -58,7 +75,34 @@ interface CommandPaletteProps { dataSource?: any; } -export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSource }: CommandPaletteProps) { +/** + * The palette on `/studio`, a frame outside every app (objectui#11863). It + * takes no app props: there is no active app to search, switch from, or build + * a `/apps/APP` link for. It lists the packages Studio opens, their objects and + * their flows, plus the organization's package-less flows. + */ +interface StudioCommandPaletteProps { + scope: 'studio'; +} + +type CommandPaletteProps = AppCommandPaletteProps | StudioCommandPaletteProps; + +/** + * What the app half of the palette reads in the `studio` scope: no app, so no + * navigation entry, no app to switch to, no object to search records of, and + * no data source (record search stays off). + */ +const NO_APP: Omit = { + apps: [], + activeApp: null, + objects: [], + onAppChange: () => {}, + dataSource: undefined, +}; + +export function CommandPalette(props: CommandPaletteProps) { + const inStudio = props.scope === 'studio'; + const { apps, activeApp, objects, onAppChange, dataSource } = inStudio ? NO_APP : props; const { open, setOpen } = useCommandPalette(); const [inputValue, setInputValue] = useState(''); const navigate = useNavigate(); @@ -124,7 +168,17 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour dark: t('console.commandPalette.darkTheme'), system: t('console.commandPalette.systemTheme'), }; - const showFullSearch = matches(FULL_SEARCH_VALUE); + // The full-search page lives under an app (`/apps/APP/search`), so the + // `studio` scope, which has none, offers no such command (objectui#11863). + const showFullSearch = !inStudio && matches(FULL_SEARCH_VALUE); + + // objectui#11863 — the Studio's packages, objects and flows, read only while + // the palette is open in the `studio` scope, matched like the entries above. + const studio = useStudioPaletteEntries(open && inStudio); + const studioMatches = (entry: StudioPaletteEntry) => matches(entry.label, entry.name); + const studioPackages = studio.packages.filter(studioMatches); + const studioObjects = studio.objects.filter(studioMatches); + const studioFlows = studio.flows.filter(studioMatches); // Whitelist of object names visible in this app's nav — used as the search // scope so we don't fan out to every object in the tenant. @@ -225,7 +279,7 @@ export function CommandPalette({ apps, activeApp, objects, onAppChange, dataSour /> - {isSearching ? ( + {isSearching || studio.loading ? ( )} + {/* Studio (objectui#11863): packages, objects and flows, `studio` scope only */} + {studioPackages.length > 0 && ( + + {studioPackages.map((entry) => ( + runCommand(() => navigate(entry.href))} + /> + ))} + + )} + {studioObjects.length > 0 && ( + + {studioObjects.map((entry) => ( + runCommand(() => navigate(entry.href))} + /> + ))} + + )} + {studioFlows.length > 0 && ( + + {studioFlows.map((entry) => ( + runCommand(() => navigate(entry.href))} + /> + ))} + + )} + {/* App Switching */} {appItems.length > 0 && ( <> @@ -442,6 +537,185 @@ const THEME_COMMANDS = [ /** The full-search command's value, which it is matched by (objectui#11812). */ const FULL_SEARCH_VALUE = 'search all results full page'; +/** One Studio entry the `studio` scope lists (objectui#11863). */ +interface StudioPaletteEntry { + /** Unique within its group, and part of its cmdk `value`. */ + key: string; + /** What the entry shows: a package's name, an object's or a flow's label. */ + label: string; + /** Its machine name, matched like the label. */ + name: string; + /** The Studio page it opens. */ + href: string; + /** The name of the package it belongs to; absent on a package and on a package-less flow. */ + packageName?: string; +} + +/** The fields of a served object or flow the Studio groups read. */ +interface StudioServedItem { + name?: unknown; + label?: Parameters[0]; + /** The owning package's machine id (the spec's `MetadataProtectionFields`). */ + _packageId?: unknown; +} + +interface StudioPaletteEntries { + packages: StudioPaletteEntry[]; + objects: StudioPaletteEntry[]; + flows: StudioPaletteEntry[]; + /** Some list has not answered yet. */ + loading: boolean; +} + +const NO_STUDIO_ENTRIES: StudioPaletteEntries = { packages: [], objects: [], flows: [], loading: false }; + +/** + * A Studio pillar opened on one item, through the pillar's `?surface=` deep + * link: the shape `studioOrgScopePath` builds for the package-less scope, under + * a package. An object opens in the Data pillar and a flow in Automations, + * the pillars `StudioDesignSurface` routes those surface types to. + */ +function studioSurfacePath(packageId: string, pillar: 'data' | 'automations', surface: { type: string; name: string }): string { + return `/studio/${encodeURIComponent(packageId)}/${pillar}?${DESIGNER_SURFACE_PARAM}=${encodeURIComponent(formatSurfaceParam(surface))}`; +} + +/** + * The Studio's packages, objects and flows, as the `studio` scope lists them + * (objectui#11863). Nothing is read until `enabled` (the palette is open in that + * scope). The package list is read again each time it opens; until that read + * answers, the previous answer is shown. + * + * - **Packages** come from `fetchPackages`, the list the Studio landing and its + * package switcher read; kernel packages are already left out there. Each + * opens its Data pillar, as a landing card does. + * - **Objects** come from the metadata cache (`useMetadata().objects`), each + * listed under the package it declares (`_packageId`) and opened in that + * package's Data pillar. One whose package is not in the list has no Studio + * page to open, and is left out. + * - **Flows** come from the same cache's `flow` list, the unscoped read that + * carries every package's flows (the read the package-less scope narrows, + * see `loadPackageLessSurfaces`). A packaged flow opens in its package's + * Automations pillar; a flow that belongs to no package opens in the + * package-less scope (objectui#11553). + * + * The published items only: an item that is still a draft is not in either + * cache list. Until the package list answers nothing is listed, so no entry + * is drawn without its package. If that read fails, the packaged entries are + * left out; the landing behind the palette shows the same read's error. + */ +function useStudioPaletteEntries(enabled: boolean): StudioPaletteEntries { + const metadata = useMetadata(); + const { t } = useObjectTranslation(); + const [packages, setPackages] = useState(null); + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + fetchPackages().then( + (list) => { + if (!cancelled) setPackages(list); + }, + () => { + if (!cancelled) setPackages([]); + }, + ); + return () => { + cancelled = true; + }; + }, [enabled]); + + if (!enabled) return NO_STUDIO_ENTRIES; + + // Read only while open: each read starts that type's fetch when it has not + // been loaded yet, and the provider re-renders this palette when it answers. + const objectItems: StudioServedItem[] = metadata.objects; + const flowItems: StudioServedItem[] = metadata.getItemsByType('flow'); + const typeLoading = (type: string) => { + const status = metadata.getTypeStatus?.(type); + return status === 'idle' || status === 'loading'; + }; + const loading = packages === null || typeLoading('object') || typeLoading('flow'); + if (packages === null) return { ...NO_STUDIO_ENTRIES, loading }; + + const byId = new Map(packages.map((pkg) => [pkg.id, pkg])); + const ownPackage = (item: StudioServedItem): PkgEntry | undefined => + typeof item._packageId === 'string' ? byId.get(item._packageId) : undefined; + const named = (item: StudioServedItem): item is StudioServedItem & { name: string } => + typeof item.name === 'string' && item.name !== ''; + + const objects: StudioPaletteEntry[] = []; + for (const item of objectItems) { + const pkg = ownPackage(item); + if (!pkg || !named(item)) continue; + objects.push({ + key: `${pkg.id}:${item.name}`, + label: resolveKeyedI18nLabel(item.label, t) || item.name, + name: item.name, + href: studioSurfacePath(pkg.id, 'data', { type: 'object', name: item.name }), + packageName: pkg.name, + }); + } + + const flows: StudioPaletteEntry[] = []; + for (const item of flowItems) { + if (!named(item)) continue; + const label = resolveKeyedI18nLabel(item.label, t) || item.name; + if (isPackageLessItem(item)) { + flows.push({ + key: `~org:${item.name}`, + label, + name: item.name, + href: studioOrgScopePath({ type: 'flow', name: item.name }), + }); + continue; + } + const pkg = ownPackage(item); + if (!pkg) continue; + flows.push({ + key: `${pkg.id}:${item.name}`, + label, + name: item.name, + href: studioSurfacePath(pkg.id, 'automations', { type: 'flow', name: item.name }), + packageName: pkg.name, + }); + } + + return { + packages: packages.map((pkg) => ({ + key: pkg.id, + label: pkg.name, + name: pkg.id, + href: `/studio/${encodeURIComponent(pkg.id)}/data`, + })), + objects, + flows, + loading, + }; +} + +/** One Studio entry: its icon, its label, and the package it belongs to on the right. */ +function StudioPaletteItem({ + kind, + entry, + Icon, + onOpen, +}: { + kind: 'studio-package' | 'studio-object' | 'studio-flow'; + entry: StudioPaletteEntry; + Icon: LucideIcon; + onOpen: () => void; +}) { + return ( + + + {entry.label} + {entry.packageName && ( + {entry.packageName} + )} + + ); +} + /** Flatten nested navigation groups into a flat list of leaf items */ function flattenNavigation(items: any[]): any[] { const result: any[] = []; diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx index c9bf2c535f..9d2e91880a 100644 --- a/packages/app-shell/src/layout/AppHeader.tsx +++ b/packages/app-shell/src/layout/AppHeader.tsx @@ -165,10 +165,11 @@ export function AppHeader({ // synthetic `⌘K` KeyboardEvent re-dispatch that did nothing under automation. const { openCommandPalette } = useCommandPalette(); // objectui#11912 — the search trigger is drawn only where a palette is - // mounted. Outside a `CommandPaletteProvider` (the `home` / `orgs` / `studio` - // frames: `/home`, `/ai`, the organizations pages, `/studio`) - // `openCommandPalette` is the inert fallback and no ⌘K handler is installed, - // so a "Search ⌘K" there opens nothing. + // mounted. Outside a `CommandPaletteProvider` (the `home` / `orgs` frames: + // `/home`, `/ai`, the organizations pages) `openCommandPalette` is the inert + // fallback and no ⌘K handler is installed, so a "Search ⌘K" there opens + // nothing. The `/studio` landing mounts one with the palette's `studio` scope + // (objectui#11863), so its `studio` variant draws the trigger. const hasCommandPalette = useCommandPaletteProviderMounted(); // Click-reachable entry for the keyboard-shortcuts dialog (was `?`-key only). // Shares the `?shortcuts=1` URL param with KeyboardShortcutsDialog (C2/C3). diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.searchTriggerNeedsPalette-11912.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.searchTriggerNeedsPalette-11912.test.tsx index ddf428cd69..cb0f5a12e3 100644 --- a/packages/app-shell/src/layout/__tests__/AppHeader.searchTriggerNeedsPalette-11912.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AppHeader.searchTriggerNeedsPalette-11912.test.tsx @@ -5,7 +5,8 @@ * mounted (objectui#11912). * * `useCommandPalette()` answers an inert fallback outside a - * `CommandPaletteProvider`, and only `ConsoleLayout` mounts one. `HomeLayout` + * `CommandPaletteProvider`, and only `ConsoleLayout` mounted one (the `/studio` + * landing mounts one too since objectui#11863). `HomeLayout` * and `AiChatPage` mount the `home` variant, `OrganizationsLayout` and * `OrganizationLayout` the `orgs` variant, all without a provider — so on * `/home`, `/ai` and the organizations frames the trigger was shown and a click @@ -165,10 +166,10 @@ afterEach(() => { }); describe('the header search trigger renders only where a command palette is mounted (objectui#11912)', () => { - // `home` is /home and /ai; `orgs` is /organizations and /organizations/SLUG; - // `studio` is the /studio front door, which mounts no palette (objectui#11863). - // `app` without a provider is not a console frame, but it pins that the gate - // is the palette's presence, not the variant name. + // `home` is /home and /ai; `orgs` is /organizations and /organizations/SLUG. + // `studio` (the /studio front door, which mounts a palette since + // objectui#11863) and `app` without a provider are not console frames, but + // they pin that the gate is the palette's presence, not the variant name. it.each(['home', 'orgs', 'studio', 'app'])( 'the `%s` variant with no CommandPaletteProvider draws neither trigger', (variant) => { @@ -181,7 +182,9 @@ describe('the header search trigger renders only where a command palette is moun }, ); - it.each(['app', 'home'])( + // `studio` is the /studio front door's own frame: `StudioLanding` mounts the + // provider with the palette's `studio` scope (objectui#11863). + it.each(['app', 'home', 'studio'])( 'CONTROL — the `%s` variant under a CommandPaletteProvider draws both triggers, and each opens the palette', (variant) => { for (const testId of [DESKTOP, MOBILE]) { diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.studioVariant-11863.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.studioVariant-11863.test.tsx index eb0c09191b..181f6a16c3 100644 --- a/packages/app-shell/src/layout/__tests__/AppHeader.studioVariant-11863.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AppHeader.studioVariant-11863.test.tsx @@ -18,9 +18,10 @@ * - the right-hand cluster is the console's: the inbox, the help menu and the * account menu with profile, theme, language and sign-out. * - * That the variant draws no search trigger, because `/studio` mounts no command - * palette, is pinned where that rule lives: - * `AppHeader.searchTriggerNeedsPalette-11912.test.tsx`. + * Whether the variant draws the search trigger follows whether a command + * palette is mounted, pinned where that rule lives: + * `AppHeader.searchTriggerNeedsPalette-11912.test.tsx`. The `/studio` landing + * mounts one (objectui#11863, Q2; `StudioRoute.searchPalette-11863.test.tsx`). * * ## Why `t` answers in keys * diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index b4fddf743a..a729475127 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2207,6 +2207,8 @@ const ar = { placeholder: "اكتب أمراً أو ابحث…", noResults: "لم يتم العثور على نتائج.", objects: "الكائنات", + packages: "الحزم", + flows: "التدفقات", dashboards: "لوحات المعلومات", pages: "الصفحات", reports: "التقارير", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index ea59a49ef3..92ffaccf57 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -2018,6 +2018,8 @@ const de = { placeholder: "Befehl eingeben oder suchen…", noResults: "Keine Ergebnisse gefunden.", objects: "Objekte", + packages: "Pakete", + flows: "Flows", dashboards: "Dashboards", pages: "Seiten", reports: "Berichte", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 9caae37a81..94268f4c5b 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2128,6 +2128,8 @@ const en = { records: 'Records', recentRecords: 'Recently viewed', objects: 'Objects', + packages: 'Packages', + flows: 'Flows', dashboards: 'Dashboards', pages: 'Pages', reports: 'Reports', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index ebab58f523..115f2f8e35 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2065,6 +2065,8 @@ const es = { placeholder: "Escriba un comando o busque…", noResults: "No se encontraron resultados.", objects: "Objetos", + packages: "Paquetes", + flows: "Flujos", dashboards: "Paneles", pages: "Páginas", reports: "Informes", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 45e77ec306..1a12d77504 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2062,6 +2062,8 @@ const fr = { placeholder: "Tapez une commande ou recherchez…", noResults: "Aucun résultat trouvé.", objects: "Objets", + packages: "Packages", + flows: "Flux", dashboards: "Tableaux de bord", pages: "Pages", reports: "Rapports", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index eecebbaf54..f7a154b38a 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -2020,6 +2020,8 @@ const ja = { placeholder: "コマンドを入力または検索…", noResults: "結果が見つかりません。", objects: "オブジェクト", + packages: "パッケージ", + flows: "フロー", dashboards: "ダッシュボード", pages: "ページ", reports: "レポート", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 4e1d9b0051..b245cd70d1 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -2018,6 +2018,8 @@ const ko = { placeholder: "명령어를 입력하거나 검색…", noResults: "결과를 찾을 수 없습니다.", objects: "객체", + packages: "패키지", + flows: "플로우", dashboards: "대시보드", pages: "페이지", reports: "보고서", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index fa9f6515a1..ccc57a3b3c 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2060,6 +2060,8 @@ const pt = { placeholder: "Digite um comando ou pesquise…", noResults: "Nenhum resultado encontrado.", objects: "Objetos", + packages: "Pacotes", + flows: "Fluxos", dashboards: "Painéis", pages: "Páginas", reports: "Relatórios", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 4bb5b5e545..7f43cb9e39 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2121,6 +2121,8 @@ const ru = { placeholder: "Введите команду или поиск…", noResults: "Результаты не найдены.", objects: "Объекты", + packages: "Пакеты", + flows: "Потоки", dashboards: "Панели мониторинга", pages: "Страницы", reports: "Отчёты", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index a388f3be88..2003d0937a 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1819,6 +1819,8 @@ const zh = { records: '记录', recentRecords: '最近访问', objects: '对象', + packages: '软件包', + flows: '流程', dashboards: '仪表盘', pages: '页面', reports: '报表',