Multi-select (part 1) (#9516)

* Multi-select
This commit is contained in:
Kaz Wesley 2024-03-25 12:05:20 -04:00 committed by GitHub
parent 0d34126b86
commit 22a2c208c0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 496 additions and 262 deletions

View File

@ -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()

View File

@ -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 }) => {

View File

@ -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)

View File

@ -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) {

View File

@ -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',
'.',

View File

@ -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([
[

View File

@ -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>

View File

@ -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 cant 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>

View File

@ -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()"

View File

@ -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>

View File

@ -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

View File

@ -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)]),
),
}
}

View File

@ -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?.()
}
}

View File

@ -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('[]')
})

View File

@ -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 }],
]),
}

View File

@ -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,

View File

@ -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"]
}
]

View File

@ -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

View File

@ -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

View File

@ -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