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
9 changes: 9 additions & 0 deletions .changeset/11794-studio-navigation-details.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/app-shell': patch
---

Studio navigation details (objectui#11794):

- **The Data pillar's *Advanced* trigger keeps its own name.** With one of its panels open it used to rename itself to that panel ("Validations"), which hid the word that says sibling panels sit behind it. It now always reads *Advanced*, takes the active pill while one of its panels is open, and the menu marks the open panel as the checked item (the items are radio items now, so assistive technology reads which one is open).
- **The Automations rail can be searched.** A search box above the flow list matches a flow's label or its machine name, case-insensitively, and a search that matches nothing says so. A flow's name wraps instead of being cut to a stub.
- **Create app opens the Interfaces pillar**, where the new app lives, instead of leaving its author on the pillar the button was pressed from. The create writes exactly what it wrote before; only where the author lands changes. Leaving a pillar that holds an unsent edit asks first, as a pillar link does.
17 changes: 11 additions & 6 deletions content/docs/guide/console.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ The console opens at **http://localhost:5180** (the port is fixed in `apps/conso
| **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. |
| **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. |
| **Create App in Studio** | In a package that has no app yet, the Studio header's **Create app** saves the new app as a draft and then opens the **Interfaces** pillar, where that app is designed. Leaving a pillar that holds an unsent edit asks first. |
| **Read-only Packages in Studio** | The Studio home lists code and installed packages under "Installed (read-only · browsable)". Clicking a card opens the package for browsing (`/studio/:packageId/data`). The card's **Customize with an overlay** link opens the package's metadata directory (`/apps/setup/metadata?package=:packageId`). Whether an item can take an org overlay is decided per metadata type (`allowOrgOverride`), not per package, and the directory marks each type. Unless the runtime reports `features.marketplace: false`, the section also links to the marketplace (`/apps/setup/system/marketplace`), the same route as Home's "Start with a template". |
| **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. |
Expand All @@ -39,13 +41,16 @@ The console opens at **http://localhost:5180** (the port is fixed in `apps/conso

### Object design (Studio Data tab)

Selecting an object in Studio's **Data** pillar (`/studio/:packageId/data`) opens a
tab strip over that object — **Records · Form · Validations · Hooks · Actions ·
API · Settings**. Each of Validations, Hooks and Actions is a no-code **config
panel driven by the corresponding metadata**, and each supports **adding** new
entries — no code round-trip required:
Selecting an object in Studio's **Data** pillar (`/studio/:packageId/data`) opens
two tabs over that object, **Records · Form**, and an **Advanced** menu that holds
**Validations · Hooks · Actions · API · Settings** (objectui#5813). The
**Advanced** trigger keeps its own name whichever of those panels is open: the
open panel is the checked item inside the menu, and the trigger shows as
selected while one of its panels is open. Each of Validations, Hooks and Actions
is a no-code **config panel driven by the corresponding metadata**, and each
supports **adding** new entries — no code round-trip required:

| Tab | Edits | Panel |
| Advanced item | Edits | Panel |
|-----|-------|-------|
| **Validations** | the object's inline `validations[]` (spec `ValidationRuleSchema`) | Master-detail covering **every** rule type — `script`, `cross_field`, `state_machine`, `format`, `json_schema`, `conditional`. The **New** menu opens on common rules in plain words (*End date on or after start date*, *Number can't be negative*, *Reject the save when…*); each writes a working rule, or waits for the condition the author gives. Every rule type is under **Advanced**. A new rule whose type carries a condition (`script`, `cross_field`, `conditional`) is saved only once it has one; a rule's type can still be switched in place. CEL predicates reuse the shared `ConditionBuilder`, fed the object's draft fields. |
| **Hooks** | the separate `hook` metadata type targeting this object | Master-detail whose editor is the platform `SchemaForm` **driven by the live `hook` JSONSchema from `/meta/types`**, so its fields and enums always match the running server's contract. |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11794 — the Automations rail can be searched, and a flow's name is
* no longer cut to a stub.
*
* Before: a rail of flows with no search, each label in a `truncate` span in a
* narrow rail, so a long name read as its first few words and an ellipsis. Now a
* search box above the list matches the label or the machine name, a search
* that matches nothing says so, and the label wraps.
*
* The REAL `AutomationsPillar`. The rail lists what `loadPackageSurfaces`
* merges: published rows by their label, a draft-only row by its name. The
* runtime status read answers 404 here, which the pillar takes as an older
* backend (no dots): the dots are `railUnboundReason-11281`'s subject.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, cleanup, fireEvent, waitFor, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';

const PKG = 'com.acme.app';
const LONG_LABEL = 'Escalate overdue high-priority support cases to the regional on-call manager';

const NODES = [
{ id: 'start', type: 'start', label: 'Start' },
{ id: 'end', type: 'end', label: 'End' },
];
const EDGES = [{ id: 'e1', source: 'start', target: 'end' }];

/** Published rows (label + name) and one draft-only header (name only). */
const PUBLISHED = [
{ name: 'nightly_digest', label: 'Nightly digest' },
{ name: 'approve_order', label: 'Approve order' },
{ name: 'escalate_overdue_cases', label: LONG_LABEL },
];
const DRAFT_HEADERS = [{ name: 'welcome_new_member' }];

const mockClient = vi.hoisted(() => ({
list: vi.fn(async () => [] as Array<Record<string, unknown>>),
listDrafts: vi.fn(async () => [] as Array<Record<string, unknown>>),
listTypes: vi.fn(async () => ({ entries: [] })),
get: vi.fn(async () => null),
references: vi.fn(async () => []),
layered: vi.fn(async () => ({ code: null, overlay: null, overlayScope: null, effective: null, editable: true, deletable: true, resettable: false, lock: 'none' })),
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 () => {
throw new Error('this suite saves nothing');
}),
publish: vi.fn(async () => ({ success: true })),
reset: vi.fn(async () => ({})),
}));

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => dataSource };
});

vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

import { AutomationsPillar } from './StudioDesignSurface';
import { createEmptyDataSource, failOnAbsorbedFetchError } from './__tests__/emptyDataSource';
import { t, tFormat } from '../metadata-admin/i18n';

const dataSource = createEmptyDataSource();
failOnAbsorbedFetchError();

beforeEach(() => {
mockClient.list.mockImplementation(async () =>
PUBLISHED.map((f) => ({ ...f, type: 'autolaunched', nodes: NODES, edges: EDGES })),
);
mockClient.listDrafts.mockImplementation(async () => DRAFT_HEADERS);
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })),
);
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

/** The rail (the `nav` holding the search box) once every row has rendered. */
async function renderRail(): Promise<HTMLElement> {
render(
<MemoryRouter initialEntries={[`/studio/${PKG}/automations`]}>
<AutomationsPillar packageId={PKG} />
</MemoryRouter>,
);
const search = await screen.findByPlaceholderText(t('engine.studio.designer.search', 'en'), undefined, { timeout: 8000 });
const rail = search.closest('nav') as HTMLElement;
await waitFor(() => expect(within(rail).getByRole('button', { name: /welcome_new_member/ })).toBeInTheDocument(), {
timeout: 8000,
});
return rail;
}

/** The rail rows' visible names, in order. */
function rowNames(rail: HTMLElement): string[] {
return within(rail)
.queryAllByRole('button')
.filter((b) => b.querySelector('svg') && b.title !== t('engine.studio.auto.newTitle', 'en'))
.map((b) => (b.textContent ?? '').trim());
}

