Skip to content
Open
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
54 changes: 52 additions & 2 deletions packages/dashboard/e2e/tests/settings/stock-locations.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { test } from '@playwright/test';
import { expect, test } from '@playwright/test';

import { BaseListPage } from '../../page-objects/list-page.base.js';
import { createCrudTestSuite } from '../../utils/crud-test-factory.js';
import { VendureAdminClient } from '../../utils/vendure-admin-client.js';

// Unique per run so repeated local runs (which don't reset the DB) don't leave duplicates that
// would break the final row-count assertion.
const DELETE_TARGET_NAME = `E2E Delete Dialog Location ${Date.now()}`;

test.describe('Stock Locations', () => {
test.describe.configure({ mode: 'serial' });
Expand All @@ -20,6 +26,50 @@ test.describe('Stock Locations', () => {
{ label: 'Name', value: 'E2E Test Warehouse Updated' },
{ label: 'Description', value: 'Updated test warehouse description' },
],
hasBulkDelete: true,
// Stock locations use a bespoke delete dialog (transfer/discard remaining stock) rather
// than the generic confirm the factory drives, so bulk delete is covered by the test below.
hasBulkDelete: false,
});

// #4641 — Deleting a stock location previously always failed because the shared bulk-delete
// action sent `{ ids }` while `deleteStockLocations` requires `input: [DeleteStockLocationInput!]!`.
// This drives the real dialog end-to-end; if the mutation variables regress, the delete fails
// and no success toast appears.
test('should bulk-delete a stock location via the transfer/discard dialog', async ({ page }) => {
// Seed a throwaway location via the API so the test is self-contained.
const client = new VendureAdminClient(page);
await client.login();
await client.gql(
`mutation ($input: CreateStockLocationInput!) {
createStockLocation(input: $input) { id }
}`,
{ input: { name: DELETE_TARGET_NAME } },
);

const listPage = new BaseListPage(page, {
path: '/stock-locations',
title: 'Stock Locations',
newButtonLabel: 'New Stock Location',
});
await listPage.goto();
await listPage.expectLoaded();
await listPage.search(DELETE_TARGET_NAME);

const row = listPage.getRows().filter({ hasText: DELETE_TARGET_NAME });
await expect(row.first()).toBeVisible();
await row.first().getByRole('checkbox').click();

await page.getByRole('button', { name: /Actions/i }).click();
await page.locator('[role="menu"]').getByText('Delete', { exact: true }).click();

// Custom delete dialog: choose what to do with any remaining stock, then confirm.
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('Delete stock locations')).toBeVisible();
await dialog.getByRole('combobox').click();
await page.getByRole('option', { name: /Discard remaining stock/i }).click();
await dialog.getByRole('button', { name: 'Delete', exact: true }).click();

await listPage.expectSuccessToast();
await expect(listPage.getRows().filter({ hasText: DELETE_TARGET_NAME })).toHaveCount(0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';

import { Button } from '@/vdb/components/ui/button.js';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/vdb/components/ui/dialog.js';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/vdb/components/ui/select.js';
import { api } from '@/vdb/graphql/api.js';
import { ResultOf } from '@/vdb/graphql/graphql.js';
import { Trans, useLingui } from '@lingui/react/macro';

import { deleteStockLocationsDocument, stockLocationListQuery } from '../stock-locations.graphql.js';

// Sentinel value for the "discard remaining stock" option, distinct from any real location id.
const DISCARD = '__discard__';

const TRANSFER_TARGETS_QUERY_KEY = 'stockLocationTransferTargets';

type StockLocationListItem = ResultOf<typeof stockLocationListQuery>['stockLocations']['items'][number];

interface DeleteStockLocationsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
selection: Array<{ id: string; name?: string }>;
onSuccess?: () => void;
}

export function DeleteStockLocationsDialog({
open,
onOpenChange,
selection,
onSuccess,
}: Readonly<DeleteStockLocationsDialogProps>) {
const { t } = useLingui();
const queryClient = useQueryClient();
const [transferTarget, setTransferTarget] = useState<string>('');
const count = selection.length;

// The dialog stays mounted, so a choice from a previous delete would otherwise persist. Reset
// it each time the dialog opens, so a stale target (possibly one now being deleted) can't be sent.
useEffect(() => {
if (open) {
setTransferTarget('');
}
}, [open]);

const selectedIds = new Set(selection.map(s => s.id));

// Load every stock location so the admin can pick where to move remaining stock. Paginate
// through all pages rather than capping, so no valid transfer target is silently hidden.
// The locations being deleted are excluded as they cannot be their own transfer target.
const { data: allLocations, isLoading } = useQuery({
queryKey: [TRANSFER_TARGETS_QUERY_KEY],
queryFn: async () => {
const pageSize = 100;
const collected: StockLocationListItem[] = [];
let totalItems = 0;
do {
const result = await api.query(stockLocationListQuery, {
options: { skip: collected.length, take: pageSize },
});
collected.push(...result.stockLocations.items);
totalItems = result.stockLocations.totalItems;
} while (collected.length < totalItems);
return collected;
},
enabled: open,
});
const availableTargets = (allLocations ?? []).filter(l => !selectedIds.has(l.id));

// Base UI's <Select> needs an `items` map (value → label) to render the selected value's label.
const selectItems: Record<string, string> = {
...Object.fromEntries(availableTargets.map(l => [l.id, t`Transfer to ${l.name}`])),
[DISCARD]: t`Discard remaining stock`,
};

const { mutate, isPending } = useMutation({
mutationFn: api.mutate(deleteStockLocationsDocument),
onSuccess: (result: ResultOf<typeof deleteStockLocationsDocument>) => {
const results = result.deleteStockLocations;
const failed = results.filter(r => r.result !== 'DELETED');
const deleted = results.length - failed.length;

if (0 < deleted) {
toast.success(t`Deleted ${deleted} stock locations`);
}
if (0 < failed.length) {
const messages = failed
.map(f => f.message)
.filter(Boolean)
.join(', ');
toast.error(
messages
? t`Failed to delete ${failed.length} stock locations: ${messages}`
: t`Failed to delete ${failed.length} stock locations`,
);
}
// Only run cleanup when something was actually deleted. If every item failed (e.g. the
// last remaining location), keep the dialog open and leave the list untouched.
if (0 < deleted) {
// Drop the cached target list so a deleted location can't be offered as a transfer
// target the next time the dialog opens.
queryClient.invalidateQueries({ queryKey: [TRANSFER_TARGETS_QUERY_KEY] });
onSuccess?.();
onOpenChange(false);
}
},
onError: () => {
toast.error(t`Failed to delete ${count} stock locations`);
},
});

const handleDelete = () => {
if (!transferTarget) {
return;
}
const transferToLocationId = transferTarget === DISCARD ? undefined : transferTarget;
mutate({
input: selection.map(s => ({ id: s.id, transferToLocationId })),
});
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>
<Trans>Delete stock locations</Trans>
</DialogTitle>
<DialogDescription>
<Trans>
Choose what to do with any stock remaining in the {count} stock location(s) you
are deleting. All selected locations transfer their remaining stock into the
single location you choose, or discard it.
</Trans>
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<label className="text-sm font-medium">
<Trans>Remaining stock</Trans>
</label>
<Select
items={selectItems}
value={transferTarget}
onValueChange={value => {
if (value != null) {
setTransferTarget(value);
}
}}
disabled={isLoading}
>
<SelectTrigger>
<SelectValue
placeholder={
isLoading
? t`Loading locations…`
: t`Select what to do with remaining stock`
}
/>
</SelectTrigger>
<SelectContent>
{availableTargets.map(location => (
<SelectItem key={location.id} value={location.id}>
<Trans>Transfer to {location.name}</Trans>
</SelectItem>
))}
<SelectItem value={DISCARD}>
<Trans>Discard remaining stock</Trans>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
<Trans>Cancel</Trans>
</Button>
<Button
variant="destructive"
onClick={handleDelete}
disabled={!transferTarget || isPending}
>
<Trans>Delete</Trans>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
Original file line number Diff line number Diff line change
@@ -1,25 +1,47 @@
import { DataTableBulkActionItem } from '@/vdb/components/data-table/data-table-bulk-action-item.js';
import { AssignToChannelBulkAction } from '@/vdb/components/shared/assign-to-channel-bulk-action.js';
import { RemoveFromChannelBulkAction } from '@/vdb/components/shared/remove-from-channel-bulk-action.js';
import { BulkActionComponent } from '@/vdb/framework/extension-api/types/data-table.js';
import { api } from '@/vdb/graphql/api.js';
import { useChannel } from '@/vdb/hooks/use-channel.js';
import { DeleteBulkAction } from '../../../../common/delete-bulk-action.js';
import { usePaginatedList } from '@/vdb/hooks/use-paginated-list.js';
import { Trans } from '@lingui/react/macro';
import { TrashIcon } from 'lucide-react';
import { useState } from 'react';

import { DeleteStockLocationsDialog } from './delete-stock-locations-dialog.js';

import {
assignStockLocationsToChannelDocument,
deleteStockLocationsDocument,
removeStockLocationsFromChannelDocument,
} from '../stock-locations.graphql.js';

export const DeleteStockLocationsBulkAction: BulkActionComponent<any> = ({ selection, table }) => {
const { refetchPaginatedList } = usePaginatedList();
const [dialogOpen, setDialogOpen] = useState(false);

return (
<DeleteBulkAction
mutationDocument={deleteStockLocationsDocument}
entityName="stock locations"
requiredPermissions={['DeleteStockLocation']}
selection={selection}
table={table}
/>
<>
<DataTableBulkActionItem
requiresPermission={['DeleteStockLocation']}
onClick={() => setDialogOpen(true)}
label={<Trans>Delete</Trans>}
icon={TrashIcon}
className="text-destructive"
// Keep the dropdown open so opening the dialog in the same tick doesn't race with
// the menu unmounting (which would prevent the dialog from mounting).
closeOnClick={false}
/>
<DeleteStockLocationsDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
selection={selection}
onSuccess={() => {
refetchPaginatedList();
table.resetRowSelection();
}}
/>
</>
);
};

Expand Down
Loading
Loading