|
| 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