describe('the Automations rail search (objectui#11794)', () => {
it('control: an empty search lists every flow, published and draft-only alike', async () => {
const rail = await renderRail();
expect(rowNames(rail)).toEqual(['Nightly digest', 'Approve order', LONG_LABEL, 'welcome_new_member']);
});

it('matches the label, case-insensitively', async () => {
const rail = await renderRail();
fireEvent.change(within(rail).getByPlaceholderText(t('engine.studio.designer.search', 'en')), {
target: { value: 'DIGEST' },
});
expect(rowNames(rail)).toEqual(['Nightly digest']);
});

it('matches the machine name where the label does not hold it', async () => {
const rail = await renderRail();
// `approve_order` is in no label: only the name answers it.
fireEvent.change(within(rail).getByPlaceholderText(t('engine.studio.designer.search', 'en')), {
target: { value: 'approve_order' },
});
expect(rowNames(rail)).toEqual(['Approve order']);
});

it('a search that matches nothing says so, and clearing it brings every flow back', async () => {
const rail = await renderRail();
const search = within(rail).getByPlaceholderText(t('engine.studio.designer.search', 'en'));
fireEvent.change(search, { target: { value: ' invoice ' } });
expect(rowNames(rail)).toEqual([]);
expect(within(rail).getByText(tFormat('engine.list.emptyQuery', 'en', { query: 'invoice' }))).toBeInTheDocument();
// Not the empty-package line: the package HAS flows.
expect(within(rail).queryByText(t('engine.studio.auto.none', 'en'))).toBeNull();

fireEvent.change(search, { target: { value: '' } });
expect(rowNames(rail)).toHaveLength(4);
});
});

describe('a flow’s name in the Automations rail is not cut to a stub (objectui#11794)', () => {
it('the long label is the row’s whole text, in a wrapping span, not a truncating one', async () => {
const rail = await renderRail();
const row = within(rail).getByRole('button', { name: new RegExp(LONG_LABEL) });
const label = within(row).getByText(LONG_LABEL);
// jsdom lays nothing out, so the class that decides clipping is what is
// read: `truncate` is `overflow: hidden; text-overflow: ellipsis;
// white-space: nowrap`, the stub the card measured.
expect(label).not.toHaveClass('truncate');
expect(label).toHaveClass('break-words');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#11794 — the Data pillar's *Advanced* trigger keeps its own name.
*
* The defect: with one of the five power panels open, the trigger renamed
* itself to that panel ("Validations ▾"), so the word that says sibling panels
* sit behind it was gone. Now the trigger always reads *Advanced*; where the
* author is shows as the trigger's active pill (`aria-pressed`) and as the
* checked radio item inside the menu.
*
* The REAL `DataPillar` and the REAL Radix menu. The panels themselves are
* stubbed: only which one is open is under test, not what it draws.
*/

import '@testing-library/jest-dom/vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';

const servedObject = {
name: 'showcase_account',
label: 'Account',
fields: { name: { type: 'text', label: 'Account Name' } },
};

const mockClient = {
list: vi.fn(async () => [{ name: 'showcase_account', label: 'Account' }]),
listDrafts: vi.fn(async () => []),
layered: vi.fn(async () => ({ effective: servedObject, code: servedObject })),
getDraft: vi.fn(async () => null),
save: vi.fn(async () => ({ ok: true })),
};

vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => ({}) };
});

vi.mock('@object-ui/plugin-view', async (importOriginal) => {
const mod = await importOriginal<Record<string, unknown>>();
return { ...mod, ObjectView: () => <div data-testid="records-panel" /> };
});

vi.mock('./ObjectValidationsPanel', () => ({ ObjectValidationsPanel: () => <div data-testid="rules-panel" /> }));
vi.mock('./ObjectSettingsPanel', () => ({ ObjectSettingsPanel: () => <div data-testid="settings-panel" /> }));

import { DataPillar } from './StudioDesignSurface';
import { SurfaceDeepLinkProvider } from './surfaceDeepLinkChannel';
import { t } from '../metadata-admin/i18n';

const ADVANCED = t('engine.studio.data.tab.advanced', 'en');
const PANELS = ['rules', 'hooks', 'actions', 'api', 'settings'].map((k) => t(`engine.studio.data.tab.${k}`, 'en'));
const VALIDATIONS = t('engine.studio.data.tab.rules', 'en');

afterEach(() => {
cleanup();
vi.clearAllMocks();
});

async function renderPillar() {
render(
<MemoryRouter initialEntries={['/studio/com.example.showcase/data']}>
<SurfaceDeepLinkProvider>
<DataPillar packageId="com.example.showcase" />
</SurfaceDeepLinkProvider>
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByTestId('records-panel')).toBeInTheDocument(), { timeout: 8000 });
return screen.getByTestId('data-tabs-advanced');
}

