mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-03-21 15:43:05 +00:00
Improve data explorer perfs
This commit is contained in:
parent
cffd1e0117
commit
57ace0b584
16 changed files with 418 additions and 409 deletions
changelog/entries/unreleased/feature
web-frontend/modules
|
@ -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"
|
||||
}
|
|
@ -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]
|
||||
})
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
.root-node {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.root-node__name {
|
||||
font-size: 12px;
|
||||
color: $color-neutral-500;
|
||||
margin-left: 10px;
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue