Skip to content

Commit 23d5bfd

Browse files
authored
Merge pull request #11 from alicoding/goal/0011-lists
Lists maturation: typed datasets + List Search (goal 0011)
2 parents 9ed9efa + 9e4c021 commit 23d5bfd

32 files changed

Lines changed: 2144 additions & 216 deletions
Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
22
// This file is automatically generated. DO NOT EDIT
33

4+
export {
5+
RowStatus
6+
} from "./models.js";
7+
48
export type {
5-
List
9+
List,
10+
Row
611
} from "./models.js";
Lines changed: 61 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,42 @@
11
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
22
// This file is automatically generated. DO NOT EDIT
33

4+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
5+
// @ts-ignore: Unused imports
6+
import * as typedfield$0 from "../typedfield/models.js";
7+
48
/**
5-
* List is one reusable, named lookup table. Entries maps an input key
6-
* (whatever a workflow's list-lookup node is configured to look up) to
7-
* the value that gets written back into the workflow's Attributes.
9+
* List is one reusable, named typed dataset. Columns declares its
10+
* schema (typedfield.Field, ADR-0029); Rows carries its data.
11+
*
12+
* Entries is the PRE-0011 flat key/value shape, kept on the struct
13+
* for wire/backward compatibility only -- MigrateLegacyEntries
14+
* (migrate.go) converts any list still carrying it (Columns empty,
15+
* Entries non-empty) into the typed Columns+Rows shape the first time
16+
* it's loaded (internal/services/configuresvc's restore()), so
17+
* list-search/list-lookup execution logic only ever deals with
18+
* Columns+Rows, never a third code path for the legacy shape. A list
19+
* persisted before this goal, loaded once, re-persists in the typed
20+
* shape; Entries is never populated by any code path after that first
21+
* load -- new lists never populate it at all. DeriveEntries below is
22+
* the read-side mirror: list-lookup's own execution keeps reading a
23+
* flat map, computed from any 2+-column typed list's first two
24+
* columns, so it never needed to change at all.
825
*/
926
export interface List {
1027
"ID": string;
1128
"Label": string;
29+
"Description": string;
30+
"Columns": typedfield$0.Field[] | null;
31+
"Rows": Row[] | null;
1232
"Entries": { [_ in string]?: string } | null;
1333

1434
/**
15-
* BuiltIn marks a seeded example list (BuiltIn() below) -- purely
16-
* informational, same as httprequest.HTTPRequest.BuiltIn/
17-
* decision.Decision.BuiltIn: drives a "built-in" badge only, never
18-
* gates Edit/Delete. A seeded example is an ordinary, fully-
19-
* editable/deletable list from the moment it exists (docs/SPEC.md
20-
* §2.2's Update note).
35+
* BuiltIn marks a seeded example list -- purely informational,
36+
* same as httprequest.HTTPRequest.BuiltIn/decision.Decision.BuiltIn:
37+
* drives a "built-in" badge only, never gates Edit/Delete. A seeded
38+
* example is an ordinary, fully-editable list from the moment it
39+
* exists (docs/SPEC.md §2.2's Update note).
2140
*/
2241
"BuiltIn": boolean;
2342

@@ -30,3 +49,36 @@ export interface List {
3049
"CreatedAt": string;
3150
"UpdatedAt": string;
3251
}
52+
53+
/**
54+
* Row is one typed record in a List. Values maps a declared Column's
55+
* Key to its string value -- the same "every value stays a plain
56+
* string on the wire" discipline typedfield.Field itself documents;
57+
* a Column's Type only governs validation/rendering/matching, never
58+
* the wire shape. CreatedAt/UpdatedAt/Status are platform-owned audit
59+
* fields, set by the owning service (internal/services/configuresvc),
60+
* never a user-declared Column.
61+
*/
62+
export interface Row {
63+
"ID": string;
64+
"Values": { [_ in string]?: string } | null;
65+
"CreatedAt": string;
66+
"UpdatedAt": string;
67+
"Status": RowStatus;
68+
}
69+
70+
/**
71+
* RowStatus is a Row's platform-owned lifecycle state -- never a
72+
* TypedField/user-declared column (goal 0011's audit-column
73+
* decision: system-managed fields are Go struct fields, not entries
74+
* in List.Columns).
75+
*/
76+
export enum RowStatus {
77+
/**
78+
* The Go zero value for the underlying type of the enum.
79+
*/
80+
$zero = "",
81+
82+
RowActive = "active",
83+
RowExpired = "expired",
84+
};

frontend/bindings/github.com/alicoding/mill/internal/services/configuresvc/configureservice.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,24 @@ import * as list$0 from "../../domain/list/models.js";
4444
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
4545
// @ts-ignore: Unused imports
4646
import * as mcpserver$0 from "../../domain/mcpserver/models.js";
47+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
48+
// @ts-ignore: Unused imports
49+
import * as typedfield$0 from "../../domain/typedfield/models.js";
4750

4851
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
4952
// @ts-ignore: Unused imports
5053
import * as $models from "./models.js";
5154

55+
/**
56+
* AddListRow appends a new, Active row to a List, minting its ID here
57+
* (row-ID generation stays a service-layer concern, same as List IDs
58+
* themselves via seeding.NewSlugID -- internal/domain/list stays pure
59+
* per .claude/rules/backend.md).
60+
*/
61+
export function AddListRow(listID: string, values: { [_ in string]?: string } | null): $CancellablePromise<list$0.List> {
62+
return $Call.ByID(197919857, listID, values);
63+
}
64+
5265
/**
5366
* CaptureShellPath returns the user's real login-shell $PATH -- the
5467
* ExecEnv form's "Capture from my shell" affordance (ADR-0026's
@@ -81,8 +94,8 @@ export function CreateHTTPRequest(label: string, baseURL: string, method: string
8194
return $Call.ByID(2634895949, label, baseURL, method, body, authType, headers, openAPISpec, auth, jose, description);
8295
}
8396

84-
export function CreateList(label: string, entries: { [_ in string]?: string } | null): $CancellablePromise<list$0.List> {
85-
return $Call.ByID(1760985996, label, entries);
97+
export function CreateList(label: string, description: string, columns: typedfield$0.Field[] | null): $CancellablePromise<list$0.List> {
98+
return $Call.ByID(1760985996, label, description, columns);
8699
}
87100

88101
export function CreateMCPServer(label: string, command: string, args: string[] | null): $CancellablePromise<mcpserver$0.MCPServer> {
@@ -132,6 +145,10 @@ export function DeleteList(id: string): $CancellablePromise<void> {
132145
return $Call.ByID(1223896803, id);
133146
}
134147

148+
export function DeleteListRow(listID: string, rowID: string): $CancellablePromise<list$0.List> {
149+
return $Call.ByID(2135971241, listID, rowID);
150+
}
151+
135152
export function DeleteMCPServer(id: string): $CancellablePromise<void> {
136153
return $Call.ByID(3847603582, id);
137154
}
@@ -309,8 +326,16 @@ export function UpdateHTTPRequest(id: string, label: string, baseURL: string, me
309326
return $Call.ByID(248664070, id, label, baseURL, method, body, authType, headers, openAPISpec, auth, jose, description);
310327
}
311328

312-
export function UpdateList(id: string, label: string, entries: { [_ in string]?: string } | null): $CancellablePromise<list$0.List> {
313-
return $Call.ByID(437956429, id, label, entries);
329+
export function UpdateList(id: string, label: string, description: string, columns: typedfield$0.Field[] | null): $CancellablePromise<list$0.List> {
330+
return $Call.ByID(437956429, id, label, description, columns);
331+
}
332+
333+
/**
334+
* UpdateListRow replaces one row's Values/Status (its ID/CreatedAt
335+
* stay put; UpdatedAt is stamped here, not client-supplied).
336+
*/
337+
export function UpdateListRow(listID: string, rowID: string, values: { [_ in string]?: string } | null, status: list$0.RowStatus): $CancellablePromise<list$0.List> {
338+
return $Call.ByID(783553507, listID, rowID, values, status);
314339
}
315340

316341
export function UpdateMCPServer(id: string, label: string, command: string, args: string[] | null): $CancellablePromise<mcpserver$0.MCPServer> {

frontend/e2e/composition.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,10 @@ test('Composition page lists built-in workflows; node primitives live in a colla
190190
// node, docs/adr/0027) + code-execution (docs/adr/0026's code
191191
// execution capability, goal 0004b) + capture-file,
192192
// process-extract-html, capture-clipboard-info (the save-page
193-
// capture floor + clipboard inspector, docs/adr/0030 / SPEC.md §5).
194-
await expect(activePanel(page).getByTestId('palette-item')).toHaveCount(24)
193+
// capture floor + clipboard inspector, docs/adr/0030 / SPEC.md §5) +
194+
// list-search (docs/goals/0011-lists-maturation.md's richer, typed
195+
// successor to list-lookup).
196+
await expect(activePanel(page).getByTestId('palette-item')).toHaveCount(25)
195197
})
196198

197199
test('A new workflow starts with a starter node placed, not a blank canvas', async ({ page }) => {

frontend/e2e/configure-export-import.spec.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,19 +57,23 @@ test('Importing a Request file adds a new, independent request', async ({ page }
5757
await expect(importedRow).toHaveCount(0)
5858
})
5959

60-
test('Exporting and importing a List round-trips its entries', async ({ page }) => {
60+
test('Exporting and importing a List round-trips its typed columns and rows', async ({ page }) => {
6161
await page.goto('/')
6262
await page.getByRole('link', { name: 'Configure' }).click()
6363
await page.getByRole('tab', { name: 'Lists' }).click()
6464

6565
await page.getByTestId('new-list').click()
6666
await page.getByLabel('Label').fill('E2E export list')
67-
await page.getByPlaceholder('key').fill('color')
68-
await page.getByPlaceholder('value').fill('blue')
67+
await page.getByTestId('list-column-key').fill('color')
6968
await page.getByRole('button', { name: 'Save list' }).click()
7069

70+
await page.getByTestId('add-list-row').click()
71+
await page.getByTestId('list-row').getByRole('textbox').fill('blue')
72+
await page.getByTestId('save-list-row').click()
73+
7174
const originalRow = page.locator('[data-testid="inventory-row"][data-entity="list"]', { has: page.getByText('E2E export list', { exact: true }) })
7275
await expect(originalRow).toBeVisible()
76+
await expect(originalRow).toContainText('1 columns, 1 rows')
7377

7478
const downloadPromise = page.waitForEvent('download')
7579
await clickRowAction(page, originalRow, 'Export')
@@ -78,7 +82,16 @@ test('Exporting and importing a List round-trips its entries', async ({ page })
7882
const chunks: Buffer[] = []
7983
for await (const chunk of stream) chunks.push(chunk as Buffer)
8084
const json = Buffer.concat(chunks).toString('utf-8')
81-
expect(JSON.parse(json).entries).toEqual({ color: 'blue' })
85+
const parsed = JSON.parse(json)
86+
// internal/domain/typedfield.Field and internal/domain/list.Row carry
87+
// no json struct tags of their own, so their fields marshal under
88+
// their real Go names (Key, Values, ...) even though the top-level
89+
// exportedList wrapper fields do (columns/rows, configureservice_
90+
// export.go's own json tags).
91+
expect(parsed.columns).toHaveLength(1)
92+
expect(parsed.columns[0].Key).toBe('color')
93+
expect(parsed.rows).toHaveLength(1)
94+
expect(parsed.rows[0].Values.color).toBe('blue')
8295

8396
await page.getByTestId('import-list').click()
8497
await page.getByTestId('import-list-input').setInputFiles({
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { test, expect } from './fixtures/server'
2+
import { clickRowAction } from './inventoryRow'
3+
4+
// docs/goals/0011-lists-maturation.md: exercises the typed List
5+
// Configure UI (column schema editor + schema-generated row editor,
6+
// ConfigureLists.tsx) and the list-search node's own Inspector
7+
// (ListSearchParamsEditor.tsx, a column picker + literal-or-attribute
8+
// value + exact/fuzzy match type) built live through the canvas --
9+
// not just against the hardcoded seed (seed-completeness.spec.ts
10+
// already covers running the seed). Deletes the workflow it creates,
11+
// same shared-settings-file discipline every other spec here follows;
12+
// reuses the seeded "Example: Country codes" List rather than
13+
// creating a second one, so there's nothing List-shaped to clean up.
14+
15+
function workflowRow(page: import('@playwright/test').Page, label: string) {
16+
return page.locator('[data-testid="inventory-row"][data-entity="workflow"]', { has: page.getByText(label, { exact: true }) })
17+
}
18+
19+
function activePanel(page: import('@playwright/test').Page) {
20+
return page.locator('[role="tabpanel"]:not([hidden])').last()
21+
}
22+
23+
async function dragPaletteItemToCanvas(page: import('@playwright/test').Page, nodeTypeID: string) {
24+
await page.evaluate((id) => {
25+
const panel = document.querySelector('[role="tabpanel"]:not([hidden])')
26+
if (!panel) throw new Error('no active tabpanel')
27+
const palette = panel.querySelector(`[data-node-type-id="${id}"]`)
28+
const canvas = panel.querySelector('.react-flow__pane')
29+
if (!palette || !canvas) throw new Error(`drag setup failed: palette found=${!!palette} canvas found=${!!canvas}`)
30+
const dataTransfer = new DataTransfer()
31+
const rect = canvas.getBoundingClientRect()
32+
const clientX = rect.x + rect.width / 2
33+
const clientY = rect.y + rect.height / 2
34+
palette.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer }))
35+
canvas.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer, clientX, clientY }))
36+
canvas.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer, clientX, clientY }))
37+
}, nodeTypeID)
38+
}
39+
40+
async function connectNodes(page: import('@playwright/test').Page, sourceLabel: string, targetLabel: string) {
41+
const panel = activePanel(page)
42+
await panel.getByRole('button', { name: 'Fit View' }).click()
43+
await page.waitForTimeout(300)
44+
const sourceHandle = panel.locator('.react-flow__node').filter({ hasText: sourceLabel }).locator('.react-flow__handle.source')
45+
const targetHandle = panel.locator('.react-flow__node').filter({ hasText: targetLabel }).locator('.react-flow__handle.target')
46+
const sourceBox = await sourceHandle.boundingBox()
47+
const targetBox = await targetHandle.boundingBox()
48+
if (!sourceBox || !targetBox) throw new Error('connectNodes: handle bounding box not found')
49+
await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2)
50+
await page.mouse.down()
51+
await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 10 })
52+
await page.mouse.up()
53+
}
54+
55+
async function clickCanvasNode(page: import('@playwright/test').Page, panel: import('@playwright/test').Locator, label: string) {
56+
const node = panel.locator('.react-flow__node').filter({ hasText: label })
57+
const box = await node.boundingBox()
58+
if (!box) throw new Error(`clickCanvasNode: node "${label}" has no bounding box`)
59+
const candidates = [
60+
{ x: box.x + 10, y: box.y + 10 },
61+
{ x: box.x + box.width - 10, y: box.y + 10 },
62+
{ x: box.x + box.width / 2, y: box.y + box.height / 2 },
63+
{ x: box.x + 10, y: box.y + box.height - 10 },
64+
]
65+
for (const point of candidates) {
66+
const insideNode = await page.evaluate(({ x, y }) => {
67+
const el = document.elementFromPoint(x, y)
68+
return !!el?.closest('.react-flow__node')
69+
}, point)
70+
if (insideNode) {
71+
await page.mouse.click(point.x, point.y)
72+
return
73+
}
74+
}
75+
throw new Error(`clickCanvasNode: no point for node "${label}" resolved inside its own card`)
76+
}
77+
78+
test('Configuring a typed List: add a column, add a row, both persist', async ({ page }) => {
79+
await page.goto('/')
80+
await page.getByRole('link', { name: 'Configure' }).click()
81+
await page.getByRole('tab', { name: 'Lists' }).click()
82+
83+
await page.getByTestId('new-list').click()
84+
await page.getByLabel('Label').fill('E2E typed list UI')
85+
await page.getByTestId('list-column-key').fill('sku')
86+
await page.getByRole('button', { name: 'Save list' }).click()
87+
88+
await expect(page.getByTestId('list-rows-editor')).toBeVisible()
89+
await page.getByTestId('add-list-row').click()
90+
const row = page.getByTestId('list-row')
91+
await expect(row).toBeVisible()
92+
await row.getByRole('textbox').fill('SKU-1')
93+
await row.getByTestId('save-list-row').click()
94+
95+
await page.getByRole('button', { name: 'Close' }).click()
96+
const listRow = page.locator('[data-testid="inventory-row"][data-entity="list"]', { has: page.getByText('E2E typed list UI', { exact: true }) })
97+
await expect(listRow).toBeVisible()
98+
await expect(listRow).toContainText('1 columns, 1 rows')
99+
100+
// Clean up.
101+
await clickRowAction(page, listRow, 'Delete')
102+
await expect(listRow).toHaveCount(0)
103+
})
104+
105+
test('list-search node: configuring a real match parameter through the Inspector, then running it end to end', async ({ page }) => {
106+
await page.goto('/')
107+
await page.getByRole('link', { name: 'Workflows' }).click()
108+
await page.getByTestId('new-workflow').click()
109+
const panel = activePanel(page)
110+
await panel.getByLabel('Label').fill('E2E list-search config test')
111+
112+
// Deliberately no apply-clipboard-write-text terminal node -- this
113+
// test proves the list-search Inspector's own authoring/execution,
114+
// not clipboard I/O, and a clipboard apply step has no clipboard on
115+
// a headless Linux CI runner (docs/SPEC.md §1.3; the same fix
116+
// applied to the seeded "Example: Country lookup (search)" workflow
117+
// after a real CI failure, builtinworkflows_list.go). Ending at
118+
// list-search itself is an accepted, warn-only Process leaf
119+
// (ADR-0028).
120+
await panel.getByTestId('toggle-palette').click()
121+
await dragPaletteItemToCanvas(page, 'list-search')
122+
await expect(panel.locator('.react-flow__node')).toHaveCount(2)
123+
await connectNodes(page, 'Trigger: manual', 'List: search')
124+
125+
await clickCanvasNode(page, panel, 'List: search')
126+
const inspector = panel.getByTestId('composition-inspector')
127+
128+
// Pick the seeded typed List via the live entity picker (ADR-0009).
129+
await inspector.getByTestId('entity-ref-field').selectOption({ label: 'Example: Country codes' })
130+
131+
const editor = inspector.getByTestId('list-search-params-editor')
132+
await expect(editor).toBeVisible()
133+
await editor.getByTestId('add-list-search-param').click()
134+
// The column Select now offers the real List's own columns (code, name).
135+
await editor.getByTestId('list-search-param-column').selectOption({ label: 'Code' })
136+
await editor.getByLabel('Value literal value').fill('US')
137+
138+
// outputAttribute is a plain generic ConfigField (not owned by the
139+
// bespoke editor) -- filled via its own normal labeled text input.
140+
await inspector.getByLabel('Output attribute').fill('searchResult')
141+
142+
await panel.getByTestId('save-workflow').click()
143+
144+
const row = workflowRow(page, 'E2E list-search config test')
145+
await expect(row).toBeVisible()
146+
await row.click()
147+
148+
// Manual trigger, no declared Attributes -- Run fires immediately,
149+
// no test-input dialog (docs/adr/0008 only opens one when Attributes
150+
// are declared).
151+
await activePanel(page).getByTestId('canvas-run').click()
152+
const bar = activePanel(page).getByTestId('current-step-bar')
153+
await expect(bar).toContainText('SUCCESS', { timeout: 15_000 })
154+
155+
// Clean up.
156+
await page.getByRole('link', { name: 'Workflows' }).click()
157+
const wfRow = workflowRow(page, 'E2E list-search config test')
158+
await clickRowAction(page, wfRow, 'Delete')
159+
await expect(wfRow).toHaveCount(0)
160+
})

0 commit comments

Comments
 (0)