/** The open menu's radio items, by their visible name, with their checked state. */
async function menuState(trigger: HTMLElement): Promise<Record<string, string | null>> {
await userEvent.click(trigger);
const menu = await screen.findByRole('menu');
const state: Record<string, string | null> = {};
for (const item of within(menu).getAllByRole('menuitemradio')) {
state[item.textContent ?? ''] = item.getAttribute('aria-checked');
}
await userEvent.keyboard('{Escape}');
await waitFor(() => expect(screen.queryByRole('menu')).toBeNull());
return state;
}

describe('the Data pillar’s Advanced trigger keeps its own name (objectui#11794)', () => {
it('control: on Records the trigger reads Advanced, is not pressed, and no panel is checked', async () => {
const trigger = await renderPillar();
expect(trigger).toHaveTextContent(ADVANCED);
expect(trigger).toHaveAttribute('aria-pressed', 'false');
const state = await menuState(trigger);
expect(Object.keys(state)).toEqual(PANELS);
expect(Object.values(state).every((checked) => checked === 'false')).toBe(true);
});

it('with Validations open the trigger still reads Advanced (not Validations), takes the active pill, and the menu checks Validations', async () => {
const trigger = await renderPillar();
await userEvent.click(trigger);
await userEvent.click(await screen.findByRole('menuitemradio', { name: VALIDATIONS }));
await waitFor(() => expect(screen.getByTestId('rules-panel')).toBeInTheDocument());

// THE PIN: the trigger's own name, and nothing else, whichever panel is open.
expect(trigger).toHaveTextContent(ADVANCED);
expect(trigger).not.toHaveTextContent(VALIDATIONS);
expect(trigger).toHaveAttribute('aria-pressed', 'true');

const state = await menuState(trigger);
expect(state[VALIDATIONS]).toBe('true');
for (const name of PANELS.filter((n) => n !== VALIDATIONS)) expect(state[name]).toBe('false');
});

it('picking a sibling from the menu moves the check and the panel; the trigger name stays', async () => {
const trigger = await renderPillar();
await userEvent.click(trigger);
await userEvent.click(await screen.findByRole('menuitemradio', { name: VALIDATIONS }));
await waitFor(() => expect(screen.getByTestId('rules-panel')).toBeInTheDocument());

const settings = t('engine.studio.data.tab.settings', 'en');
await userEvent.click(trigger);
await userEvent.click(await screen.findByRole('menuitemradio', { name: settings }));
await waitFor(() => expect(screen.getByTestId('settings-panel')).toBeInTheDocument());
expect(screen.queryByTestId('rules-panel')).toBeNull();
expect(trigger).toHaveTextContent(ADVANCED);

const state = await menuState(trigger);
expect(state[settings]).toBe('true');
expect(state[VALIDATIONS]).toBe('false');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ vi.mock('@object-ui/components', async (importOriginal) => {
DropdownMenuItem: (p: any) => (
<button type="button" onClick={() => p.onSelect?.()}>{p.children}</button>
),
// objectui#11794 — the Advanced items are radio items (the menu checks the open panel).
DropdownMenuRadioGroup: (p: any) => <div>{p.children}</div>,
DropdownMenuRadioItem: (p: any) => (
<button type="button" onClick={() => p.onSelect?.()}>{p.children}</button>
),
};
});

Expand Down
Loading
Loading