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 { 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 ---- 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'); } get clearFiltersButton(): Locator { return this.page.getByRole('button', { name: 'Clear filters' }); } /** * Toggles one status in the multi-select. Clicking a selected option removes * it, which is what the clearing test relies on. * * Two antd details decide this locator. It 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 as the option, so an * unscoped getByTitle becomes ambiguous. Matching the visible option class * avoids both. * * The dropdown is opened only when it is not already open: antd keeps it open * after a selection in multiple mode, so clicking the box again would close it. */ async toggleStatus(label: string): Promise { const option = this.page.locator(`.ant-select-item-option[title="${label}"]`); if (!(await option.isVisible().catch(() => false))) { await this.statusFilter.click(); } await option.click(); } /** * 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. */ async filterByCategory(categoryName: string, expectedRow: string): Promise { await this.categoryFilter.click(); await this.categoryFilter.fill(categoryName); await this.page.getByTitle(categoryName, { exact: true }).click(); await expect(this.row(expectedRow)).toBeVisible(); } 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(); } }