diff --git a/changelog/entries/unreleased/feature/2762_builder_improve_data_explorer_performances_and_allow_to_sele.json b/changelog/entries/unreleased/feature/2762_builder_improve_data_explorer_performances_and_allow_to_sele.json new file mode 100644 index 000000000..9f23a18dc --- /dev/null +++ b/changelog/entries/unreleased/feature/2762_builder_improve_data_explorer_performances_and_allow_to_sele.json @@ -0,0 +1,7 @@ +{ + "type": "feature", + "message": "[Builder] Improve data explorer performances and allow to select any repetition from a source", + "issue_number": 2762, + "bullet_points": [], + "created_at": "2024-07-24" +} \ No newline at end of file diff --git a/web-frontend/modules/builder/dataProviderTypes.js b/web-frontend/modules/builder/dataProviderTypes.js index 738ff40be..a03092af2 100644 --- a/web-frontend/modules/builder/dataProviderTypes.js +++ b/web-frontend/modules/builder/dataProviderTypes.js @@ -515,24 +515,10 @@ export class FormDataProviderType extends DataProviderType { ) return Object.fromEntries( accessibleFormElements.map((element) => { - let formEntry = {} - if (recordIndexPath !== undefined) { - const uniqueElementId = this.app.$registry - .get('element', element.type) - .uniqueElementId(element, recordIndexPath) - formEntry = getValueAtPath(formData, uniqueElementId) - } else { - // When `getDataContent` is called by `getNodes`, we won't have - // access to `recordIndexPath`, so we need to find the first *array* - // in the form data that corresponds to the element. - function _findActualValue(currentValue) { - if (Array.isArray(currentValue)) { - return _findActualValue(currentValue[0]) - } - return currentValue - } - formEntry = _findActualValue(formData[element.id]) - } + const uniqueElementId = this.app.$registry + .get('element', element.type) + .uniqueElementId(element, recordIndexPath) + const formEntry = getValueAtPath(formData, uniqueElementId) return [element.id, formEntry?.value] }) ) diff --git a/web-frontend/modules/builder/store/element.js b/web-frontend/modules/builder/store/element.js index 0e7277911..90675b618 100644 --- a/web-frontend/modules/builder/store/element.js +++ b/web-frontend/modules/builder/store/element.js @@ -80,15 +80,27 @@ const mutations = { updateCachedValues(page) }, UPDATE_ITEM(state, { page, element: elementToUpdate, values }) { + let updateCached = false page.elements.forEach((element) => { if (element.id === elementToUpdate.id) { + if ( + (values.order !== undefined && values.order !== element.order) || + (values.place_in_container !== undefined && + values.place_in_container !== element.place_in_container) + ) { + updateCached = true + } Object.assign(element, values) } }) if (state.selected?.id === elementToUpdate.id) { Object.assign(state.selected, values) } - updateCachedValues(page) + if (updateCached) { + // We need to update cached values only if order or place of an element has + // changed or if an element has been added or removed. + updateCachedValues(page) + } }, DELETE_ITEM(state, { page, elementId }) { const index = page.elements.findIndex((element) => element.id === elementId) diff --git a/web-frontend/modules/core/assets/scss/components/all.scss b/web-frontend/modules/core/assets/scss/components/all.scss index 50098b926..3a601191e 100644 --- a/web-frontend/modules/core/assets/scss/components/all.scss +++ b/web-frontend/modules/core/assets/scss/components/all.scss @@ -158,9 +158,8 @@ @import 'get_formula_component'; @import 'color_input'; @import 'group_bys'; -@import 'data_explorer/node'; -@import 'data_explorer/root_node'; @import 'data_explorer/data_explorer'; +@import 'data_explorer/data_explorer_node'; @import 'anchor'; @import 'call_to_action'; @import 'toast_button'; diff --git a/web-frontend/modules/core/assets/scss/components/data_explorer/data_explorer_node.scss b/web-frontend/modules/core/assets/scss/components/data_explorer/data_explorer_node.scss new file mode 100644 index 000000000..9ad48d309 --- /dev/null +++ b/web-frontend/modules/core/assets/scss/components/data_explorer/data_explorer_node.scss @@ -0,0 +1,64 @@ +.data-explorer-node__content-icon { + color: $color-neutral-600; +} + +.data-explorer-node__children { + margin-left: 5px; +} + +.data-explorer-node__content { + @extend %ellipsis; + + display: flex; + justify-content: flex-start; + align-items: center; + gap: 6px; + padding: 0 5px; + margin: 0 5px; + font-size: 13px; + border-radius: 3px; + line-height: 24px; + + .data-explorer-node--selected & { + background-color: $color-primary-100; + + .data-explorer-node__content-selected-icon { + color: $color-success-500; + } + } + + .data-explorer-node--level-0 > & { + font-size: 12px; + color: $color-neutral-500; + margin-left: 10px; + + &:hover { + background-color: initial; + } + } + + .data-explorer-node--level-0 .data-explorer-node--level-1 & { + cursor: pointer; + + &:hover { + background-color: $color-neutral-100; + } + } +} + +.data-explorer-node--level-0 { + margin-bottom: 8px; +} + +.data-explorer-node__content-name { + flex: 1; +} + +.data-explorer-node__array-node-more { + border: none; + background: none; + margin-left: 10px; + padding-top: 3px; + color: $color-neutral-600; + cursor: pointer; +} diff --git a/web-frontend/modules/core/assets/scss/components/data_explorer/node.scss b/web-frontend/modules/core/assets/scss/components/data_explorer/node.scss deleted file mode 100644 index 5e21b0c4b..000000000 --- a/web-frontend/modules/core/assets/scss/components/data_explorer/node.scss +++ /dev/null @@ -1,44 +0,0 @@ -.node { - margin: 3px 0 3px 10px; - - &.node--first-indentation { - margin-left: 6px; - margin-right: 6px; - } -} - -.node__content { - display: flex; - justify-content: space-between; - cursor: pointer; - padding: 0 5px; - font-size: 13px; - border-radius: 3px; - line-height: 24px; - - &:hover { - background-color: $color-neutral-100; - } - - &--selected { - background-color: $color-primary-100; - } -} - -.node__content-name { - @extend %ellipsis; - - display: flex; - align-items: center; -} - -.node__selected { - margin: auto 0 auto 4px; - color: $color-success-500; - text-align: center; -} - -.node__icon { - color: $color-neutral-600; - margin-right: 6px; -} diff --git a/web-frontend/modules/core/assets/scss/components/data_explorer/root_node.scss b/web-frontend/modules/core/assets/scss/components/data_explorer/root_node.scss deleted file mode 100644 index 8bd826981..000000000 --- a/web-frontend/modules/core/assets/scss/components/data_explorer/root_node.scss +++ /dev/null @@ -1,9 +0,0 @@ -.root-node { - margin-bottom: 8px; -} - -.root-node__name { - font-size: 12px; - color: $color-neutral-500; - margin-left: 10px; -} diff --git a/web-frontend/modules/core/components/dataExplorer/DataExplorer.vue b/web-frontend/modules/core/components/dataExplorer/DataExplorer.vue index 79c2b5deb..e8a84d1c3 100644 --- a/web-frontend/modules/core/components/dataExplorer/DataExplorer.vue +++ b/web-frontend/modules/core/components/dataExplorer/DataExplorer.vue @@ -6,35 +6,35 @@ class="data-explorer" @shown="onShow" > - <div - ref="wrapper" - tabindex="0" - @focusin="$emit('focusin')" - @focusout="$emit('focusout')" - > + <div ref="wrapper"> <div v-if="loading" class="context--loading"> - <div class="loading"></div> + <div class="loading" /> </div> <template v-else> <SelectSearch v-model="search" :placeholder="$t('action.search')" class="margin-bottom-1" - ></SelectSearch> - <RootNode - v-for="node in matchingNodes" + /> + <DataExplorerNode + v-for="node in nodes" :key="node.identifier" :node="node" - :node-selected="nodeSelected" :open-nodes="openNodes" - @node-selected="$emit('node-selected', $event)" + :path="node.identifier" + :search-path="node.identifier" + :node-selected="nodeSelected" + :search="debouncedSearch" + @click="$emit('node-selected', $event)" @toggle="toggleNode" + /> + <div + v-if="nodes.length === 0 || emptyResults" + class="context__description" > - </RootNode> - <div v-if="matchingNodes.length === 0" class="context__description"> - <span v-if="isSearching">{{ - $t('dataExplorer.noMatchingNodesText') - }}</span> + <span v-if="emptyResults"> + {{ $t('dataExplorer.noMatchingNodesText') }} + </span> <span v-else>{{ $t('dataExplorer.noProvidersText') }}</span> </div> </template> @@ -45,12 +45,13 @@ <script> import context from '@baserow/modules/core/mixins/context' import SelectSearch from '@baserow/modules/core/components/SelectSearch' -import RootNode from '@baserow/modules/core/components/dataExplorer/RootNode' +import DataExplorerNode from '@baserow/modules/core/components/dataExplorer/DataExplorerNode' + import _ from 'lodash' export default { name: 'DataExplorer', - components: { SelectSearch, RootNode }, + components: { SelectSearch, DataExplorerNode }, mixins: [context], props: { nodes: { @@ -82,6 +83,9 @@ export default { isSearching() { return Boolean(this.debouncedSearch) }, + emptyResults() { + return this.isSearching && this.openNodes.size === 0 + }, matchingPaths() { if (!this.isSearching) { return new Set() @@ -89,34 +93,26 @@ export default { return this.matchesSearch(this.nodes, this.debouncedSearch) } }, - matchingNodes() { - if (!this.isSearching) { - return this.nodes - } else { - return this.filterNodes( - this.nodes, - (node, path) => path === '' || this.matchingPaths.has(path) - ) - } - }, }, watch: { /** * Debounces the actual search to prevent perf issues */ - search(value) { + search(newSearch) { + this.$emit('node-unselected') clearTimeout(this.debounceSearch) this.debounceSearch = setTimeout(() => { - this.debouncedSearch = value + this.debouncedSearch = newSearch.trim().toLowerCase() || null }, 300) }, matchingPaths(value) { this.openNodes = value }, nodeSelected: { - handler(value) { - if (value !== null) { - this.toggleNode(value, true) + handler(path) { + if (path) { + this.debouncedSearch = null + this.toggleNode(path, true) } }, immediate: true, @@ -146,13 +142,16 @@ export default { * @returns A Set of path of nodes that match the search term */ matchesSearch(nodes, search, parentPath = []) { - const searchSanitised = search.trim().toLowerCase() - return (nodes || []).reduce((acc, subNode) => { - const subNodePath = [...parentPath, subNode.identifier] - + let subNodePath = [...parentPath, subNode.identifier] if (subNode.nodes) { // It's not a leaf + if (subNode.type === 'array') { + // For array we have a special case. We need to match any intermediate value + // Can be either `*` or an integer. We use the `__any__` placeholder to + // achieve that. + subNodePath = [...parentPath, subNode.identifier, '__any__'] + } const subSubNodes = this.matchesSearch( subNode.nodes, search, @@ -160,10 +159,10 @@ export default { ) acc = new Set([...acc, ...subSubNodes]) } else { - // It's a leaf we check if the name match the search + // It's a leaf we check if the name matches the search const nodeNameSanitised = subNode.name.trim().toLowerCase() - if (nodeNameSanitised.includes(searchSanitised)) { + if (nodeNameSanitised.includes(search)) { // We also add the parents of the node acc = new Set([...acc, ...this.getPathAndParents(subNodePath)]) } @@ -171,25 +170,6 @@ export default { return acc }, new Set()) }, - /** - * Filters the nodes according to the given predicate. The predicate receives the - * node itself and the path of the node. - * @param {Array} nodes Node tree to filter. - * @param {Function} predicate Should return true if the node should be kept. - * @param {Array<String>} path Current nodes path part list. - */ - filterNodes(nodes, predicate, path = []) { - const result = (nodes || []) - .filter((node) => predicate(node, [...path, node.identifier].join('.'))) - .map((node) => ({ - ...node, - nodes: this.filterNodes(node.nodes, predicate, [ - ...path, - node.identifier, - ]), - })) - return result - }, /** * Toggles a node state * @param {string} path to open/close. diff --git a/web-frontend/modules/core/components/dataExplorer/DataExplorerNode.vue b/web-frontend/modules/core/components/dataExplorer/DataExplorerNode.vue new file mode 100644 index 000000000..e782e0b40 --- /dev/null +++ b/web-frontend/modules/core/components/dataExplorer/DataExplorerNode.vue @@ -0,0 +1,223 @@ +<template> + <div + v-if="showNode" + class="data-explorer-node" + :class="{ + [`data-explorer-node--level-${depth}`]: true, + 'data-explorer-node--selected': isSelected, + }" + > + <div class="data-explorer-node__content" @click="handleClick(node)"> + <i + v-if="depth > 0" + class="data-explorer-node__content-icon" + :class="getIcon(node)" + /> + <span class="data-explorer-node__content-name">{{ node.name }}</span> + <i + v-if="isSelected" + class="data-explorer-node__content-selected-icon iconoir-check-circle" + /> + </div> + <div v-if="isNodeOpen" ref="nodes" class="data-explorer-node__children"> + <template v-if="node.type !== 'array'"> + <DataExplorerNode + v-for="subNode in sortedNodes" + :key="subNode.identifier" + :node="subNode" + :depth="depth + 1" + :open-nodes="openNodes" + :node-selected="nodeSelected" + :path="`${path}.${subNode.identifier}`" + :search-path="`${searchPath}.${subNode.identifier}`" + :search="search" + @click="$emit('click', $event)" + @toggle="$emit('toggle', $event)" + /> + </template> + <div v-else> + <DataExplorerNode + v-for="subNode in arrayNodes" + :key="subNode.identifier" + :node="subNode" + :depth="depth + 1" + :open-nodes="openNodes" + :node-selected="nodeSelected" + :search="search" + :path="`${path}.${subNode.identifier}`" + :search-path="`${searchPath}.__any__`" + @click="$emit('click', $event)" + @toggle="$emit('toggle', $event)" + /> + <button + v-tooltip="$t('dataExplorerNode.showMore')" + class="data-explorer-node__array-node-more" + @click="count += nextIncrement" + > + {{ `[ ${count}...${nextCount - 1} ]` }} + </button> + </div> + </div> + </div> +</template> + +<script> +import _ from 'lodash' + +export default { + name: 'DataExplorerNode', + props: { + node: { + type: Object, + required: true, + }, + depth: { + type: Number, + required: false, + default: 0, + }, + openNodes: { + type: Set, + required: true, + }, + path: { + type: String, + required: true, + }, + searchPath: { + type: String, + required: true, + }, + nodeSelected: { + type: String, + required: false, + default: null, + }, + search: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { count: 3 } + }, + computed: { + isSelected() { + return this.nodeSelected === this.path + }, + showNode() { + // we show the node if... + return ( + // We are not searching + this.search === null || + // It is selected + this.isSelected || + // It has children and at least one children matches + (this.hasChildren && this.openNodes.has(this.searchPath)) || + // Or it's a leaf and it matches the search term + this.node.name.trim().toLowerCase().includes(this.search) + ) + }, + hasChildren() { + return this.node.nodes?.length > 0 + }, + sortedNodes() { + if (this.hasChildren) { + return [...this.node.nodes].sort((a, b) => a.order - b.order) + } else { + return [] + } + }, + isNodeOpen() { + return ( + // It's open if we are the first level + this.depth === 0 || + // if it's in open node + this.openNodes.has(this.path) || + // or if the search path is in openNodes + // The search path is the version with `__any__` instead of array indexes + this.openNodes.has(this.searchPath) + ) + }, + nextCount() { + return this.count + 10 - ((this.count + 10) % 10) + }, + nextIncrement() { + return this.nextCount - this.count + }, + arrayNodes() { + if (this.node.type === 'array') { + // In case of array node, we generate the nodes on demand + const head = { + nodes: this.node.nodes, + identifier: '*', + name: `[${this.$t('common.all')}]`, + } + return [ + head, + ...[...Array(this.count).keys()].map((index) => ({ + nodes: this.node.nodes, + identifier: `${index}`, + name: `${index}`, + })), + ] + } + return [] + }, + }, + watch: { + nodeSelected: { + handler(newValue) { + // Generate enough array nodes to display arbitrary selected data + if (this.node.type === 'array' && newValue?.startsWith(this.path)) { + const nodeSelectedPath = _.toPath(newValue) + const pathParts = _.toPath(this.path) + const indexStr = nodeSelectedPath[pathParts.length] + + if (indexStr !== '*') { + const index = parseInt(indexStr) + if (this.count <= index) { + this.count = index + 10 - ((index + 10) % 10) + } + } + } + }, + immediate: true, + }, + isSelected: { + async handler(newValue) { + if (newValue) { + await this.$nextTick() + // We scroll it into view when it becomes selected. + this.$el.scrollIntoView({ behavior: 'smooth', block: 'center' }) + } + }, + immediate: true, + }, + }, + methods: { + handleClick(node) { + if (this.depth < 1) { + // We don't want to click on first level + return + } + if (this.hasChildren) { + if (this.search === null) { + this.$emit('toggle', this.path) + } + } else { + this.$emit('click', { path: this.path, node }) + } + }, + getIcon(node) { + if (this.hasChildren) { + return this.isNodeOpen + ? 'iconoir-nav-arrow-down' + : 'iconoir-nav-arrow-right' + } + return node.icon + }, + }, +} +</script> diff --git a/web-frontend/modules/core/components/dataExplorer/Node.vue b/web-frontend/modules/core/components/dataExplorer/Node.vue deleted file mode 100644 index b5103fc38..000000000 --- a/web-frontend/modules/core/components/dataExplorer/Node.vue +++ /dev/null @@ -1,96 +0,0 @@ -<template functional> - <div - :data-identifier="props.node.identifier" - class="node" - :class="{ 'node--first-indentation': props.indentation === 0 }" - > - <div - class="node__content" - :class="{ - 'node__content--selected': props.nodeSelected === props.path, - }" - @click="$options.methods.click(props.node, props.path, listeners)" - > - <div class="node__content-name"> - <i - class="node__icon" - :class="`${$options.methods.getIcon( - props.node, - props.openNodes.has(props.path) - )}`" - ></i> - {{ props.node.name }} - </div> - <i - v-if="props.nodeSelected === props.path" - class="node__selected iconoir-check-circle" - ></i> - </div> - - <div v-if="props.openNodes.has(props.path)"> - <Node - v-for="subNode in $options.methods.sortNodes(props.node.nodes || [])" - :key="subNode.identifier" - :node="subNode" - :open-nodes="props.openNodes" - :node-selected="props.nodeSelected" - :indentation="props.indentation + 1" - :path="`${props.path}.${subNode.identifier}`" - @click="listeners.click && listeners.click($event)" - @toggle="listeners.toggle && listeners.toggle($event)" - ></Node> - </div> - </div> -</template> - -<script> -export default { - name: 'Node', - props: { - node: { - type: Object, - required: true, - }, - openNodes: { - type: Set, - required: true, - }, - path: { - type: String, - required: true, - }, - indentation: { - type: Number, - required: false, - default: 0, - }, - nodeSelected: { - type: String, - required: false, - default: null, - }, - }, - methods: { - click(node, path, listeners) { - if (node.nodes?.length > 0 && listeners.toggle) { - listeners.toggle(path) - } else if (listeners.click) { - listeners.click({ - path, - node, - }) - } - }, - getIcon(node, isOpen) { - if (!node.nodes?.length || node.nodes?.length < 1) { - return node.icon - } - - return isOpen ? 'iconoir-nav-arrow-down' : 'iconoir-nav-arrow-right' - }, - sortNodes(nodes) { - return nodes.sort((a, b) => a.order - b.order) - }, - }, -} -</script> diff --git a/web-frontend/modules/core/components/dataExplorer/RootNode.vue b/web-frontend/modules/core/components/dataExplorer/RootNode.vue deleted file mode 100644 index 3c033c6a6..000000000 --- a/web-frontend/modules/core/components/dataExplorer/RootNode.vue +++ /dev/null @@ -1,99 +0,0 @@ -<template> - <div class="root-node"> - <div class="root-node__name"> - {{ node.name }} - </div> - <div ref="nodes"> - <Node - v-for="subNode in sortNodes(node.nodes)" - :key="subNode.identifier" - :node="subNode" - :node-selected="nodeSelected" - :open-nodes="openNodes" - :path="`${node.identifier}.${subNode.identifier}`" - @click="$emit('node-selected', $event)" - @toggle="$emit('toggle', $event)" - ></Node> - </div> - </div> -</template> - -<script> -import Node from '@baserow/modules/core/components/dataExplorer/Node' -import _ from 'lodash' - -export default { - name: 'RootNode', - components: { Node }, - props: { - node: { - type: Object, - required: true, - }, - openNodes: { - type: Set, - required: true, - }, - nodeSelected: { - type: String, - required: false, - default: null, - }, - }, - watch: { - async nodeSelected(path) { - if (path === null) { - return - } - - // Wait for the nodes to be opened - await this.$nextTick() - - const element = this.getNodeElementByPath( - this.$refs.nodes, - // Remove the first part since it is the root node, and we don't need that - _.toPath(path).slice(1) - ) - - if (element) { - element.scrollIntoView({ behavior: 'smooth', block: 'center' }) - } - }, - }, - methods: { - getNodeElementByPath(element, path) { - const [identifier, ...rest] = path - - const childMatching = [...element.children].find( - (child) => child.dataset.identifier === identifier - ) - - // We found the final element! - if (childMatching && rest.length === 0) { - return childMatching - } - - // That means we still haven't gone through the whole path - if (childMatching && rest.length > 0) { - return this.getNodeElementByPath(childMatching, rest) - } - - // That means we have gone into a dead end - if (!childMatching && !element.children.length) { - return null - } - - // That means we have to keep searching the children to find the next piece of the - // path - return ( - [...element.children] - .map((child) => this.getNodeElementByPath(child, path)) - .find((e) => e !== null) || null - ) - }, - sortNodes(nodes) { - return nodes.sort((a, b) => a.order - b.order) - }, - }, -} -</script> diff --git a/web-frontend/modules/core/components/formula/FormulaInputField.vue b/web-frontend/modules/core/components/formula/FormulaInputField.vue index ad11dbe61..733243d58 100644 --- a/web-frontend/modules/core/components/formula/FormulaInputField.vue +++ b/web-frontend/modules/core/components/formula/FormulaInputField.vue @@ -18,16 +18,15 @@ @data-component-clicked="dataComponentClicked" /> <DataExplorer + v-if="isFocused" ref="dataExplorer" :nodes="nodes" :node-selected="nodeSelected" :loading="dataExplorerLoading" :application-context="applicationContext" @node-selected="dataExplorerItemSelected" - @node-toggled="editor.commands.focus()" - @focusin="dataExplorerFocused = true" - @focusout="dataExplorerFocused = false" - ></DataExplorer> + @node-unselected="unSelectNode()" + /> </div> </template> @@ -44,6 +43,7 @@ import { FromTipTapVisitor } from '@baserow/modules/core/formula/tiptap/fromTipT import { mergeAttributes } from '@tiptap/core' import DataExplorer from '@baserow/modules/core/components/dataExplorer/DataExplorer' import { RuntimeGet } from '@baserow/modules/core/runtimeFormulaTypes' +import { isElement, onClickOutside } from '@baserow/modules/core/utils/dom' export default { name: 'FormulaInputField', @@ -98,15 +98,11 @@ export default { content: null, isFormulaInvalid: false, dataNodeSelected: null, - dataExplorerFocused: false, - formulaInputFocused: false, valueUpdateTimeout: null, + isFocused: false, } }, computed: { - isFocused() { - return this.dataExplorerFocused || this.formulaInputFocused - }, classes() { return { 'form-input--disabled': this.disabled, @@ -157,9 +153,10 @@ export default { return this.editor.getJSON() }, nodes() { - return this.dataProviders + const nodes = this.dataProviders .map((dataProvider) => dataProvider.getNodes(this.applicationContext)) .filter((dataProviderNodes) => dataProviderNodes.nodes?.length > 0) + return nodes }, nodeSelected() { return this.dataNodeSelected?.attrs?.path || null @@ -169,11 +166,14 @@ export default { disabled(newValue) { this.editor.setOptions({ editable: !newValue }) }, - isFocused(value) { + async isFocused(value) { if (!value) { this.$refs.dataExplorer.hide() this.unSelectNode() } else { + // Wait for the data explorer to appear in the DOM. + await this.$nextTick() + this.unSelectNode() /** @@ -233,7 +233,6 @@ export default { editable: !this.disabled, onUpdate: this.onUpdate, onFocus: this.onFocus, - onBlur: this.onBlur, extensions: this.extensions, parseOptions: { preserveWhitespace: 'full', @@ -260,20 +259,28 @@ export default { this.unSelectNode() this.emitChange() }, - onFocus() { + onFocus(event) { // If the input is disabled, we don't want users to be // able to open the data explorer and select nodes. - this.formulaInputFocused = !this.disabled - }, - onBlur() { - // We have to delay the browser here by just a bit, running the below will make - // sure the browser will execute all other events first, and then trigger this - // function. If we don't do this, the data explorer will be closed before the - // focus event can be fired which results in a closed data explorer once you lose - // focus on the input. - setTimeout(() => { - this.formulaInputFocused = false - }, 0) + if (this.disabled) { + return + } + this.isFocused = true + + this.$el.clickOutsideEventCancel = onClickOutside( + this.$el, + (target, event) => { + if ( + this.$refs.dataExplorer && + // We ignore clicks inside data explorer + !isElement(this.$refs.dataExplorer.$el, target) + ) { + this.isFocused = false + this.editor.commands.blur() + this.$el.clickOutsideEventCancel() + } + } + ) }, toContent(formula) { if (!formula) { diff --git a/web-frontend/modules/core/components/formula/GetFormulaComponent.vue b/web-frontend/modules/core/components/formula/GetFormulaComponent.vue index 4a0498891..6b716a06a 100644 --- a/web-frontend/modules/core/components/formula/GetFormulaComponent.vue +++ b/web-frontend/modules/core/components/formula/GetFormulaComponent.vue @@ -41,9 +41,6 @@ export default { }, mixins: [formulaComponent], inject: ['applicationContext', 'dataProviders'], - data() { - return { nodes: [], pathParts: [] } - }, computed: { availableData() { return Object.values(this.dataProviders).map((dataProvider) => @@ -68,10 +65,10 @@ export default { (dataProvider) => dataProvider.type === pathParts[0] ) }, - }, - mounted() { - if (this.dataProviderType) { - this.nodes = [this.dataProviderType.getNodes(this.applicationContext)] + nodes() { + return [this.dataProviderType.getNodes(this.applicationContext)] + }, + pathParts() { const translatedPathPart = this.rawPathParts.map((_, index) => this.dataProviderType.getPathTitle( this.applicationContext, @@ -80,8 +77,8 @@ export default { ) translatedPathPart[0] = this.dataProviderType.name - this.pathParts = translatedPathPart - } + return translatedPathPart + }, }, methods: { findNode(nodes, path) { @@ -98,7 +95,16 @@ export default { } if (rest.length > 0) { - return this.findNode(nodeFound.nodes, rest) + if (nodeFound.type === 'array') { + const [index, ...afterIndex] = rest + // Check that the index is what is expected + if (!(index === '*' || /^\d+$/.test(index))) { + return null + } + return this.findNode(nodeFound.nodes, afterIndex) + } else { + return this.findNode(nodeFound.nodes, rest) + } } return nodeFound diff --git a/web-frontend/modules/core/dataProviderTypes.js b/web-frontend/modules/core/dataProviderTypes.js index d57c45ca1..7c441bc37 100644 --- a/web-frontend/modules/core/dataProviderTypes.js +++ b/web-frontend/modules/core/dataProviderTypes.js @@ -1,6 +1,5 @@ import { Registerable } from '@baserow/modules/core/registry' import { getIconForType } from '@baserow/modules/core/utils/icon' -import _ from 'lodash' /** * A data provider gets data from the application context and populate the context for @@ -130,19 +129,13 @@ export class DataProviderType extends Registerable { * @returns {{identifier: string, name: string, nodes: []}} */ getNodes(applicationContext) { - const content = this.getDataContent(applicationContext) const schema = this.getDataSchema(applicationContext) if (schema === null) { return {} } - const result = this._toNode( - applicationContext, - [this.type], - content, - schema - ) + const result = this._toNode(applicationContext, [this.type], schema) return result } @@ -150,11 +143,10 @@ export class DataProviderType extends Registerable { * Recursive method to deeply compute the node tree for this data providers. * @param {Object} applicationContext the application context. * @param {Array<String>} pathParts the path to get to the current node. - * @param {*} content the current node content. * @param {$schema: string} schema the current node schema. * @returns {{identifier: string, name: string, nodes: []}} */ - _toNode(applicationContext, pathParts, content, schema) { + _toNode(applicationContext, pathParts, schema) { const identifier = pathParts.at(-1) const name = this.getPathTitle(applicationContext, pathParts) const order = schema?.order || null @@ -170,44 +162,16 @@ export class DataProviderType extends Registerable { } if (schema.type === 'array') { - // When the current node is an array we append a new node to its children - // that represents the "whole" array. - // This is translated to '*' in the resulting formula and '[All]' in the - // data explorer name. - // The 'head' object contains all keys of the schema assigned to null - let fakeContent = null - if (schema.items.type === 'object') { - fakeContent = _.mapValues(schema.items.properties, () => null) - fakeContent = {} - } - if (schema.items.type === 'array') { - fakeContent = [] - } - - const head = { - ...this._toNode( - applicationContext, - [...pathParts, '*'], - fakeContent, - schema.items - ), - name: `[${this.app.i18n.t('common.all')}]`, - } return { name, identifier, icon: this.getIconForNode(schema), - nodes: [ - head, - ...(content || []).map((item, index) => - this._toNode( - applicationContext, - [...pathParts, `${index}`], - item, - schema.items - ) - ), - ], + type: 'array', + nodes: this._toNode( + applicationContext, + [...pathParts, null], + schema.items + ).nodes, } } @@ -222,7 +186,6 @@ export class DataProviderType extends Registerable { this._toNode( applicationContext, [...pathParts, identifier], - (content || {})[identifier], subSchema ) ), @@ -234,7 +197,6 @@ export class DataProviderType extends Registerable { order, type: schema.type, icon: this.getIconForNode(schema), - value: content, identifier, } } diff --git a/web-frontend/modules/core/enums.js b/web-frontend/modules/core/enums.js index d4f98c449..a396eec9a 100644 --- a/web-frontend/modules/core/enums.js +++ b/web-frontend/modules/core/enums.js @@ -32,6 +32,7 @@ export const DATA_TYPE_TO_ICON_MAP = { string: 'iconoir-text', number: 'baserow-icon-hashtag', boolean: 'baserow-icon-circle-checked', + array: 'iconoir-list', } export const UNKNOWN_DATA_TYPE_ICON = 'iconoir-question-mark' diff --git a/web-frontend/modules/core/locales/en.json b/web-frontend/modules/core/locales/en.json index e7359e843..4ef6aca7b 100644 --- a/web-frontend/modules/core/locales/en.json +++ b/web-frontend/modules/core/locales/en.json @@ -647,7 +647,7 @@ "errorInvalidFormula": "The formula is invalid." }, "dataExplorer": { - "noMatchingNodesText": "No matching data providers were found.", + "noMatchingNodesText": "No matching results were found.", "noProvidersText": "No data providers were found. To get started you can, for example, add a data source or page parameter." }, "richTextEditorBubbleMenu": { @@ -718,5 +718,15 @@ "workspaceStep": { "title": "Create your workspace", "workspaceLabel": "Workspace name" + }, + "colorInput": { + "default": "Default" + }, + "imageInput": { + "labelDescription": "Select an image to upload...", + "labelButton": "Upload" + }, + "dataExplorerNode": { + "showMore": "Show more repetitions" } }