diff --git a/apps/web/src/components/places/contents-list.tsx b/apps/web/src/components/places/contents-list.tsx index 7f0404b2..7ebd6eec 100644 --- a/apps/web/src/components/places/contents-list.tsx +++ b/apps/web/src/components/places/contents-list.tsx @@ -29,7 +29,7 @@ import { can } from '@kept/shared'; import { Trans, useLingui } from '@lingui/react/macro'; import { type InfiniteData, type UseInfiniteQueryResult, useMutation } from '@tanstack/react-query'; -import { type DragEvent, lazy, type ReactNode, Suspense, useMemo, useRef, useState } from 'react'; +import { type DragEvent, type ReactNode, useMemo, useRef, useState } from 'react'; import { inventoryApi } from '@/api/inventory/queries'; import type { Page, PlaceNode, ThingListParams, ThingRow } from '@/api/inventory/types'; import { useAllLocations } from '@/api/queries'; @@ -43,7 +43,7 @@ import { ListSurface } from '@/components/list-surface'; import { EmptyState, Notice, Pill, useErrorText } from '@/components/page'; import { tagGroupOf } from '@/components/tags/chip'; import { BulkTagButtons } from '@/components/tags/lazy'; -import type { BulkTrashButton as BulkTrash } from '@/components/things/bulk-trash'; +import { BulkTrashLazy } from '@/components/things/bulk-trash-lazy'; import { ListBulkLazy } from '@/components/things/list-bulk-lazy'; import { useLocationAccountId } from '@/components/things/pickers'; import { SelectionBar } from '@/components/things/selection-bar'; @@ -106,13 +106,6 @@ const leavesList = ( ); }; -// #294: "Move to Trash" for the selection, on demand: it needs the server anyway. -const BulkTrashButton = lazy(() => - import('@/components/things/bulk-trash') - .then((m) => ({ default: m.BulkTrashButton })) - .catch(() => ({ default: (() => null) as unknown as typeof BulkTrash })), -); - export function ContentsList({ parent, canEdit, view, empty }: ContentsListProps) { const { t } = useLingui(); const typeName = useTypeName(); @@ -423,13 +416,12 @@ export function ContentsList({ parent, canEdit, view, empty }: ContentsListProps onDone={stopSelecting} trash={ canTrash ? ( - - x.isContainer && selection.ids.has(x.id))} - onDone={stopSelecting} - /> - + // #294, on demand; never hidden by a failed load (discussion #183). + x.isContainer && selection.ids.has(x.id))} + onDone={stopSelecting} + /> ) : null } > diff --git a/apps/web/src/components/things/bulk-trash-lazy.test.tsx b/apps/web/src/components/things/bulk-trash-lazy.test.tsx new file mode 100644 index 00000000..ab324c4e --- /dev/null +++ b/apps/web/src/components/things/bulk-trash-lazy.test.tsx @@ -0,0 +1,39 @@ +import { act, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { renderUI } from '@/test/render'; + +// Discussion #183: an owner's selection bar showed no Move to Trash. The button is loaded on +// demand, and a load that failed (Kept updated while the page was open) drew nothing at all, for +// good. Now the button always shows; when its load failed it says so and offers Reload, and the +// next time it's needed it is fetched again. + +let fail = true; +vi.mock('@/components/things/bulk-trash', () => { + if (fail) throw new Error('Failed to fetch dynamically imported module'); + return { + BulkTrashButton: () => , + }; +}); + +const props = { thingIds: ['a'], holdsThings: false, onDone: () => {} }; + +describe('BulkTrashLazy', () => { + it('still shows Move to Trash, off, with Reload, when its load failed', async () => { + const { BulkTrashLazy } = await import('./bulk-trash-lazy'); + const first = await renderUI(); + await act(async () => { + await new Promise((r) => setTimeout(r, 20)); + }); + expect(screen.getByRole('button', { name: 'Move to Trash' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Reload' })).toBeEnabled(); + first.unmount(); + + // The next selection asks again, and the real button arrives. + fail = false; + vi.resetModules(); + const again = await import('./bulk-trash-lazy'); + await renderUI(); + expect(await screen.findByRole('button', { name: 'Move to Trash (loaded)' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Reload' })).toBeNull(); + }); +}); diff --git a/apps/web/src/components/things/bulk-trash-lazy.tsx b/apps/web/src/components/things/bulk-trash-lazy.tsx new file mode 100644 index 00000000..1e05ab28 --- /dev/null +++ b/apps/web/src/components/things/bulk-trash-lazy.tsx @@ -0,0 +1,66 @@ +/** + * The selection's "Move to Trash" (#294), loaded on demand: it needs the server anyway, so it + * stays out of the precache. The button is always there. While it loads it shows, off; a load + * that failed (the connection, or Kept updated while the page was open and the old file is gone) + * leaves it off with a Reload beside it, and isn't kept: the next selection, or the connection + * coming back, fetches it again (as "Set a field…" does, list-bulk-lazy.tsx). Discussion #183: it + * used to draw nothing at all after a failed load. + */ +import { Trans } from '@lingui/react/macro'; +import { type ComponentType, lazy, Suspense, useEffect, useState } from 'react'; +import type { BulkTrashButton } from '@/components/things/bulk-trash'; +import { Button } from '@/components/ui/button'; +import { useOnline } from '@/lib/online'; + +type Props = Parameters[0]; +type Loaded = ComponentType; + +let loaded: Loaded | null = null; + +/** The button, off: while it loads, and with Reload once its load has failed. */ +function Unavailable({ failed }: { failed: boolean }) { + return ( + <> + + {failed ? ( + + ) : null} + + ); +} + +const Failed: Loaded = () => ; + +/** The button as a lazy component: the one that loaded, or a fresh attempt if the last failed. */ +function attempt(): Loaded { + if (loaded) return loaded; + const next: Loaded = lazy( + (): Promise<{ default: Loaded }> => + import('@/components/things/bulk-trash') + .then((m) => ({ default: m.BulkTrashButton })) + .catch(() => { + // Forgotten, so the next mount or reconnect asks again rather than keeping the failure. + if (loaded === next) loaded = null; + return { default: Failed }; + }), + ); + loaded = next; + return next; +} + +export function BulkTrashLazy(props: Props) { + const online = useOnline(); + const [Lazy, setLazy] = useState(attempt); + useEffect(() => { + if (online) setLazy(attempt); + }, [online]); + return ( + }> + + + ); +} diff --git a/apps/web/src/test/screens/browse.test.tsx b/apps/web/src/test/screens/browse.test.tsx index 8373708b..e09b49a9 100644 --- a/apps/web/src/test/screens/browse.test.tsx +++ b/apps/web/src/test/screens/browse.test.tsx @@ -683,6 +683,18 @@ describe('select and trash (#294)', () => { expect(deleted()).toEqual([null, null]); }); + it('offers Move to Trash for things selected inside a box (discussion #183)', async () => { + const { user } = await renderApp(`/t/${T.cableBox}`); + await findHeading('Cable box'); + await user.click(await screen.findByRole('button', { name: 'Select' })); + await user.click((await screen.findAllByRole('checkbox'))[0] as HTMLElement); + const bar = await screen.findByRole('toolbar', { name: 'Selection' }); + // Off while it loads (it is fetched on demand), then on. + await waitFor(() => + expect(within(bar).getByRole('button', { name: 'Move to Trash' })).toBeEnabled(), + ); + }); + it('asks what happens to what is inside a box first', async () => { const { user, mock } = await renderApp(`/loc/${IDS.home}`); const dialog = await pick(user, ['Select Galaxy S23', 'Select Cable box']);