mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 13:02:07 +03:00
parent
0d34126b86
commit
22a2c208c0
@ -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()
|
||||
|
@ -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 }) => {
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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<void> {
|
||||
await expect(this.dropDown).toBeVisible()
|
||||
async expectVisibleWithOptions(options: string[]): Promise<void> {
|
||||
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<void> {
|
||||
async clickOption(option: string): Promise<void> {
|
||||
const page = this.dropDown.page()
|
||||
await this.items.filter({ has: page.getByText(option) }).click()
|
||||
}
|
||||
|
||||
async openWithArrow(): Promise<void> {
|
||||
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<Locator> {
|
||||
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',
|
||||
'.',
|
||||
|
@ -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<string, Uint8Array | ((params: string[]) => 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([
|
||||
[
|
||||
|
@ -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<RawNodeChild> {
|
||||
@ -2187,6 +2187,11 @@ export class Vector extends Ast {
|
||||
super(module, fields)
|
||||
}
|
||||
|
||||
static tryParse(source: string, module?: MutableModule): Owned<MutableVector> | undefined {
|
||||
const parsed = parse(source, module)
|
||||
if (parsed instanceof MutableVector) return parsed
|
||||
}
|
||||
|
||||
static concrete(
|
||||
module: MutableModule,
|
||||
open: NodeChild<Token> | 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<AstFields & VectorFields>
|
||||
|
||||
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<MutableAst>
|
||||
|
@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import NodeWidget from '@/components/GraphEditor/NodeWidget.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import DropdownWidget from '@/components/widgets/DropdownWidget.vue'
|
||||
import DropdownWidget, { type DropdownEntry } from '@/components/widgets/DropdownWidget.vue'
|
||||
import { unrefElement } from '@/composables/events'
|
||||
import { defineWidget, Score, WidgetInput, widgetProps } from '@/providers/widgetRegistry'
|
||||
import {
|
||||
multipleChoiceConfiguration,
|
||||
singleChoiceConfiguration,
|
||||
type ArgumentWidgetConfiguration,
|
||||
} from '@/providers/widgetRegistry/configuration'
|
||||
@ -31,13 +32,12 @@ const graph = useGraphStore()
|
||||
|
||||
const tree = injectWidgetTree()
|
||||
|
||||
const widgetRoot = ref<HTMLElement>()
|
||||
const dropdownElement = ref<ComponentInstance<typeof DropdownWidget>>()
|
||||
|
||||
const editedValue = ref<Ast.Ast | string | undefined>()
|
||||
const isHovered = ref(false)
|
||||
|
||||
class Tag {
|
||||
class ExpressionTag {
|
||||
private cachedExpressionAst: Ast.Ast | undefined
|
||||
|
||||
constructor(
|
||||
@ -47,20 +47,28 @@ class Tag {
|
||||
public parameters?: ArgumentWidgetConfiguration[],
|
||||
) {}
|
||||
|
||||
static FromExpression(expression: string, label?: Opt<string>): Tag {
|
||||
const qn = tryQualifiedName(expression)
|
||||
if (!qn.ok) return new Tag(expression, label)
|
||||
const entry = suggestions.entries.getEntryByQualifiedName(qn.value)
|
||||
if (entry) return Tag.FromEntry(entry, label)
|
||||
return new Tag(qn.value, label ?? qnLastSegment(qn.value))
|
||||
static FromQualifiedName(qn: Ast.QualifiedName, label?: Opt<string>): ExpressionTag {
|
||||
const entry = suggestions.entries.getEntryByQualifiedName(qn)
|
||||
if (entry) return ExpressionTag.FromEntry(entry, label)
|
||||
return new ExpressionTag(qn, label ?? qnLastSegment(qn))
|
||||
}
|
||||
|
||||
static FromEntry(entry: SuggestionEntry, label?: Opt<string>): Tag {
|
||||
static FromExpression(expression: string, label?: Opt<string>): ExpressionTag {
|
||||
const qn = tryQualifiedName(expression)
|
||||
if (qn.ok) return ExpressionTag.FromQualifiedName(qn.value, label)
|
||||
return new ExpressionTag(expression, label)
|
||||
}
|
||||
|
||||
static FromEntry(entry: SuggestionEntry, label?: Opt<string>): ExpressionTag {
|
||||
const expression =
|
||||
entry.selfType != null ? `_.${entry.name}`
|
||||
: entry.memberOf ? `${qnLastSegment(entry.memberOf)}.${entry.name}`
|
||||
: entry.name
|
||||
return new Tag(expression, label ?? entry.name, requiredImports(suggestions.entries, entry))
|
||||
return new ExpressionTag(
|
||||
expression,
|
||||
label ?? entry.name,
|
||||
requiredImports(suggestions.entries, entry),
|
||||
)
|
||||
}
|
||||
|
||||
get label() {
|
||||
@ -73,122 +81,94 @@ class Tag {
|
||||
}
|
||||
return this.cachedExpressionAst
|
||||
}
|
||||
|
||||
isFilteredIn(): boolean {
|
||||
// Here is important distinction between empty string meaning the pattern is an empty string
|
||||
// literal "", and undefined meaning that there it's not a string literal.
|
||||
if (editedTextLiteralValuePattern.value != null) {
|
||||
return (
|
||||
this.expressionAst instanceof Ast.TextLiteral &&
|
||||
this.expressionAst.rawTextContent.startsWith(editedTextLiteralValuePattern.value)
|
||||
)
|
||||
} else if (editedValuePattern.value) {
|
||||
return this.expression.startsWith(editedValuePattern.value)
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CustomTag {
|
||||
class ActionTag {
|
||||
constructor(
|
||||
readonly label: string,
|
||||
readonly onClick: () => void,
|
||||
) {}
|
||||
|
||||
static FromItem(item: CustomDropdownItem): CustomTag {
|
||||
return new CustomTag(item.label, item.onClick)
|
||||
}
|
||||
|
||||
isFilteredIn(): boolean {
|
||||
// User writing something in inner inputs wants to create an expression, so custom
|
||||
// tags are hidden in that case.
|
||||
return !(editedTextLiteralValuePattern.value || editedValuePattern.value)
|
||||
static FromItem(item: CustomDropdownItem): ActionTag {
|
||||
return new ActionTag(item.label, item.onClick)
|
||||
}
|
||||
}
|
||||
|
||||
const editedValuePattern = computed(() =>
|
||||
editedValue.value instanceof Ast.Ast ? editedValue.value.code() : editedValue.value,
|
||||
)
|
||||
const editedTextLiteralValuePattern = computed(() => {
|
||||
const editedAst =
|
||||
typeof editedValue.value === 'string' ? Ast.parse(editedValue.value) : editedValue.value
|
||||
return editedAst instanceof Ast.TextLiteral ? editedAst.rawTextContent : undefined
|
||||
})
|
||||
type ExpressionFilter = (tag: ExpressionTag) => boolean
|
||||
function makeExpressionFilter(pattern: Ast.Ast | string): ExpressionFilter | undefined {
|
||||
const editedAst = typeof pattern === 'string' ? Ast.parse(pattern) : pattern
|
||||
if (editedAst instanceof Ast.TextLiteral) {
|
||||
return (tag: ExpressionTag) =>
|
||||
tag.expressionAst instanceof Ast.TextLiteral &&
|
||||
tag.expressionAst.rawTextContent.startsWith(editedAst.rawTextContent)
|
||||
}
|
||||
const editedCode = pattern instanceof Ast.Ast ? pattern.code() : pattern
|
||||
if (editedCode) {
|
||||
return (tag: ExpressionTag) => tag.expression.startsWith(editedCode)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
const staticTags = computed<Tag[]>(() => {
|
||||
const staticTags = computed<ExpressionTag[]>(() => {
|
||||
const tags = props.input[ArgumentInfoKey]?.info?.tagValues
|
||||
if (tags == null) return []
|
||||
return tags.map((t) => Tag.FromExpression(t))
|
||||
return tags.map((t) => ExpressionTag.FromExpression(t))
|
||||
})
|
||||
|
||||
const dynamicTags = computed<Tag[]>(() => {
|
||||
const dynamicTags = computed<ExpressionTag[]>(() => {
|
||||
const config = props.input.dynamicConfig
|
||||
if (config?.kind !== 'Single_Choice') return []
|
||||
if (config?.kind !== 'Single_Choice' && config?.kind !== 'Multiple_Choice') return []
|
||||
|
||||
return config.values.map((value) => {
|
||||
const tag = Tag.FromExpression(value.value, value.label)
|
||||
const tag = ExpressionTag.FromExpression(value.value, value.label)
|
||||
tag.parameters = value.parameters
|
||||
return tag
|
||||
})
|
||||
})
|
||||
|
||||
const customTags = computed(
|
||||
() => props.input[CustomDropdownItemsKey]?.map(CustomTag.FromItem) ?? [],
|
||||
)
|
||||
const tags = computed(() => {
|
||||
const standardTags = dynamicTags.value.length > 0 ? dynamicTags.value : staticTags.value
|
||||
return [...customTags.value, ...standardTags]
|
||||
})
|
||||
const filteredTags = computed(() => {
|
||||
console.log(editedValuePattern.value)
|
||||
console.log(editedTextLiteralValuePattern.value)
|
||||
return Array.from(tags.value, (tag, index) => ({
|
||||
tag,
|
||||
index,
|
||||
})).filter(({ tag }) => tag.isFilteredIn())
|
||||
const expressionTags = dynamicTags.value.length > 0 ? dynamicTags.value : staticTags.value
|
||||
const expressionFilter =
|
||||
!isMulti.value && editedValue.value && makeExpressionFilter(editedValue.value)
|
||||
if (expressionFilter) {
|
||||
return expressionTags.filter(expressionFilter)
|
||||
} else {
|
||||
const actionTags = props.input[CustomDropdownItemsKey]?.map(ActionTag.FromItem) ?? []
|
||||
return [...actionTags, ...expressionTags]
|
||||
}
|
||||
})
|
||||
interface Entry extends DropdownEntry {
|
||||
tag: ExpressionTag | ActionTag
|
||||
}
|
||||
const entries = computed<Entry[]>(() => {
|
||||
return filteredTags.value.map((tag, index) => ({
|
||||
value: tag.label,
|
||||
selected: tag instanceof ExpressionTag && selectedExpressions.value.has(tag.expression),
|
||||
tag,
|
||||
}))
|
||||
})
|
||||
const filteredTagLabels = computed(() => filteredTags.value.map(({ tag }) => tag.label))
|
||||
|
||||
const removeSurroundingParens = (expr?: string) => expr?.trim().replaceAll(/(^[(])|([)]$)/g, '')
|
||||
|
||||
const selectedIndex = ref<number>()
|
||||
// When the input changes, we need to reset the selected index.
|
||||
watch(
|
||||
() => props.input.value,
|
||||
() => (selectedIndex.value = undefined),
|
||||
)
|
||||
const selectedTag = computed(() => {
|
||||
if (selectedIndex.value != null) {
|
||||
return tags.value[selectedIndex.value]
|
||||
const selectedExpressions = computed(() => {
|
||||
const selected = new Set<string>()
|
||||
if (isMulti.value) {
|
||||
for (const element of getValues(props.input.value)) {
|
||||
const normalized = removeSurroundingParens(element.code())
|
||||
if (normalized) selected.add(normalized)
|
||||
}
|
||||
} else {
|
||||
const currentExpression = removeSurroundingParens(WidgetInput.valueRepr(props.input))
|
||||
if (!currentExpression) return undefined
|
||||
// We need to find the tag that matches the (beginning of) current expression.
|
||||
// To prevent partial prefix matches, we arrange tags in reverse lexicographical order.
|
||||
const sortedTags = tags.value
|
||||
.filter((tag) => tag instanceof Tag)
|
||||
.map(
|
||||
(tag, index) =>
|
||||
[removeSurroundingParens((tag as Tag).expression), index] as [string, number],
|
||||
)
|
||||
.sort(([a], [b]) =>
|
||||
a < b ? 1
|
||||
: a > b ? -1
|
||||
: 0,
|
||||
)
|
||||
const [_, index] = sortedTags.find(([expr]) => currentExpression.startsWith(expr)) ?? []
|
||||
return index != null ? tags.value[index] : undefined
|
||||
const code = removeSurroundingParens(WidgetInput.valueRepr(props.input))
|
||||
if (code) selected.add(code)
|
||||
}
|
||||
})
|
||||
|
||||
const selectedLabel = computed(() => {
|
||||
return selectedTag.value?.label
|
||||
return selected
|
||||
})
|
||||
const innerWidgetInput = computed<WidgetInput>(() => {
|
||||
const dynamicConfig =
|
||||
props.input.dynamicConfig?.kind === 'Single_Choice' ?
|
||||
singleChoiceConfiguration(props.input.dynamicConfig)
|
||||
: props.input.dynamicConfig?.kind === 'Multiple_Choice' ?
|
||||
multipleChoiceConfiguration(props.input.dynamicConfig)
|
||||
: props.input.dynamicConfig
|
||||
return {
|
||||
...props.input,
|
||||
@ -196,6 +176,7 @@ const innerWidgetInput = computed<WidgetInput>(() => {
|
||||
dynamicConfig,
|
||||
}
|
||||
})
|
||||
const isMulti = computed(() => props.input.dynamicConfig?.kind === 'Multiple_Choice')
|
||||
const dropdownVisible = ref(false)
|
||||
const dropDownInteraction = WidgetEditHandler.New(props.input, {
|
||||
cancel: () => {
|
||||
@ -218,6 +199,11 @@ const dropDownInteraction = WidgetEditHandler.New(props.input, {
|
||||
end: () => {
|
||||
dropdownVisible.value = false
|
||||
},
|
||||
addItem: () => {
|
||||
dropdownVisible.value = true
|
||||
editedValue.value = undefined
|
||||
return true
|
||||
},
|
||||
})
|
||||
|
||||
function toggleDropdownWidget() {
|
||||
@ -225,37 +211,66 @@ function toggleDropdownWidget() {
|
||||
else dropDownInteraction.cancel()
|
||||
}
|
||||
|
||||
function onClick(indexOfFiltered: number, keepOpen: boolean) {
|
||||
const clicked = filteredTags.value[indexOfFiltered]
|
||||
if (clicked?.tag instanceof CustomTag) clicked.tag.onClick()
|
||||
else selectedIndex.value = clicked?.index
|
||||
if (!keepOpen) {
|
||||
function onClick(clickedEntry: Entry, keepOpen: boolean) {
|
||||
if (clickedEntry.tag instanceof ActionTag) clickedEntry.tag.onClick()
|
||||
else expressionTagClicked(clickedEntry.tag, clickedEntry.selected)
|
||||
if (!(keepOpen || isMulti.value)) {
|
||||
// We cancel interaction instead of ending it to restore the old value in the inner widget;
|
||||
// if we clicked already selected entry, there would be no AST change, thus the inner
|
||||
// widget's content woud not be updated.
|
||||
// widget's content would not be updated.
|
||||
dropDownInteraction.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
// When the selected index changes, we update the expression content.
|
||||
watch(selectedIndex, (_index) => {
|
||||
let edit: Ast.MutableModule | undefined
|
||||
if (selectedTag.value instanceof CustomTag) {
|
||||
console.warn('Selecting custom drop down item does nothing!')
|
||||
return
|
||||
}
|
||||
// Unless import conflict resolution is needed, we use the selected expression as is.
|
||||
let value = selectedTag.value?.expression
|
||||
if (selectedTag.value?.requiredImports) {
|
||||
edit = graph.startEdit()
|
||||
const conflicts = graph.addMissingImports(edit, selectedTag.value.requiredImports)
|
||||
/** Add any necessary imports for `tag`, and return it with any necessary qualification. */
|
||||
function resolveTagExpression(edit: Ast.MutableModule, tag: ExpressionTag) {
|
||||
if (tag.requiredImports) {
|
||||
const conflicts = graph.addMissingImports(edit, tag.requiredImports)
|
||||
if (conflicts != null && conflicts.length > 0) {
|
||||
// Is there is a conflict, it would be a single one, because we only ask about a single entry.
|
||||
value = conflicts[0]?.fullyQualified
|
||||
return conflicts[0]?.fullyQualified!
|
||||
}
|
||||
}
|
||||
props.onUpdate({ edit, portUpdate: { value, origin: props.input.portId } })
|
||||
})
|
||||
// Unless a conflict occurs, we use the selected expression as is.
|
||||
return tag.expression
|
||||
}
|
||||
|
||||
function* getValues(expression: Ast.Ast | string | undefined) {
|
||||
if (expression instanceof Ast.Vector) {
|
||||
yield* expression.values()
|
||||
} else if (expression instanceof Ast.Ast) {
|
||||
yield expression
|
||||
}
|
||||
}
|
||||
|
||||
function toggleVectorValue(vector: Ast.MutableVector, value: string, previousState: boolean) {
|
||||
if (previousState) {
|
||||
vector.keep((ast) => ast.code() !== value)
|
||||
} else {
|
||||
vector.push(Ast.parse(value, vector.module))
|
||||
}
|
||||
}
|
||||
|
||||
function expressionTagClicked(tag: ExpressionTag, previousState: boolean) {
|
||||
const edit = graph.startEdit()
|
||||
const tagValue = resolveTagExpression(edit, tag)
|
||||
if (isMulti.value) {
|
||||
const inputValue = props.input.value
|
||||
if (inputValue instanceof Ast.Vector) {
|
||||
toggleVectorValue(edit.getVersion(inputValue), tagValue, previousState)
|
||||
props.onUpdate({ edit })
|
||||
} else {
|
||||
const vector = Ast.Vector.new(
|
||||
edit,
|
||||
inputValue instanceof Ast.Ast ? [edit.take(inputValue.id)] : [],
|
||||
)
|
||||
toggleVectorValue(vector, tagValue, previousState)
|
||||
props.onUpdate({ edit, portUpdate: { value: vector, origin: props.input.portId } })
|
||||
}
|
||||
} else {
|
||||
props.onUpdate({ edit, portUpdate: { value: tagValue, origin: props.input.portId } })
|
||||
}
|
||||
}
|
||||
|
||||
let endClippingInhibition: (() => void) | undefined
|
||||
watch(dropdownVisible, (visible) => {
|
||||
@ -270,27 +285,25 @@ watch(dropdownVisible, (visible) => {
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
function hasBooleanTagValues(parameter: SuggestionEntryArgument): boolean {
|
||||
if (parameter.tagValues == null) return false
|
||||
return arrayEquals(Array.from(parameter.tagValues).sort(), [
|
||||
'Standard.Base.Data.Boolean.Boolean.False',
|
||||
'Standard.Base.Data.Boolean.Boolean.True',
|
||||
])
|
||||
function isHandledByCheckboxWidget(parameter: SuggestionEntryArgument | undefined): boolean {
|
||||
return (
|
||||
parameter?.tagValues != null &&
|
||||
arrayEquals(Array.from(parameter.tagValues).sort(), [
|
||||
'Standard.Base.Data.Boolean.Boolean.False',
|
||||
'Standard.Base.Data.Boolean.Boolean.True',
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
|
||||
priority: 50,
|
||||
score: (props) => {
|
||||
if (props.input[CustomDropdownItemsKey] != null) return Score.Perfect
|
||||
if (props.input.dynamicConfig?.kind === 'Single_Choice') return Score.Perfect
|
||||
// Boolean arguments also have tag values, but the checkbox widget should handle them.
|
||||
if (
|
||||
props.input[ArgumentInfoKey]?.info?.tagValues != null &&
|
||||
!hasBooleanTagValues(props.input[ArgumentInfoKey].info)
|
||||
)
|
||||
return Score.Perfect
|
||||
return Score.Mismatch
|
||||
},
|
||||
score: (props) =>
|
||||
props.input[CustomDropdownItemsKey] != null ? Score.Perfect
|
||||
: props.input.dynamicConfig?.kind === 'Single_Choice' ? Score.Perfect
|
||||
: props.input.dynamicConfig?.kind === 'Multiple_Choice' ? Score.Perfect
|
||||
: isHandledByCheckboxWidget(props.input[ArgumentInfoKey]?.info) ? Score.Mismatch
|
||||
: props.input[ArgumentInfoKey]?.info?.tagValues != null ? Score.Perfect
|
||||
: Score.Mismatch,
|
||||
})
|
||||
|
||||
/** Custom item added to dropdown. These items can’t be selected, but can be clicked. */
|
||||
@ -312,23 +325,21 @@ declare module '@/providers/widgetRegistry' {
|
||||
<template>
|
||||
<!-- See comment in GraphNode next to dragPointer definition about stopping pointerdown and pointerup -->
|
||||
<div
|
||||
ref="widgetRoot"
|
||||
class="WidgetSelection"
|
||||
:class="{ multiSelect: isMulti }"
|
||||
@pointerdown.stop
|
||||
@pointerup.stop
|
||||
@click.stop="toggleDropdownWidget"
|
||||
@pointerover="isHovered = true"
|
||||
@pointerout="isHovered = false"
|
||||
>
|
||||
<NodeWidget ref="childWidgetRef" :input="innerWidgetInput" />
|
||||
<NodeWidget :input="innerWidgetInput" />
|
||||
<SvgIcon v-if="isHovered" name="arrow_right_head_only" class="arrow" />
|
||||
<DropdownWidget
|
||||
v-if="dropdownVisible"
|
||||
ref="dropdownElement"
|
||||
class="dropdownContainer"
|
||||
:color="'var(--node-color-primary)'"
|
||||
:values="filteredTagLabels"
|
||||
:selectedValue="selectedLabel"
|
||||
:entries="entries"
|
||||
@click="onClick"
|
||||
/>
|
||||
</div>
|
||||
|
@ -4,7 +4,6 @@ import ListWidget from '@/components/widgets/ListWidget.vue'
|
||||
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
||||
import { Score, WidgetInput, defineWidget, widgetProps } from '@/providers/widgetRegistry'
|
||||
import { Ast } from '@/util/ast'
|
||||
import { MutableModule } from '@/util/ast/abstract.ts'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps(widgetProps(widgetDefinition))
|
||||
@ -15,13 +14,16 @@ const itemConfig = computed(() =>
|
||||
: undefined,
|
||||
)
|
||||
|
||||
const defaultItem = computed(() => {
|
||||
if (props.input.dynamicConfig?.kind === 'Vector_Editor') {
|
||||
return Ast.parse(props.input.dynamicConfig.item_default)
|
||||
} else {
|
||||
return Ast.Wildcard.new(MutableModule.Transient())
|
||||
}
|
||||
})
|
||||
const defaultItem = computed(() =>
|
||||
props.input.dynamicConfig?.kind === 'Vector_Editor' ?
|
||||
Ast.parse(props.input.dynamicConfig.item_default)
|
||||
: DEFAULT_ITEM.value,
|
||||
)
|
||||
|
||||
function newItem() {
|
||||
if (props.input.editHandler?.addItem()) return
|
||||
return defaultItem.value
|
||||
}
|
||||
|
||||
const value = computed({
|
||||
get() {
|
||||
@ -31,11 +33,7 @@ const value = computed({
|
||||
// This doesn't preserve AST identities, because the values are not `Ast.Owned`.
|
||||
// Getting/setting an Array is incompatible with ideal synchronization anyway;
|
||||
// `ListWidget` needs to operate on the `Ast.Vector` for edits to be merged as `Y.Array` operations.
|
||||
const tempModule = MutableModule.Transient()
|
||||
const newAst = Ast.Vector.new(
|
||||
tempModule,
|
||||
value.map((element) => tempModule.copy(element)),
|
||||
)
|
||||
const newAst = Ast.Vector.build(value, (element, tempModule) => tempModule.copy(element))
|
||||
props.onUpdate({
|
||||
portUpdate: { value: newAst, origin: props.input.portId },
|
||||
})
|
||||
@ -50,16 +48,19 @@ export const widgetDefinition = defineWidget(WidgetInput.isAstOrPlaceholder, {
|
||||
priority: 500,
|
||||
score: (props) =>
|
||||
props.input.dynamicConfig?.kind === 'Vector_Editor' ? Score.Perfect
|
||||
: props.input.value instanceof Ast.Vector ? Score.Perfect
|
||||
: props.input.dynamicConfig?.kind === 'SomeOfFunctionCalls' ? Score.Perfect
|
||||
: props.input.value instanceof Ast.Vector ? Score.Good
|
||||
: props.input.expectedType?.startsWith('Standard.Base.Data.Vector.Vector') ? Score.Good
|
||||
: Score.Mismatch,
|
||||
})
|
||||
|
||||
const DEFAULT_ITEM = computed(() => Ast.Wildcard.new())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ListWidget
|
||||
v-model="value"
|
||||
:default="() => defaultItem"
|
||||
:newItem="newItem"
|
||||
:getKey="(ast: Ast.Ast) => ast.id"
|
||||
dragMimeType="application/x-enso-ast-node"
|
||||
:toPlainText="(ast: Ast.Ast) => ast.code()"
|
||||
|
@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="ts" generic="Entry extends DropdownEntry">
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import type { Icon } from '@/util/iconName'
|
||||
import { computed, ref } from 'vue'
|
||||
@ -9,34 +9,30 @@ enum SortDirection {
|
||||
descending = 'descending',
|
||||
}
|
||||
|
||||
const props = defineProps<{ color: string; selectedValue: string | undefined; values: string[] }>()
|
||||
const emit = defineEmits<{ click: [index: number, keepOpen: boolean] }>()
|
||||
const props = defineProps<{ color: string; entries: Entry[] }>()
|
||||
const emit = defineEmits<{ click: [entry: Entry, keepOpen: boolean] }>()
|
||||
|
||||
const sortDirection = ref<SortDirection>(SortDirection.none)
|
||||
|
||||
const sortedValuesAndIndices = computed(() => {
|
||||
const valuesAndIndices = props.values.map<[value: string, index: number]>((value, index) => [
|
||||
value,
|
||||
index,
|
||||
])
|
||||
function lexicalCmp(a: string, b: string) {
|
||||
return (
|
||||
a > b ? 1
|
||||
: a < b ? -1
|
||||
: 0
|
||||
)
|
||||
}
|
||||
|
||||
const sortedValues = computed<Entry[]>(() => {
|
||||
switch (sortDirection.value) {
|
||||
case SortDirection.ascending: {
|
||||
return valuesAndIndices.sort((a, b) =>
|
||||
a[0] > b[0] ? 1
|
||||
: a[0] < b[0] ? -1
|
||||
: 0,
|
||||
)
|
||||
return [...props.entries].sort((a, b) => lexicalCmp(a.value, b.value))
|
||||
}
|
||||
case SortDirection.descending: {
|
||||
return valuesAndIndices.sort((a, b) =>
|
||||
a[0] > b[0] ? -1
|
||||
: a[0] < b[0] ? 1
|
||||
: 0,
|
||||
)
|
||||
return [...props.entries].sort((a, b) => lexicalCmp(b.value, a.value))
|
||||
}
|
||||
case SortDirection.none:
|
||||
default: {
|
||||
return valuesAndIndices
|
||||
return props.entries
|
||||
}
|
||||
}
|
||||
})
|
||||
@ -57,17 +53,24 @@ const NEXT_SORT_DIRECTION: Record<SortDirection, SortDirection> = {
|
||||
const enableSortButton = ref(false)
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
export interface DropdownEntry {
|
||||
readonly value: string
|
||||
readonly selected: boolean
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="Dropdown" @pointerdown.stop @pointerup.stop @click.stop>
|
||||
<ul class="list scrollable" :style="{ background: color, borderColor: color }" @wheel.stop>
|
||||
<template v-for="[value, index] in sortedValuesAndIndices" :key="value">
|
||||
<li v-if="value === selectedValue">
|
||||
<div class="selected-item button" @click.stop="emit('click', index, $event.altKey)">
|
||||
<span v-text="value"></span>
|
||||
<template v-for="entry in sortedValues" :key="entry.value">
|
||||
<li v-if="entry.selected">
|
||||
<div class="item selected button" @click.stop="emit('click', entry, $event.altKey)">
|
||||
<span v-text="entry.value"></span>
|
||||
</div>
|
||||
</li>
|
||||
<li v-else class="selectable-item button" @click.stop="emit('click', index, $event.altKey)">
|
||||
<span v-text="value"></span>
|
||||
<li v-else class="item button" @click.stop="emit('click', entry, $event.altKey)">
|
||||
<span v-text="entry.value"></span>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
@ -107,7 +110,7 @@ li {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.selectable-item:hover {
|
||||
.item:not(.selected):hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
@ -134,8 +137,7 @@ li {
|
||||
border-bottom-left-radius: var(--radius-full);
|
||||
top: 1px;
|
||||
right: 6px;
|
||||
padding: 2px;
|
||||
padding-right: 0;
|
||||
padding: 2px 0 2px 2px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
@ -143,17 +145,15 @@ li {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selected-item {
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-port-connected);
|
||||
.item {
|
||||
margin-right: 8px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
width: min-content;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.selectable-item {
|
||||
margin-right: 16px;
|
||||
padding-left: 8px;
|
||||
.item.selected {
|
||||
border-radius: var(--radius-full);
|
||||
background-color: var(--color-port-connected);
|
||||
width: min-content;
|
||||
}
|
||||
</style>
|
||||
|
@ -13,7 +13,7 @@ import { computed, ref, shallowReactive, watchEffect, watchPostEffect } from 'vu
|
||||
<script setup lang="ts" generic="T">
|
||||
const props = defineProps<{
|
||||
modelValue: T[]
|
||||
default: () => T
|
||||
newItem: () => T | undefined
|
||||
getKey?: (item: T) => string | number | undefined
|
||||
/** If present, a {@link DataTransferItem} is added with a MIME type of `text/plain`.
|
||||
* This is useful if the drag payload has a representation that can be pasted in terminals,
|
||||
@ -354,6 +354,11 @@ function setItemRef(el: unknown, index: number) {
|
||||
watchPostEffect(() => {
|
||||
itemRefs.length = props.modelValue.length
|
||||
})
|
||||
|
||||
function addItem() {
|
||||
const item = props.newItem()
|
||||
if (item) emit('update:modelValue', [...props.modelValue, item])
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -401,11 +406,7 @@ watchPostEffect(() => {
|
||||
</template>
|
||||
</template>
|
||||
</TransitionGroup>
|
||||
<SvgIcon
|
||||
class="add-item"
|
||||
name="vector_add"
|
||||
@click.stop="emit('update:modelValue', [...props.modelValue, props.default()])"
|
||||
/>
|
||||
<SvgIcon class="add-item" name="vector_add" @click.stop="addItem" />
|
||||
<span class="token">]</span>
|
||||
</div>
|
||||
<div
|
||||
|
@ -59,7 +59,7 @@ export type Choice = z.infer<typeof choiceSchema>
|
||||
export type WidgetConfiguration =
|
||||
| SingleChoice
|
||||
| VectorEditor
|
||||
| MultiChoice
|
||||
| MultipleChoice
|
||||
| CodeInput
|
||||
| BooleanInput
|
||||
| NumericInput
|
||||
@ -68,6 +68,7 @@ export type WidgetConfiguration =
|
||||
| FileBrowse
|
||||
| FunctionCall
|
||||
| OneOfFunctionCalls
|
||||
| SomeOfFunctionCalls
|
||||
|
||||
export interface VectorEditor {
|
||||
kind: 'Vector_Editor'
|
||||
@ -75,8 +76,10 @@ export interface VectorEditor {
|
||||
item_default: string
|
||||
}
|
||||
|
||||
export interface MultiChoice {
|
||||
kind: 'Multi_Choice'
|
||||
export interface MultipleChoice {
|
||||
kind: 'Multiple_Choice'
|
||||
label: string | null
|
||||
values: Choice[]
|
||||
}
|
||||
|
||||
export interface CodeInput {
|
||||
@ -130,6 +133,11 @@ export interface OneOfFunctionCalls {
|
||||
possibleFunctions: Map<string, FunctionCall>
|
||||
}
|
||||
|
||||
export interface SomeOfFunctionCalls {
|
||||
kind: 'SomeOfFunctionCalls'
|
||||
possibleFunctions: Map<string, FunctionCall>
|
||||
}
|
||||
|
||||
export const widgetConfigurationSchema: z.ZodType<
|
||||
WidgetConfiguration & WithDisplay,
|
||||
z.ZodTypeDef,
|
||||
@ -152,7 +160,13 @@ export const widgetConfigurationSchema: z.ZodType<
|
||||
/* eslint-enable camelcase */
|
||||
})
|
||||
.merge(withDisplay),
|
||||
z.object({ kind: z.literal('Multi_Choice') }).merge(withDisplay),
|
||||
z
|
||||
.object({
|
||||
kind: z.literal('Multiple_Choice'),
|
||||
label: z.string().nullable(),
|
||||
values: z.array(choiceSchema),
|
||||
})
|
||||
.merge(withDisplay),
|
||||
z.object({ kind: z.literal('Code_Input') }).merge(withDisplay),
|
||||
z.object({ kind: z.literal('Boolean_Input') }).merge(withDisplay),
|
||||
z
|
||||
@ -193,7 +207,7 @@ export function functionCallConfiguration(
|
||||
}
|
||||
}
|
||||
|
||||
/** A configuration for the inner widget of the dropdown widget. */
|
||||
/** A configuration for the inner widget of a single-choice selection widget. */
|
||||
export function singleChoiceConfiguration(config: SingleChoice): OneOfFunctionCalls {
|
||||
return {
|
||||
kind: 'OneOfFunctionCalls',
|
||||
@ -202,3 +216,13 @@ export function singleChoiceConfiguration(config: SingleChoice): OneOfFunctionCa
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/** A configuration for the inner widget of a multiple-choice selection widget. */
|
||||
export function multipleChoiceConfiguration(config: MultipleChoice): SomeOfFunctionCalls {
|
||||
return {
|
||||
kind: 'SomeOfFunctionCalls',
|
||||
possibleFunctions: new Map(
|
||||
config.values.map((value) => [value.value, functionCallConfiguration(value.parameters)]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ export interface WidgetEditInteraction extends Interaction {
|
||||
start?(origin: PortId): void
|
||||
edit?(origin: PortId, value: Ast.Owned | string): void
|
||||
end?(origin: PortId): void
|
||||
addItem?(): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@ -52,7 +53,7 @@ export interface WidgetEditInteraction extends Interaction {
|
||||
* argument
|
||||
*/
|
||||
export class WidgetEditHandler {
|
||||
private interaction: WidgetEditInteraction
|
||||
private readonly interaction: WidgetEditInteraction
|
||||
/** This, or one's child interaction which is currently active */
|
||||
private activeInteraction: WidgetEditInteraction | undefined
|
||||
|
||||
@ -91,6 +92,9 @@ export class WidgetEditHandler {
|
||||
innerInteraction.end?.(portId)
|
||||
parent?.interaction.end?.(portId)
|
||||
},
|
||||
addItem: () => {
|
||||
return (innerInteraction.addItem?.() || parent?.interaction.addItem?.()) ?? false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -130,4 +134,8 @@ export class WidgetEditHandler {
|
||||
isActive() {
|
||||
return this.activeInteraction ? this.interactionHandler.isActive(this.activeInteraction) : false
|
||||
}
|
||||
|
||||
addItem() {
|
||||
return this.interaction.addItem?.()
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { assert } from '@/util/assert'
|
||||
import { assert, assertDefined } from '@/util/assert'
|
||||
import { Ast } from '@/util/ast'
|
||||
import {
|
||||
MutableModule,
|
||||
@ -993,3 +993,23 @@ test.each([
|
||||
accesses: accessChain.map((ast) => ast.rhs.code()),
|
||||
}).toEqual(expected)
|
||||
})
|
||||
|
||||
test('Vector modifications', () => {
|
||||
const vector = Ast.Vector.tryParse('[1, 2]')
|
||||
expect(vector).toBeDefined()
|
||||
assertDefined(vector)
|
||||
vector.push(Ast.parse('"Foo"', vector.module))
|
||||
expect(vector.code()).toBe('[1, 2, "Foo"]')
|
||||
vector.keep((ast) => ast instanceof Ast.NumericLiteral)
|
||||
expect(vector.code()).toBe('[1, 2]')
|
||||
vector.push(Ast.parse('3', vector.module))
|
||||
expect(vector.code()).toBe('[1, 2, 3]')
|
||||
vector.keep((ast) => ast.code() !== '4')
|
||||
expect(vector.code()).toBe('[1, 2, 3]')
|
||||
vector.keep((ast) => ast.code() !== '2')
|
||||
expect(vector.code()).toBe('[1, 3]')
|
||||
vector.keep((ast) => ast.code() !== '1')
|
||||
expect(vector.code()).toBe('[3]')
|
||||
vector.keep(() => false)
|
||||
expect(vector.code()).toBe('[]')
|
||||
})
|
||||
|
@ -19,7 +19,10 @@ const prefixFixture = {
|
||||
arguments: ['self', 'a', 'b', 'c', 'd'].map((name) => makeArgument(name)),
|
||||
},
|
||||
argsParameters: new Map<string, widgetCfg.WidgetConfiguration & widgetCfg.WithDisplay>([
|
||||
['a', { kind: 'Multi_Choice', display: widgetCfg.DisplayMode.Always }],
|
||||
[
|
||||
'a',
|
||||
{ kind: 'Multiple_Choice', display: widgetCfg.DisplayMode.Always, label: null, values: [] },
|
||||
],
|
||||
['b', { kind: 'Code_Input', display: widgetCfg.DisplayMode.Always }],
|
||||
['c', { kind: 'Boolean_Input', display: widgetCfg.DisplayMode.Always }],
|
||||
]),
|
||||
@ -32,7 +35,10 @@ const infixFixture = {
|
||||
arguments: ['lhs', 'rhs'].map((name) => makeArgument(name)),
|
||||
},
|
||||
argsParameters: new Map<string, widgetCfg.WidgetConfiguration & widgetCfg.WithDisplay>([
|
||||
['lhs', { kind: 'Multi_Choice', display: widgetCfg.DisplayMode.Always }],
|
||||
[
|
||||
'lhs',
|
||||
{ kind: 'Multiple_Choice', display: widgetCfg.DisplayMode.Always, label: null, values: [] },
|
||||
],
|
||||
['rhs', { kind: 'Code_Input', display: widgetCfg.DisplayMode.Always }],
|
||||
]),
|
||||
}
|
||||
|
@ -18,7 +18,7 @@ export const enum ApplicationKind {
|
||||
* represented in the AST.
|
||||
*/
|
||||
export class ArgumentPlaceholder {
|
||||
constructor(
|
||||
private constructor(
|
||||
public callId: string,
|
||||
public index: number,
|
||||
public argInfo: SuggestionEntryArgument,
|
||||
|
@ -1193,5 +1193,33 @@
|
||||
"returnType": "Standard.Table.Data.Aggregate_Column.Aggregate_Column",
|
||||
"documentation": " Creates a new column with the count of unique items in the selected\ncolumn(s) within each group. If no rows, evaluates to 0.\n\nArguments:\n- columns: either a single or set of columns (specified by name or\n index) to count across. The aggregation may also be computed over\n an expression evaluated on the Table, if provided instead of a\n single column name. Currently expressions are not supported with\n multiple selection.\n- new_name: name of new column.\n- ignore_nothing: if all values are Nothing won't be included.",
|
||||
"annotations": []
|
||||
},
|
||||
{
|
||||
"type": "method",
|
||||
"module": "Standard.Table.Data.Table",
|
||||
"name": "select_columns",
|
||||
"arguments": [
|
||||
{
|
||||
"name": "self",
|
||||
"reprType": "Standard.Table.Data.Table.Table",
|
||||
"isSuspended": false,
|
||||
"hasDefault": false,
|
||||
"defaultValue": null,
|
||||
"tagValues": null
|
||||
},
|
||||
{
|
||||
"name": "columns",
|
||||
"reprType": "Standard.Base.Data.Vector.Vector Standard.Base.Data.Text.Text",
|
||||
"isSuspended": false,
|
||||
"hasDefault": false,
|
||||
"defaultValue": null,
|
||||
"tagValues": []
|
||||
}
|
||||
],
|
||||
"selfType": "Standard.Table.Data.Table.Table",
|
||||
"returnType": "Standard.Base.Any.Any",
|
||||
"isStatic": false,
|
||||
"documentation": "",
|
||||
"annotations": ["columns"]
|
||||
}
|
||||
]
|
||||
|
@ -235,7 +235,7 @@ type DB_Table
|
||||
Select the first two columns and the last column, moving the last one to front.
|
||||
|
||||
table.select_columns [-1, 0, 1] reorder=True
|
||||
@columns Widget_Helpers.make_column_name_vector_selector
|
||||
@columns Widget_Helpers.make_column_name_multi_selector
|
||||
select_columns : Vector (Integer | Text | Regex) | Text | Integer | Regex -> Boolean -> Case_Sensitivity -> Boolean -> Problem_Behavior -> DB_Table ! No_Output_Columns | Missing_Input_Columns
|
||||
select_columns self (columns : (Vector | Text | Integer | Regex) = [self.columns.first.name]) (reorder:Boolean=False) (case_sensitivity=Case_Sensitivity.Default) (error_on_missing_columns:Boolean=True) (on_problems:Problem_Behavior=Report_Warning) =
|
||||
new_columns = self.columns_helper.select_columns columns case_sensitivity reorder error_on_missing_columns on_problems
|
||||
|
@ -383,7 +383,7 @@ type Table
|
||||
Select the first two columns and the last column, moving the last one to front.
|
||||
|
||||
table.select_columns [-1, 0, 1] reorder=True
|
||||
@columns Widget_Helpers.make_column_name_vector_selector
|
||||
@columns Widget_Helpers.make_column_name_multi_selector
|
||||
select_columns : Vector (Integer | Text | Regex) | Text | Integer | Regex -> Boolean -> Case_Sensitivity -> Boolean -> Problem_Behavior -> Table ! No_Output_Columns | Missing_Input_Columns
|
||||
select_columns self (columns : (Vector | Text | Integer | Regex) = [self.columns.first.name]) (reorder:Boolean=False) (case_sensitivity=Case_Sensitivity.Default) (error_on_missing_columns:Boolean=True) (on_problems:Problem_Behavior=Report_Warning) =
|
||||
new_columns = self.columns_helper.select_columns columns case_sensitivity reorder error_on_missing_columns on_problems
|
||||
|
@ -2,7 +2,7 @@ from Standard.Base import all
|
||||
import Standard.Base.Metadata.Display
|
||||
import Standard.Base.Metadata.Widget
|
||||
from Standard.Base.Metadata.Choice import Option
|
||||
from Standard.Base.Metadata.Widget import Numeric_Input, Single_Choice, Text_Input, Vector_Editor
|
||||
from Standard.Base.Metadata.Widget import Multiple_Choice, Numeric_Input, Single_Choice, Text_Input, Vector_Editor
|
||||
from Standard.Base.System.File_Format import format_types
|
||||
from Standard.Base.Widget_Helpers import make_format_chooser
|
||||
|
||||
@ -83,6 +83,13 @@ make_column_name_vector_selector table display=Display.Always =
|
||||
item_editor = make_column_name_selector table display=Display.Always
|
||||
Vector_Editor item_editor=item_editor item_default=item_editor.values.first.value display=display
|
||||
|
||||
## PRIVATE
|
||||
Make a multiple column-name selector that allows each value to be selected at most once.
|
||||
make_column_name_multi_selector : Table -> Display -> Widget
|
||||
make_column_name_multi_selector table display=Display.Always =
|
||||
names = table.column_names.map n-> Option n n.pretty
|
||||
Multiple_Choice values=names display=display
|
||||
|
||||
## PRIVATE
|
||||
Make a column reference by name selector.
|
||||
make_column_ref_by_name_selector : Table -> Display -> Boolean -> Boolean -> Boolean -> Boolean -> Widget
|
||||
|
Loading…
Reference in New Issue
Block a user