Skip to content

Commit ee2aaf0

Browse files
feat(app-shell): a Studio rail search that matches nothing names the query and offers to clear it (#26959)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8CbVK4eWbzTwqdFcwRSZgVo)_ Fixes #26733 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Studio 的数据、自动化、权限三个侧栏共用一个「搜索无结果」状态:写出搜不到的关键词,并给一个「清除搜索」按钮,点了清空搜索框、列表恢复。 - 为什么改:之前数据和权限侧栏搜不到时整列空白,看起来像加载失败;自动化侧栏有一句提示但没有退路。 - 风险与回滚:只动侧栏的显示,不改任何过滤或排序;回滚即 revert 本 PR,i18n 包只多一个文案键。 - 先看:`StudioRail.tsx`(新状态与 `matchCount` 判定),`StudioDesignSurface.tsx`(三处侧栏只传匹配数;数据侧栏的过滤原样上提成常量),新测试文件。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/StudioRail.noMatches-26733.test.tsx`,预期 13 条全绿。 - 需要你做的:无,席位落地。 ## What changes - `StudioRail` (shared by every Studio pillar) gains `search.matchCount`: how many rows the pillar's own filter left. When the query is non-blank, `matchCount` is 0 and the list was not empty before searching (`count !== 0`), the rail shows one state under the list: the existing `engine.list.emptyQuery` sentence naming the trimmed query, and a **Clear search** button that calls `search.onChange('')` and focuses the box again. - Data rail: the object filter is hoisted, unchanged, into `shownObjects` so its length can be handed to the rail; Automations passes `shownFlows.length`; Access passes `filtered.length`. No rail filters or sorts differently. - The Automations rail's own copy of the no-matches sentence is removed: the rail now says it, once, for all three. - `@object-ui/i18n` `studio` namespace: new row `engine.studio.rail.clearSearch` (en "Clear search", zh "清除搜索"); patch changeset. - A blank query, or an empty package, never shows the line: each pillar's existing empty sentence keeps speaking there. ## Why A builder who mistyped a rail search saw an empty column on Data and Access and could not tell a failed load from no matches (card #26733, from the studio-qa-2026-10-27 batch). ## Tests New `StudioRail.noMatches-26733.test.tsx` renders the real Data, Automations and Access pillars. Per rail: a query that matches nothing shows the state naming the query and no row; Clear search empties and focuses the box and restores every row; a matching query shows no state; an empty package keeps its own sentence with no no-matches line, with or without a query. Plus a zh case (the state's sentence and the button read in Chinese). <details><summary>Readings at head 66aa3c5</summary> - New file: Test Files 1 passed, Tests 13 passed. - `pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/` (includes the existing Automations rail search pin): Test Files 234 passed, Tests 1703 passed. - `pnpm --dir ui exec vitest run packages/i18n/`: 89 files passed, 1373 passed, 13 skipped. - `pnpm --filter @object-ui/app-shell run type-check` and `pnpm --filter @object-ui/i18n run type-check`: exit 0. - Ablation: the rail's `noMatches` branch replaced with `false` through `scripts/ablation-replace.mjs` (anchor 1 to 0, blob 805acee to a4f216f0): 7 failed, 6 passed. The 7 are every positive case (three rails times two, plus zh); the 6 green are the matching-query controls and the empty-package cases, as expected. Restored: blob equals HEAD, `git diff HEAD` empty. </details> ## Acceptance notes - Measured before the fix on `origin/main` 5ec398d: Data and Access rendered nothing under a non-matching query; Automations already printed `engine.list.emptyQuery` but had no clear action. The card's premise holds for Data and Access and half-holds for Automations. - The sentence reuses the existing `engine.list.emptyQuery` row (straight quotes around the query), so no new sentence row was added and the existing Automations pin still reads the same text. ## Verification <details><summary>Derived gates (34) at head 66aa3c5</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 families; all 34 ran with exit 0, and `--ran` reconciled 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. Also run: `check:i18n-designer-parity` exit 0, `check:new-line-citations` exit 0 (0 new citations), eslint on the five touched source files exit 0 (0 errors; 16 warnings, all on untouched lines of `StudioDesignSurface.tsx`). </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [ ] 3. No formatter ran; `pnpm lint` passes. (No formatter ran; eslint is clean on the touched files, and the repo-wide `pnpm lint` is CI's run.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbZPJiXo3QSt3ShH9c1 --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbZPJiXo3QSt3ShH9c1)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5bd3d2f commit ee2aaf0

6 files changed

Lines changed: 337 additions & 34 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/i18n': patch
3+
---
4+
5+
feat(i18n): the `studio` namespace gains `engine.studio.rail.clearSearch` (en "Clear search", zh "清除搜索")
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The Studio rails (Data, Automations, Access) now share one empty-search state: a search that matches nothing names the query under the box and offers a "Clear search" action that empties the box and puts every row back. This row is that action's label; the sentence reuses the existing `engine.list.emptyQuery` row.

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx‎

Lines changed: 37 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6327,6 +6327,11 @@ export function DataPillar({
63276327
</>
63286328
) : undefined;
63296329

6330+
const objectNeedle = query.trim().toLowerCase();
6331+
const shownObjects = objects.filter(
6332+
(o) => !objectNeedle || o.label.toLowerCase().includes(objectNeedle) || o.name.toLowerCase().includes(objectNeedle),
6333+
);
6334+
63306335
return (
63316336
<div ref={pillarRef} className="flex h-full flex-col">
63326337
{objConflictDialog}
@@ -6345,7 +6350,12 @@ export function DataPillar({
63456350
onAdd={readOnly ? undefined : openCreateObject}
63466351
addLabel={t('engine.studio.data.newObject', locale)}
63476352
addTestId="studio-rail-add"
6348-
search={{ value: query, onChange: setQuery, placeholder: t('engine.studio.data.searchObjects', locale) }}
6353+
search={{
6354+
value: query,
6355+
onChange: setQuery,
6356+
placeholder: t('engine.studio.data.searchObjects', locale),
6357+
matchCount: shownObjects.length,
6358+
}}
63496359
isMobile={isMobile}
63506360
open={railOpen}
63516361
>
@@ -6354,32 +6364,25 @@ export function DataPillar({
63546364
{error ? t('engine.studio.loadFailed', locale) : objectsLoaded ? t('engine.studio.data.noObjects', locale) : t('engine.studio.loading', locale)}
63556365
</p>
63566366
)}
6357-
{objects
6358-
.filter(
6359-
(o) =>
6360-
!query.trim() ||
6361-
o.label.toLowerCase().includes(query.trim().toLowerCase()) ||
6362-
o.name.toLowerCase().includes(query.trim().toLowerCase()),
6363-
)
6364-
.map((o) => {
6365-
const Icon = getIcon(o.icon);
6366-
return (
6367-
<StudioRailRow
6368-
key={o.name}
6369-
active={current?.name === o.name}
6370-
onClick={() => {
6371-
// objectstack#26705 — another object's load replaces the
6372-
// buffer the held edit lives in; the open one keeps it.
6373-
if (current?.name !== o.name && !confirmDropUnsavedWork(objHeld !== null, locale)) return;
6374-
setCurrent(o);
6375-
if (isMobile) setRailOpen(false);
6376-
}}
6377-
icon={<Icon className="h-4 w-4 shrink-0" />}
6378-
>
6379-
{o.label}
6380-
</StudioRailRow>
6381-
);
6382-
})}
6367+
{shownObjects.map((o) => {
6368+
const Icon = getIcon(o.icon);
6369+
return (
6370+
<StudioRailRow
6371+
key={o.name}
6372+
active={current?.name === o.name}
6373+
onClick={() => {
6374+
// objectstack#26705 — another object's load replaces the
6375+
// buffer the held edit lives in; the open one keeps it.
6376+
if (current?.name !== o.name && !confirmDropUnsavedWork(objHeld !== null, locale)) return;
6377+
setCurrent(o);
6378+
if (isMobile) setRailOpen(false);
6379+
}}
6380+
icon={<Icon className="h-4 w-4 shrink-0" />}
6381+
>
6382+
{o.label}
6383+
</StudioRailRow>
6384+
);
6385+
})}
63836386
</StudioRail>
63846387

63856388
<div className={cn('flex min-w-0 flex-1 flex-col overflow-hidden', DATA_CONTENT_CONTAINER)} data-testid="data-content-column">
@@ -7978,6 +7981,7 @@ export function AutomationsPillar({
79787981
onChange: setRailQuery,
79797982
placeholder: t('engine.studio.designer.search', locale),
79807983
testId: 'auto-rail-search',
7984+
matchCount: shownFlows.length,
79817985
}}
79827986
footer={railLegend}
79837987
isMobile={isMobile}
@@ -8039,11 +8043,6 @@ export function AutomationsPillar({
80398043
</StudioRailGroup>
80408044
);
80418045
})}
8042-
{flows.length > 0 && shownFlows.length === 0 && (
8043-
<p className="px-2 py-3 text-[11px] text-muted-foreground">
8044-
{tFormat('engine.list.emptyQuery', locale, { query: railQuery.trim() })}
8045-
</p>
8046-
)}
80478046
{flows.length === 0 && !creating && (error || !listed || openCreateFlow) && (
80488047
<p className="px-2 py-3 text-[11px] text-muted-foreground">
80498048
{error
@@ -8896,7 +8895,12 @@ export function AccessPillar({
88968895
onAdd={openCreateSet}
88978896
addLabel={t('engine.studio.access.new', locale)}
88988897
addTestId="studio-rail-add"
8899-
search={{ value: query, onChange: setQuery, placeholder: t('engine.studio.access.search', locale) }}
8898+
search={{
8899+
value: query,
8900+
onChange: setQuery,
8901+
placeholder: t('engine.studio.access.search', locale),
8902+
matchCount: filtered.length,
8903+
}}
89008904
pinned={
89018905
// Pinned sibling surface: the package-wide Record Sharing Baseline
89028906
// (OWD) overview (objectui#2505). Same package-author ownership as
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#26733 — a Studio rail search that matches nothing says so.
5+
*
6+
* Before: the Data and Access rails went blank under a query that matched no
7+
* row, so a mistyped search read as a list that failed to load; the
8+
* Automations rail printed its own sentence and offered no way back but the
9+
* keyboard. Now the shared `StudioRail` draws one state for all three, from the
10+
* match count each pillar hands it: a sentence naming the query, and a "Clear
11+
* search" action that empties the box and puts every row back.
12+
*
13+
* Per rail (the REAL pillars over a mocked metadata client):
14+
* - a query that matches nothing shows the state, naming the query;
15+
* - "Clear search" restores the list and empties (and focuses) the box;
16+
* - a query that matches shows no state;
17+
* - an empty package keeps its own empty sentence, with no no-matches line,
18+
* whether or not a query is typed.
19+
* And the state reads in zh.
20+
*/
21+
22+
import '@testing-library/jest-dom/vitest';
23+
import * as React from 'react';
24+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
25+
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
26+
import { MemoryRouter } from 'react-router-dom';
27+
import { I18nProvider } from '@object-ui/i18n';
28+
29+
const PKG = 'com.acme.app';
30+
31+
/** What `client.list(type)` answers in the current test, per metadata type. */
32+
const listed = vi.hoisted(() => ({ rows: {} as Record<string, Array<Record<string, unknown>>> }));
33+
34+
const mockClient = vi.hoisted(() => ({
35+
list: vi.fn(async (type: string) => listed.rows[type] ?? []),
36+
listDrafts: vi.fn(async () => [] as Array<Record<string, unknown>>),
37+
listTypes: vi.fn(async () => ({ entries: [] })),
38+
get: vi.fn(async () => null),
39+
references: vi.fn(async () => []),
40+
layered: vi.fn(async () => null),
41+
getDraft: vi.fn(async () => null),
42+
save: vi.fn(async () => {
43+
throw new Error('this suite saves nothing');
44+
}),
45+
publish: vi.fn(async () => ({ success: true })),
46+
reset: vi.fn(async () => ({})),
47+
}));
48+
49+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
50+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
51+
return {
52+
...mod,
53+
useMetadataClient: () => mockClient,
54+
useMetadataTypes: () => ({ loading: false, error: null, entries: [] }),
55+
};
56+
});
57+
vi.mock('./packages-io', async (importOriginal) => {
58+
const mod = await importOriginal<typeof import('./packages-io')>();
59+
return { ...mod, fetchPackages: vi.fn(async () => []) };
60+
});
61+
vi.mock('@object-ui/react', async (importOriginal) => {
62+
const mod = await importOriginal<typeof import('@object-ui/react')>();
63+
return { ...mod, useAdapter: () => dataSource };
64+
});
65+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
66+
vi.mock('../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null }));
67+
vi.mock('../metadata-admin/AccessExplainPanel', () => ({ AccessExplainPanel: () => null }));
68+
vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null }));
69+
70+
import { AccessPillar, AutomationsPillar, DataPillar } from './StudioDesignSurface';
71+
import { createEmptyDataSource, failOnAbsorbedFetchError } from './__tests__/emptyDataSource';
72+
import { t, tFormat } from '../metadata-admin/i18n';
73+
74+
// The "Clear search" row lives in `@object-ui/i18n`'s `studio` namespace, whose
75+
// `zh` catalogue is fetched on demand (objectstack#25922). Resident before any
76+
// case, so the zh case reads it synchronously.
77+
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
78+
await loadStudioCatalogue('zh');
79+
80+
const dataSource = createEmptyDataSource();
81+
failOnAbsorbedFetchError();
82+
83+
// jsdom has no matchMedia — useIsMobile (rail overlay) needs a stub.
84+
window.matchMedia = ((query: string) => ({
85+
matches: false,
86+
media: query,
87+
onchange: null,
88+
addEventListener: () => {},
89+
removeEventListener: () => {},
90+
addListener: () => {},
91+
removeListener: () => {},
92+
dispatchEvent: () => false,
93+
})) as unknown as typeof window.matchMedia;
94+
95+
const NODES = [
96+
{ id: 'start', type: 'start', label: 'Start' },
97+
{ id: 'end', type: 'end', label: 'End' },
98+
];
99+
const EDGES = [{ id: 'e1', source: 'start', target: 'end' }];
100+
101+
const ROWS: Record<string, Array<Record<string, unknown>>> = {
102+
object: [
103+
{ name: 'crm_account', label: 'Account' },
104+
{ name: 'crm_contact', label: 'Contact' },
105+
],
106+
flow: [
107+
{ name: 'nightly_digest', label: 'Nightly digest', type: 'autolaunched', nodes: NODES, edges: EDGES },
108+
{ name: 'approve_order', label: 'Approve order', type: 'autolaunched', nodes: NODES, edges: EDGES },
109+
],
110+
permission: [
111+
{ name: 'sales_rep', label: 'Sales rep' },
112+
{ name: 'support_agent', label: 'Support agent' },
113+
],
114+
};
115+
116+
beforeEach(() => {
117+
listed.rows = ROWS;
118+
vi.stubGlobal(
119+
'fetch',
120+
vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })),
121+
);
122+
});
123+
124+
afterEach(() => {
125+
cleanup();
126+
vi.unstubAllGlobals();
127+
vi.clearAllMocks();
128+
});
129+
130+
interface RailCase {
131+
name: string;
132+
railTestId: string;
133+
placeholderKey: string;
134+
/** The labels the rail lists for `ROWS`, and a query that matches only the first. */
135+
labels: [string, string];
136+
match: string;
137+
/** The sentence the rail shows for an empty package. */
138+
emptyKey: string;
139+
pillar: () => React.ReactElement;
140+
}
141+
142+
const CASES: RailCase[] = [
143+
{
144+
name: 'Data',
145+
railTestId: 'studio-rail-data',
146+
placeholderKey: 'engine.studio.data.searchObjects',
147+
labels: ['Account', 'Contact'],
148+
match: 'acc',
149+
emptyKey: 'engine.studio.data.noObjects',
150+
pillar: () => <DataPillar packageId={PKG} />,
151+
},
152+
{
153+
name: 'Automations',
154+
railTestId: 'studio-rail-automations',
155+
placeholderKey: 'engine.studio.designer.search',
156+
labels: ['Nightly digest', 'Approve order'],
157+
match: 'digest',
158+
emptyKey: 'engine.studio.auto.none',
159+
pillar: () => <AutomationsPillar packageId={PKG} />,
160+
},
161+
{
162+
name: 'Access',
163+
railTestId: 'studio-rail-access',
164+
placeholderKey: 'engine.studio.access.search',
165+
labels: ['Sales rep', 'Support agent'],
166+
match: 'sales',
167+
emptyKey: 'engine.studio.access.none',
168+
pillar: () => <AccessPillar packageId={PKG} />,
169+
},
170+
];
171+
172+
function renderRail(c: RailCase, language: 'en' | 'zh' = 'en') {
173+
render(
174+
<I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>
175+
<MemoryRouter initialEntries={[`/studio/${PKG}`]}>{c.pillar()}</MemoryRouter>
176+
</I18nProvider>,
177+
);
178+
}
179+
180+
/** The rail once its rows have rendered, and its search box. */
181+
async function loadedRail(c: RailCase, locale: 'en' | 'zh-CN' = 'en') {
182+
const rail = await screen.findByTestId(c.railTestId, undefined, { timeout: 8000 });
183+
await waitFor(() => expect(within(rail).getByText(c.labels[0])).toBeInTheDocument(), { timeout: 8000 });
184+
const search = within(rail).getByPlaceholderText(t(c.placeholderKey, locale)) as HTMLInputElement;
185+
return { rail, search };
186+
}
187+
188+
describe.each(CASES)('the $name rail: a search that matches nothing (objectstack#26733)', (c) => {
189+
it('names the query, and lists no row', async () => {
190+
renderRail(c);
191+
const { rail, search } = await loadedRail(c);
192+
fireEvent.change(search, { target: { value: ' zzzz ' } });
193+
194+
const state = within(rail).getByTestId('studio-rail-no-matches');
195+
expect(state).toHaveTextContent(tFormat('engine.list.emptyQuery', 'en', { query: 'zzzz' }));
196+
expect(within(state).getByRole('button', { name: t('engine.studio.rail.clearSearch', 'en') })).toBeInTheDocument();
197+
for (const label of c.labels) expect(within(rail).queryByText(label)).toBeNull();
198+
// Not the empty-package sentence: the package HAS rows.
199+
expect(within(rail).queryByText(t(c.emptyKey, 'en'))).toBeNull();
200+
});
201+
202+
it('"Clear search" empties the box and puts every row back', async () => {
203+
renderRail(c);
204+
const { rail, search } = await loadedRail(c);
205+
fireEvent.change(search, { target: { value: 'zzzz' } });
206+
fireEvent.click(within(rail).getByTestId('studio-rail-clear-search'));
207+
208+
expect(search.value).toBe('');
209+
expect(search).toHaveFocus();
210+
expect(within(rail).queryByTestId('studio-rail-no-matches')).toBeNull();
211+
for (const label of c.labels) expect(within(rail).getByText(label)).toBeInTheDocument();
212+
});
213+
214+
it('control: a query that matches shows its row and no state', async () => {
215+
renderRail(c);
216+
const { rail, search } = await loadedRail(c);
217+
fireEvent.change(search, { target: { value: c.match } });
218+
219+
expect(within(rail).getByText(c.labels[0])).toBeInTheDocument();
220+
expect(within(rail).queryByText(c.labels[1])).toBeNull();
221+
expect(within(rail).queryByTestId('studio-rail-no-matches')).toBeNull();
222+
});
223+
224+
it('an empty package keeps its own sentence, and no no-matches line even under a query', async () => {
225+
listed.rows = {};
226+
renderRail(c);
227+
const rail = await screen.findByTestId(c.railTestId, undefined, { timeout: 8000 });
228+
await waitFor(() => expect(within(rail).getByText(t(c.emptyKey, 'en'))).toBeInTheDocument(), { timeout: 8000 });
229+
expect(within(rail).queryByTestId('studio-rail-no-matches')).toBeNull();
230+
231+
fireEvent.change(within(rail).getByPlaceholderText(t(c.placeholderKey, 'en')), { target: { value: 'zzzz' } });
232+
expect(within(rail).queryByTestId('studio-rail-no-matches')).toBeNull();
233+
});
234+
});
235+
236+
describe('the no-matches state in zh (objectstack#26733)', () => {
237+
it('names the query and labels its action in Chinese', async () => {
238+
const c = CASES[0];
239+
renderRail(c, 'zh');
240+
const { rail, search } = await loadedRail(c, 'zh-CN');
241+
fireEvent.change(search, { target: { value: 'zzzz' } });
242+
243+
const state = within(rail).getByTestId('studio-rail-no-matches');
244+
expect(state).toHaveTextContent(tFormat('engine.list.emptyQuery', 'zh-CN', { query: 'zzzz' }));
245+
expect(within(state).getByRole('button')).toHaveTextContent('清除搜索');
246+
expect(t('engine.studio.rail.clearSearch', 'zh-CN')).toBe('清除搜索');
247+
});
248+
});

0 commit comments

Comments
 (0)