|
25 | 25 | * view it does not hold yet. |
26 | 26 | * - A refused read (no grant on the object) draws neither the section nor |
27 | 27 | * Save as my view, and the list is unaffected. |
| 28 | + * - objectstack#30034 — a personal view opened by its address is not seeded |
| 29 | + * from, and its address bar never carries, the default view's stored list |
| 30 | + * state, whichever of the two reads (the reader's own views, the list |
| 31 | + * preferences) lands first; a link's own `uf_*` params still open it. |
28 | 32 | */ |
29 | 33 |
|
30 | 34 | import * as React from 'react'; |
31 | 35 | import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; |
32 | 36 | import { render, cleanup, screen, waitFor, within, fireEvent } from '@testing-library/react'; |
33 | | -import { MemoryRouter, Routes, Route } from 'react-router-dom'; |
| 37 | +import { |
| 38 | + MemoryRouter, |
| 39 | + Routes, |
| 40 | + Route, |
| 41 | + unstable_HistoryRouter as HistoryRouter, |
| 42 | + UNSAFE_createMemoryHistory as createMemoryHistory, |
| 43 | +} from 'react-router-dom'; |
34 | 44 | import { createI18n, I18nProvider } from '@object-ui/i18n'; |
35 | 45 | import { ListViewSchema } from '@objectstack/spec/ui'; |
36 | 46 |
|
@@ -80,6 +90,9 @@ vi.mock('./RecordDetailView', () => ({ RecordDetailView: () => null })); |
80 | 90 |
|
81 | 91 | import { ObjectView } from './ObjectView'; |
82 | 92 | import { ExpressionProvider } from '../providers/ExpressionProvider'; |
| 93 | +import { UserStateAdaptersProvider, useAttachListStateFamily } from '../context/UserStateAdapters'; |
| 94 | +import { ListPreferencesProvider, listPreferenceKey } from '../context/ListPreferencesProvider'; |
| 95 | +import { applyListStateParams } from './userFilterUrlState'; |
83 | 96 |
|
84 | 97 | /** Every request the page sends through `fetch`: the owner's door among them. */ |
85 | 98 | const fetchCalls: Array<{ url: string; method: string }> = []; |
@@ -378,3 +391,155 @@ describe('objectstack#29635 — My views in the list view switcher', () => { |
378 | 391 | expect(within(menu).getByTestId('list-view-switcher-save-mine')).toBeInTheDocument(); |
379 | 392 | }); |
380 | 393 | }); |
| 394 | + |
| 395 | +/** |
| 396 | + * objectstack#30034 — the two reads a personal view opened by its address |
| 397 | + * waits on: the reader's own views (`sys_personal_view`) and the list |
| 398 | + * preferences (the attached family's first load). Each lands when the case |
| 399 | + * says, and the address bar is the page's history, read where the router |
| 400 | + * wrote it. |
| 401 | + * |
| 402 | + * The race the card measured: the location the router hands the page moves in |
| 403 | + * a transition, so a read landing between the page's URL write and its commit |
| 404 | + * renders against the address as it was before that write. `landPersonalOn` |
| 405 | + * `'firstReplace'` lands the personal views read exactly there. |
| 406 | + */ |
| 407 | +describe('objectstack#30034 — a personal view opened by its address never carries the default view\'s list state', () => { |
| 408 | + /** The reader's stored list state for the DEFAULT view, `v1`: a filter and a sort. */ |
| 409 | + const DEFAULT_VIEW_STATE = { |
| 410 | + perView: { |
| 411 | + v1: { |
| 412 | + sort: [{ field: 'status', order: 'desc' }], |
| 413 | + filters: { id: 'root', logic: 'and', conditions: [{ id: 'c1', field: 'city', operator: 'equals', value: 'Rome' }] }, |
| 414 | + }, |
| 415 | + }, |
| 416 | + }; |
| 417 | + |
| 418 | + function deferred<T>() { |
| 419 | + let resolve: (value: T) => void = () => {}; |
| 420 | + const promise = new Promise<T>((r) => { resolve = r; }); |
| 421 | + return { promise, resolve }; |
| 422 | + } |
| 423 | + |
| 424 | + function AttachFamily({ family }: { family: unknown }) { |
| 425 | + const attach = useAttachListStateFamily(); |
| 426 | + React.useEffect(() => { |
| 427 | + attach(family as never); |
| 428 | + return () => attach(null); |
| 429 | + }, [attach, family]); |
| 430 | + return null; |
| 431 | + } |
| 432 | + |
| 433 | + function mountPersonal( |
| 434 | + entry: string, |
| 435 | + { landPersonalOn }: { landPersonalOn: 'firstReplace' | 'beforePreferences' }, |
| 436 | + ) { |
| 437 | + const objects = [LEAD]; |
| 438 | + const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); |
| 439 | + const personalRead = deferred<{ data: unknown[]; total: number }>(); |
| 440 | + const preferencesRead = deferred<Record<string, unknown>>(); |
| 441 | + const landPersonal = () => personalRead.resolve({ data: [OWN], total: 1 }); |
| 442 | + const landPreferences = () => preferencesRead.resolve({ [listPreferenceKey('crm_lead')]: structuredClone(DEFAULT_VIEW_STATE) }); |
| 443 | + const family = { prefix: 'ui.list.', load: vi.fn(() => preferencesRead.promise), save: vi.fn(async () => {}) }; |
| 444 | + const history = createMemoryHistory({ initialEntries: [entry], v5Compat: true }); |
| 445 | + const replace = history.replace.bind(history); |
| 446 | + let replaced = 0; |
| 447 | + history.replace = (...args: Parameters<typeof history.replace>) => { |
| 448 | + replace(...args); |
| 449 | + replaced += 1; |
| 450 | + if (replaced === 1 && landPersonalOn === 'firstReplace') landPersonal(); |
| 451 | + }; |
| 452 | + const dataSource = { |
| 453 | + find: vi.fn(async (resource: string, _params?: unknown) => (resource === 'sys_personal_view' ? personalRead.promise : { data: ROWS, total: ROWS.length })), |
| 454 | + findOne: vi.fn(async () => null), |
| 455 | + getObjectSchema: vi.fn(async (name: string) => objects.find((o) => o.name === name) ?? null), |
| 456 | + create: vi.fn(async () => ({})), |
| 457 | + update: vi.fn(async () => ({})), |
| 458 | + delete: vi.fn(async () => ({})), |
| 459 | + }; |
| 460 | + render( |
| 461 | + <UserStateAdaptersProvider> |
| 462 | + <AttachFamily family={family} /> |
| 463 | + <ListPreferencesProvider> |
| 464 | + <I18nProvider instance={i18n}> |
| 465 | + <ExpressionProvider user={{ id: 'u1', name: 'Sales Rep' }}> |
| 466 | + <HistoryRouter history={history as never}> |
| 467 | + <Routes> |
| 468 | + <Route |
| 469 | + path="/apps/:appName/:objectName/view/:viewId" |
| 470 | + element={<ObjectView dataSource={dataSource as never} objects={objects} onEdit={() => {}} />} |
| 471 | + /> |
| 472 | + </Routes> |
| 473 | + </HistoryRouter> |
| 474 | + </ExpressionProvider> |
| 475 | + </I18nProvider> |
| 476 | + </ListPreferencesProvider> |
| 477 | + </UserStateAdaptersProvider>, |
| 478 | + ); |
| 479 | + return { |
| 480 | + dataSource, |
| 481 | + landPersonal, |
| 482 | + landPreferences, |
| 483 | + /** The `uf_*` params of the address bar, by name. */ |
| 484 | + ufParams: () => Object.fromEntries([...new URLSearchParams(history.location.search)].filter(([key]) => key.startsWith('uf_'))), |
| 485 | + }; |
| 486 | + } |
| 487 | + |
| 488 | + const settle = () => new Promise((r) => setTimeout(r, 300)); |
| 489 | + |
| 490 | + /** The `crm_lead` reads the list sent, in order (the footer's `$top: 0` count read is not one). */ |
| 491 | + const listReads = (dataSource: { find: { mock: { calls: unknown[][] } } }) => |
| 492 | + dataSource.find.mock.calls |
| 493 | + .filter(([resource, params]) => resource === 'crm_lead' && (params as { $top?: number } | undefined)?.$top !== 0) |
| 494 | + .map(([, params]) => params as Record<string, unknown>); |
| 495 | + |
| 496 | + it('the personal views read landing AFTER the preferences read: the address bar ends clean, holding only the personal view\'s own state', async () => { |
| 497 | + const page = mountPersonal('/apps/crm/crm_lead/view/pv_mine', { landPersonalOn: 'firstReplace' }); |
| 498 | + // The preferences land first; the personal views read is still out. |
| 499 | + await waitFor(() => expect(page.dataSource.find.mock.calls.some(([r]) => r === 'sys_personal_view')).toBe(true)); |
| 500 | + page.landPreferences(); |
| 501 | + await settle(); |
| 502 | + // Nothing wrote the address while the requested view was unresolved, so |
| 503 | + // the personal views read is still out: land it now. |
| 504 | + page.landPersonal(); |
| 505 | + await listToolbar(); |
| 506 | + expect(switcher()).toHaveTextContent('Paris leads'); |
| 507 | + await settle(); |
| 508 | + expect(page.ufParams()).toEqual({}); |
| 509 | + // The list's query carries the personal view's own filter, and none of the default view's. |
| 510 | + const last = JSON.stringify(listReads(page.dataSource).at(-1)); |
| 511 | + expect(last).toContain('Paris'); |
| 512 | + expect(last).not.toContain('Rome'); |
| 513 | + }); |
| 514 | + |
| 515 | + it('the reverse order — the personal views read landing BEFORE the preferences read: the address bar ends clean too', async () => { |
| 516 | + const page = mountPersonal('/apps/crm/crm_lead/view/pv_mine', { landPersonalOn: 'beforePreferences' }); |
| 517 | + await waitFor(() => expect(page.dataSource.find.mock.calls.some(([r]) => r === 'sys_personal_view')).toBe(true)); |
| 518 | + page.landPersonal(); |
| 519 | + await settle(); |
| 520 | + page.landPreferences(); |
| 521 | + await listToolbar(); |
| 522 | + expect(switcher()).toHaveTextContent('Paris leads'); |
| 523 | + await settle(); |
| 524 | + expect(page.ufParams()).toEqual({}); |
| 525 | + const last = JSON.stringify(listReads(page.dataSource).at(-1)); |
| 526 | + expect(last).toContain('Paris'); |
| 527 | + expect(last).not.toContain('Rome'); |
| 528 | + }); |
| 529 | + |
| 530 | + it('CONTROL: a link carrying `uf_*` params the reader sent opens the personal view with those params applied', async () => { |
| 531 | + const sort = [{ field: 'name', order: 'asc' as const }]; |
| 532 | + const query = applyListStateParams(new URLSearchParams(), { sort }).toString(); |
| 533 | + const page = mountPersonal(`/apps/crm/crm_lead/view/pv_mine?${query}`, { landPersonalOn: 'firstReplace' }); |
| 534 | + await waitFor(() => expect(page.dataSource.find.mock.calls.some(([r]) => r === 'sys_personal_view')).toBe(true)); |
| 535 | + page.landPreferences(); |
| 536 | + await settle(); |
| 537 | + page.landPersonal(); |
| 538 | + await listToolbar(); |
| 539 | + expect(switcher()).toHaveTextContent('Paris leads'); |
| 540 | + await settle(); |
| 541 | + expect(page.ufParams()).toEqual(Object.fromEntries(new URLSearchParams(query))); |
| 542 | + await waitFor(() => expect(listReads(page.dataSource).at(-1)?.$orderby).toEqual(sort)); |
| 543 | + expect(JSON.stringify(listReads(page.dataSource).at(-1))).not.toContain('Rome'); |
| 544 | + }); |
| 545 | +}); |
0 commit comments