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']);