From aecabfe0de3ad3aa499236440439f4c783e29ac0 Mon Sep 17 00:00:00 2001 From: Adam Obuchowicz Date: Fri, 15 Mar 2024 16:15:43 +0100 Subject: [PATCH] Drop-down filtering (#9399) Fixes #9058 - the filtering so far is a bit aggressive, but I tune it up in next PR(s). [Screencast from 2024-03-13 15-20-17.webm](https://github.com/enso-org/enso/assets/3919101/112ce65a-a8c6-4818-b8b8-9f493caf9c81) Added new special `WidgetEditHandler,` allowing handling "multi-widget" interactions needed for drop down filtering. # Important Notes * Now when clicking on argument name, the edit is accepted (as normal "outside" click), and then the dropdown is opened again (due to handling click event). I didn't figure out how to handle this case properly, left something least confusing. --- app/gui2/e2e/widgets.spec.ts | 65 ++++++ app/gui2/src/components/ComponentBrowser.vue | 2 +- .../GraphEditor/widgets/WidgetSelection.vue | 206 ++++++++++++------ .../GraphEditor/widgets/WidgetText.vue | 85 ++++++-- .../src/components/widgets/AutoSizedInput.vue | 15 +- app/gui2/src/providers/interactionHandler.ts | 10 +- app/gui2/src/providers/widgetRegistry.ts | 2 + .../__tests__/editHandler.test.ts | 145 ++++++++++++ .../providers/widgetRegistry/editHandler.ts | 133 +++++++++++ app/gui2/src/util/autoBlur.ts | 8 +- 10 files changed, 577 insertions(+), 94 deletions(-) create mode 100644 app/gui2/src/providers/widgetRegistry/__tests__/editHandler.test.ts create mode 100644 app/gui2/src/providers/widgetRegistry/editHandler.ts diff --git a/app/gui2/e2e/widgets.spec.ts b/app/gui2/e2e/widgets.spec.ts index dbaaa6449093..18d66c575999 100644 --- a/app/gui2/e2e/widgets.spec.ts +++ b/app/gui2/e2e/widgets.spec.ts @@ -119,6 +119,71 @@ test('Selection widgets in Data.read node', async ({ page }) => { 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 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(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 page.keyboard.insertText('File 1') + await dropDown.expectVisibleWithOptions(page, ['File 1']) + // Clearing input should show all text literal options + await pathArgInput.clear() + await dropDown.expectVisibleWithOptions(page, ['File 1', 'File 2']) + + // Esc should cancel editing and close drop down + await page.keyboard.press('Escape') + await expect(pathArgInput).not.toBeFocused() + await expect(pathArgInput).toHaveValue('File 2') + await expect(dropDown.dropDown).not.toBeVisible() + + // Choosing entry should finish editing + await pathArgInput.click() + await dropDown.expectVisibleWithOptions(page, ['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 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 page.keyboard.insertText('File') + await page.keyboard.press('Enter') + await expect(pathArgInput).not.toBeFocused() + await expect(pathArgInput).toHaveValue('File') + await expect(dropDown.dropDown).not.toBeVisible() + + await pathArgInput.click() + await dropDown.expectVisibleWithOptions(page, ['Choose file…', 'File 1', 'File 2']) + await page.keyboard.insertText('Foo') + await expect(pathArgInput).toHaveValue('Foo') + await page.mouse.click(200, 200) + await expect(pathArgInput).not.toBeFocused() + await expect(pathArgInput).toHaveValue('Foo') + await expect(dropDown.dropDown).not.toBeVisible() +}) + test('File Browser widget', async ({ page }) => { await actions.goToGraph(page) await mockMethodCallInfo(page, 'data', { diff --git a/app/gui2/src/components/ComponentBrowser.vue b/app/gui2/src/components/ComponentBrowser.vue index 5045b3db7fac..01cdf2685601 100644 --- a/app/gui2/src/components/ComponentBrowser.vue +++ b/app/gui2/src/components/ComponentBrowser.vue @@ -67,7 +67,7 @@ const cbOpen: Interaction = { emit('canceled') }, click: (e: PointerEvent) => { - if (targetIsOutside(e, cbRoot)) { + if (targetIsOutside(e, cbRoot.value)) { if (input.anyChange.value) { acceptInput() } else { diff --git a/app/gui2/src/components/GraphEditor/widgets/WidgetSelection.vue b/app/gui2/src/components/GraphEditor/widgets/WidgetSelection.vue index 516a1fc60eaf..59276fa51268 100644 --- a/app/gui2/src/components/GraphEditor/widgets/WidgetSelection.vue +++ b/app/gui2/src/components/GraphEditor/widgets/WidgetSelection.vue @@ -2,12 +2,13 @@ import NodeWidget from '@/components/GraphEditor/NodeWidget.vue' import SvgIcon from '@/components/SvgIcon.vue' import DropdownWidget from '@/components/widgets/DropdownWidget.vue' -import { injectInteractionHandler } from '@/providers/interactionHandler' +import { unrefElement } from '@/composables/events' import { defineWidget, Score, WidgetInput, widgetProps } from '@/providers/widgetRegistry' import { singleChoiceConfiguration, type ArgumentWidgetConfiguration, } from '@/providers/widgetRegistry/configuration' +import { WidgetEditHandler } from '@/providers/widgetRegistry/editHandler' import { useGraphStore } from '@/stores/graph' import { requiredImports, type RequiredImport } from '@/stores/graph/imports.ts' import { useSuggestionDbStore } from '@/stores/suggestionDatabase' @@ -21,75 +22,129 @@ import { ArgumentInfoKey } from '@/util/callTree' import { arrayEquals } from '@/util/data/array' import type { Opt } from '@/util/data/opt' import { qnLastSegment, tryQualifiedName } from '@/util/qualifiedName' -import { computed, ref, watch } from 'vue' +import { computed, ref, watch, type ComponentInstance } from 'vue' const props = defineProps(widgetProps(widgetDefinition)) const suggestions = useSuggestionDbStore() const graph = useGraphStore() -const interaction = injectInteractionHandler() const widgetRoot = ref() +const dropdownElement = ref>() -interface Tag { - /** If not set, the label is same as expression */ - label?: string - expression: string - requiredImports?: RequiredImport[] - parameters?: ArgumentWidgetConfiguration[] -} +const editedValue = ref() +const isHovered = ref(false) -type CustomTag = Tag & { onClick: () => void } +class Tag { + private cachedExpressionAst: Ast.Ast | undefined -function tagFromExpression(expression: string, label?: Opt): Tag { - const qn = tryQualifiedName(expression) - if (!qn.ok) return { expression, ...(label ? { label } : {}) } - const entry = suggestions.entries.getEntryByQualifiedName(qn.value) - if (entry) { - const tag = tagFromEntry(entry) - return label ? { ...tag, label: label } : tag - } - return { - label: label ?? qnLastSegment(qn.value), - expression: qn.value, + constructor( + readonly expression: string, + private explicitLabel?: Opt, + readonly requiredImports?: RequiredImport[], + public parameters?: ArgumentWidgetConfiguration[], + ) {} + + static FromExpression(expression: string, label?: Opt): 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)) } -} -function tagFromEntry(entry: SuggestionEntry): Tag { - return { - label: entry.name, - expression: + static FromEntry(entry: SuggestionEntry, label?: Opt): Tag { + const expression = entry.selfType != null ? `_.${entry.name}` : entry.memberOf ? `${qnLastSegment(entry.memberOf)}.${entry.name}` - : entry.name, - requiredImports: requiredImports(suggestions.entries, entry), + : entry.name + return new Tag(expression, label ?? entry.name, requiredImports(suggestions.entries, entry)) + } + + get label() { + return this.explicitLabel ?? this.expression + } + + get expressionAst() { + if (this.cachedExpressionAst == null) { + this.cachedExpressionAst = Ast.parse(this.expression) + } + 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 + } } } -function tagFromCustomItem(item: CustomDropdownItem): CustomTag { - const expression = item.label - return { expression, onClick: item.onClick } +class CustomTag { + 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) + } } +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 +}) + const staticTags = computed(() => { const tags = props.input[ArgumentInfoKey]?.info?.tagValues if (tags == null) return [] - return tags.map((t) => tagFromExpression(t)) + return tags.map((t) => Tag.FromExpression(t)) }) const dynamicTags = computed(() => { const config = props.input.dynamicConfig if (config?.kind !== 'Single_Choice') return [] - return config.values.map((value) => ({ - ...tagFromExpression(value.value, value.label), - parameters: value.parameters, - })) + + return config.values.map((value) => { + const tag = Tag.FromExpression(value.value, value.label) + tag.parameters = value.parameters + return tag + }) }) -const customTags = computed(() => props.input[CustomDropdownItemsKey]?.map(tagFromCustomItem) ?? []) +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 tagLabels = computed(() => tags.value.map((tag) => tag.label ?? tag.expression)) +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 filteredTagLabels = computed(() => filteredTags.value.map(({ tag }) => tag.label)) const removeSurroundingParens = (expr?: string) => expr?.trim().replaceAll(/(^[(])|([)]$)/g, '') @@ -108,7 +163,11 @@ const selectedTag = computed(() => { // 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 - .map((tag, index) => [removeSurroundingParens(tag.expression), index] as [string, number]) + .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 @@ -122,39 +181,65 @@ const selectedTag = computed(() => { const selectedLabel = computed(() => { return selectedTag.value?.label }) -const innerWidgetInput = computed(() => { - if (props.input.dynamicConfig == null) return props.input - const config = props.input.dynamicConfig - if (config.kind !== 'Single_Choice') return props.input - return { ...props.input, dynamicConfig: singleChoiceConfiguration(config) } +const innerWidgetInput = computed(() => { + const dynamicConfig = + props.input.dynamicConfig?.kind === 'Single_Choice' ? + singleChoiceConfiguration(props.input.dynamicConfig) + : props.input.dynamicConfig + return { + ...props.input, + editHandler: dropDownInteraction, + dynamicConfig, + } }) -const showDropdownWidget = ref(false) -interaction.setWhen(showDropdownWidget, { +const dropdownVisible = ref(false) +const dropDownInteraction = WidgetEditHandler.New(props.input, { cancel: () => { - showDropdownWidget.value = false + dropdownVisible.value = false }, - click: (e: PointerEvent) => { - if (targetIsOutside(e, widgetRoot)) showDropdownWidget.value = false + click: (e, _, childHandler) => { + if (targetIsOutside(e, unrefElement(dropdownElement))) { + if (childHandler) return childHandler() + else dropdownVisible.value = false + } return false }, + start: () => { + dropdownVisible.value = true + editedValue.value = undefined + }, + edit: (_, value) => { + editedValue.value = value + }, + end: () => { + dropdownVisible.value = false + }, }) function toggleDropdownWidget() { - showDropdownWidget.value = !showDropdownWidget.value + if (!dropdownVisible.value) dropDownInteraction.start() + else dropDownInteraction.cancel() } -function onClick(index: number, keepOpen: boolean) { - if (index < customTags.value.length) { - customTags.value[index]!.onClick() - } else { - selectedIndex.value = index +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) { + // 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. + dropDownInteraction.cancel() } - showDropdownWidget.value = keepOpen } // 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) { @@ -167,8 +252,6 @@ watch(selectedIndex, (_index) => { } props.onUpdate({ edit, portUpdate: { value, origin: props.input.portId } }) }) - -const isHovered = ref(false) diff --git a/app/gui2/src/components/widgets/AutoSizedInput.vue b/app/gui2/src/components/widgets/AutoSizedInput.vue index ea8ca0b400e3..14ebed1e9b0c 100644 --- a/app/gui2/src/components/widgets/AutoSizedInput.vue +++ b/app/gui2/src/components/widgets/AutoSizedInput.vue @@ -5,10 +5,17 @@ import { computed, ref, watch, type StyleValue } from 'vue' const [model, modifiers] = defineModel() const props = defineProps<{ autoSelect?: boolean }>() +const emit = defineEmits<{ + input: [value: string | undefined] + change: [value: string | undefined] +}>() const innerModel = modifiers.lazy ? ref(model.value) : model if (modifiers.lazy) watch(model, (newVal) => (innerModel.value = newVal)) -const onChange = modifiers.lazy ? () => (model.value = innerModel.value) : undefined +function onChange() { + if (modifiers.lazy) model.value = innerModel.value + emit('change', innerModel.value) +} const inputNode = ref() useAutoBlur(inputNode) @@ -40,6 +47,11 @@ defineExpose({ getTextWidth, select: () => inputNode.value?.select(), focus: () => inputNode.value?.focus(), + blur: () => inputNode.value?.blur(), + cancel: () => { + innerModel.value = model.value + inputNode.value?.blur() + }, }) @@ -52,6 +64,7 @@ defineExpose({ @keydown.backspace.stop @keydown.delete.stop @keydown.enter.stop="onEnterDown" + @input="emit('input', innerModel)" @change="onChange" @focus="onFocus" /> diff --git a/app/gui2/src/providers/interactionHandler.ts b/app/gui2/src/providers/interactionHandler.ts index 2078f8d4d510..fcb9885577f3 100644 --- a/app/gui2/src/providers/interactionHandler.ts +++ b/app/gui2/src/providers/interactionHandler.ts @@ -11,6 +11,10 @@ const { provideFn, injectFn } = createContextStore( export class InteractionHandler { private currentInteraction: Interaction | undefined = undefined + isActive(interaction: Interaction | undefined): interaction is Interaction { + return interaction != null && interaction === this.currentInteraction + } + /** Automatically activate specified interaction any time a specified condition becomes true. */ setWhen(active: WatchSource, interaction: Interaction) { watch(active, (active) => { @@ -23,7 +27,7 @@ export class InteractionHandler { } setCurrent(interaction: Interaction | undefined) { - if (interaction !== this.currentInteraction) { + if (!this.isActive(interaction)) { this.currentInteraction?.cancel?.() this.currentInteraction = interaction } @@ -31,12 +35,12 @@ export class InteractionHandler { /** Unset the current interaction, if it is the specified instance. */ end(interaction: Interaction) { - if (this.currentInteraction === interaction) this.currentInteraction = undefined + if (this.isActive(interaction)) this.currentInteraction = undefined } /** Cancel the current interaction, if it is the specified instance. */ cancel(interaction: Interaction) { - if (this.currentInteraction === interaction) this.setCurrent(undefined) + if (this.isActive(interaction)) this.setCurrent(undefined) } handleCancel(): boolean { diff --git a/app/gui2/src/providers/widgetRegistry.ts b/app/gui2/src/providers/widgetRegistry.ts index b021afd6f88e..039fde4dcb87 100644 --- a/app/gui2/src/providers/widgetRegistry.ts +++ b/app/gui2/src/providers/widgetRegistry.ts @@ -6,6 +6,7 @@ import type { Typename } from '@/stores/suggestionDatabase/entry' import { Ast } from '@/util/ast' import { MutableModule } from '@/util/ast/abstract.ts' import { computed, shallowReactive, type Component, type PropType } from 'vue' +import type { WidgetEditHandler } from './widgetRegistry/editHandler' export type WidgetComponent = Component> @@ -104,6 +105,7 @@ export interface WidgetInput { dynamicConfig?: WidgetConfiguration | undefined /** Force the widget to be a connectible port. */ forcePort?: boolean + editHandler?: WidgetEditHandler } /** diff --git a/app/gui2/src/providers/widgetRegistry/__tests__/editHandler.test.ts b/app/gui2/src/providers/widgetRegistry/__tests__/editHandler.test.ts new file mode 100644 index 000000000000..1e4319603da7 --- /dev/null +++ b/app/gui2/src/providers/widgetRegistry/__tests__/editHandler.test.ts @@ -0,0 +1,145 @@ +import type { GraphNavigator } from '@/providers/graphNavigator' +import { InteractionHandler } from '@/providers/interactionHandler' +import type { PortId } from '@/providers/portInfo' +import { assert } from 'shared/util/assert' +import { expect, test, vi, type Mock } from 'vitest' +import { WidgetEditHandler } from '../editHandler' + +// If widget's name is a prefix of another widget's name, then it is its ancestor. +// The ancestor with longest name is a direct parent. +function editHandlerTree( + widgets: string[], + interactionHandler: InteractionHandler, + createInteraction: (name: string) => Record, +): Map }> { + const handlers = new Map() + for (const id of widgets) { + let parent: string | undefined + for (const [otherId] of handlers) { + if (id.startsWith(otherId) && otherId.length > (parent?.length ?? -1)) parent = otherId + } + const interaction = createInteraction(id) + const handler = new WidgetEditHandler( + id as PortId, + interaction, + parent ? handlers.get(parent)?.handler : undefined, + interactionHandler, + ) + handlers.set(id, { handler, interaction }) + } + return handlers +} + +test.each` + widgets | edited | expectedPropagation + ${['A']} | ${'A'} | ${['A']} + ${['A', 'A1', 'B']} | ${'A1'} | ${['A', 'A1']} + ${['A', 'A1', 'A2']} | ${'A2'} | ${['A', 'A2']} + ${['A', 'A1', 'A11']} | ${'A1'} | ${['A', 'A1']} + ${['A', 'A1', 'A11']} | ${'A11'} | ${['A', 'A1', 'A11']} + ${['A', 'A1', 'A2', 'A21']} | ${'A21'} | ${['A', 'A2', 'A21']} +`( + 'Edit interaction propagation starting from $edited in $widgets tree', + ({ widgets, edited, expectedPropagation }) => { + const interactionHandler = new InteractionHandler() + const handlers = editHandlerTree(widgets, interactionHandler, () => ({ + start: vi.fn(), + edit: vi.fn(), + end: vi.fn(), + cancel: vi.fn(), + })) + const expectedPropagationSet = new Set(expectedPropagation) + const checkCallbackCall = (callback: string, ...args: any[]) => { + for (const [id, { interaction }] of handlers) { + if (expectedPropagationSet.has(id)) { + expect(interaction[callback]).toHaveBeenCalledWith(...args) + } else { + expect(interaction[callback]).not.toHaveBeenCalled() + } + interaction[callback]?.mockClear() + } + } + + const editedHandler = handlers.get(edited) + assert(editedHandler != null) + + editedHandler.handler.start() + checkCallbackCall('start', edited) + for (const [id, { handler }] of handlers) { + expect(handler.isActive()).toBe(expectedPropagationSet.has(id)) + } + + editedHandler.handler.edit('13') + checkCallbackCall('edit', edited, '13') + + for (const ended of expectedPropagation) { + const endedHandler = handlers.get(ended)?.handler + + editedHandler.handler.start() + expect(editedHandler.handler.isActive()).toBeTruthy() + endedHandler?.end() + checkCallbackCall('end', ended) + expect(editedHandler.handler.isActive()).toBeFalsy() + + editedHandler.handler.start() + expect(editedHandler.handler.isActive()).toBeTruthy() + endedHandler?.cancel() + checkCallbackCall('cancel') + expect(editedHandler.handler.isActive()).toBeFalsy() + } + + editedHandler.handler.start() + expect(editedHandler.handler.isActive()).toBeTruthy() + interactionHandler.setCurrent(undefined) + checkCallbackCall('cancel') + expect(editedHandler.handler.isActive()).toBeFalsy() + }, +) + +test.each` + name | widgets | edited | propagatingHandlers | nonPropagatingHandlers | expectedHandlerCalls + ${'Propagating'} | ${['A', 'A1']} | ${'A1'} | ${['A', 'A1']} | ${[]} | ${['A', 'A1']} + ${'Parent edited'} | ${['A', 'A1']} | ${'A'} | ${['A', 'A1']} | ${[]} | ${['A']} + ${'Not propagating'} | ${['A', 'A1']} | ${'A1'} | ${['A1']} | ${['A']} | ${['A']} + ${'Child only'} | ${['A', 'A1']} | ${'A1'} | ${['A1']} | ${[]} | ${['A1']} + ${'Skipping handler without click'} | ${['A', 'A1', 'A12']} | ${'A12'} | ${['A', 'A12']} | ${[]} | ${['A', 'A12']} + ${'Stopping propagation'} | ${['A', 'A1', 'A12']} | ${'A12'} | ${['A', 'A12']} | ${['A1']} | ${['A', 'A1']} +`( + 'Handling clicks in WidgetEditHandlers case $name', + ({ widgets, edited, propagatingHandlers, nonPropagatingHandlers, expectedHandlerCalls }) => { + const event = new MouseEvent('click') as PointerEvent + const navigator = {} as GraphNavigator + const interactionHandler = new InteractionHandler() + + const propagatingHandlersSet = new Set(propagatingHandlers) + const nonPropagatingHandlersSet = new Set(nonPropagatingHandlers) + const expectedHandlerCallsSet = new Set(expectedHandlerCalls) + + const handlers = editHandlerTree(widgets, interactionHandler, (id) => + propagatingHandlersSet.has(id) ? + { + click: vi.fn((e, nav, childHandler) => { + expect(e).toBe(event) + expect(nav).toBe(navigator) + childHandler?.() + }), + } + : nonPropagatingHandlersSet.has(id) ? + { + click: vi.fn((e, nav) => { + expect(e).toBe(event) + expect(nav).toBe(navigator) + }), + } + : {}, + ) + handlers.get(edited)?.handler.start() + interactionHandler.handleClick(event, navigator) + for (const [id, { interaction }] of handlers) { + if (expectedHandlerCallsSet.has(id)) + expect(interaction.click, `${id} click handler`).toHaveBeenCalled() + else if (interaction.click) + expect(interaction.click, `${id} click handler`).not.toHaveBeenCalled() + } + }, +) diff --git a/app/gui2/src/providers/widgetRegistry/editHandler.ts b/app/gui2/src/providers/widgetRegistry/editHandler.ts new file mode 100644 index 000000000000..decaed6dded6 --- /dev/null +++ b/app/gui2/src/providers/widgetRegistry/editHandler.ts @@ -0,0 +1,133 @@ +import type { PortId } from '@/providers//portInfo' +import type { GraphNavigator } from '@/providers/graphNavigator' +import { + injectInteractionHandler, + type Interaction, + type InteractionHandler, +} from '@/providers/interactionHandler' +import type { WidgetInput } from '@/providers/widgetRegistry' +import type { Ast } from '@/util/ast' + +/** An extend {@link Interaction} used in {@link WidgetEditHandler} */ +export interface WidgetEditInteraction extends Interaction { + /** Click handler from {@link Interaction}, but receives child's click handler. See + * {@link WidgetEditHandler} for details */ + click?( + event: PointerEvent, + navigator: GraphNavigator, + childHandler?: () => boolean | void, + ): boolean | void + start?(origin: PortId): void + edit?(origin: PortId, value: Ast.Owned | string): void + end?(origin: PortId): void +} + +/** + * Widget edit handler. + * + * This handler takes an extended interaction and allows cooperation between parent/child + * interactions. A usage example is WidgetSelection, which wants to open when the child is edited + * and filters entries by edited temporary value. + * + * Widget's edit state should be manipulated by `start`, `end` and `cancel` methods; they will set + * proper interaction in the global {@link InteractionHandler} and call the additional callbacks in + * {@link WidgetEditInteraction} passed during construction. + * + * The parent widget may pass its edit handler to one or more children's {@link WidgetInput} to + * bound their interactions; when this child is edited, the parent is also considered edited, + * along with any further ancestors. In particular: + * - Starting, ending and cancelling (including automatic canceling by the global interaction + * handler) of child edit will also call proper callbacks in parent. + * - Cancelling or ending parent edit will cancel/end the child's interaction. + * - `isActive` method of both edit handlers will return true. + * + * This `edited` state is propagated only upwards: if only parent is edited, its children are not + * considered edited. If child starts being edited while parent is still edited, the parent interaction + * will be considered cancelled and then immediately started again. Similarly, when a parent's handler + * is bound to two children, and one of them starts editing while the other is edited, the parent + * will receive `cancel` feedback from the latter and then `start` from the former. + * + * **The `click` handler is a special case:** it will be called only on top-most parent, but its + * handler may decide to delegate it further by calling child's handler passed as an additional + * argument + */ +export class WidgetEditHandler { + private interaction: WidgetEditInteraction + /** This, or one's child interaction which is currently active */ + private activeInteraction: WidgetEditInteraction | undefined + + constructor( + private portId: PortId, + innerInteraction: WidgetEditInteraction, + private parent?: WidgetEditHandler, + private interactionHandler: InteractionHandler = injectInteractionHandler(), + ) { + this.interaction = { + cancel: () => { + this.activeInteraction = undefined + innerInteraction.cancel?.() + parent?.interaction.cancel?.() + }, + click: (event, navigator, childHandler) => { + const innerInteractionClick = innerInteraction.click + const thisHandler = + innerInteractionClick ? + () => innerInteractionClick(event, navigator, childHandler) + : childHandler + if (parent && parent.interaction.click) + return parent.interaction.click(event, navigator, thisHandler) + else return thisHandler ? thisHandler() : false + }, + start: (portId) => { + innerInteraction.start?.(portId) + parent?.interaction.start?.(portId) + }, + edit: (portId, value) => { + innerInteraction.edit?.(portId, value) + parent?.interaction.edit?.(portId, value) + }, + end: (portId) => { + this.activeInteraction = undefined + innerInteraction.end?.(portId) + parent?.interaction.end?.(portId) + }, + } + } + + static New(input: WidgetInput, myInteraction: WidgetEditInteraction) { + return new WidgetEditHandler(input.portId, myInteraction, input.editHandler) + } + + cancel() { + if (this.activeInteraction) { + this.interactionHandler.cancel(this.activeInteraction) + } + } + + start() { + this.interactionHandler.setCurrent(this.interaction) + for ( + let handler: WidgetEditHandler | undefined = this; + handler != null; + handler = handler.parent + ) { + handler.activeInteraction = this.interaction + } + this.interaction.start?.(this.portId) + } + + edit(value: Ast.Owned | string) { + this.interaction.edit?.(this.portId, value) + } + + end() { + if (this.activeInteraction) { + this.interactionHandler.end(this.activeInteraction) + this.activeInteraction.end?.(this.portId) + } + } + + isActive() { + return this.activeInteraction ? this.interactionHandler.isActive(this.activeInteraction) : false + } +} diff --git a/app/gui2/src/util/autoBlur.ts b/app/gui2/src/util/autoBlur.ts index 0eb777cc11a2..9fdb1fde78c1 100644 --- a/app/gui2/src/util/autoBlur.ts +++ b/app/gui2/src/util/autoBlur.ts @@ -1,4 +1,5 @@ import { useEvent } from '@/composables/events' +import type { Opt } from 'shared/util/data/opt' import { watchEffect, type Ref } from 'vue' /** Automatically `blur` the currently active element on any mouse click outside of `root`. @@ -41,9 +42,6 @@ export function registerAutoBlurHandler() { } /** Returns true if the target of the event is in the DOM subtree of the given `area` element. */ -export function targetIsOutside( - e: Event, - area: Ref, -): boolean { - return !!area.value && e.target instanceof Element && !area.value.contains(e.target) +export function targetIsOutside(e: Event, area: Opt): boolean { + return !!area && e.target instanceof Element && !area.contains(e.target) }