@open-mercato/ui 0.7.1-develop.7186.1.6e080a5017 → 0.7.1-develop.7193.1.910a5b0a1e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,312 @@
1
+ /** @jest-environment jsdom */
2
+ import * as React from 'react'
3
+ import { DataTable, writePerspectiveSnapshot, readPerspectiveSnapshot } from '../DataTable'
4
+ import type { ColumnDef } from '@tanstack/react-table'
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
6
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
7
+ import { render, act } from '@testing-library/react'
8
+ import type { PerspectivesIndexResponse } from '@open-mercato/shared/modules/perspectives/types'
9
+ import { createEmptyTree } from '@open-mercato/shared/lib/query/advanced-filter-tree'
10
+
11
+ jest.mock('next/navigation', () => ({
12
+ useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
13
+ }))
14
+
15
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
16
+ useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
17
+ }))
18
+
19
+ jest.mock('../injection/useGuardedMutation', () => ({
20
+ useGuardedMutation: () => ({
21
+ runMutation: async ({ operation }: { operation: () => Promise<unknown> }) => operation(),
22
+ retryLastMutation: async () => false,
23
+ }),
24
+ }))
25
+
26
+ jest.mock('../FlashMessages', () => ({ flash: jest.fn() }))
27
+
28
+ jest.mock('../utils/apiCall', () => ({
29
+ apiCall: jest.fn(async () => ({
30
+ ok: true,
31
+ status: 200,
32
+ result: undefined,
33
+ response: { ok: true, status: 200 } as Response,
34
+ cacheStatus: null as const,
35
+ })),
36
+ withScopedApiRequestHeaders: async (_headers: Record<string, string>, run: () => Promise<unknown>) => run(),
37
+ }))
38
+
39
+ jest.mock('../PerspectiveSidebar', () => ({
40
+ PerspectiveSidebar: () => null,
41
+ }))
42
+
43
+ type Row = { id: string; name: string }
44
+
45
+ const TABLE_ID = 'reconcile-table'
46
+ const SERVER_UPDATED_AT = '2026-08-06T00:00:00.000Z'
47
+ const SERVER_UPDATED_AT_MS = Date.parse(SERVER_UPDATED_AT)
48
+
49
+ const columns: ColumnDef<Row>[] = [
50
+ { accessorKey: 'name', header: 'Name' },
51
+ { accessorKey: 'id', header: 'Id' },
52
+ ]
53
+
54
+ function buildIndexResponse(
55
+ perspectives: PerspectivesIndexResponse['perspectives'],
56
+ overrides?: Partial<PerspectivesIndexResponse>,
57
+ ): PerspectivesIndexResponse {
58
+ return {
59
+ tableId: TABLE_ID,
60
+ perspectives,
61
+ defaultPerspectiveId: null,
62
+ rolePerspectives: [],
63
+ manageableRolePerspectives: [],
64
+ roles: [],
65
+ canApplyToRoles: false,
66
+ ...overrides,
67
+ }
68
+ }
69
+
70
+ function buildPerspective(
71
+ id: string,
72
+ searchValue: string,
73
+ updatedAt: string = SERVER_UPDATED_AT,
74
+ ): PerspectivesIndexResponse['perspectives'][number] {
75
+ return {
76
+ id,
77
+ name: id,
78
+ tableId: TABLE_ID,
79
+ settings: { searchValue },
80
+ isDefault: false,
81
+ createdAt: 'now',
82
+ updatedAt,
83
+ }
84
+ }
85
+
86
+ function renderTable(response: PerspectivesIndexResponse, options?: { withAdvancedFilterHost?: boolean }) {
87
+ const searchChanges: string[] = []
88
+ const appliedTrees: unknown[] = []
89
+ const queryClient = new QueryClient({
90
+ defaultOptions: {
91
+ queries: { staleTime: Infinity, gcTime: Infinity, retry: false },
92
+ mutations: { retry: false },
93
+ },
94
+ })
95
+ queryClient.setQueryData(['feature-check', 'perspectives'], { use: true, roleDefaults: true })
96
+ queryClient.setQueryData(['table-perspectives', TABLE_ID], response)
97
+
98
+ const utils = render(
99
+ <QueryClientProvider client={queryClient}>
100
+ <I18nProvider locale="en" dict={{}}>
101
+ <DataTable<Row>
102
+ columns={columns}
103
+ data={[]}
104
+ searchValue=""
105
+ onSearchChange={(value) => { searchChanges.push(value) }}
106
+ perspective={{ tableId: TABLE_ID }}
107
+ advancedFilter={options?.withAdvancedFilterHost
108
+ ? {
109
+ fields: [],
110
+ value: createEmptyTree(),
111
+ onChange: () => {},
112
+ onApply: () => {},
113
+ onClear: () => {},
114
+ onApplyTree: (tree) => { appliedTrees.push(tree) },
115
+ }
116
+ : undefined}
117
+ />
118
+ </I18nProvider>
119
+ </QueryClientProvider>,
120
+ )
121
+ return { ...utils, searchChanges, appliedTrees, queryClient }
122
+ }
123
+
124
+ describe('DataTable localStorage snapshot vs. server perspective reconciliation (#5113)', () => {
125
+ beforeEach(() => {
126
+ localStorage.clear()
127
+ for (const cookie of document.cookie ? document.cookie.split(';') : []) {
128
+ const name = cookie.split('=')[0].trim()
129
+ if (name) document.cookie = `${name}=; Path=/; Max-Age=0`
130
+ }
131
+ })
132
+
133
+ it('yields to a newer server perspective instead of staying pinned to the snapshot', () => {
134
+ // Tab A mounted before tab B saved: the snapshot predates the stored row.
135
+ writePerspectiveSnapshot(TABLE_ID, {
136
+ perspectiveId: 'persp-1',
137
+ settings: { searchValue: 'stale' },
138
+ updatedAt: SERVER_UPDATED_AT_MS - 60_000,
139
+ })
140
+
141
+ const { searchChanges } = renderTable(buildIndexResponse([buildPerspective('persp-1', 'fresh')]))
142
+
143
+ expect(searchChanges[0]).toBe('stale')
144
+ expect(searchChanges[searchChanges.length - 1]).toBe('fresh')
145
+ })
146
+
147
+ it('keeps the snapshot when the server row is older, so a stale read never wins', () => {
148
+ writePerspectiveSnapshot(TABLE_ID, {
149
+ perspectiveId: 'persp-1',
150
+ settings: { searchValue: 'local' },
151
+ updatedAt: SERVER_UPDATED_AT_MS + 60_000,
152
+ })
153
+
154
+ const { searchChanges } = renderTable(buildIndexResponse([buildPerspective('persp-1', 'older-server')]))
155
+
156
+ expect(searchChanges).toEqual(['local'])
157
+ })
158
+
159
+ it('does not re-apply on clock skew alone when the settings are identical', () => {
160
+ // `updatedAt` in the snapshot is a browser clock reading and the server's is
161
+ // a database one, so "server is newer" on its own must not repaint the table.
162
+ writePerspectiveSnapshot(TABLE_ID, {
163
+ perspectiveId: 'persp-1',
164
+ settings: { searchValue: 'same' },
165
+ updatedAt: SERVER_UPDATED_AT_MS - 60_000,
166
+ })
167
+
168
+ const { searchChanges } = renderTable(buildIndexResponse([buildPerspective('persp-1', 'same')]))
169
+
170
+ expect(searchChanges).toEqual(['same'])
171
+ })
172
+
173
+ it('drops a snapshot pointing at a deleted perspective and resumes normal resolution', () => {
174
+ writePerspectiveSnapshot(TABLE_ID, {
175
+ perspectiveId: 'deleted-1',
176
+ settings: { searchValue: 'orphaned' },
177
+ updatedAt: SERVER_UPDATED_AT_MS - 60_000,
178
+ })
179
+
180
+ const { searchChanges } = renderTable(buildIndexResponse(
181
+ [buildPerspective('persp-2', 'server-default')],
182
+ { defaultPerspectiveId: 'persp-2' },
183
+ ))
184
+
185
+ expect(searchChanges[searchChanges.length - 1]).toBe('server-default')
186
+ expect(readPerspectiveSnapshot(TABLE_ID)?.perspectiveId).toBe('persp-2')
187
+ })
188
+
189
+ it('drops an orphaned snapshot without touching a host-owned advanced filter when a replacement view exists', () => {
190
+ // Same background correction as the "no replacement left" case below — the
191
+ // active view was deleted, unshared or reassigned in another session — but
192
+ // taken through the *common* branch: normal resolution finds a replacement
193
+ // (here, the server default) instead of finding nothing at all.
194
+ writePerspectiveSnapshot(TABLE_ID, {
195
+ perspectiveId: 'deleted-1',
196
+ settings: { searchValue: 'orphaned' },
197
+ updatedAt: SERVER_UPDATED_AT_MS - 60_000,
198
+ })
199
+
200
+ const { searchChanges, appliedTrees } = renderTable(
201
+ buildIndexResponse(
202
+ [buildPerspective('persp-2', 'server-default')],
203
+ { defaultPerspectiveId: 'persp-2' },
204
+ ),
205
+ { withAdvancedFilterHost: true },
206
+ )
207
+
208
+ expect(searchChanges[searchChanges.length - 1]).toBe('server-default')
209
+ expect(readPerspectiveSnapshot(TABLE_ID)?.perspectiveId).toBe('persp-2')
210
+ expect(appliedTrees).toHaveLength(0)
211
+ })
212
+
213
+ it('clears an orphaned snapshot even when no replacement view is left to fall back to', () => {
214
+ // "my only saved view was deleted elsewhere" is one of the two #5113
215
+ // scenarios. With no default, no role default and no remaining perspective,
216
+ // normal resolution finds no target, so the orphaned settings would stay
217
+ // painted — and `activePerspectiveId` would keep naming a deleted row — for
218
+ // the rest of this page load unless the empty case clears explicitly.
219
+ writePerspectiveSnapshot(TABLE_ID, {
220
+ perspectiveId: 'deleted-1',
221
+ settings: { searchValue: 'orphaned' },
222
+ updatedAt: SERVER_UPDATED_AT_MS - 60_000,
223
+ })
224
+
225
+ const { searchChanges } = renderTable(buildIndexResponse([]))
226
+
227
+ expect(searchChanges[0]).toBe('orphaned')
228
+ expect(searchChanges[searchChanges.length - 1]).toBe('')
229
+ expect(readPerspectiveSnapshot(TABLE_ID)).toBeNull()
230
+ })
231
+
232
+ it('clears without touching a host-owned advanced filter when no replacement view is left', () => {
233
+ // The clear above is a background correction — the view was deleted in
234
+ // another session — so it follows the same rule as the reconciling apply:
235
+ // on People/Companies/Deals the URL owns the filter and the user must not
236
+ // lose what is on screen because a view they were not looking at vanished.
237
+ writePerspectiveSnapshot(TABLE_ID, {
238
+ perspectiveId: 'deleted-1',
239
+ settings: { searchValue: 'orphaned' },
240
+ updatedAt: SERVER_UPDATED_AT_MS - 60_000,
241
+ })
242
+
243
+ const { searchChanges, appliedTrees } = renderTable(
244
+ buildIndexResponse([]),
245
+ { withAdvancedFilterHost: true },
246
+ )
247
+
248
+ expect(searchChanges[searchChanges.length - 1]).toBe('')
249
+ expect(readPerspectiveSnapshot(TABLE_ID)).toBeNull()
250
+ expect(appliedTrees).toHaveLength(0)
251
+ })
252
+
253
+ it('leaves a "No view" widths-only snapshot alone rather than forcing the server default', () => {
254
+ // #1835: column widths survive a refresh without an active perspective, and
255
+ // "No view" is an explicit user choice the server default must not override.
256
+ writePerspectiveSnapshot(TABLE_ID, {
257
+ perspectiveId: null,
258
+ settings: { columnSizing: { name: 240 } },
259
+ updatedAt: SERVER_UPDATED_AT_MS - 60_000,
260
+ })
261
+
262
+ const { searchChanges } = renderTable(buildIndexResponse(
263
+ [buildPerspective('persp-2', 'server-default')],
264
+ { defaultPerspectiveId: 'persp-2' },
265
+ ))
266
+
267
+ expect(searchChanges).not.toContain('server-default')
268
+ })
269
+
270
+ it('does not overwrite a host-owned advanced filter while reconciling', () => {
271
+ // Reconciliation is a background correction, so it follows the mount-time
272
+ // restore: on People/Companies/Deals the URL owns the filter and a repaint
273
+ // the user never asked for must not clear what is on screen.
274
+ writePerspectiveSnapshot(TABLE_ID, {
275
+ perspectiveId: 'persp-1',
276
+ settings: { searchValue: 'stale' },
277
+ updatedAt: SERVER_UPDATED_AT_MS - 60_000,
278
+ })
279
+
280
+ const { searchChanges, appliedTrees } = renderTable(
281
+ buildIndexResponse([buildPerspective('persp-1', 'fresh')]),
282
+ { withAdvancedFilterHost: true },
283
+ )
284
+
285
+ expect(searchChanges[searchChanges.length - 1]).toBe('fresh')
286
+ expect(appliedTrees).toHaveLength(0)
287
+ })
288
+
289
+ it('reconciles once per table, so a later refetch cannot clobber post-mount edits', () => {
290
+ writePerspectiveSnapshot(TABLE_ID, {
291
+ perspectiveId: 'persp-1',
292
+ settings: { searchValue: 'stale' },
293
+ updatedAt: SERVER_UPDATED_AT_MS - 60_000,
294
+ })
295
+
296
+ const { searchChanges, queryClient } = renderTable(
297
+ buildIndexResponse([buildPerspective('persp-1', 'fresh')]),
298
+ )
299
+ expect(searchChanges[searchChanges.length - 1]).toBe('fresh')
300
+ const callsAfterReconcile = searchChanges.length
301
+
302
+ act(() => {
303
+ queryClient.setQueryData(
304
+ ['table-perspectives', TABLE_ID],
305
+ buildIndexResponse([buildPerspective('persp-1', 'refetched', '2027-01-01T00:00:00.000Z')]),
306
+ )
307
+ })
308
+
309
+ expect(searchChanges).toHaveLength(callsAfterReconcile)
310
+ expect(searchChanges).not.toContain('refetched')
311
+ })
312
+ })
@@ -0,0 +1,252 @@
1
+ /** @jest-environment jsdom */
2
+ import * as React from 'react'
3
+ import { render, screen, fireEvent, act } from '@testing-library/react'
4
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
5
+ import { PerspectiveSidebar, type PerspectiveSidebarProps } from '../PerspectiveSidebar'
6
+ import type { PerspectiveDto, RolePerspectiveDto } from '@open-mercato/shared/modules/perspectives/types'
7
+
8
+ const mockFlash = jest.fn()
9
+ jest.mock('../FlashMessages', () => ({ flash: (...args: unknown[]) => mockFlash(...args) }))
10
+
11
+ // The column chooser owns drag-and-drop sensors jsdom cannot exercise; the stub
12
+ // keeps this suite on what is under test — how a column toggle is routed into
13
+ // autosave — while still going through the real prop wiring.
14
+ jest.mock('../columns/ColumnChooserPanel', () => ({
15
+ ColumnChooserSection: (props: { onToggleColumn: (key: string) => void }) => (
16
+ <button type="button" data-testid="toggle-column" onClick={() => props.onToggleColumn('email')} />
17
+ ),
18
+ }))
19
+
20
+ const PERSONAL_VIEW: PerspectiveDto = {
21
+ id: 'persp-1',
22
+ name: 'My view',
23
+ tableId: 'test-table',
24
+ settings: { searchValue: 'acme' },
25
+ isDefault: false,
26
+ createdAt: 'now',
27
+ updatedAt: '2026-08-06T00:00:00.000Z',
28
+ }
29
+
30
+ const ROLE_VIEW: RolePerspectiveDto = {
31
+ id: 'role-1',
32
+ name: 'Team view',
33
+ tableId: 'test-table',
34
+ settings: { searchValue: 'acme' },
35
+ isDefault: true,
36
+ createdAt: 'now',
37
+ updatedAt: '2026-08-06T00:00:00.000Z',
38
+ roleId: 'role-sales',
39
+ roleName: 'Sales',
40
+ tenantId: null,
41
+ organizationId: null,
42
+ }
43
+
44
+ function renderSidebar(overrides: Partial<PerspectiveSidebarProps> = {}) {
45
+ const onSave = jest.fn(async () => {})
46
+ const props: PerspectiveSidebarProps = {
47
+ open: true,
48
+ onOpenChange: () => {},
49
+ loading: false,
50
+ perspectives: [PERSONAL_VIEW],
51
+ rolePerspectives: [ROLE_VIEW],
52
+ roles: [],
53
+ activePerspectiveId: null,
54
+ onActivatePerspective: () => {},
55
+ onDeletePerspective: async () => {},
56
+ onClearRole: async () => {},
57
+ onSave,
58
+ canApplyToRoles: false,
59
+ availableColumns: [{ key: 'email', label: 'Email' }],
60
+ visibleColumnKeys: ['email'],
61
+ columnOrder: ['email'],
62
+ onToggleColumn: () => {},
63
+ onReorderColumns: () => {},
64
+ saving: false,
65
+ deletingIds: [],
66
+ roleClearingIds: [],
67
+ ...overrides,
68
+ }
69
+ const utils = render(
70
+ <I18nProvider locale="en" dict={{}}>
71
+ <PerspectiveSidebar {...props} />
72
+ </I18nProvider>,
73
+ )
74
+ const rerenderWith = (next: Partial<PerspectiveSidebarProps>) => {
75
+ utils.rerender(
76
+ <I18nProvider locale="en" dict={{}}>
77
+ <PerspectiveSidebar {...props} {...next} />
78
+ </I18nProvider>,
79
+ )
80
+ }
81
+ return { ...utils, onSave, rerenderWith }
82
+ }
83
+
84
+ function toggleColumn() {
85
+ fireEvent.click(screen.getByTestId('toggle-column'))
86
+ }
87
+
88
+ async function flushAutosave() {
89
+ await act(async () => {
90
+ jest.advanceTimersByTime(500)
91
+ })
92
+ }
93
+
94
+ describe('PerspectiveSidebar autosave with an active role perspective (#5113)', () => {
95
+ beforeEach(() => {
96
+ mockFlash.mockClear()
97
+ jest.useFakeTimers()
98
+ })
99
+
100
+ afterEach(() => {
101
+ jest.runOnlyPendingTimers()
102
+ jest.useRealTimers()
103
+ })
104
+
105
+ it('warns instead of silently discarding a column change on a shared view', async () => {
106
+ const { onSave } = renderSidebar({ activePerspectiveId: ROLE_VIEW.id })
107
+
108
+ toggleColumn()
109
+ await flushAutosave()
110
+
111
+ expect(onSave).not.toHaveBeenCalled()
112
+ expect(mockFlash).toHaveBeenCalledTimes(1)
113
+ expect(mockFlash).toHaveBeenCalledWith(
114
+ expect.stringContaining('Shared views do not save automatically'),
115
+ 'warning',
116
+ )
117
+ })
118
+
119
+ it('debounces the warning across a burst of toggles', async () => {
120
+ renderSidebar({ activePerspectiveId: ROLE_VIEW.id })
121
+
122
+ toggleColumn()
123
+ toggleColumn()
124
+ await flushAutosave()
125
+
126
+ expect(mockFlash).toHaveBeenCalledTimes(1)
127
+ })
128
+
129
+ it('still autosaves into an active personal view', async () => {
130
+ const { onSave } = renderSidebar({ activePerspectiveId: PERSONAL_VIEW.id })
131
+
132
+ toggleColumn()
133
+ await flushAutosave()
134
+
135
+ expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
136
+ perspectiveId: PERSONAL_VIEW.id,
137
+ name: PERSONAL_VIEW.name,
138
+ }))
139
+ })
140
+
141
+ it('drops a pending warning when the user switches to a personal view first', async () => {
142
+ // The warning names the shared view the edit was made on. Firing it after the
143
+ // user has already moved to a personal view would warn about a view they are
144
+ // no longer editing, which is its own kind of misinformation.
145
+ const { rerenderWith } = renderSidebar({ activePerspectiveId: ROLE_VIEW.id })
146
+
147
+ toggleColumn()
148
+ rerenderWith({ activePerspectiveId: PERSONAL_VIEW.id })
149
+ await flushAutosave()
150
+
151
+ expect(mockFlash).not.toHaveBeenCalled()
152
+ })
153
+
154
+ it('stays silent when no view is active', async () => {
155
+ const { onSave } = renderSidebar({ activePerspectiveId: null })
156
+
157
+ toggleColumn()
158
+ await flushAutosave()
159
+
160
+ expect(onSave).not.toHaveBeenCalled()
161
+ expect(mockFlash).not.toHaveBeenCalled()
162
+ })
163
+
164
+ it('raises the shared-view warning immediately when the panel closes inside the debounce window', async () => {
165
+ // Closing the panel before the 400ms debounce fires must not drop the
166
+ // warning in silence — that is the same discarded-edit bug (#5113) the
167
+ // warning itself exists to surface.
168
+ const { rerenderWith } = renderSidebar({ activePerspectiveId: ROLE_VIEW.id })
169
+
170
+ toggleColumn()
171
+ await act(async () => {
172
+ rerenderWith({ open: false })
173
+ jest.advanceTimersByTime(100)
174
+ })
175
+
176
+ expect(mockFlash).toHaveBeenCalledTimes(1)
177
+ expect(mockFlash).toHaveBeenCalledWith(
178
+ expect.stringContaining('Shared views do not save automatically'),
179
+ 'warning',
180
+ )
181
+ })
182
+
183
+ it('saves a personal view immediately when the panel closes inside the debounce window', async () => {
184
+ const { onSave, rerenderWith } = renderSidebar({ activePerspectiveId: PERSONAL_VIEW.id })
185
+
186
+ toggleColumn()
187
+ await act(async () => {
188
+ rerenderWith({ open: false })
189
+ jest.advanceTimersByTime(100)
190
+ })
191
+
192
+ expect(onSave).toHaveBeenCalledWith(expect.objectContaining({
193
+ perspectiveId: PERSONAL_VIEW.id,
194
+ name: PERSONAL_VIEW.name,
195
+ }))
196
+ })
197
+ })
198
+
199
+ describe('PerspectiveSidebar new-view mode affordances (#5113)', () => {
200
+ beforeEach(() => {
201
+ mockFlash.mockClear()
202
+ })
203
+
204
+ it('explains why the create control is inert while the name is blank', () => {
205
+ renderSidebar({ activePerspectiveId: ROLE_VIEW.id })
206
+
207
+ fireEvent.click(screen.getByRole('button', { name: 'New' }))
208
+
209
+ const confirm = screen.getByRole('button', { name: 'Create view' })
210
+ expect(confirm).toBeDisabled()
211
+ expect(confirm).toHaveAttribute('title', 'Enter a name to create the view')
212
+ expect(screen.getByText('Enter a name to create the view')).toBeInTheDocument()
213
+ })
214
+
215
+ it('keeps the hint out of the box that centres the confirm/cancel buttons', () => {
216
+ // The buttons are `top-1/2 -translate-y-1/2` against their positioning
217
+ // context, so a hint sharing that box grows it and drags them out of the
218
+ // input. jsdom has no layout engine, so assert the structure that causes it.
219
+ renderSidebar({ activePerspectiveId: ROLE_VIEW.id })
220
+
221
+ fireEvent.click(screen.getByRole('button', { name: 'New' }))
222
+
223
+ const confirm = screen.getByRole('button', { name: 'Create view' })
224
+ const positioningContext = confirm.closest('.relative') as HTMLElement
225
+ const hint = screen.getByText('Enter a name to create the view')
226
+ expect(positioningContext).not.toBeNull()
227
+ expect(positioningContext.contains(hint)).toBe(false)
228
+ })
229
+
230
+ it('keeps the hint mounted once a name is typed, so nothing reflows mid-edit', () => {
231
+ renderSidebar({ activePerspectiveId: ROLE_VIEW.id })
232
+
233
+ fireEvent.click(screen.getByRole('button', { name: 'New' }))
234
+ fireEvent.change(screen.getByPlaceholderText('View name...'), { target: { value: 'Q1' } })
235
+
236
+ const hint = screen.getByText('Enter a name to create the view')
237
+ expect(hint).toBeInTheDocument()
238
+ expect(hint.className).toContain('invisible')
239
+ expect(screen.getByRole('button', { name: 'Create view' })).toBeEnabled()
240
+ })
241
+
242
+ it('drops the active-view highlight while the new-view form is open', () => {
243
+ renderSidebar({ activePerspectiveId: ROLE_VIEW.id })
244
+
245
+ const chip = screen.getByRole('button', { name: ROLE_VIEW.name }).parentElement as HTMLElement
246
+ expect(chip.className).toContain('border-brand-violet/30')
247
+
248
+ fireEvent.click(screen.getByRole('button', { name: 'New' }))
249
+
250
+ expect(chip.className).not.toContain('border-brand-violet/30')
251
+ })
252
+ })
@@ -15,40 +15,54 @@ type Props = {
15
15
  export function NewViewForm({ name, onNameChange, onSubmit, onCancel, saving }: Props) {
16
16
  const t = useT()
17
17
  const trimmed = name.trim()
18
+ const hintId = React.useId()
19
+ // A greyed-out check that does nothing when clicked is the dead end reported
20
+ // in #5113 — always render the reason the control is inert.
21
+ const nameRequiredHint = t('ui.perspectives.form.nameRequired', 'Enter a name to create the view')
18
22
  return (
19
- <div className="relative">
20
- <input
21
- value={name}
22
- onChange={(e) => onNameChange(e.target.value)}
23
- placeholder={t('ui.perspectives.form.namePlaceholder', 'View name...')}
24
- autoFocus
25
- className="w-full h-9 rounded border border-primary pl-2 pr-16 text-sm"
26
- onKeyDown={(e) => {
27
- if (e.key === 'Enter' && trimmed) onSubmit()
28
- if (e.key === 'Escape') onCancel()
29
- }}
30
- />
31
- <div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5">
32
- <IconButton
33
- type="button"
34
- variant="ghost"
35
- size="xs"
36
- onClick={() => { if (trimmed) onSubmit() }}
37
- disabled={!trimmed || saving}
38
- aria-label={t('ui.perspectives.form.confirmCreate', 'Create view')}
39
- >
40
- <Check className={`size-4 ${trimmed ? 'text-brand-violet' : 'text-muted-foreground opacity-50'}`} />
41
- </IconButton>
42
- <IconButton
43
- type="button"
44
- variant="ghost"
45
- size="xs"
46
- onClick={onCancel}
47
- aria-label={t('ui.perspectives.form.cancelCreate', 'Cancel')}
48
- >
49
- <X className="size-4 text-muted-foreground hover:text-destructive" />
50
- </IconButton>
23
+ <div>
24
+ {/* The overlay buttons are centred against this wrapper, so it must contain
25
+ nothing but the input — a sibling hint here would drag them off-centre
26
+ and make them jump on the keystroke that hides it. */}
27
+ <div className="relative">
28
+ <input
29
+ value={name}
30
+ onChange={(e) => onNameChange(e.target.value)}
31
+ placeholder={t('ui.perspectives.form.namePlaceholder', 'View name...')}
32
+ autoFocus
33
+ aria-describedby={trimmed ? undefined : hintId}
34
+ className="w-full h-9 rounded border border-primary pl-2 pr-16 text-sm"
35
+ onKeyDown={(e) => {
36
+ if (e.key === 'Enter' && trimmed) onSubmit()
37
+ if (e.key === 'Escape') onCancel()
38
+ }}
39
+ />
40
+ <div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-0.5">
41
+ <IconButton
42
+ type="button"
43
+ variant="ghost"
44
+ size="xs"
45
+ onClick={() => { if (trimmed) onSubmit() }}
46
+ disabled={!trimmed || saving}
47
+ title={trimmed ? undefined : nameRequiredHint}
48
+ aria-label={t('ui.perspectives.form.confirmCreate', 'Create view')}
49
+ >
50
+ <Check className={`size-4 ${trimmed ? 'text-brand-violet' : 'text-muted-foreground opacity-50'}`} />
51
+ </IconButton>
52
+ <IconButton
53
+ type="button"
54
+ variant="ghost"
55
+ size="xs"
56
+ onClick={onCancel}
57
+ aria-label={t('ui.perspectives.form.cancelCreate', 'Cancel')}
58
+ >
59
+ <X className="size-4 text-muted-foreground hover:text-destructive" />
60
+ </IconButton>
61
+ </div>
51
62
  </div>
63
+ {/* Kept mounted and merely hidden so the first keystroke does not reflow the
64
+ sidebar, and so the aria-describedby target never disappears mid-edit. */}
65
+ <p id={hintId} className={`mt-1 text-xs text-muted-foreground ${trimmed ? 'invisible' : ''}`}>{nameRequiredHint}</p>
52
66
  </div>
53
67
  )
54
68
  }