1
0
Fork 0
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:
Jérémie Pardou 2024-07-26 09:40:13 +00:00
parent cffd1e0117
commit 57ace0b584
16 changed files with 418 additions and 409 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +0,0 @@
.root-node {
margin-bottom: 8px;
}
.root-node__name {
font-size: 12px;
color: $color-neutral-500;
margin-left: 10px;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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