diff --git a/app/gui2/e2e/collapsingAndEntering.spec.ts b/app/gui2/e2e/collapsingAndEntering.spec.ts index 46b98964edc..e7830945924 100644 --- a/app/gui2/e2e/collapsingAndEntering.spec.ts +++ b/app/gui2/e2e/collapsingAndEntering.spec.ts @@ -5,6 +5,8 @@ import { expect } from './customExpect' import { mockCollapsedFunctionInfo } from './expressionUpdates' import * as locate from './locate' +const MAIN_FILE_NODES = 11 + const COLLAPSE_SHORTCUT = os.platform() === 'darwin' ? 'Meta+G' : 'Control+G' test('Entering nodes', async ({ page }) => { @@ -108,7 +110,7 @@ test('Collapsing nodes', async ({ page }) => { async function expectInsideMain(page: Page) { await actions.expectNodePositionsInitialized(page, 64) - await expect(locate.graphNode(page)).toHaveCount(10) + await expect(locate.graphNode(page)).toHaveCount(MAIN_FILE_NODES) await expect(locate.graphNodeByBinding(page, 'five')).toExist() await expect(locate.graphNodeByBinding(page, 'ten')).toExist() await expect(locate.graphNodeByBinding(page, 'sum')).toExist() diff --git a/app/gui2/e2e/componentBrowser.spec.ts b/app/gui2/e2e/componentBrowser.spec.ts index bc29c72c1f6..dae3bd6feb1 100644 --- a/app/gui2/e2e/componentBrowser.spec.ts +++ b/app/gui2/e2e/componentBrowser.spec.ts @@ -66,18 +66,18 @@ test('Opening Component Browser with small plus buttons', async ({ page }) => { await page.keyboard.press('Escape') await page.mouse.move(100, 80) await expect(locate.smallPlusButton(page)).not.toBeVisible() - await locate.graphNodeIcon(locate.graphNodeByBinding(page, 'aggregated')).hover() + await locate.graphNodeIcon(locate.graphNodeByBinding(page, 'selected')).hover() await expect(locate.smallPlusButton(page)).toBeVisible() await locate.smallPlusButton(page).click() - await expectAndCancelBrowser(page, 'aggregated.') + await expectAndCancelBrowser(page, 'selected.') // Small (+) button shown when node is sole selection await page.keyboard.press('Escape') await expect(locate.smallPlusButton(page)).not.toBeVisible() - await locate.graphNodeByBinding(page, 'aggregated').click() + await locate.graphNodeByBinding(page, 'selected').click() await expect(locate.smallPlusButton(page)).toBeVisible() await locate.smallPlusButton(page).click() - await expectAndCancelBrowser(page, 'aggregated.') + await expectAndCancelBrowser(page, 'selected.') }) test('Graph Editor pans to Component Browser', async ({ page }) => { diff --git a/app/gui2/e2e/edgeRendering.spec.ts b/app/gui2/e2e/edgeRendering.spec.ts index 44e03d92aa1..cb0d795eccc 100644 --- a/app/gui2/e2e/edgeRendering.spec.ts +++ b/app/gui2/e2e/edgeRendering.spec.ts @@ -12,14 +12,16 @@ test('Existence of edges between nodes', async ({ page }) => { await expect(await edgesFromNodeWithBinding(page, 'aggregated')).toHaveCount(0) await expect(await edgesFromNodeWithBinding(page, 'filtered')).toHaveCount(0) - await expect(await edgesFromNodeWithBinding(page, 'data')).toHaveCount(2 * EDGE_PARTS) + await expect(await edgesFromNodeWithBinding(page, 'data')).toHaveCount(3 * EDGE_PARTS) await expect(await edgesFromNodeWithBinding(page, 'list')).toHaveCount(0) await expect(await edgesFromNodeWithBinding(page, 'final')).toHaveCount(0) + await expect(await edgesFromNodeWithBinding(page, 'selected')).toHaveCount(0) await expect(await edgesFromNodeWithBinding(page, 'prod')).toHaveCount(EDGE_PARTS) await expect(await edgesFromNodeWithBinding(page, 'sum')).toHaveCount(EDGE_PARTS) await expect(await edgesFromNodeWithBinding(page, 'ten')).toHaveCount(EDGE_PARTS) await expect(await edgesFromNodeWithBinding(page, 'five')).toHaveCount(EDGE_PARTS) + await expect(await edgesToNodeWithBinding(page, 'selected')).toHaveCount(EDGE_PARTS) await expect(await edgesToNodeWithBinding(page, 'aggregated')).toHaveCount(EDGE_PARTS) await expect(await edgesToNodeWithBinding(page, 'filtered')).toHaveCount(EDGE_PARTS) await expect(await edgesToNodeWithBinding(page, 'data')).toHaveCount(0) diff --git a/app/gui2/e2e/locate.ts b/app/gui2/e2e/locate.ts index 897c32e884d..8c15a15ca7c 100644 --- a/app/gui2/e2e/locate.ts +++ b/app/gui2/e2e/locate.ts @@ -124,7 +124,7 @@ export function graphNode(page: Page | Locator): Node { } export function graphNodeByBinding(page: Locator | Page, binding: string): Node { return graphNode(page).filter({ - has: page.locator('.binding').and(page.getByText(binding)), + has: page.locator('.binding').and(page.getByText(binding, { exact: true })), }) as Node } export function graphNodeIcon(node: Node) { diff --git a/app/gui2/e2e/widgets.spec.ts b/app/gui2/e2e/widgets.spec.ts index 18d66c57599..9cc15287b2f 100644 --- a/app/gui2/e2e/widgets.spec.ts +++ b/app/gui2/e2e/widgets.spec.ts @@ -5,16 +5,21 @@ import { mockMethodCallInfo } from './expressionUpdates' import * as locate from './locate' class DropDownLocator { + readonly rootWidget: Locator readonly dropDown: Locator readonly items: Locator + readonly selectedItems: Locator - constructor(page: Page) { - this.dropDown = page.locator('.dropdownContainer') - this.items = this.dropDown.locator('.selectable-item, .selected-item') + constructor(ancestor: Locator) { + this.rootWidget = ancestor.locator('.WidgetSelection') + this.dropDown = ancestor.locator('.Dropdown') + this.items = this.dropDown.locator('.item') + this.selectedItems = this.dropDown.locator('.item.selected') } - async expectVisibleWithOptions(page: Page, options: string[]): Promise { - await expect(this.dropDown).toBeVisible() + async expectVisibleWithOptions(options: string[]): Promise { + const page = this.dropDown.page() + await expect(this.items.first()).toBeVisible() for (const option of options) { await expect( this.items.filter({ has: page.getByText(option, { exact: true }) }), @@ -23,9 +28,17 @@ class DropDownLocator { await expect(this.items).toHaveCount(options.length) } - async clickOption(page: Page, option: string): Promise { + async clickOption(option: string): Promise { + const page = this.dropDown.page() await this.items.filter({ has: page.getByText(option) }).click() } + + async openWithArrow(): Promise { + await this.rootWidget.hover() + await expect(this.rootWidget.locator('.arrow')).toBeVisible() + await this.rootWidget.locator('.arrow').click({ force: true }) + await expect(this.dropDown).toBeVisible() + } } test('Widget in plain AST', async ({ page }) => { @@ -45,8 +58,72 @@ test('Widget in plain AST', async ({ page }) => { await expect(textWidget.locator('input')).toHaveValue('test') }) -test('Selection widgets in Data.read node', async ({ page }) => { +test('Multi-selection widget', async ({ page }) => { await actions.goToGraph(page) + await mockMethodCallInfo(page, 'selected', { + methodPointer: { + module: 'Standard.Table.Data.Table', + definedOnType: 'Standard.Table.Data.Table.Table', + name: 'select_columns', + }, + notAppliedArguments: [1], + }) + + // Click the argument to open the dropdown. + const node = locate.graphNodeByBinding(page, 'selected') + const argumentNames = node.locator('.WidgetArgumentName') + await expect(argumentNames).toHaveCount(1) + await argumentNames.first().click() + + // Get the dropdown and corresponding vector; they both have 0 items. + const dropDown = new DropDownLocator(node) + await dropDown.expectVisibleWithOptions(['Column A', 'Column B']) + await expect(dropDown.rootWidget).toHaveClass(/multiSelect/) + const vector = node.locator('.WidgetVector') + const vectorItems = vector.locator('.item .WidgetPort input') + await expect(vector).toBeVisible() + await expect(dropDown.selectedItems).toHaveCount(0) + await expect(vectorItems).toHaveCount(0) + + // Enable an item. + await dropDown.clickOption('Column A') + await expect(vector).toBeVisible() + await expect(vectorItems).toHaveCount(1) + await expect(vectorItems.first()).toHaveValue('Column A') + // Known bug: Dropdown closes after first item has been set. + //await page.keyboard('Escape') + + // Add-item button opens dropdown. + await vector.locator('.add-item').click() + await expect(dropDown.items).toHaveCount(2) + await expect(dropDown.selectedItems).toHaveCount(1) + + // Enable another item. + await dropDown.clickOption('Column B') + await expect(vectorItems).toHaveCount(2) + await expect(vectorItems.first()).toHaveValue('Column A') + await expect(vectorItems.nth(1)).toHaveValue('Column B') + await expect(dropDown.dropDown).toBeVisible() + await expect(dropDown.items).toHaveCount(2) + await expect(dropDown.selectedItems).toHaveCount(2) + + // Disable an item. + await dropDown.clickOption('Column A') + await expect(vectorItems).toHaveCount(1) + await expect(vectorItems.first()).toHaveValue('Column B') + await expect(dropDown.dropDown).toBeVisible() + await expect(dropDown.items).toHaveCount(2) + await expect(dropDown.selectedItems).toHaveCount(1) + + // Disable the last item. + await dropDown.clickOption('Column B') + await expect(vectorItems).toHaveCount(0) + await expect(dropDown.dropDown).toBeVisible() + await expect(dropDown.items).toHaveCount(2) + await expect(dropDown.selectedItems).toHaveCount(0) +}) + +async function dataReadNodeWithMethodCallInfo(page: Page): Promise { await mockMethodCallInfo(page, 'data', { methodPointer: { module: 'Standard.Base.Data', @@ -55,11 +132,15 @@ test('Selection widgets in Data.read node', async ({ page }) => { }, notAppliedArguments: [0, 1, 2], }) + return locate.graphNodeByBinding(page, 'data') +} - const dropDown = new DropDownLocator(page) +test('Selection widgets in Data.read node', async ({ page }) => { + await actions.goToGraph(page) // Check initially visible arguments - const node = locate.graphNodeByBinding(page, 'data') + const node = await dataReadNodeWithMethodCallInfo(page) + const dropDown = new DropDownLocator(node) const argumentNames = node.locator('.WidgetArgumentName') await expect(argumentNames).toHaveCount(1) @@ -70,8 +151,8 @@ test('Selection widgets in Data.read node', async ({ page }) => { // Set value on `on_problems` (static drop-down) const onProblemsArg = argumentNames.filter({ has: page.getByText('on_problems') }) await onProblemsArg.click() - await dropDown.expectVisibleWithOptions(page, ['Ignore', 'Report_Warning', 'Report_Error']) - await dropDown.clickOption(page, 'Report_Error') + await dropDown.expectVisibleWithOptions(['Ignore', 'Report_Warning', 'Report_Error']) + await dropDown.clickOption('Report_Error') await expect(onProblemsArg.locator('.WidgetToken')).toContainText([ 'Problem_Behavior', '.', @@ -88,8 +169,8 @@ test('Selection widgets in Data.read node', async ({ page }) => { notAppliedArguments: [0, 1], }) await page.getByText('Report_Error').click() - await dropDown.expectVisibleWithOptions(page, ['Ignore', 'Report_Warning', 'Report_Error']) - await dropDown.clickOption(page, 'Report_Warning') + await dropDown.expectVisibleWithOptions(['Ignore', 'Report_Warning', 'Report_Error']) + await dropDown.clickOption('Report_Warning') await expect(onProblemsArg.locator('.WidgetToken')).toContainText([ 'Problem_Behavior', '.', @@ -99,9 +180,9 @@ test('Selection widgets in Data.read node', async ({ page }) => { // Set value on `path` (dynamic config) const pathArg = argumentNames.filter({ has: page.getByText('path') }) await pathArg.click() - await expect(page.locator('.dropdownContainer')).toBeVisible() - await dropDown.expectVisibleWithOptions(page, ['Choose file…', 'File 1', 'File 2']) - await dropDown.clickOption(page, 'File 2') + await expect(page.locator('.Dropdown')).toBeVisible() + await dropDown.expectVisibleWithOptions(['Choose file…', 'File 1', 'File 2']) + await dropDown.clickOption('File 2') await expect(pathArg.locator('.WidgetText > input')).toHaveValue('File 2') // Change value on `path` (dynamic config) @@ -114,40 +195,32 @@ test('Selection widgets in Data.read node', async ({ page }) => { notAppliedArguments: [1], }) await page.getByText('path').click() - await dropDown.expectVisibleWithOptions(page, ['Choose file…', 'File 1', 'File 2']) - await dropDown.clickOption(page, 'File 1') + await dropDown.expectVisibleWithOptions(['Choose file…', 'File 1', 'File 2']) + await dropDown.clickOption('File 1') await expect(pathArg.locator('.WidgetText > input')).toHaveValue('File 1') }) test('Selection widget with text widget as input', async ({ page }) => { await actions.goToGraph(page) - await mockMethodCallInfo(page, 'data', { - methodPointer: { - module: 'Standard.Base.Data', - definedOnType: 'Standard.Base.Data', - name: 'read', - }, - notAppliedArguments: [0, 1, 2], - }) - const dropDown = new DropDownLocator(page) - const node = locate.graphNodeByBinding(page, 'data') + const node = await dataReadNodeWithMethodCallInfo(page) + const dropDown = new DropDownLocator(node) const argumentNames = node.locator('.WidgetArgumentName') const pathArg = argumentNames.filter({ has: page.getByText('path') }) const pathArgInput = pathArg.locator('.WidgetText > input') await pathArg.click() - await expect(page.locator('.dropdownContainer')).toBeVisible() - await dropDown.clickOption(page, 'File 2') + await expect(page.locator('.Dropdown')).toBeVisible() + await dropDown.clickOption('File 2') await expect(pathArgInput).toHaveValue('File 2') // Editing text input shows and filters drop down await pathArgInput.click() - await dropDown.expectVisibleWithOptions(page, ['Choose file…', 'File 1', 'File 2']) + await dropDown.expectVisibleWithOptions(['Choose file…', 'File 1', 'File 2']) await page.keyboard.insertText('File 1') - await dropDown.expectVisibleWithOptions(page, ['File 1']) + await dropDown.expectVisibleWithOptions(['File 1']) // Clearing input should show all text literal options await pathArgInput.clear() - await dropDown.expectVisibleWithOptions(page, ['File 1', 'File 2']) + await dropDown.expectVisibleWithOptions(['File 1', 'File 2']) // Esc should cancel editing and close drop down await page.keyboard.press('Escape') @@ -157,17 +230,17 @@ test('Selection widget with text widget as input', async ({ page }) => { // Choosing entry should finish editing await pathArgInput.click() - await dropDown.expectVisibleWithOptions(page, ['Choose file…', 'File 1', 'File 2']) + await dropDown.expectVisibleWithOptions(['Choose file…', 'File 1', 'File 2']) await page.keyboard.insertText('File') - await dropDown.expectVisibleWithOptions(page, ['File 1', 'File 2']) - await dropDown.clickOption(page, 'File 1') + await dropDown.expectVisibleWithOptions(['File 1', 'File 2']) + await dropDown.clickOption('File 1') await expect(pathArgInput).not.toBeFocused() await expect(pathArgInput).toHaveValue('File 1') await expect(dropDown.dropDown).not.toBeVisible() // Clicking-off and pressing enter should accept text as-is await pathArgInput.click() - await dropDown.expectVisibleWithOptions(page, ['Choose file…', 'File 1', 'File 2']) + await dropDown.expectVisibleWithOptions(['Choose file…', 'File 1', 'File 2']) await page.keyboard.insertText('File') await page.keyboard.press('Enter') await expect(pathArgInput).not.toBeFocused() @@ -175,7 +248,7 @@ test('Selection widget with text widget as input', async ({ page }) => { await expect(dropDown.dropDown).not.toBeVisible() await pathArgInput.click() - await dropDown.expectVisibleWithOptions(page, ['Choose file…', 'File 1', 'File 2']) + await dropDown.expectVisibleWithOptions(['Choose file…', 'File 1', 'File 2']) await page.keyboard.insertText('Foo') await expect(pathArgInput).toHaveValue('Foo') await page.mouse.click(200, 200) @@ -194,16 +267,16 @@ test('File Browser widget', async ({ page }) => { }, notAppliedArguments: [0, 1, 2], }) - const dropDown = new DropDownLocator(page) // Wait for arguments to load. const node = locate.graphNodeByBinding(page, 'data') + const dropDown = new DropDownLocator(node) const argumentNames = node.locator('.WidgetArgumentName') await expect(argumentNames).toHaveCount(1) const pathArg = argumentNames.filter({ has: page.getByText('path') }) await pathArg.click() - await dropDown.expectVisibleWithOptions(page, ['Choose file…', 'File 1', 'File 2']) - await dropDown.clickOption(page, 'Choose file…') + await dropDown.expectVisibleWithOptions(['Choose file…', 'File 1', 'File 2']) + await dropDown.clickOption('Choose file…') await expect(pathArg.locator('.WidgetText > input')).toHaveValue('/path/to/some/mock/file') }) @@ -217,10 +290,10 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => { }, notAppliedArguments: [1, 2, 3], }) - const dropDown = new DropDownLocator(page) // Check initially visible arguments const node = locate.graphNodeByBinding(page, 'aggregated') + const dropDown = new DropDownLocator(node) const argumentNames = node.locator('.WidgetArgumentName') await expect(argumentNames).toHaveCount(1) @@ -255,8 +328,8 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => { // Change aggregation type const firstItem = columnsArg.locator('.item > .WidgetPort > .WidgetSelection') await firstItem.click() - await dropDown.expectVisibleWithOptions(page, ['Group_By', 'Count', 'Count_Distinct']) - await dropDown.clickOption(page, 'Count_Distinct') + await dropDown.expectVisibleWithOptions(['Group_By', 'Count', 'Count_Distinct']) + await dropDown.clickOption('Count_Distinct') await expect(columnsArg.locator('.WidgetToken')).toContainText([ 'Aggregate_Column', '.', @@ -281,8 +354,8 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => { // Set column const columnArg = firstItem.locator('.WidgetSelection').first() await columnArg.click() - await dropDown.expectVisibleWithOptions(page, ['column 1', 'column 2']) - await dropDown.clickOption(page, 'column 1') + await dropDown.expectVisibleWithOptions(['column 1', 'column 2']) + await dropDown.clickOption('column 1') await expect(columnsArg.locator('.WidgetToken')).toContainText([ 'Aggregate_Column', '.', @@ -320,8 +393,8 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => { const secondItem = columnsArg.locator('.item > .WidgetPort > .WidgetSelection').nth(1) const secondColumnArg = secondItem.locator('.WidgetSelection').first() await secondColumnArg.click() - await dropDown.expectVisibleWithOptions(page, ['column 1', 'column 2']) - await dropDown.clickOption(page, 'column 2') + await dropDown.expectVisibleWithOptions(['column 1', 'column 2']) + await dropDown.clickOption('column 2') await expect(secondItem.locator('.WidgetToken')).toContainText([ 'Aggregate_Column', '.', diff --git a/app/gui2/mock/engine.ts b/app/gui2/mock/engine.ts index 78924651176..55b7eadc55a 100644 --- a/app/gui2/mock/engine.ts +++ b/app/gui2/mock/engine.ts @@ -68,6 +68,7 @@ main = data = Data.read filtered = data.filter aggregated = data.aggregate + selected = data.select_columns ` export function getMainFile() { @@ -192,6 +193,34 @@ const mockVizData: Record Uint8Array }, ], ]) + case '.select_columns': + return encodeJSON([ + [ + 'columns', + { + type: 'Widget', + constructor: 'Multiple_Choice', + label: null, + values: [ + { + type: 'Choice', + constructor: 'Option', + value: "'Column A'", + label: 'Column A', + parameters: [], + }, + { + type: 'Choice', + constructor: 'Option', + value: "'Column B'", + label: 'Column B', + parameters: [], + }, + ], + display: { type: 'Display', constructor: 'Always' }, + }, + ], + ]) case '.aggregate': return encodeJSON([ [ diff --git a/app/gui2/shared/ast/tree.ts b/app/gui2/shared/ast/tree.ts index bc45145ac2b..d6a4af45d27 100644 --- a/app/gui2/shared/ast/tree.ts +++ b/app/gui2/shared/ast/tree.ts @@ -2148,9 +2148,9 @@ export class Wildcard extends Ast { return asOwned(new MutableWildcard(module, fields)) } - static new(module: MutableModule) { + static new(module?: MutableModule) { const token = Token.new('_', RawAst.Token.Type.Wildcard) - return this.concrete(module, unspaced(token)) + return this.concrete(module ?? MutableModule.Transient(), unspaced(token)) } *concreteChildren(_verbatim?: boolean): IterableIterator { @@ -2187,6 +2187,11 @@ export class Vector extends Ast { super(module, fields) } + static tryParse(source: string, module?: MutableModule): Owned | undefined { + const parsed = parse(source, module) + if (parsed instanceof MutableVector) return parsed + } + static concrete( module: MutableModule, open: NodeChild | undefined, @@ -2269,6 +2274,23 @@ export class Vector extends Ast { export class MutableVector extends Vector implements MutableAst { declare readonly module: MutableModule declare readonly fields: FixedMap + + push(value: Owned) { + const elements = this.fields.get('elements') + const element = mapRefs( + delimitVectorElement({ value: autospaced(value) }), + ownedToRaw(this.module, this.id), + ) + this.fields.set('elements', [...elements, element]) + } + + keep(predicate: (ast: Ast) => boolean) { + const elements = this.fields.get('elements') + const filtered = elements.filter( + (element) => element.value && predicate(this.module.get(element.value.node)), + ) + this.fields.set('elements', filtered) + } } export interface MutableVector extends Vector, MutableAst { values(): IterableIterator diff --git a/app/gui2/src/components/GraphEditor/widgets/WidgetSelection.vue b/app/gui2/src/components/GraphEditor/widgets/WidgetSelection.vue index 095d7beccbd..a70a2903b11 100644 --- a/app/gui2/src/components/GraphEditor/widgets/WidgetSelection.vue +++ b/app/gui2/src/components/GraphEditor/widgets/WidgetSelection.vue @@ -1,10 +1,11 @@