Skip to content

Commit dc0a041

Browse files
committed
fix(app-shell): the command palette matches navigation entries on word prefixes and substrings
The navigation groups (objects, dashboards, pages, reports, apps) and the theme and full-search commands went through cmdk's default subsequence scorer, so "zzzz" listed Field Zoo and a nonsense query never reached "No results". The palette now matches them itself with a module-private matcher (a word prefix, or a contiguous substring from three characters on, of the label or machine name; every Han or kana character starts a word) and renders only the matches. Record hits are unchanged. Claude-Session: https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5d69133 commit dc0a041

5 files changed

Lines changed: 488 additions & 42 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
The ⌘K command palette lists a navigation entry only when the query is a word prefix or a contiguous substring of its label or machine name, so a query that matches nothing shows "No results" (objectui#11812).
6+
7+
The Objects, Dashboards, Pages, Reports and Switch App groups went through cmdk's default subsequence scorer, which matches letters scattered across an entry. On the showcase app `zzzz` listed "Field Zoo" and "New Project (Wizard)", `ingest` listed "In-Progress Tasks", "Cascading Select", "Page Authoring" and "Styling (ADR-0065)" next to the real record hit, and `wayne` listed "Styling (ADR-0065)" next to Wayne Enterprises.
8+
9+
- **An entry matches** when the query, ignoring case, starts a word of its label or machine name (`field` finds "Field Zoo", `zoo` finds `showcase_field_zoo`), or, from three characters on, appears anywhere in one of them (`view` finds "Task Overview"). A hyphen and a space read alike. There is no typo tolerance.
10+
- **Chinese and Japanese labels**: Han, Hiragana and Katakana are written without spaces, so each of their characters starts a word, and a one-character query finds a label that holds it anywhere.
11+
- **The theme and "Open Full Search Page" commands** follow the same rule over the words they were already searched by (`theme light`, `search all results full page`).
12+
- **Record hits are unchanged**: the server search finds them, and each is listed as before.
13+
14+
Nothing is added to the package entry: no export, prop, type member or language-pack key.
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The ⌘K palette shows a navigation entry only when the query is a word
11+
* prefix, or a contiguous substring, of its label or machine name
12+
* (objectui#11812).
13+
*
14+
* The navigation groups used to go through cmdk's default subsequence scorer,
15+
* so on the showcase app `zzzz` listed "Field Zoo" and "New Project (Wizard)",
16+
* `ingest` listed four entries made of scattered letters next to the real
17+
* record hit, and a nonsense query never reached "No results". The palette now
18+
* matches those entries itself (`matchesPaletteQuery`) and renders only the
19+
* matches; the theme and full-search commands follow the same rule over their
20+
* values. Record hits are unchanged: the server search finds them.
21+
*
22+
* Pinned through the real `CommandPaletteProvider` (opened by its `?palette=1`
23+
* deep link) over the showcase's own labels, with a `searchAll` stub standing
24+
* in for the server search. What is pinned is what the DOM holds, since cmdk
25+
* leaves an item it filters out unrendered:
26+
*
27+
* - `zzzz` shows no entry and "No results", once the record search settles;
28+
* - `ingest` and `wayne` show their record hit and no unrelated entry;
29+
* `ingest` shows the one entry whose machine name contains it;
30+
* - a word prefix (`field`), a mid-word substring (`view`) and a Han
31+
* substring (`大屏`) each show their entry, so cmdk's scorer, which still
32+
* ranks the rendered entries, never hides a match;
33+
* - CONTROLS: the record hit's item is the one the palette always rendered,
34+
* and an empty query shows every entry and command.
35+
*/
36+
37+
import * as React from 'react';
38+
import { describe, it, expect, vi, afterEach } from 'vitest';
39+
import { render, fireEvent, waitFor, cleanup, screen } from '@testing-library/react';
40+
import { MemoryRouter } from 'react-router-dom';
41+
42+
vi.mock('@object-ui/auth', async (importOriginal) => ({
43+
...(await importOriginal<Record<string, unknown>>()),
44+
useAuth: () => ({ user: { id: 'u1' }, activeOrganization: null }),
45+
}));
46+
47+
import { CommandPalette } from './CommandPalette';
48+
import { CommandPaletteProvider } from '../context/CommandPaletteProvider';
49+
50+
// The showcase app's entries the QA pass read, in its own nesting, plus one
51+
// entry whose machine name (not its label) contains `ingest`.
52+
const SHOWCASE = {
53+
name: 'showcase_app',
54+
label: 'Showcase',
55+
navigation: [
56+
{ id: 'nav_start_here', type: 'page', pageName: 'showcase_start_here', label: 'Page Authoring' },
57+
{
58+
id: 'grp_workspace',
59+
type: 'group',
60+
label: 'Workspace',
61+
children: [
62+
{ id: 'nav_new_project_wizard', type: 'page', pageName: 'showcase_new_project_wizard', label: 'New Project (Wizard)' },
63+
],
64+
},
65+
{
66+
id: 'grp_data',
67+
type: 'group',
68+
label: 'Data Model',
69+
children: [
70+
{ id: 'nav_tasks', type: 'object', objectName: 'showcase_task', label: 'Tasks' },
71+
{ id: 'nav_accounts', type: 'object', objectName: 'showcase_account', label: 'Accounts' },
72+
{ id: 'nav_field_zoo', type: 'object', objectName: 'showcase_field_zoo', label: 'Field Zoo' },
73+
{ id: 'nav_cascade', type: 'object', objectName: 'showcase_cascade', label: 'Cascading Select' },
74+
{ id: 'nav_slice_in_progress', type: 'object', objectName: 'showcase_task', label: 'In-Progress Tasks' },
75+
],
76+
},
77+
{
78+
id: 'grp_analytics',
79+
type: 'group',
80+
label: 'Analytics',
81+
children: [
82+
{ id: 'nav_command_center', type: 'page', pageName: 'showcase_command_center', label: 'Command Center (大屏)' },
83+
{ id: 'nav_ops', type: 'dashboard', dashboardName: 'showcase_ops_dashboard', label: 'Delivery Operations' },
84+
{ id: 'nav_report_joined', type: 'report', reportName: 'showcase_task_overview', label: 'Task Overview' },
85+
],
86+
},
87+
{ id: 'nav_styling_gallery', type: 'page', pageName: 'showcase_styling_gallery', label: 'Styling (ADR-0065)' },
88+
{ id: 'nav_replay', type: 'page', pageName: 'ops_reingest_queue', label: 'Replay Queue' },
89+
],
90+
};
91+
const CRM = { name: 'crm_app', label: 'CRM', navigation: [] };
92+
93+
const OBJECTS = [
94+
{ name: 'showcase_task', label: 'Task' },
95+
{ name: 'showcase_account', label: 'Account' },
96+
{ name: 'showcase_field_zoo', label: 'Field Zoo' },
97+
{ name: 'showcase_cascade', label: 'Cascading Select' },
98+
];
99+
100+
/** The records the server search knows, found by a plain substring of their title. */
101+
const RECORDS = [
102+
{ object: 'showcase_task', id: 't1', title: 'Ingest pipeline' },
103+
{ object: 'showcase_account', id: 'a1', title: 'Wayne Enterprises' },
104+
];
105+
106+
function mount(query: string) {
107+
const dataSource = {
108+
find: vi.fn(async () => ({ data: [] })),
109+
searchAll: vi.fn(async (q: string) => ({
110+
hits: RECORDS.filter((r) => r.title.toLowerCase().includes(q.toLowerCase())),
111+
})),
112+
};
113+
render(
114+
<MemoryRouter initialEntries={['/apps/showcase_app?palette=1']}>
115+
<CommandPaletteProvider>
116+
<CommandPalette
117+
apps={[SHOWCASE, CRM]}
118+
activeApp={SHOWCASE}
119+
objects={OBJECTS}
120+
onAppChange={() => {}}
121+
dataSource={dataSource}
122+
/>
123+
</CommandPaletteProvider>
124+
</MemoryRouter>,
125+
);
126+
const input = document.querySelector('[cmdk-input]');
127+
if (!input) throw new Error('the palette did not open');
128+
if (query) fireEvent.change(input, { target: { value: query } });
129+
return dataSource;
130+
}
131+
132+
/** The visible text of every item cmdk rendered whose value starts with `kind `. */
133+
function shown(kind: string): string[] {
134+
return Array.from(document.querySelectorAll(`[cmdk-item][data-value^="${kind} "]`)).map((el) =>
135+
(el.textContent ?? '').trim(),
136+
);
137+
}
138+
139+
/** Every navigation entry, app and command cmdk rendered: everything but record hits. */
140+
function entries(): string[] {
141+
return ['object', 'dashboard', 'page', 'report', 'app', 'theme', 'search'].flatMap(shown);
142+
}
143+
144+
/** Waits for the debounced record search to answer, then returns the hits' text. */
145+
async function settledRecordHits(dataSource: { searchAll: ReturnType<typeof vi.fn> }): Promise<string[]> {
146+
await waitFor(() => expect(dataSource.searchAll).toHaveBeenCalled(), { timeout: 4000 });
147+
await waitFor(() => expect(document.body.textContent).not.toContain('Searching…'), { timeout: 4000 });
148+
return shown('record');
149+
}
150+
151+
afterEach(() => cleanup());
152+
153+
describe('objectui#11812 — the ⌘K palette matches navigation entries on word prefixes and substrings', () => {
154+
it('`zzzz` shows no entry and, once the record search finds nothing, "No results"', async () => {
155+
const ds = mount('zzzz');
156+
expect(await settledRecordHits(ds)).toEqual([]);
157+
expect(entries()).toEqual([]);
158+
const empty = document.querySelector('[cmdk-empty]');
159+
expect(empty).not.toBeNull();
160+
expect(empty!.textContent).toBe('No results found.');
161+
});
162+
163+
it('`ingest` shows its record hit and only the entry whose machine name contains it', async () => {
164+
const ds = mount('ingest');
165+
expect(await settledRecordHits(ds)).toEqual(['Ingest pipeline']);
166+
expect(entries()).toEqual(['Replay Queue']);
167+
expect(document.querySelector('[cmdk-empty]')).toBeNull();
168+
});
169+
170+
it('`wayne` shows Wayne Enterprises and no page', async () => {
171+
const ds = mount('wayne');
172+
expect(await settledRecordHits(ds)).toEqual(['Wayne Enterprises']);
173+
expect(entries()).toEqual([]);
174+
});
175+
176+
it('a word prefix, a mid-word substring and a Han substring each show their entry', () => {
177+
mount('field');
178+
expect(entries()).toEqual(['Field Zoo']);
179+
cleanup();
180+
181+
mount('view');
182+
expect(entries()).toEqual(['Task Overview']);
183+
cleanup();
184+
185+
mount('大屏');
186+
expect(entries()).toEqual(['Command Center (大屏)']);
187+
cleanup();
188+
189+
mount('屏');
190+
expect(entries()).toEqual(['Command Center (大屏)']);
191+
});
192+
193+
it('apps, the theme commands and the full-search command follow the same rule', () => {
194+
mount('crm');
195+
expect(shown('app')).toEqual(['CRM']);
196+
expect(shown('theme')).toEqual([]);
197+
cleanup();
198+
199+
mount('dark');
200+
expect(entries()).toEqual(['Dark Theme']);
201+
cleanup();
202+
203+
mount('theme');
204+
expect(shown('theme')).toEqual(['Light Theme', 'Dark Theme', 'System Theme']);
205+
cleanup();
206+
207+
mount('search');
208+
expect(entries()).toEqual(['Open Full Search Page']);
209+
});
210+
211+
it('CONTROL: the record hit is the item the palette always rendered, its value carrying the query', async () => {
212+
const ds = mount('wayne');
213+
await settledRecordHits(ds);
214+
const hit = document.querySelector('[cmdk-item][data-value^="record "]');
215+
expect(hit?.getAttribute('data-value')).toBe('record wayne Wayne Enterprises Account showcase_account a1');
216+
});
217+
218+
it('CONTROL: an empty query shows every entry, app and command', () => {
219+
mount('');
220+
expect(shown('object')).toEqual(['Tasks', 'Accounts', 'Field Zoo', 'Cascading Select', 'In-Progress Tasks']);
221+
expect(shown('page')).toEqual([
222+
'Page Authoring',
223+
'New Project (Wizard)',
224+
'Command Center (大屏)',
225+
'Styling (ADR-0065)',
226+
'Replay Queue',
227+
]);
228+
expect(shown('dashboard')).toEqual(['Delivery Operations']);
229+
expect(shown('report')).toEqual(['Task Overview']);
230+
expect(shown('app')).toEqual(['ShowcaseCurrent', 'CRM']);
231+
expect(shown('theme')).toEqual(['Light Theme', 'Dark Theme', 'System Theme']);
232+
expect(shown('search')).toEqual(['Open Full Search Page']);
233+
expect(screen.getByTestId('overlay:command-palette')).toBeTruthy();
234+
});
235+
});

0 commit comments

Comments
 (0)