diff --git a/apps/web/e2e/flows/rankings.spec.ts b/apps/web/e2e/flows/rankings.spec.ts index e813995..ae79893 100644 --- a/apps/web/e2e/flows/rankings.spec.ts +++ b/apps/web/e2e/flows/rankings.spec.ts @@ -91,7 +91,8 @@ test.describe('rankings', () => { // The archived view, kept across a reload await page.getByRole('button', { name: 'Remove filter Intent: Commercial' }).click(); - await page.getByRole('radio', { name: 'Archived' }).click(); + await page.getByRole('button', { name: 'More actions' }).click(); + await page.getByRole('menuitemcheckbox', { name: 'Show archived keywords' }).click(); await expect(page).toHaveURL(/[?&]archived=true/); await page.reload(); await expect(page.getByText('3 keywords', { exact: true }).first()).toBeVisible(); @@ -746,7 +747,8 @@ test.describe('rankings', () => { const grid = page.getByRole('grid', { name: 'Tracked keywords' }); await expect(page.getByText('58 keywords', { exact: true }).first()).toBeVisible(); - await page.getByRole('button', { name: 'Import', exact: true }).click(); + await page.getByRole('button', { name: 'More ways to add keywords' }).click(); + await page.getByRole('menuitem', { name: 'Import from a file' }).click(); await expect(page).toHaveURL(/\/seo\/keywords\/import(\?.*)?$/); const dialog = page.getByRole('dialog', { name: 'Import keywords' }); await expect(dialog).toBeVisible(); @@ -783,7 +785,7 @@ test.describe('rankings', () => { // The dialog is gone, the table stays, and the header follows the write until the report is ready await expect(page).toHaveURL(/\/seo\/keywords(\?.*)?$/); await expect(dialog).toBeHidden(); - await expect(page.getByRole('button', { name: 'Import', exact: true })).toBeFocused(); + await expect(page.getByRole('button', { name: 'More ways to add keywords' })).toBeFocused(); await expect(page.getByRole('progressbar', { name: 'Importing keywords' })).toBeVisible(); await expect(page.getByText('Import finished: 3 added, 1 skipped')).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole('progressbar', { name: 'Importing keywords' })).toBeHidden(); diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 1299c23..2e4f55c 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1930,8 +1930,40 @@ "downloadReport": "Download report", "reportName": "keyword-import-{date}.csv" }, + "export": { + "open": "Export", + "xlsx": "Excel workbook with summary", + "csv": "CSV, table only", + "failed": { + "title": "The export did not finish", + "description": "The keywords could not be read. Try again." + }, + "sheet": "Keywords", + "country": "Country", + "devices": "Devices", + "filters": "Filters", + "filtersNone": "None", + "filtersCount": "{count, plural, one {# filter} other {# filters}}", + "search": "Search", + "archived": "Archived keywords only", + "exported": "Exported", + "columns": { + "keyword": "Keyword", + "url": "Ranking URL", + "volume": "Search volume", + "difficulty": "KD", + "cpc": "CPC (USD)", + "intent": "Intent", + "priority": "Priority", + "groups": "Groups", + "checked": "Last checked" + } + }, "add": { "open": "Add keywords", + "options": "Add keywords options", + "moreWays": "More ways to add keywords", + "importFile": "Import from a file", "title": "Add keywords", "description": "Keywords are tracked on Google. Their metrics arrive within minutes.", "keywords": "Keywords", @@ -2151,7 +2183,7 @@ }, "scope": { "device": "Device", - "both": "Both", + "both": "Desktop and mobile", "country": "Country: {country}", "countrySearch": "Search countries", "countryEmpty": "No countries match “{query}”", @@ -2173,7 +2205,10 @@ "view": { "label": "Show", "active": "Active", - "archived": "Archived" + "archived": "Archived", + "showArchived": "Show archived keywords", + "archivedChip": "Archived keywords", + "backToActive": "Show active keywords" }, "error": { "title": "The keywords did not load", diff --git a/apps/web/package.json b/apps/web/package.json index 2ddf030..715ad85 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -82,6 +82,7 @@ "react-hook-form": "^7.89.0", "server-only": "^0.0.1", "tailwind-merge": "^3.7.0", + "write-excel-file": "^4.1.1", "zod": "^4.6.5" }, "msw": { diff --git a/apps/web/src/components/app/shell/page-header.test.tsx b/apps/web/src/components/app/shell/page-header.test.tsx new file mode 100644 index 0000000..1db09d4 --- /dev/null +++ b/apps/web/src/components/app/shell/page-header.test.tsx @@ -0,0 +1,29 @@ +import { screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { PageHeader } from './page-header'; +import { renderWithProviders } from '@/test/render'; + +describe('PageHeader with the title hidden', () => { + it('keeps the page’s heading for assistive technology but does not draw it', () => { + renderWithProviders( + toolbar} + actions={} + />, + ); + const heading = screen.getByRole('heading', { level: 1, name: 'Keywords' }); + expect(heading).toHaveClass('sr-only'); + expect(screen.getByText('toolbar')).toBeInTheDocument(); + expect(screen.getByText('Updated 2h ago')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument(); + }); + + it('draws the title as before without it', () => { + renderWithProviders(); + expect(screen.getByRole('heading', { level: 1, name: 'Keywords' })).not.toHaveClass('sr-only'); + }); +}); diff --git a/apps/web/src/components/app/shell/page-header.tsx b/apps/web/src/components/app/shell/page-header.tsx index 5a998df..688adfe 100644 --- a/apps/web/src/components/app/shell/page-header.tsx +++ b/apps/web/src/components/app/shell/page-header.tsx @@ -19,8 +19,12 @@ export interface PageHeaderProps { * beside the title instead of under it, so a table page spends one row above its table. */ toolbar?: ReactNode | undefined; - /** Keeps the toolbar on its own row under the title at every width, for a toolbar too wide to share the title row. */ - stacked?: boolean | undefined; + /** + * The title is read by assistive technology and takes route focus but is not drawn, for a page whose title + * the top bar's breadcrumb already shows. The toolbar then takes the row, with the meta line and the + * actions at its end, so a table page spends one row above its table. + */ + titleHidden?: boolean | undefined; /** The id the skip link and route focus use; the heading takes focus when a route changes. */ headingId?: string | undefined; } @@ -38,19 +42,36 @@ export function PageHeader({ actions, tabs, toolbar, - stacked = false, + titleHidden = false, headingId, }: PageHeaderProps): ReactElement { const compact = toolbar !== undefined; + if (titleHidden) { + return ( +
+
+

+ {title} +

+ {toolbar === undefined ? null :
{toolbar}
} + {meta === undefined && actions === undefined ? null : ( +
+ {meta === undefined ? null :

{meta}

} + {actions} +
+ )} +
+ {tabs} +
+ ); + } return (

@@ -60,19 +81,10 @@ export function PageHeader({

{toolbar === undefined ? null : ( // Narrow: its own row under the title and actions. Wide (the header's own width, so a docked inspector counts): between them -
- {toolbar} -
+
{toolbar}
)} {actions === undefined ? null : ( -
- {actions} -
+
{actions}
)}
{tabs} diff --git a/apps/web/src/components/ui/segmented-control.test.tsx b/apps/web/src/components/ui/segmented-control.test.tsx index ead91c4..47258a4 100644 --- a/apps/web/src/components/ui/segmented-control.test.tsx +++ b/apps/web/src/components/ui/segmented-control.test.tsx @@ -164,4 +164,25 @@ describe('SegmentedControl', () => { ); expect(await axe(container)).toHaveNoViolations(); }); + + it('shows only icons when asked, each named by its label', () => { + renderWithProviders( + + semantics="view" + label="Device" + value="desktop" + onValueChange={vi.fn()} + iconOnly + size="sm" + items={[ + { value: 'desktop', label: 'Desktop', icon: Monitor }, + { value: 'mobile', label: 'Mobile', icon: Monitor }, + ]} + />, + ); + expect(screen.getByRole('radio', { name: 'Desktop' })).toBeChecked(); + expect(screen.getByRole('radio', { name: 'Mobile' })).not.toBeChecked(); + // The label is the name, not text on the page + expect(screen.queryByText('Desktop')).toBeNull(); + }); }); diff --git a/apps/web/src/components/ui/segmented-control.tsx b/apps/web/src/components/ui/segmented-control.tsx index e4341a0..c5b3964 100644 --- a/apps/web/src/components/ui/segmented-control.tsx +++ b/apps/web/src/components/ui/segmented-control.tsx @@ -8,6 +8,7 @@ import type { ReactElement, Ref } from 'react'; import { Icon } from './icon'; import { useRadioFocus, type FocusHandle } from './radio-group'; +import { Tooltip } from './tooltip'; import { cn } from '@/lib/utils'; export interface SegmentedControlItem { @@ -35,6 +36,11 @@ export interface SegmentedControlProps extends Omit< onValueChange: (value: TValue) => void; /** 2 to 5 for a view option, 2 to 4 for a form value. */ items: readonly SegmentedControlItem[]; + /** + * Shows only the icons, each with its label as accessible name and tooltip (07-icons.md section 4): every + * item needs an icon. For options whose icons are understood without a word, such as the devices. + */ + iconOnly?: boolean | undefined; /** 32 px in toolbars and panels, 36 px in page headers and forms. */ size?: 'sm' | 'md' | undefined; /** Lets a form move focus to the chosen segment after a failed submit: `focusRef={field.ref}`. */ @@ -69,32 +75,49 @@ export function SegmentedControl({ onValueChange, items, size = 'md', + iconOnly = false, focusRef, className, ...props }: SegmentedControlProps): ReactElement { const groupRef = useRadioFocus(focusRef); - const segments = items.map((item) => ( - - {item.icon ? : null} - - {item.label} - {/* The heavier copy is never seen or read: it only holds the width the label has when chosen */} - - {/* A flex container ignores the space for layout; it keeps label and count apart in the name */} - {item.count === undefined ? null : ' '} - {/* The count keeps the regular weight, so its width does not change with the choice */} - {item.count === undefined ? null : {item.count}} - - )); + {/* A flex container ignores the space for layout; it keeps label and count apart in the name */} + {item.count === undefined ? null : ' '} + {/* The count keeps the regular weight, so its width does not change with the choice */} + {item.count === undefined ? null : {item.count}} + + ); + }); // A view option is named without showing the name; a form value gets a visible legend below const viewProps = semantics === 'view' ? { 'aria-label': label, 'data-slot': 'segmented-control' } : {}; const group = ( diff --git a/apps/web/src/components/ui/split-button.test.tsx b/apps/web/src/components/ui/split-button.test.tsx new file mode 100644 index 0000000..1e86caa --- /dev/null +++ b/apps/web/src/components/ui/split-button.test.tsx @@ -0,0 +1,51 @@ +import { screen } from '@testing-library/react'; +import { Plus, Upload } from 'lucide-react'; +import { describe, expect, it, vi } from 'vitest'; + +import { SplitButton } from './split-button'; +import { axe } from '@/test/axe'; +import { renderWithProviders } from '@/test/render'; + +function setup(onClick = vi.fn(), onImport = vi.fn()): ReturnType { + return renderWithProviders( + + Add keywords + , + ); +} + +describe('SplitButton', () => { + it('is a named group of the default action and the menu that sits beside it', () => { + setup(); + const group = screen.getByRole('group', { name: 'Add keywords options' }); + expect(group).toContainElement(screen.getByRole('button', { name: 'Add keywords' })); + expect(group).toContainElement(screen.getByRole('button', { name: 'More ways to add keywords' })); + }); + + it('runs the default action from its own half, and the alternatives from the menu', async () => { + const onClick = vi.fn(); + const onImport = vi.fn(); + const { user } = setup(onClick, onImport); + await user.click(screen.getByRole('button', { name: 'Add keywords' })); + expect(onClick).toHaveBeenCalledOnce(); + expect(onImport).not.toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'More ways to add keywords' })); + await user.click(await screen.findByRole('menuitem', { name: 'Import from a file' })); + expect(onImport).toHaveBeenCalledOnce(); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('has no accessibility violations', async () => { + const { container } = setup(); + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/apps/web/src/components/ui/split-button.tsx b/apps/web/src/components/ui/split-button.tsx new file mode 100644 index 0000000..e1e9b00 --- /dev/null +++ b/apps/web/src/components/ui/split-button.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { ChevronDown, type LucideIcon } from 'lucide-react'; +import type { ReactElement } from 'react'; + +import { Button, type ButtonProps } from './button'; +import { IconButton } from './icon-button'; +import { Menu, type MenuItemDef } from './menu'; +import { cn } from '@/lib/utils'; + +export interface SplitButtonProps extends Pick { + /** The default action. */ + children: string; + icon?: LucideIcon | undefined; + variant?: 'primary' | 'secondary' | undefined; + size?: 'sm' | 'md' | undefined; + /** Names the group of the two halves ("Add keywords options"). */ + groupLabel: string; + /** Names the menu half ("More ways to add keywords"); never contains the word "button". */ + menuLabel: string; + /** The alternatives. They never repeat the default action. */ + items: readonly MenuItemDef[]; + onMenuOpenChange?: ((open: boolean) => void) | undefined; +} + +/** + * A default action with a menu of alternatives beside it (09-buttons-controls.md section 4). Two sibling + * buttons share one border: the left half acts, the right half is the menu trigger, square at the button's + * height. For `primary` the right half carries a divider in the hover color. + */ +export function SplitButton({ + children, + icon, + variant = 'secondary', + size = 'md', + groupLabel, + menuLabel, + items, + onMenuOpenChange, + onClick, + disabled, +}: SplitButtonProps): ReactElement { + return ( +
+ + + } + /> +
+ ); +} diff --git a/apps/web/src/features/rankings/bulk/bulk.test.tsx b/apps/web/src/features/rankings/bulk/bulk.test.tsx index d0b76af..111e057 100644 --- a/apps/web/src/features/rankings/bulk/bulk.test.tsx +++ b/apps/web/src/features/rankings/bulk/bulk.test.tsx @@ -590,9 +590,10 @@ describe('Bulk actions', () => { it('does not bring a selection back when the filters return to what they were', async () => { const { user } = renderWithProviders(as('editor')); await choose(user, 'seo agency'); - await user.click(screen.getByRole('radio', { name: 'Archived' })); + await user.click(screen.getByRole('button', { name: 'More actions' })); + await user.click(await screen.findByRole('menuitemcheckbox', { name: 'Show archived keywords' })); await waitFor(() => expect(screen.queryByRole('group', { name: /Bulk actions/ })).toBeNull()); - await user.click(screen.getByRole('radio', { name: 'Active' })); + await user.click(screen.getByRole('button', { name: 'Show active keywords' })); await loadedGrid(); expect(screen.queryByRole('group', { name: /Bulk actions/ })).toBeNull(); expect(screen.getByRole('checkbox', { name: 'Select seo agency, United States' })).not.toBeChecked(); diff --git a/apps/web/src/features/rankings/export/export-file.test.ts b/apps/web/src/features/rankings/export/export-file.test.ts new file mode 100644 index 0000000..66a0b39 --- /dev/null +++ b/apps/web/src/features/rankings/export/export-file.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { buildCsv, buildWorkbook, fileName } from './export-file'; +import type { KeywordExport } from './export-model'; + +const TABLE: KeywordExport = { + kpis: [ + { label: 'Keywords', value: 110 }, + { label: 'Top 3', value: 8 }, + ], + header: ['Keyword', 'Desktop'], + rows: [ + ['soc service', 1], + ['managed soc', '>100'], + ['lonely', null], + ], +}; + +describe('export files', () => { + it('names a file for the day in UTC', () => { + expect(fileName('xlsx', new Date('2026-11-04T23:30:00Z'))).toBe('keywords-2026-11-04.xlsx'); + expect(fileName('csv', new Date('2026-11-04T00:00:00Z'))).toBe('keywords-2026-11-04.csv'); + }); + + it('writes the table alone as CSV', async () => { + const text = await buildCsv(TABLE).text(); + expect(text).toContain('Keyword,Desktop\r\nsoc service,1\r\nmanaged soc,>100\r\nlonely,\r\n'); + expect(text).not.toContain('Top 3'); + }); + + it('writes a workbook, a zip file of one sheet, with the counts above the table', async () => { + const blob = await buildWorkbook('Keywords', [['Country', 'India']], TABLE); + const bytes = new Uint8Array(await blob.arrayBuffer()); + // A .xlsx is a zip file: it starts with "PK" + expect([bytes[0], bytes[1]]).toEqual([0x50, 0x4b]); + expect(blob.size).toBeGreaterThan(1000); + }); +}); diff --git a/apps/web/src/features/rankings/export/export-file.ts b/apps/web/src/features/rankings/export/export-file.ts new file mode 100644 index 0000000..e19f3ea --- /dev/null +++ b/apps/web/src/features/rankings/export/export-file.ts @@ -0,0 +1,75 @@ +import type { ExportCell, KeywordExport } from './export-model'; +import { toCsv } from './export-model'; + +/** The file types of the Export menu. */ +export type ExportFormat = 'xlsx' | 'csv'; + +export const XLSX_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; +export const CSV_TYPE = 'text/csv;charset=utf-8'; + +/** A line of facts above the table: what was exported, and how. */ +export type ContextLine = readonly [label: string, value: string]; + +const WIDTH_MIN = 8; +const WIDTH_MAX = 50; + +function widthOf(column: number, table: KeywordExport): number { + const longest = Math.max( + String(table.header[column] ?? '').length, + ...table.rows.map((row) => String(row[column] ?? '').length), + ); + return Math.min(WIDTH_MAX, Math.max(WIDTH_MIN, longest + 2)); +} + +/** + * The workbook: the title and what was exported, the counts of the strip, then the table under its header. + * Everything sits on one sheet, so the counts stay above the table they sum up. The writer loads on first use. + */ +export async function buildWorkbook( + title: string, + context: readonly ContextLine[], + table: KeywordExport, +): Promise { + const { default: writeExcelFile } = await import('write-excel-file/universal'); + const bold = { fontWeight: 'bold' as const }; + const text = (value: string, style: object = {}): { value: string; type: StringConstructor } => ({ + value, + type: String, + ...style, + }); + const cell = (value: ExportCell, style: object = {}): object | null => { + if (value === null) return null; + return typeof value === 'number' ? { value, type: Number, ...style } : { value, type: String, ...style }; + }; + const data = [ + [text(title, { fontWeight: 'bold', fontSize: 14 })], + ...context.map(([label, value]) => [text(label, bold), text(value)]), + [], + ...table.kpis.map((kpi) => [text(kpi.label, bold), { value: kpi.value, type: Number, align: 'left' as const }]), + [], + table.header.map((label) => text(label, { ...bold, bottomBorderStyle: 'thin' })), + ...table.rows.map((row) => row.map((value) => cell(value))), + ]; + const columns = table.header.map((_, index) => ({ width: widthOf(index, table) })); + return writeExcelFile(data as never, { columns, sheet: title }).toBlob(); +} + +export const buildCsv = (table: KeywordExport): Blob => new Blob([toCsv(table)], { type: CSV_TYPE }); + +/** The name of a file: what it holds and the day, in UTC. */ +export function fileName(format: ExportFormat, now: Date): string { + return `keywords-${now.toISOString().slice(0, 10)}.${format}`; +} + +/** Hands a file to the browser as a download. */ +export function download(blob: Blob, name: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = name; + document.body.append(link); + link.click(); + link.remove(); + // The browser has been handed the file by the time the click returns; the address is released after + window.setTimeout(() => URL.revokeObjectURL(url), 0); +} diff --git a/apps/web/src/features/rankings/export/export-menu.test.tsx b/apps/web/src/features/rankings/export/export-menu.test.tsx new file mode 100644 index 0000000..ed3902e --- /dev/null +++ b/apps/web/src/features/rankings/export/export-menu.test.tsx @@ -0,0 +1,60 @@ +import { screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { loadedGrid, rankingsAs, restoreWideScreen, stubWideScreen } from '@/test/rankings-harness'; +import { renderWithProviders } from '@/test/render'; + +vi.mock('@/components/app/chart/echart', () => ({ EChart: () =>
})); +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), + usePathname: () => '/digibranders/fynix/seo/keywords', +})); + +const saved: Blob[] = []; +const names: string[] = []; + +beforeEach(() => { + stubWideScreen(); + saved.length = 0; + names.length = 0; + URL.createObjectURL = (blob: Blob | MediaSource): string => { + saved.push(blob as Blob); + return 'blob:export'; + }; + URL.revokeObjectURL = vi.fn(); + // jsdom does not follow a download link: the link's name is what the browser would save the file as + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (this: HTMLAnchorElement) { + names.push(this.download); + }); +}); +afterEach(() => { + restoreWideScreen(); + vi.restoreAllMocks(); +}); + +describe('Export', () => { + it('saves the table as CSV from the Export menu, one row for each keyword with a rank for each device', async () => { + const { user } = renderWithProviders(rankingsAs('viewer')); + await loadedGrid(); + await user.click(screen.getByRole('button', { name: 'Export' })); + await user.click(await screen.findByRole('menuitem', { name: 'CSV, table only' })); + + await waitFor(() => expect(saved).toHaveLength(1)); + expect(names[0]).toMatch(/^keywords-\d{4}-\d{2}-\d{2}\.csv$/); + const text = await (saved[0] as Blob).text(); + expect(text.split('\r\n')[0]).toContain('Keyword,Desktop,Mobile'); + expect(text.split('\r\n').length).toBeGreaterThan(5); + }); + + it('saves a workbook with the summary from the same menu, for a viewer too', async () => { + const { user } = renderWithProviders(rankingsAs('viewer')); + await loadedGrid(); + await user.click(screen.getByRole('button', { name: 'Export' })); + await user.click(await screen.findByRole('menuitem', { name: 'Excel workbook with summary' })); + + await waitFor(() => expect(saved).toHaveLength(1)); + expect(names[0]).toMatch(/\.xlsx$/); + const bytes = new Uint8Array(await (saved[0] as Blob).arrayBuffer()); + expect([bytes[0], bytes[1]]).toEqual([0x50, 0x4b]); + }); +}); diff --git a/apps/web/src/features/rankings/export/export-model.test.ts b/apps/web/src/features/rankings/export/export-model.test.ts new file mode 100644 index 0000000..e2150b5 --- /dev/null +++ b/apps/web/src/features/rankings/export/export-model.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it } from 'vitest'; + +import type { RankCell, TrackedKeywordRow } from '@/lib/api/types'; +import type { PairedRow } from '../keyword-pairs'; +import { buildExport, rankCell, safeText, toCsv, type ExportLabels } from './export-model'; + +const LABELS: ExportLabels = { + keyword: 'Keyword', + desktop: 'Desktop', + mobile: 'Mobile', + url: 'Ranking URL', + volume: 'Volume', + difficulty: 'KD', + cpc: 'CPC (USD)', + intent: 'Intent', + priority: 'Priority', + groups: 'Groups', + checked: 'Last checked', + keywords: 'Keywords', + top3: 'Top 3', + top10: 'Top 10', + top20: 'Top 20', +}; + +const cell = (over: Partial): RankCell => ({ + absolute_rank: null, + depth_requested: 100, + depth_returned: 100, + observed_at: '2026-11-04T05:00:00Z', + rank: null, + serp_snapshot_id: null, + state: 'beyond_depth', + url: null, + ...over, +}); + +function tracked(device: 'desktop' | 'mobile', current: RankCell): TrackedKeywordRow { + return { + device, + state: { current }, + keyword: { text: 'soc service', keyword_id: 'k1' }, + metrics: { search_volume: { value: 8100 }, keyword_difficulty: 13, cpc_usd: '2.660000' }, + groups: [{ group_id: 'g', name: 'Cybersecurity', path: 'Cybersecurity' }], + intent: 'informational', + priority: 3, + } as unknown as TrackedKeywordRow; +} + +function paired(desktop: RankCell | null, mobile: RankCell | null, text = 'soc service'): PairedRow { + const d = desktop === null ? null : tracked('desktop', desktop); + const m = mobile === null ? null : tracked('mobile', mobile); + const primary = (d ?? m) as TrackedKeywordRow; + return { ...primary, keyword: { ...primary.keyword, text }, devices: { desktop: d, mobile: m } }; +} + +const url = { + canonical_url: 'https://eventussecurity.com/soc', + host: 'h', + path: '/', + registrable_domain: 'r', + url_id: '1', +}; +const ranked = (rank: number): RankCell => cell({ state: 'ranked', rank, url }); + +describe('rankCell', () => { + it('is a number up to the depth, `>` and the depth beyond it, and nothing before a check', () => { + expect(rankCell(ranked(7))).toBe(7); + expect(rankCell(cell({}))).toBe('>100'); + expect(rankCell(cell({ state: 'not_checked', depth_requested: null }))).toBeNull(); + expect(rankCell(undefined)).toBeNull(); + }); +}); + +describe('buildExport', () => { + const rows = [ + paired(ranked(1), cell({})), + paired(ranked(8), ranked(2), 'managed soc'), + paired(cell({}), null, 'lonely'), + ]; + + it('counts the tracked keywords of both devices, as the strip does', () => { + const { kpis } = buildExport(rows, 'both', LABELS); + expect(kpis).toEqual([ + { label: 'Keywords', value: 5 }, + { label: 'Top 3', value: 2 }, + { label: 'Top 10', value: 3 }, + { label: 'Top 20', value: 3 }, + ]); + }); + + it('counts only the device chosen, and drops the other device’s column', () => { + const mobile = buildExport(rows, 'mobile', LABELS); + expect(mobile.kpis.map((kpi) => kpi.value)).toEqual([2, 1, 1, 1]); + expect(mobile.header).not.toContain('Desktop'); + expect(mobile.header).toContain('Mobile'); + const desktop = buildExport(rows, 'desktop', LABELS); + expect(desktop.kpis[0]?.value).toBe(3); + expect(desktop.header).not.toContain('Mobile'); + }); + + it('writes a row for each keyword with a rank for each device, the volume and the ranking address', () => { + const { header, rows: body } = buildExport(rows, 'both', LABELS); + expect(header.slice(0, 3)).toEqual(['Keyword', 'Desktop', 'Mobile']); + expect(body[0]).toEqual([ + 'soc service', + 1, + '>100', + 'https://eventussecurity.com/soc', + 8100, + 13, + 2.66, + 'informational', + 3, + 'Cybersecurity', + '2026-11-04 05:00 UTC', + ]); + expect(body[2]?.slice(0, 3)).toEqual(['lonely', '>100', null]); + }); +}); + +describe('text written to a file', () => { + it('keeps a spreadsheet from reading a keyword as a formula', () => { + expect(safeText('=SUM(A1)')).toBe("'=SUM(A1)"); + expect(safeText('+1 soc')).toBe("'+1 soc"); + expect(safeText('soc service')).toBe('soc service'); + }); + + it('quotes a field with a comma, a quote or a line break, and starts with a byte order mark', () => { + const csv = toCsv({ + header: ['Keyword', 'Desktop'], + rows: [ + ['a, "b"', 3], + ['line\nbreak', null], + ], + }); + expect(csv).toBe('Keyword,Desktop\r\n"a, ""b""",3\r\n"line\nbreak",\r\n'); + }); +}); diff --git a/apps/web/src/features/rankings/export/export-model.ts b/apps/web/src/features/rankings/export/export-model.ts new file mode 100644 index 0000000..15d8505 --- /dev/null +++ b/apps/web/src/features/rankings/export/export-model.ts @@ -0,0 +1,141 @@ +import type { RankCell, TrackedKeywordRow } from '@/lib/api/types'; +import type { DeviceScope, PairedRow } from '../keyword-pairs'; + +/** A cell of the exported table: text, a number, or nothing. */ +export type ExportCell = string | number | null; + +export interface ExportKpi { + label: string; + value: number; +} + +/** The words of the file, from the catalog, so the model itself holds no text a person reads. */ +export interface ExportLabels { + keyword: string; + desktop: string; + mobile: string; + url: string; + volume: string; + difficulty: string; + cpc: string; + intent: string; + priority: string; + groups: string; + checked: string; + keywords: string; + top3: string; + top10: string; + top20: string; +} + +export interface KeywordExport { + /** Counts of tracked keywords, one per device, as the strip above the table shows them. */ + kpis: ExportKpi[]; + header: string[]; + rows: ExportCell[][]; +} + +const TOPS = [3, 10, 20] as const; + +/** A rank as a number up to the depth read; beyond it `>` and that depth; nothing for a keyword not checked yet. */ +export function rankCell(cell: RankCell | undefined): ExportCell { + if (cell === undefined) return null; + if (cell.state === 'ranked' && cell.rank !== null) return cell.rank; + if (cell.state === 'beyond_depth' && cell.depth_requested !== null) return `>${String(cell.depth_requested)}`; + return null; +} + +/** The tracked keywords of a row on the devices the scope shows. */ +function trackedIn(row: PairedRow, scope: DeviceScope): TrackedKeywordRow[] { + const found = scope === 'both' ? [row.devices.desktop, row.devices.mobile] : [row.devices[scope]]; + return found.filter((tracked): tracked is TrackedKeywordRow => tracked !== null); +} + +/** A cell that a spreadsheet would read as a formula is written as text (CSV injection). */ +export function safeText(value: string): string { + return /^[=+\-@\t\r]/.test(value) ? `'${value}` : value; +} + +/** A figure the API sends as a number or as a decimal string, as a number. */ +function number(value: number | string | null | undefined): number | null { + if (value === null || value === undefined) return null; + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function latestCheck(tracked: readonly TrackedKeywordRow[]): string | null { + const times = tracked.map((row) => row.state.current.observed_at).filter((at): at is string => at !== null); + const latest = times.sort().at(-1); + // Whole minutes in UTC, so the same check reads the same in every file + return latest === undefined ? null : `${latest.slice(0, 16).replace('T', ' ')} UTC`; +} + +/** + * The table as it is shown, one row for each keyword with a rank for each device chosen, and the counts + * of the strip above it: the keywords of each device chosen, and those in the top 3, 10 and 20. + */ +export function buildExport(rows: readonly PairedRow[], scope: DeviceScope, labels: ExportLabels): KeywordExport { + const tracked = rows.flatMap((row) => trackedIn(row, scope)); + const within = (limit: number): number => + tracked.filter((row) => row.state.current.state === 'ranked' && (row.state.current.rank ?? Infinity) <= limit) + .length; + const kpis: ExportKpi[] = [ + { label: labels.keywords, value: tracked.length }, + { label: labels.top3, value: within(TOPS[0]) }, + { label: labels.top10, value: within(TOPS[1]) }, + { label: labels.top20, value: within(TOPS[2]) }, + ]; + const showDesktop = scope !== 'mobile'; + const showMobile = scope !== 'desktop'; + const header = [ + labels.keyword, + ...(showDesktop ? [labels.desktop] : []), + ...(showMobile ? [labels.mobile] : []), + labels.url, + labels.volume, + labels.difficulty, + labels.cpc, + labels.intent, + labels.priority, + labels.groups, + labels.checked, + ]; + const body = rows.map((row): ExportCell[] => { + const desktop = row.devices.desktop?.state.current; + const mobile = row.devices.mobile?.state.current; + const shown = trackedIn(row, scope); + const ranking = (scope === 'mobile' ? [mobile, desktop] : [desktop, mobile]).find( + (cell) => cell?.state === 'ranked' && cell.url !== null, + ); + return [ + safeText(row.keyword.text), + ...(showDesktop ? [rankCell(desktop)] : []), + ...(showMobile ? [rankCell(mobile)] : []), + ranking?.url?.canonical_url ?? null, + number(row.metrics?.search_volume.value), + row.metrics?.keyword_difficulty ?? null, + number(row.metrics?.cpc_usd), + row.intent, + row.priority, + (row.groups ?? []).map((group) => group.path).join('; ') || null, + latestCheck(shown), + ]; + }); + return { kpis, header, rows: body }; +} + +/** The byte order mark that tells Excel a CSV is UTF-8. */ +const BOM = String.fromCharCode(0xfeff); + +/** The text of one CSV field (RFC 4180): quoted when it holds a comma, a quote or a line break. */ +function field(cell: ExportCell): string { + if (cell === null) return ''; + const text = String(cell); + return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text; +} + +/** The table alone, as CSV with a byte order mark so Excel reads it as UTF-8. KPIs are left to the workbook: lines above the header would break the table for most tools. */ +export function toCsv(table: Pick): string { + const lines = [table.header, ...table.rows].map((line) => line.map(field).join(',')); + return `${BOM}${lines.join('\r\n')}\r\n`; +} diff --git a/apps/web/src/features/rankings/groups/groups.test.tsx b/apps/web/src/features/rankings/groups/groups.test.tsx index 7bc7655..675fa84 100644 --- a/apps/web/src/features/rankings/groups/groups.test.tsx +++ b/apps/web/src/features/rankings/groups/groups.test.tsx @@ -772,9 +772,12 @@ describe('Manage groups', () => { await waitFor(() => expect(urls.at(-1)?.has('grp')).toBe(false)); }, 25_000); - it('offers no More actions to a viewer', async () => { - renderWithProviders(as('viewer')); + it('offers a viewer no way to manage groups', async () => { + const { user } = renderWithProviders(as('viewer')); await loadedGrid(); - expect(screen.queryByRole('button', { name: 'More actions' })).toBeNull(); + // The menu is there for the archived keywords, and holds nothing that changes anything + await user.click(screen.getByRole('button', { name: 'More actions' })); + expect(await screen.findByRole('menuitemcheckbox', { name: 'Show archived keywords' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: 'Manage groups' })).toBeNull(); }); }); diff --git a/apps/web/src/features/rankings/import/import-fixes.test.tsx b/apps/web/src/features/rankings/import/import-fixes.test.tsx index af81bbc..15113f4 100644 --- a/apps/web/src/features/rankings/import/import-fixes.test.tsx +++ b/apps/web/src/features/rankings/import/import-fixes.test.tsx @@ -16,6 +16,7 @@ import { answerImportAs, choose, chooseFile, + chooseImport, csv, fileInput, openImport, @@ -570,7 +571,7 @@ describe('Import dialog: closing', () => { expect(screen.queryByText('Drop a CSV file here, or choose one')).toBeNull(); animation.open(); await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Import keywords' })).toBeNull()); - await user.click(screen.getByRole('button', { name: 'Import' })); + await chooseImport(user); const again = await screen.findByRole('dialog', { name: 'Import keywords' }); expect(within(again).getByText('Drop a CSV file here, or choose one')).toBeInTheDocument(); }, diff --git a/apps/web/src/features/rankings/import/import-lazy.test.tsx b/apps/web/src/features/rankings/import/import-lazy.test.tsx index 74f41d7..bd88341 100644 --- a/apps/web/src/features/rankings/import/import-lazy.test.tsx +++ b/apps/web/src/features/rankings/import/import-lazy.test.tsx @@ -1,7 +1,7 @@ import { screen } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { Controlled } from '@/test/import-harness'; +import { chooseImport, Controlled } from '@/test/import-harness'; import { loadedGrid, rankingsAs, restoreWideScreen, stubWideScreen } from '@/test/rankings-harness'; import { renderWithProviders } from '@/test/render'; @@ -36,7 +36,7 @@ describe('The import dialog’s code', () => { const { user } = renderWithProviders(); await loadedGrid(); expect(loaded.count).toBe(0); - await user.click(screen.getByRole('button', { name: 'Import' })); + await chooseImport(user); expect(await screen.findByRole('dialog', { name: 'Import keywords' })).toBeInTheDocument(); expect(loaded.count).toBe(1); }); diff --git a/apps/web/src/features/rankings/import/import.test.tsx b/apps/web/src/features/rankings/import/import.test.tsx index a23e4db..9dd5163 100644 --- a/apps/web/src/features/rankings/import/import.test.tsx +++ b/apps/web/src/features/rankings/import/import.test.tsx @@ -18,6 +18,7 @@ import { WRITTEN, answerImportAs, choose, + chooseImport, countIn, csv, fileInput, @@ -89,7 +90,7 @@ describe('Import dialog', () => { expect(confirms.bodies).toEqual([{ duplicate_action: 'skip' }]); await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Import keywords' })).toBeNull()); // Focus goes back to the button that opened the dialog - expect(screen.getByRole('button', { name: 'Import' })).toHaveFocus(); + expect(screen.getByRole('button', { name: 'More ways to add keywords' })).toHaveFocus(); }, SLOW_TEST, ); @@ -382,21 +383,21 @@ describe('Import dialog', () => { expect(changes).toEqual([true, false]); await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Import keywords' })).toBeNull()); // Opened again, the dialog starts empty - await user.click(screen.getByRole('button', { name: 'Import' })); + await chooseImport(user); const again = await screen.findByRole('dialog', { name: 'Import keywords' }); expect(within(again).getByText('Drop a CSV file here, or choose one')).toBeInTheDocument(); }, SLOW_TEST, ); - it('shows the Import button to people who may import, and to nobody else', async () => { + it('shows the import to people who may import, under the arrow of Add keywords, and to nobody else', async () => { const editor = renderWithProviders(); await loadedGrid(); - expect(screen.getByRole('button', { name: 'Import' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'More ways to add keywords' })).toBeInTheDocument(); editor.unmount(); renderWithProviders(); await loadedGrid(); - expect(screen.queryByRole('button', { name: 'Import' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'More ways to add keywords' })).toBeNull(); }); }); diff --git a/apps/web/src/features/rankings/keyword-scope.tsx b/apps/web/src/features/rankings/keyword-scope.tsx index e22fe27..68bebf4 100644 --- a/apps/web/src/features/rankings/keyword-scope.tsx +++ b/apps/web/src/features/rankings/keyword-scope.tsx @@ -1,7 +1,7 @@ 'use client'; import { useQuery } from '@tanstack/react-query'; -import { ChevronDown, MapPin } from 'lucide-react'; +import { ChevronDown, MapPin, Monitor, MonitorSmartphone, Smartphone } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { parseAsInteger, parseAsStringLiteral, useQueryStates } from 'nuqs'; import { useMemo, type ReactElement } from 'react'; @@ -118,7 +118,7 @@ export function KeywordScopeControls({ [countries, current, everywhere], ); const trigger = ( - ); + const [exporting, setExporting] = useState(false); + // The table as it is filtered, read whole whatever its mode, with the counts of the strip above it + const exportKeywords = async (format: ExportFormat): Promise => { + setExporting(true); + try { + const device = keywordScope.device; + const rows = pairRows(await readAllKeywords(scope, filters, view.q, new AbortController().signal), device); + const table = buildExport(rows, device, { + keyword: exportColumnsT('keyword'), + desktop: columnsT('desktop'), + mobile: columnsT('mobile'), + url: exportColumnsT('url'), + volume: exportColumnsT('volume'), + difficulty: exportColumnsT('difficulty'), + cpc: exportColumnsT('cpc'), + intent: exportColumnsT('intent'), + priority: exportColumnsT('priority'), + groups: exportColumnsT('groups'), + checked: exportColumnsT('checked'), + keywords: stripT('tracked'), + top3: stripT('top3'), + top10: stripT('top10'), + top20: stripT('top20'), + }); + const now = new Date(); + const country = + keywordScope.countryId === ALL_COUNTRIES + ? t('scope.allCountries') + : (countries.find((candidate) => candidate.location_id === keywordScope.countryId)?.name ?? ''); + const deviceText = device === 'both' ? t('scope.both') : columnsT(device); + const filterText = + activeFilters + (view.q.trim() === '' ? 0 : 1) === 0 + ? t('export.filtersNone') + : t('export.filtersCount', { count: activeFilters + (view.q.trim() === '' ? 0 : 1) }); + const context = [ + [t('export.country'), country], + [t('export.devices'), deviceText], + [t('export.filters'), view.archived ? `${filterText}, ${t('export.archived')}` : filterText], + [t('export.exported'), `${now.toISOString().slice(0, 16).replace('T', ' ')} UTC`], + ] as const; + const blob = format === 'xlsx' ? await buildWorkbook(t('export.sheet'), context, table) : buildCsv(table); + download(blob, fileName(format, now)); + } catch { + toast({ + type: 'error', + title: t('export.failed.title'), + description: t('export.failed.description'), + }); + } finally { + setExporting(false); + } + }; + const exportMenu = ( + + {t('export.open')} + + } + items={[ + { id: EXPORT_XLSX, label: t('export.xlsx'), onSelect: () => void exportKeywords('xlsx') }, + { id: EXPORT_CSV, label: t('export.csv'), onSelect: () => void exportKeywords('csv') }, + ]} + /> + ); + const importItem: MenuItemDef = { + id: 'import', + label: t('add.importFile'), + icon: Upload, + onSelect: () => onImportOpenChange?.(true), + }; + // Import is another way to add keywords: one small button with the file import under its arrow + const addControl = + canWrite && canImport ? ( + { + if (open) void loadImportDialog(); + }} + onClick={() => setAdding(true)} + > + {t('add.open')} + + ) : canWrite ? ( + addButton + ) : canImport ? ( + + ) : null; const headerActions = ( <> - {menuItems.length > 0 ? ( - } - items={menuItems} - header={refresh.menuHeader} - onOpenChange={refresh.onMenuOpenChange} - /> - ) : null} - {canImport ? ( - - ) : null} - {canWrite ? addButton : null} + } + items={menuItems} + header={refresh.menuHeader} + onOpenChange={refresh.onMenuOpenChange} + /> + {exportMenu} + {addControl} ); const emptyState = ( @@ -540,40 +641,30 @@ export function RankingsScreen({
0 || canImport ? headerActions : undefined} - stacked + actions={headerActions} toolbar={ - // Two rows (summary and scope, then the filters) until the toolbar is wide enough for one -
-
-
- {showStrip ? ( -
- -
- ) : null} - -
-
- - semantics="view" - label={t('view.label')} - value={view.archived ? 'archived' : 'active'} - onValueChange={(next) => void setView({ archived: next === 'archived' ? true : null })} - items={[ - { value: 'active', label: t('view.active') }, - { value: 'archived', label: t('view.archived') }, - ]} - /> - } - /> +
+ {showStrip ? ( +
+
+ ) : null} + + {view.archived ? ( + + ) : null} +
+
} diff --git a/apps/web/src/features/rankings/rankings.test.tsx b/apps/web/src/features/rankings/rankings.test.tsx index bd5bcaf..32e21a6 100644 --- a/apps/web/src/features/rankings/rankings.test.tsx +++ b/apps/web/src/features/rankings/rankings.test.tsx @@ -98,7 +98,7 @@ describe('Rankings', () => { await waitFor(() => expect(within(grid).queryByRole('columnheader', { name: /^Desktop$/ })).toBeNull()); expect(within(grid).getByRole('columnheader', { name: /^Mobile$/ })).toBeInTheDocument(); - await user.click(screen.getByRole('radio', { name: 'Both' })); + await user.click(screen.getByRole('radio', { name: 'Desktop and mobile' })); await waitFor(() => expect(within(grid).getByRole('columnheader', { name: /^Desktop$/ })).toBeInTheDocument()); expect(within(grid).getByRole('columnheader', { name: /^Mobile$/ })).toBeInTheDocument(); }); @@ -252,12 +252,14 @@ describe('Rankings', () => { const url = urlWrites(); const { user } = renderWithProviders(as('viewer'), { onUrlUpdate: url.onUrlUpdate }); const grid = await loadedGrid(); - await user.click(screen.getByRole('radio', { name: 'Archived' })); + await user.click(screen.getByRole('button', { name: 'More actions' })); + await user.click(await screen.findByRole('menuitemcheckbox', { name: 'Show archived keywords' })); await waitFor(() => expect(url.latest().get('archived')).toBe('true')); await waitFor(() => expect(keywordsIn(grid).sort()).toEqual(['seo meme', 'seo newsletter', 'wordpress seo plugin']), ); - await user.click(screen.getByRole('radio', { name: 'Active' })); + // The chip that says the archived keywords are shown takes the table back to the active ones + await user.click(screen.getByRole('button', { name: 'Show active keywords' })); await waitFor(() => expect(url.latest().has('archived')).toBe(false)); }); @@ -1191,21 +1193,20 @@ describe('Rankings summary strip', () => { }); describe('Rankings import button', () => { - it('opens the import for a member who may import, before Add keywords', async () => { + it('opens the import from the menu of Add keywords for a member who may import', async () => { const onImportOpenChange = vi.fn<(open: boolean) => void>(); const { user } = renderWithProviders(as('editor', undefined, { onImportOpenChange })); await loadedGrid(); - const importButton = screen.getByRole('button', { name: 'Import' }); - const addButton = screen.getByRole('button', { name: 'Add keywords' }); - expect(importButton.compareDocumentPosition(addButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); - await user.click(importButton); + expect(screen.queryByRole('button', { name: 'Import' })).toBeNull(); + await user.click(screen.getByRole('button', { name: 'More ways to add keywords' })); + await user.click(await screen.findByRole('menuitem', { name: 'Import from a file' })); expect(onImportOpenChange).toHaveBeenCalledExactlyOnceWith(true); }); it('shows no import to a viewer', async () => { renderWithProviders(as('viewer')); await loadedGrid(); - expect(screen.queryByRole('button', { name: 'Import' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'More ways to add keywords' })).toBeNull(); }); }); diff --git a/apps/web/src/features/rankings/refresh/refresh.test.tsx b/apps/web/src/features/rankings/refresh/refresh.test.tsx index 96f1d46..dde7412 100644 --- a/apps/web/src/features/rankings/refresh/refresh.test.tsx +++ b/apps/web/src/features/rankings/refresh/refresh.test.tsx @@ -339,11 +339,14 @@ describe('Who sees Refresh metrics', () => { expect(within(menu).getByRole('menuitem', { name: 'Manage groups' })).toBeInTheDocument(); }); - it('gives a viewer no menu, and asks the API nothing about runs', async () => { + it('gives a viewer no Refresh metrics, and asks the API nothing about runs', async () => { const served = serveRuns({ used: 0, open: [] }); - renderWithProviders(as('viewer')); + const { user } = renderWithProviders(as('viewer')); await loadedGrid(); - expect(screen.queryByRole('button', { name: 'More actions' })).toBeNull(); + // The menu is there for the archived keywords only + await user.click(screen.getByRole('button', { name: 'More actions' })); + expect(await screen.findByRole('menuitemcheckbox', { name: 'Show archived keywords' })).toBeInTheDocument(); + expect(screen.queryByRole('menuitem', { name: /Refresh metrics/ })).toBeNull(); expect(served.reads()).toBe(0); }); diff --git a/apps/web/src/features/rankings/strip/rank-strip.tsx b/apps/web/src/features/rankings/strip/rank-strip.tsx index 70225d2..1205b7c 100644 --- a/apps/web/src/features/rankings/strip/rank-strip.tsx +++ b/apps/web/src/features/rankings/strip/rank-strip.tsx @@ -18,7 +18,8 @@ export interface RankStripProps { /** * Summary metric strip of the filtered keywords (24-page-templates.md section 9.3). - * Four counts: Tracked (with checked count), Top 3, Top 10 and Top 20 (each of checked count). + * Four counts of tracked keywords (a keyword on each device is two): Keywords (with checked count), Top 3, Top 10 + * and Top 20 (each of checked count). The device chosen narrows all four. */ export function RankStrip({ scope, filters, total }: RankStripProps): ReactElement { const t = useTranslations('rankings.strip'); @@ -51,12 +52,13 @@ export function RankStrip({ scope, filters, total }: RankStripProps): ReactEleme }; // The strip ignores rows without a check date for rank counts (24-page-templates.md section 9.3). - // Tracked count is the total tracked keywords matching the active filters. + // The count of keywords is of tracked keywords, one for each device: both devices chosen, a keyword counts + // twice, as its Top 3, Top 10 and Top 20 do, and one device chosen counts only its own. const items: MetricStripItem[] = [ { id: 'tracked', label: t('tracked'), - value: formatInteger(total), + value: formatInteger(counts?.total ?? total), context: checkedNote, }, itemOf('top3', t('top3'), counts?.top3, itemErrors.top3, ofChecked), diff --git a/apps/web/src/test/import-harness.tsx b/apps/web/src/test/import-harness.tsx index 00de449..22b29b2 100644 --- a/apps/web/src/test/import-harness.tsx +++ b/apps/web/src/test/import-harness.tsx @@ -104,12 +104,18 @@ export function Controlled({ ); } +/** Chooses "Import from a file" under the arrow of Add keywords, where the import is. */ +export async function chooseImport(user: ProvidedRender['user']): Promise { + await user.click(screen.getByRole('button', { name: 'More ways to add keywords' })); + await user.click(await screen.findByRole('menuitem', { name: 'Import from a file' })); +} + export async function openImport( user: ProvidedRender['user'], changes: boolean[] = [], ): Promise<{ dialog: HTMLElement; changes: boolean[] }> { await loadedGrid(); - await user.click(screen.getByRole('button', { name: 'Import' })); + await chooseImport(user); return { dialog: await screen.findByRole('dialog', { name: 'Import keywords' }), changes }; } diff --git a/docs/design-system/07-icons.md b/docs/design-system/07-icons.md index 7c06897..17f4b46 100644 --- a/docs/design-system/07-icons.md +++ b/docs/design-system/07-icons.md @@ -35,7 +35,7 @@ An icon-only control must have: 2. a tooltip that appears on hover and keyboard focus after 400 ms, with the shortcut if one exists, 3. a 24 px minimum target: 24 px inside 32 px rows, 32 px (`h-control-sm`) in tables, inspectors, toolbars and everywhere else. -Icon-only is allowed only for universally understood actions (close, more, copy, external link, collapse sidebar, settings, search, filter, sort direction, density, column chooser, previous and next record or page, open page (`Maximize2` in the inspector header, tooltip "Open page"), clear, metric info, show secret, stepper decrease and increase, help menu, theme menu, notifications) and for the collapsed sidebar. Everything else has a text label. +Icon-only is allowed only for universally understood actions (close, more, copy, external link, collapse sidebar, settings, search, filter, a device scope (`Monitor` desktop, `Smartphone` mobile, `MonitorSmartphone` both, as an icon-only segmented control), sort direction, density, column chooser, previous and next record or page, open page (`Maximize2` in the inspector header, tooltip "Open page"), clear, metric info, show secret, stepper decrease and increase, help menu, theme menu, notifications) and for the collapsed sidebar. Everything else has a text label. ## 5. Engines, brands and favicons diff --git a/docs/design-system/09-buttons-controls.md b/docs/design-system/09-buttons-controls.md index 40922a7..be0e16b 100644 --- a/docs/design-system/09-buttons-controls.md +++ b/docs/design-system/09-buttons-controls.md @@ -278,6 +278,7 @@ A primary action plus a menu of alternatives, for example "Export" (default CSV) - Left half: `rounded-r-none`. Right half: `rounded-l-none`, `-ml-px` so borders overlap, square at the button height, `ChevronDown` 14 px, accessible name "More export options". It is a Base UI `Menu.Trigger`. - Allowed variants: `secondary` (default) and `primary`. For `primary`, the right half has `border-l border-l-primary-hover` as the divider. - The menu never repeats the default action. Use a split button only when the default is chosen at least 80% of the time; otherwise use a menu button (section 10). +- Implemented as `SplitButton` (`components/ui/split-button.tsx`): `variant`, `size` (`sm` or `md`), `groupLabel`, `menuLabel` and `items`. The Rankings header uses it as a `primary` `sm` "Add keywords" with "Import from a file" under its arrow. ## 5. Toggle button @@ -311,6 +312,8 @@ One visual component with three semantic implementations. The visual: Items fill the full height of the group; there is no inner track padding. +`iconOnly` shows the icons of the items alone, each segment a square of the group's height with its label as `aria-label` and tooltip (every item needs an icon). It is for options whose icons need no word, such as the device scope (desktop, mobile, both). + **Construction.** Each item draws its own 1 px border; items overlap by 1 px (`-ml-px` on every item after the first), so neighbouring borders form single dividers. The first item takes `rounded-l-md`, the last `rounded-r-md`, and middle items stay square. The selected item is `relative`, so its `primary-border` outline paints above the neighbouring borders and follows the rounded outer corner exactly. Do not build the group as one bordered, `overflow-hidden` box with an inset ring on the selected item: the ring cannot follow the clipped corner and shows square corners at the rounded end (seen and fixed in the preview on 2026-09-30). | Item state | Classes | diff --git a/docs/design-system/24-page-templates.md b/docs/design-system/24-page-templates.md index fc10b42..278807c 100644 --- a/docs/design-system/24-page-templates.md +++ b/docs/design-system/24-page-templates.md @@ -499,7 +499,7 @@ At 1280 px the inspector overlays (the main column would be 672 px, 1,072 minus | Region | Component | Notes | |---|---|---| -| Header | `PageHeader`, `SavedViewMenu` | Primary "Add keywords"; "Import" secondary; Export in toolbar and More | +| Header | `PageHeader` with `titleHidden` (the top bar's breadcrumb already names the page; the `h1` stays for assistive technology), one row: the strip, the device scope (icons) and country, the filter bar, then "Updated", More (refresh metrics, manage groups, show archived keywords), Export (Excel workbook with the counts above the table, or the table as CSV) and the primary `sm` split button "Add keywords" with "Import from a file" under its arrow | The archived view is a menu choice with a chip that leaves it; the strip counts tracked keywords, one for each device chosen | | Filter bar | Compare and Device scopes; chips from [13-filters-search.md](13-filters-search.md) section 4. Date range and location are in the top bar | The Rank filter editor shows bucket counts as facets, which replaces a separate distribution bar | | Strip | `MetricStrip` `md`, 6 items for the filtered scope | Top 50 and 100 counts are in the Rank filter facets and Overview | | Toolbar | Table search, count, grouping, density, columns, export | Bulk bar replaces it on selection | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0b624b..333c9ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,7 +15,7 @@ importers: version: 2.1.10(react-dom@19.3.0(react@19.3.0))(react@19.3.0) geist: specifier: ^1.7.2 - version: 1.7.2(next@16.3.7(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@24.19.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) + version: 1.7.2(next@16.3.7(@playwright/test@1.63.0)(@types/node@24.19.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) gsap: specifier: ^3.13.0 version: 3.15.0 @@ -106,7 +106,7 @@ importers: version: 6.1.0 geist: specifier: ^1.7.2 - version: 1.7.2(next@16.3.7(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@24.19.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) + version: 1.7.2(next@16.3.7(@playwright/test@1.63.0)(@types/node@24.19.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)) lucide-react: specifier: 1.48.0 version: 1.48.0(react@19.3.0) @@ -140,6 +140,9 @@ importers: tailwind-merge: specifier: ^3.7.0 version: 3.7.0 + write-excel-file: + specifier: ^4.1.1 + version: 4.1.1 zod: specifier: ^4.6.5 version: 4.6.5 @@ -203,7 +206,7 @@ importers: version: 4.7.0(eslint@10.11.0(jiti@2.7.0))(tailwindcss@4.3.3)(typescript@6.0.3) eslint-plugin-boundaries: specifier: ^7.2.0 - version: 7.2.0(eslint-import-resolver-typescript@4.4.5(eslint@10.11.0(jiti@2.7.0)))(eslint@10.11.0(jiti@2.7.0)) + version: 7.2.0(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@4.4.5(eslint@10.11.0(jiti@2.7.0)))(eslint@10.11.0(jiti@2.7.0)) eslint-plugin-i18next: specifier: ^6.1.5 version: 6.1.5 @@ -2557,6 +2560,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -4569,6 +4575,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + write-excel-file@4.1.1: + resolution: {integrity: sha512-MUnCnNtQrcZek832ZcU24uU0rSphFmKPD1DvIjXOlygVb93CV7Tme6H3jUTkxsMmjB2W7HIzERzjqTi5kui71A==} + engines: {node: '>=18'} + wsl-utils@1.0.0: resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==} engines: {node: '>=20'} @@ -4869,10 +4879,10 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - '@boundaries/elements@3.1.1(eslint-import-resolver-typescript@4.4.5(eslint@10.11.0(jiti@2.7.0)))(eslint@10.11.0(jiti@2.7.0))': + '@boundaries/elements@3.1.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@4.4.5(eslint@10.11.0(jiti@2.7.0)))(eslint@10.11.0(jiti@2.7.0))': dependencies: eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.5(eslint@10.11.0(jiti@2.7.0)))(eslint@10.11.0(jiti@2.7.0)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.5(eslint@10.11.0(jiti@2.7.0)))(eslint@10.11.0(jiti@2.7.0)) handlebars: 4.7.9 is-core-module: 2.16.1 micromatch: 4.0.8 @@ -6552,10 +6562,11 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.5(eslint@10.11.0(jiti@2.7.0)))(eslint@10.11.0(jiti@2.7.0)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.5(eslint@10.11.0(jiti@2.7.0)))(eslint@10.11.0(jiti@2.7.0)): dependencies: debug: 3.2.7 optionalDependencies: + '@typescript-eslint/parser': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) eslint: 10.11.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 4.4.5(eslint@10.11.0(jiti@2.7.0)) @@ -6579,13 +6590,13 @@ snapshots: - '@eslint/css' - typescript - eslint-plugin-boundaries@7.2.0(eslint-import-resolver-typescript@4.4.5(eslint@10.11.0(jiti@2.7.0)))(eslint@10.11.0(jiti@2.7.0)): + eslint-plugin-boundaries@7.2.0(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@4.4.5(eslint@10.11.0(jiti@2.7.0)))(eslint@10.11.0(jiti@2.7.0)): dependencies: - '@boundaries/elements': 3.1.1(eslint-import-resolver-typescript@4.4.5(eslint@10.11.0(jiti@2.7.0)))(eslint@10.11.0(jiti@2.7.0)) + '@boundaries/elements': 3.1.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@4.4.5(eslint@10.11.0(jiti@2.7.0)))(eslint@10.11.0(jiti@2.7.0)) chalk: 4.1.2 eslint: 10.11.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.5(eslint@10.11.0(jiti@2.7.0)))(eslint@10.11.0(jiti@2.7.0)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.5(eslint@10.11.0(jiti@2.7.0)))(eslint@10.11.0(jiti@2.7.0)) handlebars: 4.7.9 micromatch: 4.0.8 transitivePeerDependencies: @@ -6842,6 +6853,8 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fflate@0.8.3: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -6926,7 +6939,7 @@ snapshots: fuzzysort@3.1.0: {} - geist@1.7.2(next@16.3.7(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@24.19.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)): + geist@1.7.2(next@16.3.7(@playwright/test@1.63.0)(@types/node@24.19.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)): dependencies: next: 16.3.7(@babel/core@7.29.7)(@playwright/test@1.63.0)(@types/node@24.19.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -8790,6 +8803,10 @@ snapshots: wrappy@1.0.2: {} + write-excel-file@4.1.1: + dependencies: + fflate: 0.8.3 + wsl-utils@1.0.0: dependencies: is-wsl: 3.1.1