Merge pull request 'feat(frontend): preview an inventory item as a customer sees it (#89)' (#91) from feature/89-item-preview-panel into main
Reviewed-on: #91
This commit was merged in pull request #91.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Layout, Table, Button, Form, Input, InputNumber, Upload, Modal,
|
||||
Layout, Table, Button, Drawer, Form, Input, InputNumber, Upload, Modal,
|
||||
Space, Tag, Typography, Switch, message, Image as AntImage, theme, Tabs,
|
||||
Select
|
||||
} from 'antd';
|
||||
@@ -20,6 +20,7 @@ import Settings from './Settings';
|
||||
import Categories from './Categories';
|
||||
import Tags from './Tags';
|
||||
import CategoryTreeSelect from './CategoryTreeSelect';
|
||||
import ItemCard from '../components/ItemCard';
|
||||
import InventoryFilters from './InventoryFilters';
|
||||
import { ItemFilters, EMPTY_FILTERS } from '../filters';
|
||||
|
||||
@@ -34,6 +35,10 @@ function Inventory() {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<Item | null>(null);
|
||||
// The item whose storefront appearance is being previewed, or null when the
|
||||
// panel is closed. Holds the row object itself — admin and storefront share
|
||||
// one Item type, so there is nothing to convert and nothing to drift.
|
||||
const [previewItem, setPreviewItem] = useState<Item | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [description, setDescription] = useState<string>('');
|
||||
@@ -181,7 +186,17 @@ function Inventory() {
|
||||
</span>
|
||||
) : null
|
||||
},
|
||||
{ title: 'Name', dataIndex: 'name' },
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
// A button rather than a clickable cell so it is reachable by keyboard
|
||||
// and announces itself as an action.
|
||||
render: (name: string, item: Item) => (
|
||||
<Button type="link" style={{ padding: 0, height: 'auto', textAlign: 'left' }} onClick={() => setPreviewItem(item)}>
|
||||
{name}
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Category',
|
||||
dataIndex: 'category_name',
|
||||
@@ -283,6 +298,26 @@ function Inventory() {
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* The real storefront card, rendered inert. Width is pinned to what the
|
||||
storefront grid actually gives a card at its widest column, so the
|
||||
proportions here match what a customer sees rather than stretching to
|
||||
fill the drawer. */}
|
||||
<Drawer
|
||||
title={previewItem ? `Preview: ${previewItem.name}` : 'Preview'}
|
||||
open={previewItem !== null}
|
||||
onClose={() => setPreviewItem(null)}
|
||||
width={420}
|
||||
destroyOnHidden
|
||||
>
|
||||
{previewItem && (
|
||||
<div style={{ maxWidth: 340, margin: '0 auto' }}>
|
||||
{/* onChanged never fires: every handler that would call it is
|
||||
short-circuited by `preview`. */}
|
||||
<ItemCard item={previewItem} onChanged={() => undefined} preview />
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,9 +16,21 @@ const { Text, Title } = Typography;
|
||||
interface Props {
|
||||
item: Item;
|
||||
onChanged: () => void;
|
||||
// Render exactly as the storefront does, but inert. The admin's inventory
|
||||
// preview uses this: the cart and favorites providers wrap the whole app, so
|
||||
// without it an admin looking at an item could add their own stock to their
|
||||
// own cart — and on a one-of-a-kind catalogue that reserves the item and
|
||||
// takes it off sale.
|
||||
//
|
||||
// Deliberately not `disabled` on the buttons. A disabled antd button renders
|
||||
// in a different colour with a different cursor and no hover, and the entire
|
||||
// point of the preview is to show what a customer will actually see. The
|
||||
// controls keep their normal appearance and their correct state for the
|
||||
// item's status; only the handlers stop.
|
||||
preview?: boolean;
|
||||
}
|
||||
|
||||
export default function ItemCard({ item, onChanged }: Props) {
|
||||
export default function ItemCard({ item, onChanged, preview = false }: Readonly<Props>) {
|
||||
const carouselRef = useRef<CarouselRef>(null);
|
||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||
const [adding, setAdding] = useState(false);
|
||||
@@ -48,6 +60,7 @@ export default function ItemCard({ item, onChanged }: Props) {
|
||||
}
|
||||
|
||||
function handleAddClick() {
|
||||
if (preview) return;
|
||||
// Until the session has resolved, `customer` is null for a signed-in
|
||||
// visitor too, and prompting them to sign in again would be wrong.
|
||||
if (authLoading) return;
|
||||
@@ -104,6 +117,7 @@ export default function ItemCard({ item, onChanged }: Props) {
|
||||
}
|
||||
|
||||
function handleFavoriteClick() {
|
||||
if (preview) return;
|
||||
if (authLoading) return;
|
||||
if (!customer) {
|
||||
setPendingAction('favorite');
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
const suffix = () => `p${Date.now().toString(36)}${Math.random().toString(36).slice(2, 7)}`;
|
||||
|
||||
async function createItem(page: import('@playwright/test').Page, name: string, price: string) {
|
||||
const created = await page.request.post('/api/admin/items', {
|
||||
multipart: { name, description: 'A preview subject', price, category_id: '', tags: '[]' }
|
||||
});
|
||||
expect(created.ok()).toBeTruthy();
|
||||
return (await created.json()).id as number;
|
||||
}
|
||||
|
||||
test.describe('Admin item preview', () => {
|
||||
test('opens the storefront card from the item name', async ({ page }) => {
|
||||
const name = `Preview ${suffix()}`;
|
||||
await createItem(page, name, '42');
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('button', { name }).click();
|
||||
|
||||
const drawer = page.getByRole('dialog', { name: `Preview: ${name}` });
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
// The card itself, not just an empty panel: the storefront renders the
|
||||
// price formatted from cents, so this only passes if ItemCard rendered.
|
||||
await expect(drawer.getByText('$42.00')).toBeVisible();
|
||||
});
|
||||
|
||||
// The claim the preview has to make: it looks live, and it is not. The
|
||||
// buttons must keep their normal appearance rather than being disabled,
|
||||
// because showing a customer's view is the entire purpose of the panel.
|
||||
test('shows the Add to Cart button in its normal enabled state', async ({ page }) => {
|
||||
const name = `Preview ${suffix()}`;
|
||||
await createItem(page, name, '15');
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('button', { name }).click();
|
||||
|
||||
const drawer = page.getByRole('dialog', { name: `Preview: ${name}` });
|
||||
const addToCart = drawer.getByRole('button', { name: 'Add to Cart' });
|
||||
|
||||
await expect(addToCart).toBeVisible();
|
||||
await expect(addToCart).toBeEnabled();
|
||||
});
|
||||
|
||||
// The assertion that matters, and the one a reviewer cannot make by looking
|
||||
// at the screen. Signed out, a real Add to Cart opens the sign-in prompt
|
||||
// before it can add anything — so if that modal never appears, the handler
|
||||
// short-circuited before reaching any of its real work.
|
||||
test('does not act when the preview card is clicked', async ({ page }) => {
|
||||
const name = `Preview ${suffix()}`;
|
||||
await createItem(page, name, '99');
|
||||
|
||||
await page.goto('/admin');
|
||||
await page.getByRole('button', { name }).click();
|
||||
|
||||
const drawer = page.getByRole('dialog', { name: `Preview: ${name}` });
|
||||
await drawer.getByRole('button', { name: 'Add to Cart' }).click();
|
||||
|
||||
// Nothing succeeded and nothing prompted.
|
||||
await expect(page.getByText('Added to cart')).toHaveCount(0);
|
||||
await expect(page.getByRole('dialog', { name: /sign in/i })).toHaveCount(0);
|
||||
|
||||
// And the drawer is still simply sitting there, unchanged.
|
||||
await expect(drawer).toBeVisible();
|
||||
await expect(drawer.getByRole('button', { name: 'Add to Cart' })).toBeEnabled();
|
||||
});
|
||||
|
||||
// The storefront card must stay live where it is actually used, or this
|
||||
// change would have quietly broken buying things.
|
||||
test('leaves the real storefront card working', async ({ page }) => {
|
||||
const name = `Live ${suffix()}`;
|
||||
await createItem(page, name, '20');
|
||||
|
||||
await page.goto('/');
|
||||
const card = page.locator('.ant-card').filter({ hasText: name });
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
await card.getByRole('button', { name: 'Add to Cart' }).click();
|
||||
|
||||
// Signed out, the real card prompts for sign-in — proof the handler ran.
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user