Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 62 additions & 12 deletions frontend/e2e/settings-extensions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,25 @@ import { clickAtlasTrayTool } from './fixtures/atlasTray'
import { deleteViaContextMenu, shapeDrawPoints, shapeObjects } from './fixtures/atlasShapeTool'
import { paletteDialog } from './fixtures/palette'

// Settings > Extensions (goal 0237 S2): a registry-derived list of
// every canvas tool, each toggleable off. Shared pool: the only global
// state this spec writes (Shape's own disabled flag) is restored to
// its default (enabled) before the file ends, same cleanup discipline
// display-density.spec.ts already establishes for a Settings toggle in
// the shared pool; every board object created here is deleted here.
// Settings > Extensions (goal 0237 S2, extended by goal 0237 S3's
// rider): a registry-derived list of every registered canvas NOUN --
// every tray tool plus every tool-less noun (diagram, sheet -- native
// file-drop only, no tray button), each toggleable off. Shared pool:
// the only global state this spec writes (Shape's own disabled flag)
// is restored to its default (enabled) before the file ends, same
// cleanup discipline display-density.spec.ts already establishes for a
// Settings toggle in the shared pool; every board object created here
// is deleted here.
//
// Disabling diagram/sheet gates useAtlasNativeFileDrop.ts's own drop
// routing (a disabled drop falls through to the plain-card path). The
// OS drop GESTURE itself is a structural e2e gap (testing.md's own
// manual-only registry: WindowFilesDropped needs a real
// *WebviewWindow, which server-mode Playwright's connection is not),
// so the routing DECISION is proven at the honest layer instead --
// useAtlasNativeFileDrop.test.ts's resolveFileDropKind Vitest suite --
// and this spec only proves the row/toggle exists and states its
// narrower scope.

