Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 8 additions & 16 deletions apps/web/src/components/places/contents-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -423,13 +416,12 @@ export function ContentsList({ parent, canEdit, view, empty }: ContentsListProps
onDone={stopSelecting}
trash={
canTrash ? (
<Suspense fallback={null}>
<BulkTrashButton
thingIds={selected}
holdsThings={things.some((x) => x.isContainer && selection.ids.has(x.id))}
onDone={stopSelecting}
/>
</Suspense>
// #294, on demand; never hidden by a failed load (discussion #183).
<BulkTrashLazy
thingIds={selected}
holdsThings={things.some((x) => x.isContainer && selection.ids.has(x.id))}
onDone={stopSelecting}
/>
) : null
}
>
Expand Down
39 changes: 39 additions & 0 deletions apps/web/src/components/things/bulk-trash-lazy.test.tsx
Original file line number Diff line number Diff line change
@@ -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: () => <button type="button">Move to Trash (loaded)</button>,
};
});

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(<BulkTrashLazy {...props} />);
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(<again.BulkTrashLazy {...props} />);
expect(await screen.findByRole('button', { name: 'Move to Trash (loaded)' })).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Reload' })).toBeNull();
});
});
66 changes: 66 additions & 0 deletions apps/web/src/components/things/bulk-trash-lazy.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof BulkTrashButton>[0];
type Loaded = ComponentType<Props>;

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 (
<>
<Button size="small" variant="secondary" isDisabled>
<Trans>Move to Trash</Trans>
</Button>
{failed ? (
<Button size="small" variant="ghost" onPress={() => window.location.reload()}>
<Trans>Reload</Trans>
</Button>
) : null}
</>
);
}

const Failed: Loaded = () => <Unavailable failed />;

/** 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 (
<Suspense fallback={<Unavailable failed={false} />}>
<Lazy {...props} />
</Suspense>
);
}
12 changes: 12 additions & 0 deletions apps/web/src/test/screens/browse.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down
Loading