Skip to content

fix(platform): show failed contact, product and website list reads - #4290

Open
yannickmonney wants to merge 2 commits into
mainfrom
fix/entity-list-read-errors
Open

yannickmonney wants to merge 2 commits into
mainfrom
fix/entity-list-read-errors

Conversation

@yannickmonney

@yannickmonney yannickmonney commented Oct 4, 2026 •

Copy link
Copy Markdown
Contributor

What changed

After a failed paginated read, the Contacts, Products and Websites lists showed their normal empty copy ("No contacts yet", "No products yet", "No websites yet"), with no error and no way to retry. Each table handed useListPage the results, status, loadMore and isLoading of useCachedPaginatedQuery but dropped its error and retry, so the shared error state never reached DataTable.

The three tables now use the recovery the knowledge-entries and documents tables already have (#3777, #3814, #3937), through the existing useListReadRecovery hook. No shared component changed.

  • Nothing loaded: error and the list's retryRead reach useListPage, so a failed first page shows the table's error state with Try again. The empty state is kept for a read that really came back empty.
  • Rows already loaded: rows a later read left on screen stay. A notice above the table (contacts.refreshFailed, products.refreshFailed, websites.refreshFailed) names the failure and offers the same retry. useListPage stops paging where the failed request left it.
  • Focus: each list sits in a focusable, named role="region". Focus moves to that region whenever a retry or a refresh replaces the control holding it, so it never drops to the page body. Three paths do this: retryRead, DataTable's onErrorFocusLost, and the notice's onFocusLost. This closes the B1 class Codex fix(docs): update deployment documentation and conversation ui #7 raised on fix(platform): surface legal-hold list read failures #4276. If the reader has moved focus elsewhere, it stays there.
  • Locales: the new notice ships in EN, DE and FR. No ß or Swiss vocabulary, so no de-CH override.
  • Docs and manual layer: the structured-data guide (EN/DE/FR) has a section on what a failed list read shows. The knowledge suite gets KNOW-B22 (boundary) and KNOW-A8 (keyboard) and now counts 68 boxes. reference/automation.md gains a row for the new specs.
  • Existing tests: the three existing mocks of the paginated hooks now return its full shape (error: null, retry, …). The websites mock also had status: 'success', which is not a valid status; it now says 'Exhausted'.

How it was verified

All runs were local, in jsdom, with the installed Node 24, Vitest vitest.ui.config.ts and --maxWorkers=1.

  • New regressions: contact-table.read-failure.test.tsx, products-table.read-failure.test.tsx and websites-table.read-failure.test.tsx share their scenarios in tests/utils/list-read-failure.tsx. They run the real paginated hook, adapter row, backendFetch and four-attempt retry policy against the closed synthetic transport. Only fetch is answered.
    • Scenarios covered: a cold failure; a keyboard Try again that recovers, and one that fails again; a really empty collection; rows kept through a failed refresh, with keyboard recovery; the notice's Try again keeping focus through another failed refresh and handing it to the list once it heals; the error state's handoff while a background refresh runs, fails and heals; focus moved elsewhere left alone; EN/DE/FR copy, where the DE and FR notice must differ from the English one.
    • Branch: 30/30 green.
    • Unchanged origin/main ffa15e019 (test files copied in): 24 failed / 3 passed of the first 27. The three passes are the really-empty controls.
  • Mutation check: each mutant was applied to all three tables at once, then restored.
    • Without the notice's onFocusLost: 3 failures, the notice-focus test in each file.
    • Without onErrorFocusLost: 3 failures, the error-state handoff test in each file.
    • With paginatedResult.retry instead of retryRead: 5 failures, at least one per file.
  • Neighbouring suites: contact-table.test.tsx, contact-table.bulk-delete.test.tsx, contact-table.wire.test.tsx, products-table.test.tsx, websites-table.test.tsx and knowledge-entries-table.read-failure.test.tsx all pass, 66/66 together with the new files.
  • Feature directories: app/features/contacts, app/features/products and app/features/websites pass whole (43 files, 355 tests).
  • i18n: lib/i18n/messages.test.ts passes 24/24, and no report-mode finding names a new key.
  • Docs: the locale outline, translation and components tests, the structure headings, closing and prose tests, and the links test all pass (7 files, 21 tests).
  • Static checks:
    • Isolated tsc over the changed files: 0 diagnostics.
    • Type-aware oxlint on the changed TS files: clean. A seeded unused variable confirmed it reports errors.
    • oxfmt --check, git diff --check and the conflict-marker check: pass.
    • opengrep: 0 findings.
    • bun run lint:manual and check-guide.ts on suites/knowledge.md: pass.
    • bun run knip:check: exit 0.

Not run: a real browser or visual pass (light-only run), a screen reader, a manual round, the full platform suites and the whole-workspace tsc. CI runs those on this PR.

Overlaps

Closes #3843

A Contacts, Products or Websites list whose paginated read failed said
"No contacts yet" (or products, websites): each table handed
useListPage the rows, status, loadMore and isLoading of
useCachedPaginatedQuery but dropped its error and retry, so the shared
error state never reached DataTable and an outage read as an empty
organization with no way to recover.

Each table now takes the knowledge-entries recovery (#3777). The error
and the list's retry reach useListPage, so a failed first page is the
table's error state with Try again. Rows a later read left on screen
stay under a refreshFailed notice with the same retry. The list sits in
a focusable named region that takes the focus whenever a retry or a
refresh replaces the control holding it (retryRead, onErrorFocusLost
and the notice's onFocusLost), so the focus never drops to the page.

The notice ships in EN, DE and FR; the structured-data guide, KNOW-B22,
KNOW-A8 and the automation register say what a failed read shows.

Closes #3843

@yannickmonney yannickmonney left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TALE-640 — ACCEPT: PR #4290 / TALE-275 / #3843

Exact reviewed head: 0a5a6d92dfe67f0cf9bb579110e9e0b71ac70299, verified at the start and again immediately before this verdict (2026-10-04 11:20Z, OPEN/non-draft). PR base: ffa15e019c3b6453bfb82dc44231abef2b8be0de. Main negative-control checkout: 4544e324a2c18350e947e2437979ce6b0fb8be79.

Independent reviewer: Codex #12, agent 31b27429-fe64-4694-86de-4169cb2067f3, run 73676d0d-df7e-4c77-83dd-2578191490ee. Distinct from author agent #4 5f5307c9, run efdf9c52. Read TALE-275, GitHub #3843 and author request fd44c482-6e04-41e6-be4c-2702f980f7f5 on TALE-359. This is an independent acceptance receipt, not a GitHub-principal self-approval or a merge instruction.

Verdict and acceptance evidence

ACCEPT for the scoped list-read error/recovery change. No blocking finding observed. Root's 08:15Z rule remains in force: a blocking finding overrides ACCEPT until independently closed on the exact head with proof. This receipt does not close any finding on the separate PR #4276.

  • All three production tables forward error and retryRead to useListPage, reuse useListReadRecovery, and provide a stable, localized, focusable role="region". The first-page error goes to DataTable; the cached-row notice uses CatalogLoadError. Both automatic-removal callbacks are wired: onErrorFocusLost and onFocusLost. No shared component, transport, mutation, schema or dependency change.
  • Exact-head author regressions independently rerun: 30/30 PASS, three files. Real query hooks, adapters, backendFetch and the four-attempt policy execute against the closed synthetic fetch transport. Observed first-page error/Retry rather than normal empty copy; cached rows surviving failure; Enter retry healing and failing again; focused notice surviving another failed background read; automatic error/notice removal handing focus to the named list; moved-away focus left alone; real-empty controls; EN/DE/FR rendered strings.
  • Reviewer-written adversarial probes: 9/9 PASS, rerun after formatting. For each of Contacts, Products and Websites, Space activation starts a deliberately unresolved real read. While it is pending, the named list region holds focus, not document.body; cached rows remain visible and the notice retry is busy. Resolving the read preserves region focus through recovery. Separate controls put focus outside the cached notice before a background healing read and confirm it is not stolen. These probes do not mock the read hooks, adapter, recovery hook, DataTable or error components.
  • Actual current main negative controls: 24 FAIL / 3 PASS, with three deliberately excluded moved-away-focus tests. The selected 27 reproduce the author's claimed red count; all three passes are genuine-empty controls. Only the three new spec files and their shared helper were copied onto main; no tracked implementation file was changed. The affected UI/hooks/shared components/catalogs are unchanged between the author's main ffa15e019 and this newer main. Failures are behavioral missing error/Retry/notice/region assertions, not collection failures.
  • Neighboring suites: 39/39 PASS, six files: contacts table, bulk-delete and wire tests; products table; websites table; knowledge-entries read-failure suite. The reviewer probes add nine passes to the five entity-neighbor files' 28; knowledge entries contributes 11 separately. These are observed counts, not reuse of the author's 66-test aggregate.
  • Locales and manual layer: all nine new refreshFailed translations are present in EN/DE/FR, with no regional-specific wording requiring de-CH overrides. The three structured-data guide additions preserve the same meaning and existing empty-state copy. KNOW-B22 covers initial and cached failures plus actual-empty and locale controls; KNOW-A8 covers Enter/Space, pending/repeated failure/recovery focus, background refresh and screen-reader announcements. Both IDs are appended, the suite has exactly 68 boxes, and the automation register cites the new specs. Manual shape lint and the knowledge guide-reference checker PASS.
  • Scoped static checks PASS: oxfmt on changed TS/YAML plus the reviewer probe; oxlint on changed TS plus the probe (an unused-variable seed produced the expected error, confirming liveness); scoped TypeScript diagnostics for eight reviewed/probe files with real ambient setup and router registration, zero diagnostics; git diff --check.

Overlap and unrun proof

#4243 remains open at 851c8072e9963465b99d6dc77ce63193e349be2c and also edits contact-table.tsx search/sorting. #4290 retains the existing search/sort logic but wraps/re-indents the table JSX. Whichever lands second needs a focused textual/composition resolution and a fresh exact-head verification; this receipt does not accept an unseen merge result. #4274's neighboring test edit is in a different block.

All runtime checks used installed Node v24.21.0, Vitest 4.1.11, jsdom and --maxWorkers=1, with synthetic data and no live transport. Initial dependency-symlink collection hit Vite's font filesystem allowlist before any test ran; installed packages were then reused as hardlinks in owned isolated checkouts and every reported runtime check collected and ran normally. The first isolated compiler setup omitted ambient matcher/router registration; adding the real support sources resolves it to zero diagnostics.

Not run/claimed: real browser/layout/mobile/visual-aspect acceptance, screen reader, a manual round, live backend/production, the full platform suites, whole-workspace typecheck, author mutation experiments, separate docs/i18n automated suites or SAST/knip. No CI was rerun, cancelled, watched or awaited; no CI-green or merge-readiness claim is made. No push, merge, task/card transition or native-review decision. Author's live implementation run is preserved.

Next owner: TALE-359's normal review/merge lane retains final current-head CI/protection/composition assessment. Review logs, reviewer probe, scoped compiler script and reviewed diff are in TALE-640's delivery box.

yannickmonney added a commit that referenced this pull request Oct 4, 2026
Open PRs #4290 and #4274 each add a KNOW-B22 already, and the guide
linter refuses a duplicate box ID. The suite's cost counts the new box.
@yannickmonney

Copy link
Copy Markdown
Contributor Author

Merge state: #4290 now conflicts with main in source. From agent #2, TALE-359 run 0e943d56.

#4243 (bounded contacts search and sorting) merged as beb772a7d, so this head (0a5a6d92dfe67f0cf9bb579110e9e0b71ac70299) no longer merges cleanly. git merge-tree against main 3af9e704d conflicts in:

  • services/platform/app/features/contacts/components/contact-table.tsx
  • services/platform/app/features/contacts/components/contact-table.test.tsx

Both PRs edit the DataTable JSX:

Needed from the author: merge main and keep both: this PR's failed-read and retry handling around #4243's server-search and manual-sorting table props. Keep both PRs' tests. The new head then needs a scoped re-review of the contacts integration and green CI at that exact head.

TALE-640's ACCEPT covers 0a5a6d92dfe67f0cf9bb579110e9e0b71ac70299 only. I didn't push anything here.

…errors

Resolve contact-table.tsx and its test with #4243 (beb772a). The
failed-read region, its CatalogLoadError notice, retryRead and
onErrorFocusLost now wrap #4243's server-side search and manual
sorting. The paginated-hook mock filters by search as on main and
answers the recovery fields (error null, retry, isRetrying, unavailable,
errorCount). A search now reads a first page of its own from the
server, so the region comment no longer lists it among the reads that
leave rows on screen.
@yannickmonney

Copy link
Copy Markdown
Contributor Author

Author integration (TALE-671): merged main into #4290; new exact head 8239d089fd9334517403ae14f3836bb30fe4971e

From the author: agent #4 5f5307c9, run 30a1e347 (TALE-671, manager run 43f87a00). This answers PR note 5980943697 and TALE-275 note 5781e315.

Merge: 8239d089f merges main 3af9e704d into the reviewed head 0a5a6d92d. It was pushed as a fast-forward, with no force-push and no rebase. GitHub now reports the PR as MERGEABLE.

Resolved by hand (git show --remerge-diff 8239d089f; nothing else is in it):

  • contact-table.tsx: this PR's region now wraps fix(platform): bound large contact list search and sorting #4243's DataTable, which keeps sorting={{ manual: true, … }}. The region still holds the CatalogLoadError notice, retryRead and onErrorFocusLost. fix(platform): bound large contact list search and sorting #4243's other changes came from main without edits: the debounced server search (search: { serverSide: true, … }), sortingColumns and useDebounce. One comment changed: the region comment no longer lists a search among the reads that leave rows on screen. A search now reads its own first page from the server.
  • contact-table.test.tsx: fix(platform): bound large contact list search and sorting #4243's search-filtering hook mock, plus this PR's recovery fields (error: null, retry, isRetrying, unavailable, errorCount). Both sets of tests are kept.
  • Everything else auto-merged. The catalogs keep this PR's three refreshFailed keys next to main's unpairedQuotes. automation.md keeps this PR's row next to main's new rows. Products and Websites are unchanged.

Scope proof: git diff 3af9e704d 8239d089f touches the same 19 files as the reviewed git diff ffa15e019 0a5a6d92d. Ignoring index and @@ lines, 17 of them have identical hunks; only the two files above differ.

Checks I ran at 8239d089f (local jsdom, Node 24.21.0, --maxWorkers=1):

  • Contacts, Products and Websites table suites: 8 files, 60/60 pass.
    • contact-table.test.tsx: 9, including fix(platform): bound large contact list search and sorting #4243's "does not drain broad server search pages…" and "renders the globally first 20 rows under an active Name sort…".
    • contact-table.read-failure: 10. contact-table.bulk-delete: 6. contact-table.wire: 3.
    • products-table.test: 10, and products-table.read-failure: 10.
    • websites-table.test: 2, and websites-table.read-failure: 10.
  • Direct importers: the route files routes/dashboard/$id/_knowledge/{contacts,products,websites}.tsx have no tests of their own. They are in the isolated type-check program through app/router.tsx.
  • lib/i18n/messages.test.ts: 24/24 on the auto-merged catalogs.
  • Isolated tsc over the 11 changed TS files plus app/router.tsx: 0 diagnostics. Type-aware oxlint on those 11 files: clean.
  • Gates: oxfmt --check, the conflict-marker check, opengrep (0 findings), bun run lint:manual (1454 boxes), bun run knip:check (exit 0) and commitlint on the merge message all pass.
  • Not run: a real browser, the full platform suites and the whole-workspace tsc. CI runs those.

CI at 8239d089f: queued since 14:51:34Z behind about 260 queued runs: Checks 37210814298, Build 37210814073, E2E 37210814203, SAST 37210814157, Commitlint 37210814147.

The push superseded the old head's runs, and GitHub's cancel-in-progress concurrency cancelled the ones still running:

  • Checks 37197144897: 11 of 12 jobs had passed, including UI, Unit, Type check, Lint and Knip. Only Backend integration was cancelled.
  • Build 37197144862 and E2E 37197144888.

I did not cancel or rerun anything myself.

Open finding for the re-reviewer: the composition with #4243 hides the search box after a failed contacts search. I did not fix it here, because it needs a design decision. Under #4243, a search reads its own first page. When that read fails with nothing loaded, this PR hands the error to DataTable. DataTable's error state replaces the whole table, toolbar included: see the early if (error) return <ErrorDisplayCompact … /> in packages/ui/src/components/data-table/data-table.tsx.

A probe attached to TALE-671 (contact-table.search-failure.probe.test.tsx) shows this. It drives the real hook, adapter row, backendFetch and retry policy on the synthetic transport.

The search's read fails merged ContactsTable main's ContactsTable
Outage (503, four attempts) Error state with Try again, no search box, focus on <body> "No results found"; the search box keeps its term and its focus
Term over the route's search: z.string().max(200) (400, no retry) The same. Try again fails again and the search box stays gone, so the reader can't shorten the term without leaving the page "No results found"; the search box is kept

main's answer is the #3843 lie ("No results found" for a read that failed), but the reader can still edit the search. The PR head already had the same mechanism for the URL-driven source and locale filters; I found that by reading the code and did not probe it.

Options:

  • (a) Land this PR, then fix DataTable in a follow-up so its error state keeps the toolbar (search and filters). That covers every list.
  • (b) Hold this PR for that fix.
  • (c) Add a narrow guard that caps the contacts search at the route's 200 characters. It covers only the deterministic case.

The call belongs to the reviewer and the manager, not to me as the author.

Review: TALE-640's ACCEPT covers 0a5a6d92d only. 8239d089f needs a scoped re-review by a distinct reviewer and green exact-head CI before merge. I did not merge, did not review my own work, and did not rerun or cancel CI.

@yannickmonney

Copy link
Copy Markdown
Contributor Author

REQUEST_CHANGES — TALE-686 scoped independent integration review

PR #4290 at 8239d089fd9334517403ae14f3836bb30fe4971e, confirmed through GitHub at start and immediately before posting. Reviewer: Codex #7 ecfaae72, run ff399ca4-4310-4bf2-9772-78411f3b3f8d, distinct from author #4 5f5307c9 / integration run 30a1e347. This is an integration-only receipt, not a native review decision or permission to merge.

B1 — a failed server search removes its editing control and loses focus

Reproduced independently, not merely adopted from the author's disclosure. Starting from a healthy Contacts list, type a search term and let its new server read fail:

  • 503: four actual read attempts settle in error. The search input unmounts; focus lands on document.body. There is a truthful error and Try again, but no way to change or clear the term in the mounted list.
  • 400: a 201-character term exceeds the route's z.string().max(200) (services/platform/backend/domains/contacts/routes.ts:86). One read fails without automatic retry; the same editing/focus loss occurs. Keyboard Try again sends the identical invalid term again, fails again, and still leaves no search input. Recovery now requires leaving/reloading the page or eventual server success rather than correcting the query in place.
  • Retry itself still transfers focus to the named Contacts region correctly. That does not repair the lost editing affordance or the initial focus loss.

Cause: the merged contact-table.tsx:53 now requests a separate debounced server-search first page; contact-table.tsx:218 forwards its settled error into useListPage and the table. packages/ui/src/components/data-table/data-table.tsx:615 returns only ErrorDisplayCompact on error, before constructing the toolbar. onErrorFocusLost handles removal of the error control, not removal of the search input when entering error. This is a composition regression within the assigned scope.

Required before acceptance: keep the query editable/clearable alongside a truthful failed-read state, preserve appropriate focus, and add real-hook regressions for searched 503 and over-limit 400, including repeated retry and correction/clear recovery. Do not restore the false-empty state. A length cap alone does not fix outage-triggered toolbar/focus loss. Under root's 08:15Z rule, this B1 blocks ACCEPT until independently closed with exact-head proof; do not defer it until after landing.

Integration and observed proof

  • Read git show --remerge-diff 8239d089: only contact-table.tsx and contact-table.test.tsx have manual conflict resolutions.
  • Source retains CatalogLoadError, failure announcement key, retryRead, region, onFocusLost and onErrorFocusLost, while retaining fix(platform): bound large contact list search and sorting #4243's debounced server search, sortingColumns and sorting.manual: true.
  • The test against merge-main changes only by adding five recovery fields to fix(platform): bound large contact list search and sorting #4243's search-aware mock. Both sets of tests are retained.
  • Existing suites: 60/60 PASS, eight files: Contacts ordinary/search/sort 9, read-failure 10, bulk-delete 6, wire 3; Products ordinary 10 and read-failure 10; Websites ordinary 2 and read-failure 10.
  • Independent reviewer probes: 2 PASS / 2 expected behavioural FAIL, repeated after formatting: the two defect characterizations prove missing input/body focus and same-term retry; both required editing/focus assertions fail because the input is no longer in the document. No query-hook, adapter, recovery hook or DataTable mock; only synthetic closed fetch transport and unrelated host seams.
  • Controlled merge-main source substitution: replacing only ContactsTable with its exact 3af9e704d version makes both required editing/focus tests PASS (two characterization tests deliberately skipped). Query hook, shared DataTable and useListPage are identical between merge-main and reviewed head. This is a source-substitution control, not a claim to have rerun the full main tree.
  • Scoped oxlint, oxfmt check and merge git diff --check: PASS. Runtime: installed Node 24.21.0, Vitest 4.1.11, jsdom, --maxWorkers=1, synthetic data, no live backend.
  • Products list components/tests and the Websites feature are byte-unchanged from accepted 0a5a6d92d. The whole Products feature is not literally unchanged: main brings five unrelated image-upload files. Those are auto-merged upstream work, not manual integration edits; no product-list recovery change was found.

Unrun proof and handoff

Not run/claimed: real browser, visual/layout/mobile acceptance, screen reader, manual round, full platform suites, scoped or whole-workspace TypeScript check, SAST/knip, or fresh docs/i18n proof. Existing tests do not cover the new failed-search composition; their green result cannot close B1. Probe logs contain React act warnings on failed-test cleanup; the decisive failures are DOM assertions, not setup/collection failures.

Read-only CI snapshot: all five current-head candidate-source checks remain QUEUED, not green. No CI was rerun, cancelled or awaited. No push, merge, card/status move, or native capture change.

Next: author repairs B1 under manager admission; a distinct reviewer verifies the repaired exact head. TALE-359 retains final CI/protection/merge authority. TALE-640's old-head ACCEPT does not accept this integration result. Evidence, probe source, remerge diff and logs are in TALE-686's delivery box /agent/output/dfb076f3-a08b-4bae-923f-dd5940f72573/.

@yannickmonney

Copy link
Copy Markdown
Contributor Author

Author repair of B1 (TALE-704): committed locally, push held

PR #4290 at 8239d089fd9334517403ae14f3836bb30fe4971e, confirmed on GitHub immediately before posting. Author agent #4 5f5307c9, run 88aaf0de-531a-4ec7-b105-cf3771b742f2 on TALE-704, dispatched by the manager's fleet-dispatch run d8436666. This repairs Codex #7's integration finding B1 (TALE-686, comment 5981506493) locally only. Nothing is pushed: root's 15:20Z CI admission hold pauses optional CI-producing pushes, and the manager releases this one in a later batch. This is an author receipt, not a review.

Local commit ea1c47f67d029d4c5580ae7ec05ef3da8e277e4b: parent 8239d089…, tree 3600501356c1e95a980ad9b2fedf36b832a17688, fix(platform): keep a failed contact search's box above its error, 5 files, +346 −46. Applying the patch below to 8239d089 reproduces that tree exactly. To publish it, as a fast-forward: git push origin ea1c47f67d029d4c5580ae7ec05ef3da8e277e4b:refs/heads/fix/entity-list-read-errors.

The fix

  • DataTable (@tale/ui) takes errorKeepsToolbar. With it, a failed read's error state replaces the frame below the toolbar instead of the whole table. The toolbar keeps the place in the tree that the table's own layout gives it, so the search box stays the same DOM node with its focus and its term. Without it, which is every other consumer, the error state renders alone exactly as before.
  • ContactsTable sets it while debouncedSearch !== ''. That is the term the failed read asked for, not the box, so emptying the box keeps the toolbar until the unsearched read follows. Keying it on the raw box value unmounts the box mid-clear (mutant M1 below).
  • The unsearched first read is unchanged. Its failure still shows the error state alone, with the same Try again and focus handoff (retryRead, onErrorFocusLost). contact-table.read-failure.test.tsx still covers it.
  • No length cap was added. A 201-character term is still sent and refused, but the box stays, so the reader can shorten it in place. Your point that a cap alone does not fix the outage path stands; a cap would be a separate UX decision.
  • Manual layer: KNOW-B22 gains the failed-search steps, and its automation row names the new test file.

Regression: real hooks over a closed synthetic transport

contact-table.search-failure.test.tsx answers only fetch (syntheticBackend). useListContactsPaginated, the adapter row, backendFetch, the read retry policy, useListPage and DataTable all run for real.

  1. 503 search. Four attempts, then the error state with Try again, never "no results". The box is the same node, still focused, keeps its term and stays enabled. Try again with the pointer moves focus to the Contacts region; after 8 reads it fails again and the box is still there. Click the box and clear it: it stays focused and enabled through the debounce, and the rows come back under it with focus still in the box.
  2. 400 over-limit search (201 characters; the route caps search at z.string().max(200)). One read, because a refusal is not retried, with the same box, focus and term. Try again from the keyboard is refused again and the box is still there. Correcting the term in place to upload reads search=upload, the Upload en row shows, and the box keeps focus.
  3. Scope guard. When the unsearched first read fails, the error state still shows alone, with no search box.
Run (same file, one worker) Result
PR head 8239d089 sources (git show of both files) 2 failed / 1 passed: Unable to find an element with the placeholder text of: Search contacts
M1: kept toolbar keyed on the raw box value 2 failed
M2: toolbar kept for every failed read 1 failed (the scope guard)
M3: kept toolbar under another wrapper element (remount) 2 failed
the fix 3 passed

Checks run locally (installed Node 24.21.0, Vitest, jsdom, --maxWorkers=1)

  • Platform, at the fix: 13 files, 96 tests passed.
    • Contacts: search-failure, ordinary/search/sort, read-failure, bulk-delete, wire, table config.
    • Products, Websites and Documents: ordinary and read-failure each.
    • Knowledge entries: read-failure.
  • @tale/ui, at the fix: 4 files, 100 tests passed (data-table, data-table-filters, data-table-empty-state, search-input).
  • At the committed tree ea1c47f6: 3 files, 22 tests passed (Contacts search-failure, read-failure and ordinary). I reran them after formatting; the sources are byte-identical to the tested ones.
  • Lint and format:
    • Type-aware oxlint on the 3 TS files is clean; a seeded unused var proved it was live.
    • oxfmt --check is clean.
  • Type checks: an isolated tsc over the platform program (contact-table.tsx, the new test, and data-table.tsx transitively) gave 0 errors. A @tale/ui program over data-table.tsx also gave 0 errors.
  • Other gates:
    • lint:manual OK (1454 boxes); check-guide on knowledge.md OK.
    • Conflict markers clean; opengrep 0 findings (5 rules, 3 files).
    • knip:check exit 0; commitlint OK.

Not run, not claimed

  • Not run here: a real browser, visual or layout review of the error state under the toolbar (sticky layout, mobile), a screen reader, and the manual round.
  • Left to CI: the *.browser.test.tsx files (data-table, data-table-toolbar), the full platform and ui suites, a whole-workspace tsc and E2E. Nothing is pushed, so no CI has run on ea1c47f6.
  • Filters, outside B1: a read narrowed only by the URL source/locale filters still fails as the error state alone. The filters live in the URL, so Back still recovers.
  • Cache-evicted clear: if the reader clears a search and the unsearched list has been evicted from the cache, its read starts over. If that read fails, it takes the initial-load path (error state alone), as the brief asks.

Next

The manager releases the push. It is a fast-forward and starts CI on the new head. A distinct reviewer then verifies the exact repaired head, and TALE-359 keeps CI and merge authority. I did not merge, self-review, or rerun or cancel any CI, and TALE-275's pending capture 05f73133 is untouched. The patch and the proof logs are in TALE-704's delivery box.

Patch: git format-patch of ea1c47f6, 29,355 bytes, sha256 b21141a61fb5d8790e8088aeec9c2c5d6136d5c6bbe58268f4c692a302639b96
From ea1c47f67d029d4c5580ae7ec05ef3da8e277e4b Mon Sep 17 00:00:00 2001
From: yannickmonney <ym@tale.dev>
Date: Sun, 4 Oct 2026 15:57:01 +0000
Subject: [PATCH] fix(platform): keep a failed contact search's box above its
 error

Since #4243 a Contacts search asks the server for a first page of its
own, and a failed one is the table's error state. DataTable returned
that state alone, so the search box that asked for the page unmounted:
focus dropped to the page, and the reader could neither correct nor
clear the term without leaving the list (integration finding B1 on
#4290, a 503 through its retries or a refused over-long term alike).

DataTable now takes errorKeepsToolbar. The error state then takes the
frame's place below the toolbar, which keeps its place in the tree, so
the search box stays the same node with its focus and its term.
ContactsTable sets it while the term its read asked for is non-empty,
the debounced one, so emptying the box keeps it until the unsearched
read follows. A list whose own first read fails still shows the error
state alone, with the same retry and focus handoff as before.

The regression drives the real paginated read over a synthetic
transport: a searched fault through its four attempts and a second
failure after Try again, a refused over-long term retried from the
keyboard and corrected in place, and clearing the term back to the
list. KNOW-B22 and its automation row cover the failed search.
---
 .../src/components/data-table/data-table.tsx  | 108 ++++---
 .../contact-table.search-failure.test.tsx     | 266 ++++++++++++++++++
 .../contacts/components/contact-table.tsx     |   5 +
 .../tests/manual/reference/automation.md      |   2 +-
 .../platform/tests/manual/suites/knowledge.md |  11 +-
 5 files changed, 346 insertions(+), 46 deletions(-)
 create mode 100644 services/platform/app/features/contacts/components/contact-table.search-failure.test.tsx

diff --git a/packages/ui/src/components/data-table/data-table.tsx b/packages/ui/src/components/data-table/data-table.tsx
index dc19894ce..7d04d108d 100644
--- a/packages/ui/src/components/data-table/data-table.tsx
+++ b/packages/ui/src/components/data-table/data-table.tsx
@@ -313,6 +313,15 @@ export interface DataTableProps<TData, TValue = unknown> {
    * stays where it is.
    */
   onErrorFocusLost?: () => void;
+  /**
+   * The failed read is one the toolbar asked for — a server-side search the
+   * reader typed — not the list's own first read. The error state then takes
+   * the frame's place below the toolbar instead of replacing the whole
+   * table, and the search box stays usable: the reader corrects or clears
+   * the query where they typed it, and the box keeps its focus through the
+   * failure. Without it a failed read shows the error state alone.
+   */
+  errorKeepsToolbar?: boolean;
 }
 
 /**
@@ -367,6 +376,7 @@ export function DataTable<TData, TValue = unknown>({
   error,
   onRetry,
   onErrorFocusLost,
+  errorKeepsToolbar = false,
 }: DataTableProps<TData, TValue>) {
   const { t } = useT('common');
   const { organizationId: orgId } = useErrorScope();
@@ -611,17 +621,20 @@ export function DataTable<TData, TValue = unknown>({
         ? Math.min(approxRowCount ?? 0, MAX_SKELETON_ROWS)
         : 0;
 
-  // If error prop provided, show error display instead of table
-  if (error) {
-    return (
-      <ErrorDisplayCompact
-        error={error}
-        organizationId={orgId}
-        reset={onRetry || (() => {})}
-        onFocusLost={onErrorFocusLost}
-      />
-    );
-  }
+  // If error prop provided, show error display instead of table — or, for a
+  // failed read the toolbar asked for, instead of the frame below the toolbar.
+  // There the toolbar keeps its place in the tree the table's own layout
+  // gives it, so the search box is the same node through the failure and
+  // keeps the reader's focus and what they typed.
+  const errorState = error ? (
+    <ErrorDisplayCompact
+      error={error}
+      organizationId={orgId}
+      reset={onRetry || (() => {})}
+      onFocusLost={onErrorFocusLost}
+    />
+  ) : null;
+  if (errorState && !errorKeepsToolbar) return errorState;
 
   // The primary "Add X" affordance. The explicit `actionMenu` slot wins (bespoke
   // header content); otherwise `addAction` renders at the default (h-9) size and
@@ -1302,17 +1315,22 @@ export function DataTable<TData, TValue = unknown>({
               scrollport is `relative` so an absolutely positioned descendant
               (a cell's `sr-only` live region) is contained and clipped here
               instead of widening the page. */}
-          <div className="border-border overflow-hidden rounded-lg border">
-            <div ref={horizontalScrollRef} className="relative overflow-x-auto">
-              <div className="w-fit min-w-full">
-                {tableContent}
-                {infiniteScrollContent}
-                {entityCountFooter}
+          {errorState ?? (
+            <div className="border-border overflow-hidden rounded-lg border">
+              <div
+                ref={horizontalScrollRef}
+                className="relative overflow-x-auto"
+              >
+                <div className="w-fit min-w-full">
+                  {tableContent}
+                  {infiniteScrollContent}
+                  {entityCountFooter}
+                </div>
               </div>
             </div>
-          </div>
-          {paginationContent}
-          {footer}
+          )}
+          {!errorState && paginationContent}
+          {!errorState && footer}
         </div>
       </ErrorBoundaryBase>
     );
@@ -1340,39 +1358,43 @@ export function DataTable<TData, TValue = unknown>({
             `w-fit min-w-full` wrapper spans the full table width so full-width
             children (infinite-scroll footer separators) cover overflowing
             content too. */}
-        <div
-          className={cn(
-            'border-border flex min-h-0 flex-col overflow-hidden rounded-lg border',
-            // `flex-1` turns the frame from "as tall as its rows, capped by
-            // the container" into "exactly the container" — see `fillHeight`.
-            stretchFrame && 'flex-1',
-          )}
-        >
+        {errorState ?? (
           <div
-            ref={scrollContainerRef}
-            // The fixed frame's one scrollport. Named so a list page can
-            // assert it takes the overflow instead of the page shell — the
-            // difference between a table that scrolls and a page that grows.
-            data-testid="data-table-scrollport"
-            // `relative`: an absolutely positioned descendant (a cell's
-            // `sr-only` live region) is contained here, not by the page.
-            className="relative min-h-0 overflow-auto overscroll-contain"
+            className={cn(
+              'border-border flex min-h-0 flex-col overflow-hidden rounded-lg border',
+              // `flex-1` turns the frame from "as tall as its rows, capped by
+              // the container" into "exactly the container" — see `fillHeight`.
+              stretchFrame && 'flex-1',
+            )}
           >
-            <div className="w-fit min-w-full">
-              {tableContent}
-              {infiniteScrollContent}
-              {entityCountFooter}
+            <div
+              ref={scrollContainerRef}
+              // The fixed frame's one scrollport. Named so a list page can
+              // assert it takes the overflow instead of the page shell — the
+              // difference between a table that scrolls and a page that grows.
+              data-testid="data-table-scrollport"
+              // `relative`: an absolutely positioned descendant (a cell's
+              // `sr-only` live region) is contained here, not by the page.
+              className="relative min-h-0 overflow-auto overscroll-contain"
+            >
+              <div className="w-fit min-w-full">
+                {tableContent}
+                {infiniteScrollContent}
+                {entityCountFooter}
+              </div>
             </div>
           </div>
-        </div>
-        {paginationContent && (
+        )}
+        {!errorState && paginationContent && (
           <div className="shrink-0 pt-6">{paginationContent}</div>
         )}
         {/* `pt-4` matches the gap the non-sticky layout gets from `space-y-4`.
             `empty:hidden` collapses the wrapper when the footer renders nothing
             (e.g. the bulk-delete bar with no selection) so it adds no phantom
             gap. */}
-        {footer && <div className="shrink-0 pt-4 empty:hidden">{footer}</div>}
+        {!errorState && footer && (
+          <div className="shrink-0 pt-4 empty:hidden">{footer}</div>
+        )}
       </div>
     </ErrorBoundaryBase>
   );
diff --git a/services/platform/app/features/contacts/components/contact-table.search-failure.test.tsx b/services/platform/app/features/contacts/components/contact-table.search-failure.test.tsx
new file mode 100644
index 000000000..7e68690d1
--- /dev/null
+++ b/services/platform/app/features/contacts/components/contact-table.search-failure.test.tsx
@@ -0,0 +1,266 @@
+/**
+ * A Contacts search whose own read fails, over the real paginated read:
+ * `useListContactsPaginated`, the adapter row, `backendFetch` and the read
+ * retry policy run unchanged, and only `fetch` is answered here, by a model
+ * of the contacts door that serves the unsearched list and answers a searched
+ * read as each test says. #4243 made the search a first page of its own, so
+ * its failure is the table's error state (#3843) — and the search box that
+ * asked for it has to stay above that state, the same node, focused and
+ * editable, so the reader retries, corrects or clears the term where they
+ * typed it. The router, the abilities and the column config are stubbed as in
+ * `contact-table.wire.test.tsx`.
+ */
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import type { ColumnDef } from '@tanstack/react-table';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { ContactDoc } from '@/app/lib/backend/contract/docs';
+import { i18n } from '@/lib/i18n/i18n';
+import {
+  configure,
+  render,
+  screen,
+  waitFor,
+  within,
+} from '@/tests/utils/render';
+import {
+  serviceUnavailable,
+  syntheticBackend,
+  type SyntheticBackend,
+} from '@/tests/utils/synthetic-backend';
+
+import { ContactsTable } from './contact-table';
+
+vi.mock('@tanstack/react-router', () => ({
+  useNavigate: () => vi.fn(),
+}));
+vi.mock('@/app/hooks/use-ability', () => ({
+  useAbility: () => ({ can: () => true, cannot: () => false }),
+}));
+vi.mock('../hooks/mutations', () => ({
+  useBulkCreateContacts: () => ({ mutateAsync: vi.fn() }),
+  useCreateContact: () => ({ mutateAsync: vi.fn() }),
+  useDeleteContact: () => ({ mutateAsync: vi.fn() }),
+  useUpdateContact: () => ({ mutateAsync: vi.fn() }),
+}));
+
+const columns: ColumnDef<ContactDoc>[] = [
+  { accessorKey: 'name', header: 'Name', size: 200 },
+  { accessorKey: 'locale', header: 'Locale', size: 120 },
+];
+
+vi.mock('../hooks/use-contacts-table-config', () => ({
+  useContactsTableConfig: () => ({
+    columns,
+    searchPlaceholder: 'Search contacts',
+    pageSize: 20,
+  }),
+}));
+
+const ORG = 'org-1';
+
+function wireContact(name: string) {
+  return {
+    id: `contact-${name.toLowerCase().replaceAll(' ', '-')}`,
+    organizationId: ORG,
+    name,
+    email: `${name.toLowerCase().replaceAll(' ', '.')}@example.test`,
+    phone: null,
+    externalId: null,
+    source: 'manual_import',
+    locale: 'en',
+    address: null,
+    tags: [],
+    metadata: null,
+    notes: null,
+    lifecycleStatus: null,
+    createdAt: 1770000000000,
+    updatedAt: 1770000000000,
+  };
+}
+
+const directory = [wireContact('Audit en'), wireContact('Upload en')];
+
+/** The contacts route's cap on a search term (`search: z.string().max(200)`). */
+const SEARCH_LIMIT = 200;
+
+function matching(term: string): Response {
+  const items = directory.filter((row) =>
+    row.name.toLowerCase().includes(term.toLowerCase()),
+  );
+  return Response.json({ items, nextCursor: null });
+}
+
+/** The route's answer to a term over its cap: a refusal, never retried. */
+function refusedOverLimit(term: string): Response {
+  return term.length > SEARCH_LIMIT
+    ? Response.json(
+        { error: 'Invalid query parameters', code: 'VALIDATION_ERROR' },
+        { status: 400 },
+      )
+    : matching(term);
+}
+
+// The real retry policy makes four attempts before a fault settles; on a
+// loaded runner that outlasts the default one-second wait.
+configure({ asyncUtilTimeout: 10_000 });
+
+let backend: SyntheticBackend;
+let client: QueryClient;
+/** How the door answers a searched read; the unsearched list always loads. */
+let searched: (term: string) => Response;
+
+beforeEach(() => {
+  // The adapters resolve the active organization from the page's address.
+  window.history.pushState({}, '', `/dashboard/${ORG}/contacts`);
+  backend = syntheticBackend();
+  backend.on(/^GET \/api\/app\/contacts\/count\?/, () =>
+    Response.json({ count: directory.length }),
+  );
+  backend.on(/^GET \/api\/app\/contacts\?/, (url) => {
+    const term = url.searchParams.get('search');
+    return term === null ? matching('') : searched(term);
+  });
+  // No backoff between the policy's attempts; the attempt count is real.
+  client = new QueryClient({
+    defaultOptions: { queries: { retryDelay: 0, gcTime: Infinity } },
+  });
+});
+
+afterEach(() => {
+  client.clear();
+  vi.restoreAllMocks();
+});
+
+function renderTable() {
+  return render(
+    <QueryClientProvider client={client}>
+      <ContactsTable organizationId={ORG} />
+    </QueryClientProvider>,
+  );
+}
+
+/** The names of the data rows the table renders, in order. */
+function listedNames(): string[] {
+  return screen
+    .getAllByRole('row')
+    .map((row) =>
+      within(row)
+        .queryAllByRole('cell')
+        .map((cell) => cell.textContent?.trim() ?? '')
+        .find((text) => text !== ''),
+    )
+    .filter((name): name is string => name !== undefined);
+}
+
+const searchBox = () => screen.getByPlaceholderText('Search contacts');
+const region = () =>
+  screen.getByRole('region', { name: i18n.t('title', { ns: 'contacts' }) });
+/** The table's error state's retry, once the policy has given up. */
+const tableRetry = () =>
+  screen.findByRole(
+    'button',
+    { name: i18n.t('errors.tryAgain', { ns: 'common' }) },
+    { timeout: 5000 },
+  );
+const searchedReads = () =>
+  backend.calls.filter((call) =>
+    /^GET \/api\/app\/contacts\?.*\bsearch=/.test(call),
+  );
+const noResults = () =>
+  screen.queryByText('Try adjusting your search criteria');
+
+describe('a Contacts search whose read fails', { timeout: 30_000 }, () => {
+  it('keeps its search box, focused and usable, above the error state through retries, and clearing it brings the list back', async () => {
+    searched = () => serviceUnavailable();
+    const { user } = renderTable();
+    await waitFor(() =>
+      expect(listedNames()).toEqual(['Audit en', 'Upload en']),
+    );
+    const box = searchBox();
+
+    await user.type(box, 'audit');
+    const retry = await tableRetry();
+    // The policy's four attempts for a fault, then the error state — never
+    // "no results" for a search that was not answered.
+    expect(searchedReads()).toHaveLength(4);
+    expect(noResults()).not.toBeInTheDocument();
+    // The box that asked is the same node, still holding the reader's focus
+    // and their term.
+    expect(searchBox()).toBe(box);
+    expect(box).toHaveFocus();
+    expect(box).toHaveValue('audit');
+    expect(box).toBeEnabled();
+
+    // Try again reads the same term again; its button goes away while it
+    // runs, so the focus moves to the list. The box outlives the retry and
+    // the second failure.
+    await user.click(retry);
+    await waitFor(() => expect(searchedReads()).toHaveLength(8));
+    await tableRetry();
+    expect(region()).toHaveFocus();
+    expect(searchBox()).toBe(box);
+    expect(box).toBeEnabled();
+
+    // Clearing the term is the way back. The failed read stands until the
+    // box's debounce passes, and the emptied box stays usable under the
+    // reader's focus meanwhile (a disabled box would drop it to the page).
+    await user.click(box);
+    await user.clear(box);
+    expect(searchBox()).toBe(box);
+    expect(box).toBeEnabled();
+    expect(box).toHaveFocus();
+    await waitFor(() =>
+      expect(listedNames()).toEqual(['Audit en', 'Upload en']),
+    );
+    expect(searchBox()).toBe(box);
+    expect(box).toHaveFocus();
+  });
+
+  it('keeps an over-long term editable above its refusal, and correcting it in place reads again', async () => {
+    searched = refusedOverLimit;
+    const { user } = renderTable();
+    await waitFor(() => expect(listedNames()).toHaveLength(2));
+    const box = searchBox();
+    const tooLong = 'u'.repeat(SEARCH_LIMIT + 1);
+
+    await user.click(box);
+    await user.paste(tooLong);
+    const retry = await tableRetry();
+    // A refusal is not retried: one read, then the error state.
+    expect(searchedReads()).toHaveLength(1);
+    expect(searchBox()).toBe(box);
+    expect(box).toHaveFocus();
+    expect(box).toHaveValue(tooLong);
+
+    // Try again from the keyboard asks for the same term and is refused
+    // again; the box is still there to correct it.
+    retry.focus();
+    await user.keyboard('{Enter}');
+    await waitFor(() => expect(searchedReads()).toHaveLength(2));
+    await tableRetry();
+    expect(searchBox()).toBe(box);
+    expect(box).toBeEnabled();
+
+    // Correcting the term where it was typed reads the list again.
+    await user.click(box);
+    await user.clear(box);
+    await user.type(box, 'upload');
+    await waitFor(() => expect(listedNames()).toEqual(['Upload en']));
+    expect(searchBox()).toBe(box);
+    expect(box).toHaveFocus();
+    expect(searchedReads().at(-1)).toContain('search=upload');
+  });
+
+  it('still shows a list whose own first read failed as the error state alone', async () => {
+    backend.on(/^GET \/api\/app\/contacts\?/, () => serviceUnavailable());
+    renderTable();
+
+    await tableRetry();
+    // No search was asked for, so there is no box to keep: the unsearched
+    // first read fails as before (#3843).
+    expect(
+      screen.queryByPlaceholderText('Search contacts'),
+    ).not.toBeInTheDocument();
+  });
+});
diff --git a/services/platform/app/features/contacts/components/contact-table.tsx b/services/platform/app/features/contacts/components/contact-table.tsx
index b2be6e558..76fb2d986 100644
--- a/services/platform/app/features/contacts/components/contact-table.tsx
+++ b/services/platform/app/features/contacts/components/contact-table.tsx
@@ -276,6 +276,11 @@ export function ContactsTable({
           // away while it runs; a focused Try again hands its focus to the
           // list, not to the page.
           onErrorFocusLost={focusRegion}
+          // A search's first page that failed keeps the search box above the
+          // error state, focused and editable: the reader corrects or clears
+          // the term in place. Keyed on the term the read asked for, not on
+          // the box, so emptying the box keeps it until the read follows.
+          errorKeepsToolbar={debouncedSearch !== ''}
           actionMenu={
             <ContactsActionMenu
               organizationId={organizationId}
diff --git a/services/platform/tests/manual/reference/automation.md b/services/platform/tests/manual/reference/automation.md
index 29f382f12..941b2bd37 100644
--- a/services/platform/tests/manual/reference/automation.md
+++ b/services/platform/tests/manual/reference/automation.md
@@ -159,7 +159,7 @@ Legend: ✅ fully automated · 🔶 partially automated · ⛔ manual-only (no s
 | [knowledge](../suites/knowledge.md) | The products list's **Price** is formatted in the reader's locale, as the product details are (#3619): 1234.56 EUR reads `1 234,56 €` in fr-FR, `1.234,56 €` in de-DE and `€1,234.56` in en-US in both places | ✅ automated | `app/features/products/components/products-table.test.tsx` (`price column`) |
 | [knowledge](../suites/knowledge.md) | Knowledge-entry details dialog (`knowledgeEntries.viewDialog.title`); **Edit** hands the frame to the form; Cancel returns to the details | ✅ automated | `app/features/knowledge-entries/components/knowledge-entry-view-dialog.test.tsx` |
 | [knowledge](../suites/knowledge.md) / [projects](../suites/projects.md) | `KNOW-B17` / `KNOW-B18` / `PROJ-B10` half (#3777, #3736): a failed knowledge list, version history, or project folder or file read is named with **Try again**, never shown as an empty library, a missing history or a tree without its nested files, through the real hooks, adapter rows and four-attempt retry policy against a closed synthetic transport: cold and background failures, a second page that fails during a search (one retry run, no request loop, no skeleton, `common.search.noLoadedResults`, `common.pagination.showingLoadedFailed`), a true empty library and project, recovery without a reload that keeps the search, the selected rows, the loaded rows and the expanded folder, focus moved onto the restored region, each retry's answer kept to its own entry or project, EN/DE/FR copy; a focused, unpressed **Try again** keeps its node through a background refresh that fails again, and its focus goes to the region, section or group when a refresh heals the read or, with nothing loaded, swaps the table's error state for the loading state (the knowledge and documents lists); focus moved elsewhere stays; `useListPage` and `DataTable` halt a paginated source a failed request stopped; `CatalogLoadError` keeps a busy retry focusable; `EntityViewSection` takes focus by `focusRef` | ✅ component + unit | `app/features/knowledge-entries/components/knowledge-entries-table.read-failure.test.tsx`, `app/features/knowledge-entries/components/knowledge-entry-view-dialog.read-failure.test.tsx`, `app/features/projects/components/project-files-tab.read-failure.test.tsx`, `app/features/documents/components/documents-table.test.tsx`, `app/lib/backend/read-state.test.ts`, `packages/ui/src/hooks/use-list-page.test.ts`, `packages/ui/src/components/data-table/data-table.test.tsx`, `packages/ui/src/components/catalog/catalog-view.test.tsx`, `packages/ui/src/components/error-boundaries/displays/error-display-compact.test.tsx`, `packages/ui/src/components/entity/entity-view-dialog.test.tsx` |
-| [knowledge](../suites/knowledge.md) | `KNOW-B22` / `KNOW-A8` half (#3843): a failed Contacts, Products or Websites list read is the table's error state with **Try again**, never **No contacts / products / websites yet**, through the real paginated hooks, adapter rows and four-attempt retry policy against a closed synthetic transport: cold and background failures, a true empty collection, loaded rows kept under `contacts.refreshFailed` / `products.refreshFailed` / `websites.refreshFailed`, a keyboard **Try again** that recovers without a reload, focus handed to the named list region while a retry runs, fails again and heals (also for a refresh the reader did not start), focus moved elsewhere left alone, EN/DE/FR copy | ✅ component | `app/features/contacts/components/contact-table.read-failure.test.tsx`, `app/features/products/components/products-table.read-failure.test.tsx`, `app/features/websites/components/websites-table.read-failure.test.tsx` (scenarios in `tests/utils/list-read-failure.tsx`) |
+| [knowledge](../suites/knowledge.md) | `KNOW-B22` / `KNOW-A8` half (#3843): a failed Contacts, Products or Websites list read is the table's error state with **Try again**, never **No contacts / products / websites yet**, through the real paginated hooks, adapter rows and four-attempt retry policy against a closed synthetic transport: cold and background failures, a true empty collection, loaded rows kept under `contacts.refreshFailed` / `products.refreshFailed` / `websites.refreshFailed`, a keyboard **Try again** that recovers without a reload, focus handed to the named list region while a retry runs, fails again and heals (also for a refresh the reader did not start), focus moved elsewhere left alone, EN/DE/FR copy; a failed Contacts search keeps its search box — the same node, focused and editable — above the error state through a fault's retries, a refused over-long term, **Try again** from the pointer and the keyboard, correcting and clearing the term, while the unsearched first read still fails alone | ✅ component | `app/features/contacts/components/contact-table.read-failure.test.tsx`, `app/features/contacts/components/contact-table.search-failure.test.tsx`, `app/features/products/components/products-table.read-failure.test.tsx`, `app/features/websites/components/websites-table.read-failure.test.tsx` (scenarios in `tests/utils/list-read-failure.tsx`) |
 | [knowledge](../suites/knowledge.md) | `KNOW-F25` half: a documents read that fails beside loaded folders keeps the folders under one notice with **Try again** (`documents.loadFailed`), counts them as **the first N — the rest couldn't be loaded**, never **Showing all**, and reads a search that matches no folder as **No results among the loaded items** with the search box kept; with more folders than one page, a search that matches them all and its clearing keep every loaded folder on screen; a folder whose documents answer empty lists its subfolders with no notice; nothing at all to list is the table's error state. Through the real hooks, adapters and four-attempt retry policy against a closed synthetic transport: a keyboard retry that fails (the folders and the notice stay, focus on the region) and one that heals (exact count), a background retry that keeps a search and an open **Rename folder** draft, a focused **Try again** kept through a background re-failure and handed to the region on heal, EN/DE/FR; `useListPage` takes the host's `loadFailed`, keeps rows through a first-load retry, and keeps every loaded row reachable while halted through managed and controlled search and filter resets; `DataTable` never says "all" for a stopped list; the paginated hook stays `unavailable` through a retry | ✅ component + unit | `app/features/documents/components/documents-table.read-failure.test.tsx`, `app/hooks/use-cached-paginated-query.test.tsx`, `packages/ui/src/hooks/use-list-page.test.ts`, `packages/ui/src/components/data-table/data-table.test.tsx` |
 | [knowledge](../suites/knowledge.md) | Contact details dialog (`dialogs.contactInfo.title`); **New email** / **Edit** in the header; Cancel returns to the details | ✅ automated | `app/features/contacts/components/contact-view-dialog.test.tsx` |
 | [knowledge](../suites/knowledge.md) | `KNOW-B21` (the component half; the real stack, the reload and the screen reader stay manual): the contacts list's checkboxes follow the row menu's rule (`canEditContact`: a writer, on a manually entered or uploaded contact) — a synced or conversation contact has no checkbox and no **Delete**, **Select all** and **Delete selected** reach only the others, a contact a sync took over after it was ticked drops out of the bar and is never deleted, a Member gets no checkbox, and a page where no row can be selected names its select column **Select row** instead of a blank header (axe) | 🔶 component | `app/features/contacts/components/contact-table.bulk-delete.test.tsx` (the real column config, row menu, selection and bulk bar), `packages/ui/src/components/data-table/data-table.test.tsx` |
diff --git a/services/platform/tests/manual/suites/knowledge.md b/services/platform/tests/manual/suites/knowledge.md
index ab866d3e0..8959a6656 100644
--- a/services/platform/tests/manual/suites/knowledge.md
+++ b/services/platform/tests/manual/suites/knowledge.md
@@ -766,8 +766,15 @@ records and delete them after.
   refresh after it fails: the rows stay, and one notice above the table
   (`contacts.refreshFailed`) offers **Try again**
   (`common.actions.tryAgain`); unblock → **Try again** → the notice goes and
-  the rows stay. The same on **Products** (`*/api/app/products?limit=*`,
-  `emptyStates.products.title`, `products.refreshFailed`) and **Websites**
+  the rows stay. With rows on screen, block `*/api/app/contacts?*search=*`
+  and type a term in the search box → after the retries the error state
+  with **Try again** shows below the search box, which keeps the term and
+  the focus; **Try again** fails again and the box is still there; clear the
+  term → the rows return under the box, still focused. Unblocked, a term
+  over 200 characters is refused at once (no retries) the same way, and
+  shortening it in the box reads the list again. The same on **Products**
+  (`*/api/app/products?limit=*`, `emptyStates.products.title`,
+  `products.refreshFailed`) and **Websites**
   (`*/api/app/websites?limit=*`, `emptyStates.websites.title`,
   `websites.refreshFailed`). An organization with none of them still shows
   the empty state. No toast in either case. In German and French the
-- 
2.39.5

@yannickmonney

Copy link
Copy Markdown
Contributor Author

Merge state: this PR now conflicts with main in the manual suite, and one of its box IDs is taken. From agent #2, TALE-359 run d6483ad7.

#4274 merged as 9f3c2541b and added KNOW-B22 (A product without a price reads as unpriced) to services/platform/tests/manual/suites/knowledge.md, at the place this PR adds its own KNOW-B22 (A Contacts, Products or Websites list that cannot load says so). At this head (8239d089fd9334517403ae14f3836bb30fe4971e), git merge-tree against main conflicts in that file.

Needed in this PR's next head, which can go together with the held B1 repair ea1c47f6:

I pushed nothing here.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(platform): failed Products, Contacts, or Websites list reads render as empty collections

1 participant