async function openExtensionsSection(page: import('@playwright/test').Page) {
await page.getByRole('link', { name: 'Settings' }).click()
Expand Down Expand Up @@ -59,8 +72,10 @@ test('Turn all off empties the tray of every non-built-in tool; turn all on rest
await toggleAll.click()
await expect(toggleAll).toHaveText('Turn all on')

// Every row but card now shows its toggle off.
for (const id of ['note', 'area', 'table', 'image', 'pencil', 'eraser', 'laser', 'shape']) {
// Every row but card now shows its toggle off -- including the
// tool-less nouns (diagram, sheet), which have no tray button to
// empty but still participate in the bulk toggle.
for (const id of ['note', 'area', 'table', 'image', 'pencil', 'eraser', 'laser', 'shape', 'diagram', 'sheet']) {
const toggle = page.locator(`[data-testid="extensions-row"][data-extension-id="${id}"]`).getByTestId('extensions-row-toggle').getByRole('button')
await expect(toggle).toHaveAttribute('data-checked', 'false')
}
Expand All @@ -80,7 +95,7 @@ test('Turn all off empties the tray of every non-built-in tool; turn all on rest
await expect(toggleAll).toHaveText('Turn all on')
await toggleAll.click()
await expect(toggleAll).toHaveText('Turn all off')
for (const id of ['note', 'area', 'table', 'image', 'pencil', 'eraser', 'laser', 'shape']) {
for (const id of ['note', 'area', 'table', 'image', 'pencil', 'eraser', 'laser', 'shape', 'diagram', 'sheet']) {
const toggle = page.locator(`[data-testid="extensions-row"][data-extension-id="${id}"]`).getByTestId('extensions-row-toggle').getByRole('button')
await expect(toggle).toHaveAttribute('data-checked', 'true')
}
Expand All @@ -90,9 +105,10 @@ test('Extensions section lists every registered canvas tool; the built-in card r
await page.goto('/')
await openExtensionsSection(page)

// Every ATLAS_TOOLS member (atlas/atlasTools.ts) gets exactly one
// row -- card, note, area, table, image, pencil, eraser, laser, shape.
await expect(page.getByTestId('extensions-row')).toHaveCount(9)
// Every ATLAS_TOOLS member (atlas/atlasTools.ts) plus every
// tool-less noun (diagram, sheet) gets exactly one row -- card, note,
// area, table, image, pencil, eraser, laser, shape, diagram, sheet.
await expect(page.getByTestId('extensions-row')).toHaveCount(11)

const cardRow = page.locator('[data-testid="extensions-row"][data-extension-id="card"]')
await expect(cardRow).toBeVisible()
Expand All @@ -105,6 +121,40 @@ test('Extensions section lists every registered canvas tool; the built-in card r
await expect(tableToggle).toHaveAttribute('data-checked', 'true')
})

test('A tool-less noun (diagram, sheet) gets a row with a toggle and states its narrower disable scope', async ({ page }) => {
await page.goto('/')
await openExtensionsSection(page)

const diagramRow = page.locator('[data-testid="extensions-row"][data-extension-id="diagram"]')
await expect(diagramRow).toBeVisible()
await expect(diagramRow.getByTestId('extensions-row-toggle').getByRole('button')).toHaveAttribute('data-checked', 'true')
await diagramRow.locator('summary').click()
await expect(diagramRow.getByTestId('extensions-row-description')).toHaveText(
'View and edit diagrams — draw.io files open in the real editor.',
)
await expect(diagramRow.getByTestId('extensions-row-disable-scope')).toHaveText(
'Turning this off stops new diagrams from landing on drop and closes the built-in editor. Diagrams already on the board keep working.',
)

const sheetRow = page.locator('[data-testid="extensions-row"][data-extension-id="sheet"]')
await expect(sheetRow).toBeVisible()
await expect(sheetRow.getByTestId('extensions-row-toggle').getByRole('button')).toHaveAttribute('data-checked', 'true')
await sheetRow.locator('summary').click()
await expect(sheetRow.getByTestId('extensions-row-description')).toHaveText(
'Preview spreadsheets and CSV files dropped onto the board.',
)
await expect(sheetRow.getByTestId('extensions-row-disable-scope')).toHaveText(
'Turning this off stops new sheets from landing on drop. Sheets already on the board keep working, including opening in your default app.',
)

// A tray tool's row never shows a disable-scope note -- its toggle's
// scope (tray button + palette command) is already the standing
// default every row implicitly shares.
const tableRow = page.locator('[data-testid="extensions-row"][data-extension-id="table"]')
await tableRow.locator('summary').click()
await expect(tableRow.getByTestId('extensions-row-disable-scope')).toHaveCount(0)
})

test('Disabling a tool removes it from the tray and palette, keeps existing objects rendering, and persists across reload', async ({ page }) => {
await page.goto('/')
await page.getByRole('link', { name: 'Atlas' }).click()
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/atlas/atlasNounDeclarationFields.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
{
"field": "content",
"legalValues": "an object with Component (a React component accepting { object, mirrorVersion }), ariaLabelKey (a string), and role ('img' or undefined) -- or null",
"meaning": "this noun's own placed-instance content contribution. registerNoun() feeds it into the board-object content registry (atlasNounRegistry.ts's registerBoardObjectContent) whenever boardObjectKind is non-null, killing AtlasBoardObjectNode.tsx's former per-Kind hand branch. A tool-less noun (diagram) calls registerBoardObjectContent directly instead of declaring this field at all, since it has no AtlasToolShape to satisfy. mirrorVersion bumps on a live disk change to a fileBacked Kind's own mirrored file (see fileBacked below) -- a non-file-backed Component simply ignores it."
"meaning": "this noun's own placed-instance content contribution. registerNoun() feeds it into the board-object content registry (atlasNounRegistry.ts's registerBoardObjectContent) whenever boardObjectKind is non-null, killing AtlasBoardObjectNode.tsx's former per-Kind hand branch. A tool-less noun (diagram, sheet) calls registerBoardObjectContent directly instead of declaring this field at all, since it has no AtlasToolShape to satisfy -- it instead sets this same content shape's own optional `extension` member (icon, label, description, disableScopeNote) so Settings > Extensions can still render an honest row for it. mirrorVersion bumps on a live disk change to a fileBacked Kind's own mirrored file (see fileBacked below) -- a non-file-backed Component simply ignores it."
},
{
"field": "capabilities",
Expand Down
56 changes: 55 additions & 1 deletion frontend/src/atlas/atlasNounRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,32 @@ export interface AtlasNounContent {
// RESOLVER -- see EditRouteDecl's own header for why a single Kind
// (diagram) needs the function form.
editRoute?: EditRouteDecl
// extension (goal 0237 S3 rider): Settings > Extensions row metadata
// for a NOUN WITH NO TRAY TOOL. A tool-bearing noun already carries
// icon/label/description on its own AtlasToolShapeBase descriptor
// (registerNoun below), so this stays undefined there; a tool-less
// noun (diagram, sheet -- file-drop only, no AtlasToolShape to
// declare these on) sets it directly in its own registerBoardObjectContent
// call so the Extensions list can render an honest row for it too,
// mirroring goal 0211's own description-field precedent. Presence of
// this field is exactly how toolLessNounExtensions() below finds a
// tool-less noun worth listing.
extension?: ExtensionRowMeta
}

// ExtensionRowMeta -- the fields ExtensionRow.tsx needs for a tool-less
// noun's own row that AtlasToolShapeBase would otherwise supply.
// disableScopeNote is REQUIRED (never optional): a tool-less noun has
// no tray button to hide, so its own disable toggle gates a narrower,
// noun-specific seam (file-drop routing, and for diagram the embedded-
// editor door) -- the row must always say so rather than silently
// implying the same tray-wide scope a tool row's toggle has.
export interface ExtensionRowMeta {
icon: Icon
label: string
description: string
disableScopeNote: string
capabilities?: readonly string[]
}

// AtlasBoardObjectContent -- AtlasNounContent plus the board-facts
Expand All @@ -110,7 +136,7 @@ export interface AtlasNounContent {
// than independently settable -- a Kind with no source still declares
// this field directly (shape/ink today), so the field itself stays
// required.
interface AtlasBoardObjectContent extends AtlasNounContent {
export interface AtlasBoardObjectContent extends AtlasNounContent {
dragBand: boolean
fileBacked: boolean
}
Expand Down Expand Up @@ -146,6 +172,34 @@ export function boardObjectContentFor(kind: string): AtlasBoardObjectContent | u
return boardObjectContentRegistry.get(kind as AtlasBoardObjectKind)
}

// ToolLessNounExtension -- one entry of toolLessNounExtensions() below,
// with `extension` already narrowed to non-optional (the filter that
// builds this array is the one place that check happens, so every
// consumer downstream gets a guaranteed ExtensionRowMeta instead of
// re-checking for undefined itself).
export interface ToolLessNounExtension {
kind: AtlasBoardObjectKind
content: AtlasBoardObjectContent
extension: ExtensionRowMeta
}

// toolLessNounExtensions -- every registered noun with NO AtlasToolShape
// of its own (diagram, sheet: file-drop only) that has declared
// Extensions-row metadata, so Settings > Extensions (ExtensionsSection.tsx)
// can list it alongside every tray tool. A tool-bearing noun's content
// also lives in this same registry (registerNoun folds it in below) but
// never sets `extension`, so it's excluded here -- it already gets its
// own row from ATLAS_TOOLS directly. Sorted by kind for a stable,
// deterministic row order independent of import.meta.glob's own
// alphabetical file-discovery order.
export function toolLessNounExtensions(): ToolLessNounExtension[] {
const found: ToolLessNounExtension[] = []
for (const [kind, content] of boardObjectContentRegistry.entries()) {
if (content.extension) found.push({ kind, content, extension: content.extension })
}
return found.sort((a, b) => a.kind.localeCompare(b.kind))
}

interface AtlasToolShapeBase {
icon: Icon
label: string
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/atlas/tools/diagramNoun.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { lazy } from 'react'
import { FlowchartIcon } from '@primer/octicons-react'
import type { BoardObject } from '../../../bindings/github.com/alicoding/mill/internal/domain/atlas/models'
import { registerBoardObjectContent } from '../atlasNounRegistry'
import { isDrawioEditableExtension } from '../atlasDiagramMirror'
Expand Down Expand Up @@ -45,4 +46,16 @@ registerBoardObjectContent('diagram', {
? { kind: 'embedded-engine', engine: 'drawio' }
: { kind: 'external-app' }
),
// extension (goal 0237 S3 rider): the Settings > Extensions row for
// this tool-less noun. disableScopeNote states its scope honestly --
// there is no tray button to hide, so the toggle instead gates
// useAtlasNativeFileDrop.ts's own routing (a disabled drop falls
// through to the plain card path) and dispatchObjectEdit's
// embedded-engine arm (objectSeams.ts, already keyed off object.Kind).
extension: {
icon: FlowchartIcon,
label: 'Diagram',
description: 'View and edit diagrams — draw.io files open in the real editor.',
disableScopeNote: 'Turning this off stops new diagrams from landing on drop and closes the built-in editor. Diagrams already on the board keep working.',
},
})
13 changes: 13 additions & 0 deletions frontend/src/atlas/tools/sheetNoun.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { lazy } from 'react'
import { ColumnsIcon } from '@primer/octicons-react'
import { registerBoardObjectContent } from '../atlasNounRegistry'

// Lazy-imported (React.lazy + Suspense, AtlasBoardObjectNode.tsx's own
Expand Down Expand Up @@ -40,4 +41,16 @@ registerBoardObjectContent('sheet', {
// directly.
source: { kind: 'file', pathKey: 'mirrorPath' },
editRoute: { kind: 'external-app' },
// extension (goal 0237 S3 rider): the Settings > Extensions row for
// this tool-less noun. disableScopeNote states its scope honestly --
// there is no tray button to hide and no embedded editor to close
// (editRoute above is a static 'external-app', which dispatchObjectEdit
// never gates), so the toggle only affects
// useAtlasNativeFileDrop.ts's own routing.
extension: {
icon: ColumnsIcon,
label: 'Sheet',
description: 'Preview spreadsheets and CSV files dropped onto the board.',
disableScopeNote: 'Turning this off stops new sheets from landing on drop. Sheets already on the board keep working, including opening in your default app.',
},
})
39 changes: 39 additions & 0 deletions frontend/src/atlas/useAtlasNativeFileDrop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { resolveFileDropKind } from './useAtlasNativeFileDrop'

const alwaysEnabled = () => true
const alwaysDisabled = () => false

describe('resolveFileDropKind (goal 0237 S3 rider)', () => {
it('routes a diagram path to "diagram" when the diagram extension is enabled', () => {
expect(resolveFileDropKind('/tmp/plan.drawio', alwaysEnabled)).toBe('diagram')
})

// Regression: disabling diagram from Settings > Extensions has no
// tray button to remove (diagram is file-drop only), so it must
// instead fall the drop through to the generic card path.
it('falls a disabled diagram drop through to the "card" path', () => {
expect(resolveFileDropKind('/tmp/plan.drawio', alwaysDisabled)).toBe('card')
expect(resolveFileDropKind('/tmp/flow.mmd', (id) => id !== 'diagram')).toBe('card')
})

it('routes a sheet path to "sheet" when the sheet extension is enabled', () => {
expect(resolveFileDropKind('/tmp/data.xlsx', alwaysEnabled)).toBe('sheet')
expect(resolveFileDropKind('/tmp/data.csv', alwaysEnabled)).toBe('sheet')
})

// Regression: same fall-through as diagram -- sheet has no tray
// button either.
it('falls a disabled sheet drop through to the "card" path', () => {
expect(resolveFileDropKind('/tmp/data.xlsx', alwaysDisabled)).toBe('card')
expect(resolveFileDropKind('/tmp/data.csv', (id) => id !== 'sheet')).toBe('card')
})

it('routes an image path to "image" regardless of diagram/sheet enablement', () => {
expect(resolveFileDropKind('/tmp/photo.png', alwaysDisabled)).toBe('image')
})

it('falls an unrelated extension through to "card"', () => {
expect(resolveFileDropKind('/tmp/notes.md', alwaysEnabled)).toBe('card')
})
})
Loading
Loading