mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
The tree was a debranded snapshot of upstream taken around 2026-02-20, and upstream has moved about five months since. This replaces it with upstream master, mechanically re-derived: bin/debrand does the rename, and the pipeline and squashed migrations are restored from the shipped tree. Not a merge. The previous import replaced the tree wholesale in a single commit, so there is no shared content history to merge against. Reproducing the rename on the new tree is what actually converges. What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints, compose files, LLM.md, and insights/migrations. The squashed initial migration is load-bearing — the live database records it by name, so upstream's ~918 individual migrations cannot come back. Nine products are dropped as unreferenced. Twenty-one more that we do not run are kept anyway because the core app still imports them; unpicking those is a refactor and does not belong in a rebase. This base does not build yet, and the reasons are not in the rename: upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against our 4.2, and the dependency manifests still name upstream packages that we republish under our own names. Those are decisions, not renames.
289 lines
11 KiB
TypeScript
289 lines
11 KiB
TypeScript
import { Locator, Page, expect } from '@playwright/test'
|
|
|
|
import { urls } from 'scenes/urls'
|
|
|
|
import { randomString } from '../utils'
|
|
|
|
export class DashboardPage {
|
|
readonly page: Page
|
|
|
|
readonly topBarName: Locator
|
|
readonly items: Locator
|
|
readonly insightCards: Locator
|
|
readonly textCards: Locator
|
|
readonly dateFilter: Locator
|
|
readonly overridesBanner: Locator
|
|
readonly variableButtons: Locator
|
|
|
|
constructor(page: Page) {
|
|
this.page = page
|
|
|
|
this.topBarName = page.getByTestId('top-bar-name')
|
|
this.items = page.locator('.dashboard-items-wrapper')
|
|
this.insightCards = page.locator('.InsightCard')
|
|
this.textCards = page.getByTestId('text-card')
|
|
this.dateFilter = page.getByTestId('date-filter')
|
|
this.overridesBanner = page.getByText('You are viewing this dashboard with filter overrides.')
|
|
this.variableButtons = page.locator('.DataVizVariable_Button')
|
|
}
|
|
|
|
async createNew(dashboardName?: string): Promise<DashboardPage> {
|
|
// CI occasionally hits net::ERR_NETWORK_CHANGED on this goto.
|
|
await expect(async () => {
|
|
await this.page.goto(urls.dashboards())
|
|
}).toPass({ timeout: 60000 })
|
|
await this.page.getByTestId('new-dashboard').click()
|
|
await this.page.getByTestId('create-dashboard-blank').click()
|
|
await expect(this.page.locator('.dashboard')).toBeVisible()
|
|
await this.dismissQuickStartIfVisible()
|
|
|
|
if (dashboardName) {
|
|
const nameButton = this.page.locator('[data-attr="scene-name"] button').first()
|
|
const textarea = this.page.locator('[data-attr="scene-title-textarea"]')
|
|
// Click the title button to enter edit mode, retrying if loading resets the state
|
|
await expect(async () => {
|
|
if (!(await textarea.isVisible().catch(() => false))) {
|
|
await nameButton.click()
|
|
}
|
|
await expect(textarea).toBeVisible({ timeout: 3000 })
|
|
}).toPass({ timeout: 30000 })
|
|
await textarea.fill(dashboardName)
|
|
await textarea.blur()
|
|
}
|
|
|
|
return this
|
|
}
|
|
|
|
async createFromTemplate(): Promise<DashboardPage> {
|
|
await expect(async () => {
|
|
await this.page.goto(urls.dashboards())
|
|
}).toPass({ timeout: 60000 })
|
|
await this.page.getByTestId('new-dashboard').click()
|
|
|
|
// New dashboard modal uses DialogPrimitive, not Modal (see NewDashboardModal.tsx).
|
|
await expect(this.page.getByTestId('new-dashboard-chooser')).toBeVisible()
|
|
|
|
// Pick a template with no variables — `.first()` can hit e.g. AARRR or Product Analytics,
|
|
// which open the variable picker instead of creating and never leave #newDashboard=modal.
|
|
const templateOption = this.page
|
|
.locator(
|
|
'[data-attr="create-dashboard-from-template"], [data-attr="create-dashboard-from-template-featured"]'
|
|
)
|
|
.filter({ hasText: 'Website Metrics' })
|
|
.first()
|
|
await expect(templateOption).toBeVisible()
|
|
await templateOption.click()
|
|
|
|
await expect(this.page).toHaveURL(/\/dashboard\/\d+/)
|
|
return this
|
|
}
|
|
|
|
async addInsightToNewDashboard(insightName?: string): Promise<void> {
|
|
await this.page.getByTestId('dashboard-add-graph-header').click()
|
|
const row = insightName
|
|
? this.page.locator('.Modal .Table tbody tr').filter({ hasText: insightName }).first()
|
|
: this.page.locator('.Modal .Table tbody tr').first()
|
|
await row.click()
|
|
await this.page.getByRole('button', { name: 'Close' }).click()
|
|
}
|
|
|
|
async addTextCard(text: string): Promise<void> {
|
|
await this.page.getByTestId('dashboard-add-tile').click()
|
|
const addTextTileButton = this.page.getByTestId('dashboard-add-text-tile')
|
|
await expect(addTextTileButton).toBeVisible()
|
|
await addTextTileButton.click()
|
|
|
|
await expect(this.page).toHaveURL(/\/dashboard\/\d+\/text-tiles\/new(?:\?.*)?$/, { timeout: 5000 })
|
|
|
|
// Text card edit UI uses DialogPrimitive, not Modal (see TextCardModal.tsx).
|
|
const modal = this.page.getByTestId('text-card-modal')
|
|
await expect(modal).toBeVisible()
|
|
|
|
const textEditor = modal
|
|
.locator(
|
|
'textarea[data-attr="text-card-edit-area"], [data-attr="text-card-edit-area"][contenteditable="true"], [data-attr="text-card-edit-area"] [contenteditable="true"]'
|
|
)
|
|
.first()
|
|
await expect(textEditor).toBeVisible()
|
|
await textEditor.fill(text)
|
|
await modal.getByTestId('save-new-text-tile').click()
|
|
|
|
await expect(this.textCards.filter({ hasText: text })).toBeVisible()
|
|
}
|
|
|
|
async addToNewDashboardFromInsightPage(): Promise<void> {
|
|
await this.openInfoPanel()
|
|
const addButton = this.page.getByTestId('insight-add-to-dashboard-button')
|
|
await expect(addButton).toBeVisible()
|
|
await addButton.click()
|
|
|
|
const modal = this.page.locator('.Modal').filter({ hasText: 'Add to dashboard' })
|
|
await expect(modal).toBeVisible()
|
|
|
|
await modal.getByRole('button', { name: 'Add to a new dashboard' }).click()
|
|
await this.page.getByTestId('create-dashboard-blank').click()
|
|
|
|
// After creating a new dashboard from the insight page, the app either:
|
|
// 1. Navigates directly to the dashboard (when _dashboardToNavigateTo is set), OR
|
|
// 2. Shows a toast "Insight added to dashboard" and stays on the insight page
|
|
// Wait for either signal to confirm the async API call has completed.
|
|
await expect(this.page.getByText('Insight added to dashboard').or(this.insightCards)).toBeVisible({
|
|
timeout: 30000,
|
|
})
|
|
}
|
|
|
|
async openInfoPanel(): Promise<void> {
|
|
await this.page.getByTestId('open-context-panel-button').first().click()
|
|
}
|
|
|
|
async closeInfoPanel(): Promise<void> {
|
|
const closeButton = this.page.getByTestId('context-panel-close-button')
|
|
if (await closeButton.isVisible({ timeout: 2000 }).catch(() => false)) {
|
|
await closeButton.click()
|
|
}
|
|
}
|
|
|
|
async duplicate(): Promise<void> {
|
|
await this.openInfoPanel()
|
|
await this.page.getByTestId('dashboard-duplicate-button').click()
|
|
|
|
const modal = this.page.locator('.Modal').filter({ hasText: 'Duplicate dashboard' })
|
|
await expect(modal).toBeVisible()
|
|
await this.page.getByTestId('dashboard-submit-and-go').click()
|
|
|
|
await expect(this.page).toHaveURL(/\/dashboard\//)
|
|
}
|
|
|
|
async deleteDashboard(): Promise<void> {
|
|
await this.openInfoPanel()
|
|
await this.page.getByRole('button', { name: 'Delete dashboard' }).click()
|
|
|
|
const modal = this.page.locator('.Modal').filter({ hasText: 'Delete dashboard' })
|
|
await expect(modal).toBeVisible()
|
|
await this.page.getByTestId('dashboard-delete-submit').click()
|
|
}
|
|
|
|
async setDateFilter(option: string): Promise<void> {
|
|
const dataAttr = `date-filter-${option.toLowerCase().replace(/\s+/g, '-')}`
|
|
await this.dateFilter.click()
|
|
await this.page.getByTestId(dataAttr).click()
|
|
await expect(this.dateFilter).toContainText(option)
|
|
}
|
|
|
|
async setVariable(name: string, value: string | number): Promise<void> {
|
|
const field = this.page.locator('.Field').filter({ hasText: name })
|
|
await field.locator('.DataVizVariable_Button').click()
|
|
|
|
const popover = this.page.locator('.DataVizVariable_Popover')
|
|
await expect(popover).toBeVisible()
|
|
|
|
const input = popover.locator('input')
|
|
await input.fill(String(value))
|
|
await popover.getByRole('button', { name: 'Update' }).click()
|
|
|
|
await expect(popover).not.toBeVisible()
|
|
}
|
|
|
|
async closeSidePanels(): Promise<void> {
|
|
await this.page.keyboard.press('Escape')
|
|
|
|
const closePanelX = this.page.locator('.scene-layout__content-panel button:has(svg)').first()
|
|
if (await closePanelX.isVisible({ timeout: 1000 }).catch(() => false)) {
|
|
await closePanelX.click()
|
|
}
|
|
}
|
|
|
|
async dismissQuickStartIfVisible(): Promise<void> {
|
|
await this.page.keyboard.press('Escape')
|
|
}
|
|
|
|
async withReload(callback: () => Promise<void>, beforeFn?: () => Promise<void>): Promise<void> {
|
|
await beforeFn?.()
|
|
await callback()
|
|
await this.page.reload({ waitUntil: 'networkidle' })
|
|
await callback()
|
|
}
|
|
|
|
async editName(dashboardName: string = randomString('dashboard')): Promise<void> {
|
|
await this.topBarName.getByRole('button').click()
|
|
await this.topBarName.getByRole('textbox').fill(dashboardName)
|
|
await this.topBarName.getByRole('button').getByText('Save').click()
|
|
}
|
|
|
|
async findCardByTitle(title: string): Promise<Locator> {
|
|
const count = await this.insightCards.count()
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
const card = this.insightCards.nth(i)
|
|
await card.scrollIntoViewIfNeeded()
|
|
const titleText = await card
|
|
.locator('[data-attr="insight-card-title"]')
|
|
.textContent({ timeout: 5000 })
|
|
.catch(() => null)
|
|
|
|
if (titleText?.includes(title)) {
|
|
return card
|
|
}
|
|
}
|
|
|
|
throw new Error(`Could not find InsightCard with title "${title}"`)
|
|
}
|
|
|
|
async openFirstTileMenu(): Promise<void> {
|
|
const card = this.insightCards.first()
|
|
await card.scrollIntoViewIfNeeded()
|
|
await card.hover()
|
|
await card.getByTestId('more-button').click()
|
|
}
|
|
|
|
async selectTileMenuOption(option: string): Promise<void> {
|
|
const editLink = this.page
|
|
.locator('.Popover')
|
|
.getByRole(option === 'Edit' ? 'link' : 'button', { name: option })
|
|
await editLink.click()
|
|
}
|
|
|
|
async enterEditMode(): Promise<void> {
|
|
await this.page.getByTestId('dashboard-edit-mode-button').click()
|
|
await expect(this.page.getByTestId('dashboard-edit-mode-save')).toBeVisible()
|
|
}
|
|
|
|
async saveEditMode(): Promise<void> {
|
|
await this.page.getByTestId('dashboard-edit-mode-save').click()
|
|
await expect(this.page.getByTestId('dashboard-edit-mode-save')).not.toBeVisible()
|
|
}
|
|
|
|
async hoverFirstCard(): Promise<void> {
|
|
const infoButton = this.insightCards.first().locator('[data-attr="card-meta-info"]')
|
|
await infoButton.click()
|
|
}
|
|
|
|
get tilePopover(): Locator {
|
|
return this.page.locator('.Popover')
|
|
}
|
|
|
|
get popoverTitleField(): Locator {
|
|
return this.tilePopover.locator('[data-attr="insight-card-title"]')
|
|
}
|
|
|
|
get popoverDescriptionField(): Locator {
|
|
return this.tilePopover.locator('[data-attr="insight-card-description"]')
|
|
}
|
|
|
|
async editPopoverTitle(newTitle: string): Promise<void> {
|
|
await this.popoverTitleField.locator('.EditableField__display').click()
|
|
const input = this.popoverTitleField.locator('input')
|
|
await expect(input).toBeVisible()
|
|
await input.fill(newTitle)
|
|
await input.press('Enter')
|
|
}
|
|
|
|
async editPopoverDescription(description: string): Promise<void> {
|
|
await this.popoverDescriptionField.locator('.EditableField__display').click()
|
|
const textarea = this.popoverDescriptionField.locator('textarea')
|
|
await expect(textarea).toBeVisible()
|
|
await textarea.fill(description)
|
|
await textarea.blur()
|
|
}
|
|
}
|