The screen that makes the intake pipeline usable. Until now a draft existed only in item_drafts and nothing rendered it, so a successful draft and a failed one looked identical from the admin — the item shows its placeholder submission-timestamp name either way, and telling them apart needed SQL.
The price carries the weight the schema no longer does. It is labelled with where the number came from, anything not set by a person is marked unconfirmed, and publishing at an unconfirmed price asks first rather than reporting afterwards. Editing the field is what confirms it, so opening the card and leaving the price alone is not recorded as approval — the same rule the server applies, which this only has to agree with.
Discard is offered rather than delete, and a discarded card offers Restore in its place.
The e2e page object's AdminTab union is extended alongside the tab itself. It is a closed union, so admin.open('Review queue') would not type-check without it — and the tab strip and that union have to be changed together or the next spec to use it fails to compile.
Both frontend lint and build clean, still at zero warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
113 lines
3.8 KiB
TypeScript
113 lines
3.8 KiB
TypeScript
import { Locator, Page, expect } from '@playwright/test';
|
|
|
|
/** The admin tabs, in the order the shell renders them. */
|
|
export type AdminTab =
|
|
| 'Inventory'
|
|
| 'Categories'
|
|
| 'Tags'
|
|
| 'Customers'
|
|
| 'Emails'
|
|
| 'Settings'
|
|
| 'Review queue';
|
|
|
|
/**
|
|
* The admin shell: the tab strip and the panel it swaps.
|
|
*
|
|
* Only the active tab's panel is mounted, which is what keeps the locators
|
|
* inside each panel unambiguous — the email editors used to be stacked and a
|
|
* locator for "Save" matched all six.
|
|
*
|
|
* `activePanel` exists because several specs reached for
|
|
* `.ant-tabs-tabpane-active .ant-table` and similar to scope themselves to the
|
|
* visible panel. That knowledge belongs here rather than in five spec files.
|
|
*/
|
|
export class AdminPage {
|
|
readonly tabList: Locator;
|
|
readonly activePanel: Locator;
|
|
readonly activeTabLabel: Locator;
|
|
readonly themeToggle: Locator;
|
|
readonly themedBody: Locator;
|
|
/**
|
|
* antd renders Select popups into a portal at the end of <body>, outside the
|
|
* panel they belong to — so a dropdown cannot be found by scoping to the tab.
|
|
*/
|
|
readonly selectDropdown: Locator;
|
|
|
|
constructor(private readonly page: Page) {
|
|
this.tabList = page.getByRole('tablist');
|
|
this.activePanel = page.locator('.ant-tabs-tabpane-active').first();
|
|
this.activeTabLabel = page.locator('.ant-tabs-tab-active .ant-tabs-tab-btn').first();
|
|
this.themeToggle = page.getByRole('switch');
|
|
this.themedBody = page.locator('body');
|
|
this.selectDropdown = page.locator('.ant-select-dropdown').first();
|
|
}
|
|
|
|
async goto(): Promise<void> {
|
|
await this.page.goto('/admin');
|
|
}
|
|
|
|
/**
|
|
* A top-level admin tab.
|
|
*
|
|
* `exact` matters on the Emails tab: the template rail inside it also renders
|
|
* tabs, and "Email verification" contains "Email". Exact matching keeps the
|
|
* shell's tabs and the rail's apart.
|
|
*/
|
|
tab(name: AdminTab | RegExp, exact = false): Locator {
|
|
return typeof name === 'string'
|
|
? this.page.getByRole('tab', { name, exact })
|
|
: this.page.getByRole('tab', { name });
|
|
}
|
|
|
|
/**
|
|
* Opens a tab from wherever the browser is, navigating to /admin first.
|
|
*
|
|
* Takes the navigation rather than assuming it, because every caller in the
|
|
* suite did both and half of them wrote the goto themselves.
|
|
*/
|
|
async open(name: AdminTab): Promise<void> {
|
|
await this.goto();
|
|
await this.openTab(name);
|
|
}
|
|
|
|
/** The tree or table inside whichever panel is showing. */
|
|
get activeTree(): Locator {
|
|
return this.activePanel.locator('.ant-tree').first();
|
|
}
|
|
|
|
get activeTable(): Locator {
|
|
return this.activePanel.locator('.ant-table').first();
|
|
}
|
|
|
|
/**
|
|
* Puts the admin in dark mode, whatever it was in before.
|
|
*
|
|
* Idempotent rather than a toggle: the theme persists across reloads, so a
|
|
* blind click leaves the state depending on what the previous test chose.
|
|
*/
|
|
async switchToDark(): Promise<void> {
|
|
await this.goto();
|
|
if ((await this.themeToggle.getAttribute('aria-checked')) !== 'true') {
|
|
await this.themeToggle.click();
|
|
}
|
|
await expect(this.themedBody).toHaveAttribute('data-theme', 'dark');
|
|
}
|
|
|
|
/** The computed background of an element, for the contrast assertions. */
|
|
static async backgroundOf(locator: Locator): Promise<string> {
|
|
return locator.evaluate((el) => getComputedStyle(el).backgroundColor);
|
|
}
|
|
|
|
static async colorOf(locator: Locator): Promise<string> {
|
|
return locator.evaluate((el) => getComputedStyle(el).color);
|
|
}
|
|
|
|
/** Switches tabs without renavigating, for a test that visits two of them. */
|
|
async openTab(name: AdminTab): Promise<void> {
|
|
await this.tab(name, true).click();
|
|
// The panel being mounted is the completion of the click. Without this a
|
|
// caller's first locator resolves against the outgoing panel.
|
|
await expect(this.activePanel).toBeVisible();
|
|
}
|
|
}
|