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 }); // exact, because Playwright matches an accessible name case-insensitively // as a *substring* by default. Item names carry a random base36 suffix, and // one containing "ok" — plus any leftover toast still on screen, such as // "Freed rmtiq9okg22k9e" — makes this resolve to two buttons and fail as a // strict mode violation. See #253. this.confirmButton = page.getByRole('button', { name: 'OK', exact: true }); 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 { 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 { 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 { 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 { 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 { 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 { 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 { await this.clearFiltersButton.click(); } async openItemForm(): Promise { 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 { 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 { await this.openItemForm(); await this.name.fill(name); await this.price.fill(price); await this.confirmButton.click(); } }