Files
redefined-designs/frontend/tests/e2e/pages/AdminInventory.ts
T
bermudalamb 9db3c6d94c
Linting / lint (pull_request) Successful in 2m1s
SonarQube Analysis / sonarqube (pull_request) Failing after 5m14s
feat(admin): filter inventory through the same flyout the storefront uses (#169)
The two screens asked the same questions through different UI. The storefront had searchable multi-selects in a flyout; the admin still had an always-visible row of controls with a single-select category that held a list of at most one, which is what #139 left behind so the shared filter type would not have to change shape twice.

The drawer is now one component with the sections that differ driven by props rather than a second copy that would drift. Favorites is storefront-only. Status is admin-only, since pending is excluded from every public read and Published or Unpublished are not distinctions a customer can draw — the storefront keeps its three-way preset outside the drawer. The price slider needs real catalogue-wide bounds to be honest about where the prices are, and the admin has none, so there it is the two number inputs alone.

What is shared is not only the markup but the phrasing: that categories are OR and tags are AND has to read the same on both screens or it stops being one rule.

This reverses a decision `InventoryFilters.tsx` argued for in a comment — that hiding controls above a data table costs more than the space it saves, and that a drawer overlays the very rows being filtered. Both are true and both are traded for consistency between the panels. The active-filter chips are what makes the trade bearable: the current filter stays readable beside the button without opening anything, which is the part the always-visible row was really protecting. Status gets chips too, since it is now behind the button and is the filter most likely to empty a table.

`STATUS_OPTIONS` moves beside the filter type, because the drawer and the chips both need to turn a status into a label and a second copy is a second place for a new status to be forgotten.

The admin page object opens the flyout, acts, and closes it again — closing matters, because the drawer overlays the table every assertion in those specs is about.

Closes #169
2026-08-24 16:41:24 -05:00

225 lines
7.9 KiB
TypeScript

import { Locator, Page, expect } from '@playwright/test';
/**
* The admin inventory tab: the item table and the form that adds to it.
*
* Rows are located by the item's name rather than by index. The table is shared
* with every other run's items and is paginated and sortable, so an index means
* a different row depending on what else exists.
*/
export class AdminInventory {
readonly addItemButton: Locator;
readonly name: Locator;
readonly price: Locator;
readonly category: Locator;
readonly saveButton: Locator;
/**
* The item form is an antd Modal, whose footer button is "OK" rather than
* anything named for what it does. Named here so a spec says what it means.
*/
readonly confirmButton: Locator;
readonly formDialog: Locator;
readonly tagPicker: Locator;
constructor(private readonly page: Page) {
this.addItemButton = page.getByRole('button', { name: 'Add Item' });
this.name = page.getByLabel('Name');
this.price = page.getByLabel('Price (USD)');
this.category = page.getByLabel('Category', { exact: true });
this.saveButton = page.getByRole('button', { name: 'Save', exact: true });
this.confirmButton = page.getByRole('button', { name: 'OK' });
this.formDialog = page.getByRole('dialog');
this.tagPicker = page.getByText('Pick existing tags');
}
row(itemName: string): Locator {
return this.page.getByRole('row').filter({ hasText: itemName });
}
/** The status chip in an item's row — PENDING, AVAILABLE, RESERVED, SOLD. */
status(itemName: string, status: string): Locator {
return this.row(itemName).getByText(status);
}
publishButton(itemName: string): Locator {
return this.row(itemName).getByRole('button', { name: 'Publish' });
}
unpublishButton(itemName: string): Locator {
return this.row(itemName).getByRole('button', { name: 'Unpublish' });
}
/**
* The preview drawer, opened by clicking an item's name.
*
* It renders the item as the storefront card will, which is the question an
* admin is asking of a pending item — a customer never sees the pending state.
*/
previewDrawer(itemName: string): Locator {
return this.page.getByRole('dialog', { name: `Preview: ${itemName}` });
}
async openPreview(itemName: string): Promise<void> {
await this.page.getByRole('button', { name: itemName }).click();
}
deleteButton(itemName: string): Locator {
return this.row(itemName).getByRole('button', { name: 'Delete' });
}
/** The category field inside the form, and the inline-create controls in its popup. */
get categoryField(): Locator {
return this.formDialog.getByLabel('Category', { exact: true });
}
get newCategoryName(): Locator {
return this.page.getByPlaceholder('New category name');
}
get createCategoryButton(): Locator {
return this.page.getByRole('button', { name: 'Create category' });
}
// ---- The inventory filter bar ----
//
// The controls moved into the storefront's flyout (#169), so each method here
// opens the drawer, acts, and closes it again. Closing matters: the drawer
// overlays the table, and every assertion in these specs is about rows.
get filtersButton(): Locator {
return this.page.getByRole('button', { name: 'Filters' });
}
get filterDrawer(): Locator {
return this.page.getByRole('dialog', { name: 'Filters' });
}
/**
* The chip row's own "Clear all", scoped to the group so it stays distinct
* from the identically-labelled button in the drawer's footer.
*
* It exists only while something is filtered, which is what lets a spec assert
* that clearing worked by its absence.
*/
get clearFiltersButton(): Locator {
return this.page
.getByRole('group', { name: 'Active filters' })
.getByRole('button', { name: 'Clear all' });
}
get categoryFilter(): Locator {
return this.page.getByRole('combobox', { name: 'Filter by category' });
}
get statusFilter(): Locator {
return this.page.getByRole('combobox', { name: 'Filter by status' });
}
get minimumPrice(): Locator {
return this.page.getByLabel('Minimum price');
}
get maximumPrice(): Locator {
return this.page.getByLabel('Maximum price');
}
/** Opens the flyout, or leaves it open if it already is. */
async openFilters(): Promise<void> {
if (await this.filterDrawer.isVisible().catch(() => false)) return;
await this.filtersButton.click();
await expect(this.filterDrawer).toBeVisible();
}
/** Closes it through the footer button, which is what a person would click. */
async closeFilters(): Promise<void> {
await this.filterDrawer.getByRole('button', { name: /^Show / }).click();
await expect(this.filterDrawer).toBeHidden();
}
/**
* Toggles one status in the multi-select. Clicking a selected option removes
* it, which is what the clearing test relies on.
*
* The option is matched by class rather than by role because antd renders an
* invisible role="listbox" shim beside the real list for accessibility, so
* getByRole('option') finds something zero-sized that cannot be clicked; and
* once a status is selected it also renders as a tag carrying the same title,
* so an unscoped getByTitle becomes ambiguous.
*/
async toggleStatus(label: string): Promise<void> {
await this.openFilters();
const option = this.page.locator(`.ant-select-item-option[title="${label}"]`);
if (!(await option.isVisible().catch(() => false))) {
await this.statusFilter.click();
}
await option.click();
await this.closeFilters();
}
/**
* Narrows the table to one category.
*
* The table paginates and other specs create items concurrently, so an
* unfiltered page 1 is not a reliable place to look for a fixture. Waiting for
* a known row is the action's contract — the filter has been applied when the
* table has re-rendered under it.
*
* Typing the name before clicking it is not for realism: the tree is
* virtualized, so against a database holding hundreds of categories the wanted
* row is never rendered until a search narrows to it.
*/
async filterByCategory(categoryName: string, expectedRow: string): Promise<void> {
await this.openFilters();
await this.categoryFilter.click();
await this.categoryFilter.fill(categoryName);
await this.page.getByRole('treeitem', { name: categoryName }).click();
await this.closeFilters();
await expect(this.row(expectedRow)).toBeVisible();
}
/** Sets either end of the price range, leaving an omitted end untouched. */
async setPriceRange(minimum?: string, maximum?: string): Promise<void> {
await this.openFilters();
if (minimum !== undefined) await this.minimumPrice.fill(minimum);
if (maximum !== undefined) await this.maximumPrice.fill(maximum);
await this.closeFilters();
}
async clearFilters(): Promise<void> {
await this.clearFiltersButton.click();
}
async openItemForm(): Promise<void> {
await this.addItemButton.click();
}
/**
* Opens the form and waits for the data it fetches on open.
*
* Opening fetches both categories and tags, and each re-renders the modal as
* it lands. Waiting for only one still leaves the second to reflow the popup
* mid-interaction, so anything touching the category field has to wait for
* both rather than racing them.
*/
async openItemFormLoaded(): Promise<void> {
const loaded = Promise.all([
this.page.waitForResponse(
(res) => res.url().includes('/api/admin/categories') && res.request().method() === 'GET'
),
this.page.waitForResponse(
(res) => res.url().includes('/api/admin/tags') && res.request().method() === 'GET'
)
]);
await this.addItemButton.click();
await loaded;
}
/** Fills the item form and submits it. */
async addItem(name: string, price: string): Promise<void> {
await this.openItemForm();
await this.name.fill(name);
await this.price.fill(price);
await this.confirmButton.click();
}
}