1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-17 18:32:35 +00:00

Resolve "Data Explorer"

This commit is contained in:
Alexander Haller 2023-09-21 14:24:02 +00:00
parent 95208e4f8b
commit c2ce168564
39 changed files with 1196 additions and 241 deletions

View file

@ -23,7 +23,7 @@
"content": [
{
"type": "get-formula-component",
"attrs": { "path": "data_source.hello.there" }
"attrs": { "path": "data_source.hello.there", "isSelected": false }
}
]
},
@ -32,7 +32,7 @@
"content": [
{
"type": "get-formula-component",
"attrs": { "path": "data_source.hello.there" }
"attrs": { "path": "data_source.hello.there", "isSelected": false }
},
{ "type": "text", "text": "friend :)" }
]
@ -66,7 +66,7 @@
},
{
"type": "get-formula-component",
"attrs": { "path": "data_source.hello.there" }
"attrs": { "path": "data_source.hello.there", "isSelected": false }
}
]
}

View file

@ -0,0 +1,56 @@
<template>
<FormulaInputGroup
v-bind="$attrs"
:data-explorer-loading="dataExplorerLoading"
:data-providers="dataProviders"
v-on="$listeners"
></FormulaInputGroup>
</template>
<script>
import FormulaInputGroup from '@baserow/modules/core/components/formula/FormulaInputGroup'
import { DataSourceDataProviderType } from '@baserow/modules/builder/dataProviderTypes'
export default {
name: 'ApplicationBuilderFormulaInputGroup',
components: { FormulaInputGroup },
inject: ['page'],
props: {
dataProvidersAllowed: {
type: Array,
required: false,
default: () => [],
},
},
computed: {
dataSourceLoading() {
return this.$store.getters['dataSource/getLoading'](this.page)
},
dataSourceContentLoading() {
return this.$store.getters['dataSourceContent/getLoading'](this.page)
},
dataProviders() {
return Object.values(this.$registry.getAll('builderDataProvider')).filter(
(dataProvider) =>
this.dataProvidersAllowed.includes(dataProvider.getType())
)
},
dataExplorerLoading() {
return this.dataProvidersAllowed.some(
(dataProviderName) => this.dataProviderLoadingMap[dataProviderName]
)
},
/**
* This mapping defines which data providers are affected by what loading states.
* Since not all data providers are always used in every data explorer we
* shouldn't put the data explorer in a loading state whenever some inaccessible
* data is loading.
*/
dataProviderLoadingMap() {
return {
[new DataSourceDataProviderType().getType()]:
this.dataSourceLoading || this.dataSourceContentLoading,
}
},
},
}
</script>

View file

@ -7,9 +7,6 @@
class="data-source-form__name-input"
:placeholder="$t('dataSourceForm.namePlaceholder')"
/>
<!-- TODO This and it's corresponding prop will be removed in the data
explorer MR -->
({{ id }})
<Dropdown
v-model="values.type"
class="data-source-form__type-dropdown"

View file

@ -17,25 +17,23 @@
</Dropdown>
</div>
</FormElement>
<FormulaInputGroup
<ApplicationBuilderFormulaInputGroup
v-model="values.value"
:label="$t('headingElementForm.textTitle')"
:placeholder="$t('elementForms.textInputPlaceholder')"
:error="
!$v.values.value.validFormula ? $t('elementForms.invalidFormula') : ''
"
:data-providers-allowed="DATA_PROVIDERS_ALLOWED_SIDEBAR"
/>
</form>
</template>
<script>
import form from '@baserow/modules/core/mixins/form'
import FormulaInputGroup from '@baserow/modules/core/components/formula/FormulaInputGroup'
import { isValidFormula } from '@baserow/modules/core/formula'
import { DATA_PROVIDERS_ALLOWED_SIDEBAR } from '@baserow/modules/builder/enums'
import ApplicationBuilderFormulaInputGroup from '@baserow/modules/builder/components/ApplicationBuilderFormulaInputGroup'
export default {
name: 'HeaderElementForm',
components: { FormulaInputGroup },
components: { ApplicationBuilderFormulaInputGroup },
mixins: [form],
props: {},
data() {
@ -50,12 +48,8 @@ export default {
})),
}
},
validations() {
return {
values: {
value: { validFormula: isValidFormula },
},
}
computed: {
DATA_PROVIDERS_ALLOWED_SIDEBAR: () => DATA_PROVIDERS_ALLOWED_SIDEBAR,
},
}
</script>

View file

@ -1,12 +1,10 @@
<template>
<form @submit.prevent @keydown.enter.prevent>
<FormulaInputGroup
<ApplicationBuilderFormulaInputGroup
v-model="values.value"
:label="$t('linkElementForm.text')"
:placeholder="$t('linkElementForm.textPlaceholder')"
:error="
!$v.values.value.validFormula ? $t('elementForms.invalidFormula') : ''
"
:data-providers-allowed="DATA_PROVIDERS_ALLOWED_SIDEBAR"
/>
<FormElement class="control">
<label class="control__label">
@ -42,17 +40,12 @@
</div>
</FormElement>
<FormElement v-if="navigateTo === 'custom'" class="control">
<FormulaInputGroup
<ApplicationBuilderFormulaInputGroup
v-model="values.navigate_to_url"
:page="page"
:label="$t('linkElementForm.url')"
:placeholder="$t('linkElementForm.urlPlaceholder')"
:error="
!$v.values.navigate_to_url.validFormula
? $t('elementForms.invalidFormula')
: ''
"
@blur="$v.values.navigate_to_url.$touch()"
:data-providers-allowed="DATA_PROVIDERS_ALLOWED_SIDEBAR"
/>
</FormElement>
<FormElement v-if="destinationPage" class="control">
@ -73,23 +66,17 @@
</template>
<div v-else>
<div
v-for="(param, index) in values.page_parameters"
v-for="param in values.page_parameters"
:key="param.name"
class="link-element-form__param"
>
<FormulaInputGroup
<ApplicationBuilderFormulaInputGroup
v-model="param.value"
:page="page"
:label="param.name"
horizontal
:placeholder="$t('linkElementForm.paramPlaceholder')"
:error="
$v.values.page_parameters.$each[index].$dirty &&
$v.values.page_parameters.$each[index].$error
? $t('elementForms.invalidFormula')
: ''
"
@blur="$v.values.page_parameters.$each[index].$touch()"
:data-providers-allowed="DATA_PROVIDERS_ALLOWED_PAGE_PARAMETERS"
/>
</div>
</div>
@ -133,14 +120,24 @@
import form from '@baserow/modules/core/mixins/form'
import { LinkElementType } from '@baserow/modules/builder/elementTypes'
import HorizontalAlignmentSelector from '@baserow/modules/builder/components/elements/components/forms/general/settings/HorizontalAlignmentsSelector'
import { HORIZONTAL_ALIGNMENTS, WIDTHS } from '@baserow/modules/builder/enums'
import {
DATA_PROVIDERS_ALLOWED_SIDEBAR,
HORIZONTAL_ALIGNMENTS,
WIDTHS,
} from '@baserow/modules/builder/enums'
import FormulaInputGroup from '@baserow/modules/core/components/formula/FormulaInputGroup'
import { isValidFormula } from '@baserow/modules/core/formula'
import WidthSelector from '@baserow/modules/builder/components/elements/components/forms/general/settings/WidthSelector'
import { PageParameterDataProviderType } from '@baserow/modules/builder/dataProviderTypes'
import ApplicationBuilderFormulaInputGroup from '@baserow/modules/builder/components/ApplicationBuilderFormulaInputGroup'
export default {
name: 'LinkElementForm',
components: { WidthSelector, HorizontalAlignmentSelector, FormulaInputGroup },
components: {
WidthSelector,
FormulaInputGroup,
ApplicationBuilderFormulaInputGroup,
HorizontalAlignmentSelector,
},
mixins: [form],
props: {
builder: { type: Object, required: true },
@ -172,6 +169,15 @@ export default {
}
},
computed: {
DATA_PROVIDERS_ALLOWED_SIDEBAR: () => DATA_PROVIDERS_ALLOWED_SIDEBAR,
DATA_PROVIDERS_ALLOWED_PAGE_PARAMETERS() {
const PROVIDERS_TO_REMOVE = [
new PageParameterDataProviderType().getType(),
]
return this.DATA_PROVIDERS_ALLOWED_SIDEBAR.filter(
(dataProvider) => !PROVIDERS_TO_REMOVE.includes(dataProvider)
)
},
pages() {
return this.builder.pages
},
@ -218,11 +224,6 @@ export default {
}
},
methods: {
getPageParameterType(parameterName) {
return (this.destinationPage?.path_params || []).find(
(pathParam) => pathParam.name === parameterName
)?.type
},
updatePageParameters() {
this.values.page_parameters = (
this.destinationPage?.path_params || []
@ -231,22 +232,7 @@ export default {
return { name, value: previousValue }
})
this.parametersInError = false
this.$v.values.page_parameters.$touch()
},
validatePageParameter(pageParameter) {
return isValidFormula(pageParameter.value)
},
},
validations() {
return {
values: {
value: { validFormula: isValidFormula },
page_parameters: {
$each: { $validator: this.validatePageParameter },
},
navigate_to_url: { validFormula: isValidFormula },
},
}
},
}
</script>

View file

@ -1,12 +1,10 @@
<template>
<form @submit.prevent @keydown.enter.prevent>
<FormulaInputGroup
<ApplicationBuilderFormulaInputGroup
v-model="values.value"
:label="$t('paragraphElementForm.textTitle')"
:placeholder="$t('elementForms.textInputPlaceholder')"
:error="
!$v.values.value.validFormula ? $t('elementForms.invalidFormula') : ''
"
:data-providers-allowed="DATA_PROVIDERS_ALLOWED_SIDEBAR"
/>
</form>
</template>
@ -14,12 +12,12 @@
<script>
import form from '@baserow/modules/core/mixins/form'
import FormulaInputGroup from '@baserow/modules/core/components/formula/FormulaInputGroup'
import { isValidFormula } from '@baserow/modules/core/formula'
import { DATA_PROVIDERS_ALLOWED_SIDEBAR } from '@baserow/modules/builder/enums'
import ApplicationBuilderFormulaInputGroup from '@baserow/modules/builder/components/ApplicationBuilderFormulaInputGroup'
export default {
name: 'ParagraphElementForm',
components: { FormulaInputGroup },
components: { ApplicationBuilderFormulaInputGroup },
mixins: [form],
props: {},
data() {
@ -29,12 +27,8 @@ export default {
},
}
},
validations() {
return {
values: {
value: { validFormula: isValidFormula },
},
}
computed: {
DATA_PROVIDERS_ALLOWED_SIDEBAR: () => DATA_PROVIDERS_ALLOWED_SIDEBAR,
},
}
</script>

View file

@ -1,4 +1,5 @@
import { DataProviderType } from '@baserow/modules/core/dataProviderTypes'
import GenerateSchema from 'generate-schema'
import _ from 'lodash'
@ -20,29 +21,90 @@ export class DataSourceDataProviderType extends DataProviderType {
return this.app.i18n.t('dataProviderType.dataSource')
}
async init(runtimeFormulaContext) {
async init(applicationContext) {
const dataSources = this.app.store.getters['dataSource/getPageDataSources'](
runtimeFormulaContext.applicationContext.page
applicationContext.page
)
// Dispatch the data sources
await this.app.store.dispatch(
'dataSourceContent/fetchPageDataSourceContent',
{
page: runtimeFormulaContext.applicationContext.page,
data: runtimeFormulaContext.getAllBackendContext(),
page: applicationContext.page,
data: DataProviderType.getAllBackendContext(
this.app.$registry.getAll('builderDataProvider'),
applicationContext
),
dataSources,
}
)
}
getDataChunk(runtimeFormulaContext, [dataSourceId, ...rest]) {
const dataSourceContents = this.app.store.getters[
'dataSourceContent/getDataSourceContents'
](runtimeFormulaContext.applicationContext.page)
getDataChunk(applicationContext, [dataSourceId, ...rest]) {
const content = this.getDataSourceContent(applicationContext, dataSourceId)
const content = dataSourceContents[dataSourceId]
return content ? _.get(content, rest.join('.')) : null
}
getDataSourceContent(applicationContext, dataSourceId) {
const page = applicationContext.page
const dataSourceContents =
this.app.store.getters['dataSourceContent/getDataSourceContents'](page)
return dataSourceContents[dataSourceId]
}
getDataSourceSchema(applicationContext, dataSourceId) {
return GenerateSchema.json(
this.getDataSourceContent(applicationContext, dataSourceId)
)
}
getDataContent(applicationContext) {
const page = applicationContext.page
const dataSources =
this.app.store.getters['dataSource/getPageDataSources'](page)
return Object.fromEntries(
dataSources.map((dataSource) => {
return [
dataSource.id,
this.getDataSourceContent(applicationContext, dataSource.id),
]
})
)
}
getDataSchema(applicationContext) {
const page = applicationContext.page
const dataSources =
this.app.store.getters['dataSource/getPageDataSources'](page)
const dataSourcesSchema = Object.fromEntries(
dataSources.map((dataSource) => {
const dsSchema = this.getDataSourceSchema(
applicationContext,
dataSource.id
)
delete dsSchema.$schema
return [dataSource.id, dsSchema]
})
)
return { type: 'object', properties: dataSourcesSchema }
}
pathPartToDisplay(applicationContext, part, position) {
if (position === 1) {
const page = applicationContext?.page
return this.app.store.getters['dataSource/getPageDataSourceById'](
page,
parseInt(part)
)?.name
}
return super.pathPartToDisplay(applicationContext, part, position)
}
}
export class PageParameterDataProviderType extends DataProviderType {
@ -54,9 +116,8 @@ export class PageParameterDataProviderType extends DataProviderType {
return this.app.i18n.t('dataProviderType.pageParameter')
}
async init(runtimeFormulaContext) {
const { page, mode, pageParamsValue } =
runtimeFormulaContext.applicationContext
async init(applicationContext) {
const { page, mode, pageParamsValue } = applicationContext
if (mode === 'editing') {
// Generate fake values for the parameters
await Promise.all(
@ -82,14 +143,14 @@ export class PageParameterDataProviderType extends DataProviderType {
}
}
getDataChunk(runtimeFormulaContext, path) {
getDataChunk(applicationContext, path) {
if (path.length !== 1) {
return null
}
const [prop] = path
const parameters = this.app.store.getters['pageParameter/getParameters'](
runtimeFormulaContext.applicationContext.page
applicationContext.page
)
if (parameters[prop] === undefined) {
@ -99,9 +160,31 @@ export class PageParameterDataProviderType extends DataProviderType {
return parameters[prop]
}
getBackendContext(runtimeFormulaContext) {
getBackendContext(applicationContext) {
return this.getDataContent(applicationContext)
}
getDataContent(applicationContext) {
return this.app.store.getters['pageParameter/getParameters'](
runtimeFormulaContext.applicationContext.page
applicationContext.page
)
}
getDataSchema(applicationContext) {
const page = applicationContext.page
const toJSONType = { text: 'string', numeric: 'number' }
return {
type: 'object',
properties: Object.fromEntries(
(page?.path_params || []).map(({ name, type }) => [
name,
{
name,
type: toJSONType[type],
},
])
),
}
}
}

View file

@ -2,6 +2,10 @@ import {
ensureInteger,
ensureString,
} from '@baserow/modules/core/utils/validator'
import {
DataSourceDataProviderType,
PageParameterDataProviderType,
} from '@baserow/modules/builder/dataProviderTypes'
export const PLACEMENTS = {
BEFORE: 'before',
@ -56,3 +60,23 @@ export const WIDTHS = {
AUTO: { value: 'auto', name: 'widthSelector.widthAuto' },
FULL: { value: 'full', name: 'widthSelector.widthFull' },
}
/**
* A list of all the data providers that can be used in the formula field on the right
* sidebar in the application builder.
*
* @type {String[]}
*/
export const DATA_PROVIDERS_ALLOWED_SIDEBAR = [
new DataSourceDataProviderType().getType(),
new PageParameterDataProviderType().getType(),
]
/**
* A list of all the data provider that can be used to configure data sources.
*
* @type {String[]}
*/
export const DATA_PROVIDERS_ALLOWED_DATA_SOURCES = [
new PageParameterDataProviderType().getType(),
]

View file

@ -288,5 +288,8 @@
},
"eventTypes": {
"clickLabel": "On click"
},
"getFormulaComponent": {
"errorTooltip": "Invalid reference"
}
}

View file

@ -3,6 +3,7 @@ import _ from 'lodash'
import { clone } from '@baserow/modules/core/utils/object'
import { notifyIf } from '@baserow/modules/core/utils/error'
import BigNumber from 'bignumber.js'
export default {
inject: ['builder', 'page'],
@ -36,6 +37,8 @@ export default {
async onChange(newValues) {
const oldValues = this.element
newValues.order = new BigNumber(newValues.order)
if (!this.$refs.panelForm.isFormValid()) {
return
}

View file

@ -20,7 +20,7 @@ import { StoreItemLookupError } from '@baserow/modules/core/errors'
import PageHeader from '@baserow/modules/builder/components/page/header/PageHeader'
import PagePreview from '@baserow/modules/builder/components/page/PagePreview'
import PageSidePanels from '@baserow/modules/builder/components/page/PageSidePanels'
import RuntimeFormulaContext from '@baserow/modules/core/runtimeFormulaContext'
import { DataProviderType } from '@baserow/modules/core/dataProviderTypes'
export default {
name: 'PageEditor',
@ -57,17 +57,11 @@ export default {
store.dispatch('element/fetch', { page }),
])
const runtimeFormulaContext = new RuntimeFormulaContext(
$registry.getAll('builderDataProvider'),
{
builder,
page,
mode: 'editing',
}
)
// Initialize all data provider contents
await runtimeFormulaContext.initAll()
await DataProviderType.initAll($registry.getAll('builderDataProvider'), {
builder,
page,
mode: 'editing',
})
// And finally select the page to display it
await store.dispatch('page/selectById', {
@ -89,21 +83,21 @@ export default {
return data
},
computed: {
applicationContext() {
return {
builder: this.builder,
page: this.page,
mode: 'editing',
}
},
dataSources() {
return this.$store.getters['dataSource/getPageDataSources'](this.page)
},
runtimeFormulaContext() {
return new RuntimeFormulaContext(
this.$registry.getAll('builderDataProvider'),
{
builder: this.builder,
page: this.page,
mode: 'editing',
}
)
},
backendContext() {
return this.runtimeFormulaContext.getAllBackendContext()
return DataProviderType.getAllBackendContext(
this.$registry.getAll('builderDataProvider'),
this.applicationContext
)
},
},
watch: {

View file

@ -10,7 +10,8 @@
<script>
import PageContent from '@baserow/modules/builder/components/page/PageContent'
import { resolveApplicationRoute } from '@baserow/modules/builder/utils/routing'
import RuntimeFormulaContext from '@baserow/modules/core/runtimeFormulaContext'
import { DataProviderType } from '@baserow/modules/core/dataProviderTypes'
export default {
components: { PageContent },
@ -83,18 +84,12 @@ export default {
store.dispatch('element/fetchPublished', { page }),
])
const runtimeFormulaContext = new RuntimeFormulaContext(
$registry.getAll('builderDataProvider'),
{
builder,
page,
pageParamsValue,
mode,
}
)
// Initialize all data provider contents
await runtimeFormulaContext.initAll()
await DataProviderType.initAll($registry.getAll('builderDataProvider'), {
builder,
page,
pageParamsValue,
mode,
})
// And finally select the page to display it
await store.dispatch('page/selectById', {
@ -123,19 +118,19 @@ export default {
elements() {
return this.$store.getters['element/getRootElements'](this.page)
},
runtimeFormulaContext() {
return new RuntimeFormulaContext(
this.$registry.getAll('builderDataProvider'),
{
builder: this.builder,
page: this.page,
pageParamsValue: this.params,
mode: this.mode,
}
)
applicationContext() {
return {
builder: this.builder,
page: this.page,
pageParamsValue: this.params,
mode: this.mode,
}
},
backendContext() {
return this.runtimeFormulaContext.getAllBackendContext()
return DataProviderType.getAllBackendContext(
this.$registry.getAll('builderDataProvider'),
this.applicationContext
)
},
},
watch: {

View file

@ -4,6 +4,10 @@ export class PathParamType extends Registerable {
get name() {
return null
}
get icon() {
return null
}
}
export class TextPathParamType extends PathParamType {
@ -18,6 +22,10 @@ export class TextPathParamType extends PathParamType {
get name() {
return this.app.i18n.t('pathParamTypes.textName')
}
get icon() {
return 'font'
}
}
export class NumericPathParamType extends PathParamType {
@ -32,4 +40,8 @@ export class NumericPathParamType extends PathParamType {
get name() {
return this.app.i18n.t('pathParamTypes.numericName')
}
get icon() {
return 'hashtag'
}
}

View file

@ -52,6 +52,9 @@ const mutations = {
CLEAR_ITEMS(state, { page }) {
page.dataSources = []
},
SET_LOADING(state, { page, value }) {
page._.dataSourceLoading = value
},
}
const actions = {
@ -82,7 +85,8 @@ const actions = {
commit('MOVE_ITEM', { page, index, oldIndex })
}
},
async create({ dispatch }, { page, values, beforeId }) {
async create({ commit, dispatch }, { page, values, beforeId }) {
commit('SET_LOADING', { page, value: true })
const { data: dataSource } = await DataSourceService(this.$client).create(
page.id,
values,
@ -90,8 +94,9 @@ const actions = {
)
await dispatch('forceCreate', { page, dataSource, beforeId })
commit('SET_LOADING', { page, value: false })
},
async update({ dispatch }, { page, dataSourceId, values }) {
async update({ commit, dispatch }, { page, dataSourceId, values }) {
const dataSourcesOfPage = getters.getPageDataSources(page)
const dataSource = dataSourcesOfPage.find(
(dataSource) => dataSource.id === dataSourceId
@ -107,12 +112,14 @@ const actions = {
await dispatch('forceUpdate', { page, dataSource, values: newValues })
commit('SET_LOADING', { page, value: true })
try {
await DataSourceService(this.$client).update(dataSource.id, values)
} catch (error) {
await dispatch('forceUpdate', { page, dataSource, values: oldValues })
throw error
}
commit('SET_LOADING', { page, value: false })
},
async debouncedUpdate(
@ -143,6 +150,7 @@ const actions = {
const fire = async () => {
const toUpdate = updateContext.valuesToUpdate
updateContext.valuesToUpdate = {}
commit('SET_LOADING', { page, value: true })
try {
const { data } = await DataSourceService(this.$client).update(
dataSource.id,
@ -161,6 +169,7 @@ const actions = {
updateContext.lastUpdatedValues = null
reject(error)
}
commit('SET_LOADING', { page, value: false })
}
if (updateContext.promiseResolve) {
@ -178,7 +187,7 @@ const actions = {
updateContext.promiseResolve = resolve
})
},
async delete({ dispatch, getters }, { page, dataSourceId }) {
async delete({ commit, dispatch, getters }, { page, dataSourceId }) {
const dataSourcesOfPage = getters.getPageDataSources(page)
const dataSourceIndex = dataSourcesOfPage.findIndex(
(dataSource) => dataSource.id === dataSourceId
@ -191,6 +200,7 @@ const actions = {
await dispatch('forceDelete', { page, dataSourceId })
commit('SET_LOADING', { page, value: true })
try {
await DataSourceService(this.$client).delete(dataSourceId)
} catch (error) {
@ -201,8 +211,10 @@ const actions = {
})
throw error
}
commit('SET_LOADING', { page, value: false })
},
async fetch({ dispatch, commit }, { page }) {
commit('SET_LOADING', { page, value: true })
dispatch(
'dataSourceContent/clearDataSourceContents',
{ page },
@ -218,10 +230,12 @@ const actions = {
dispatch('forceCreate', { page, dataSource })
)
)
commit('SET_LOADING', { page, value: false })
return dataSources
},
async fetchPublished({ dispatch, commit }, { page }) {
commit('SET_LOADING', { page, value: true })
dispatch(
'dataSourceContent/clearDataSourceContents',
{ page },
@ -238,6 +252,7 @@ const actions = {
dispatch('forceCreate', { page, dataSource })
)
)
commit('SET_LOADING', { page, value: false })
return dataSources
},
@ -258,14 +273,16 @@ const actions = {
throw error
}
},
async duplicate({ getters, dispatch }, { page, dataSourceId }) {
async duplicate({ commit, getters, dispatch }, { page, dataSourceId }) {
const dataSourcesOfPage = getters.getPageDataSources(page)
const dataSource = dataSourcesOfPage.find((e) => e.id === dataSourceId)
commit('SET_LOADING', { page, value: true })
await dispatch('create', {
page,
dataSourceType: dataSource.type,
beforeId: dataSource.id,
})
commit('SET_LOADING', { page, value: false })
},
}
@ -273,6 +290,12 @@ const getters = {
getPageDataSources: (state) => (page) => {
return page.dataSources
},
getPageDataSourceById: (state) => (page, id) => {
return page.dataSources.find((dataSource) => dataSource.id === id)
},
getLoading: (state) => (page) => {
return page._.dataSourceLoading
},
}
export default {

View file

@ -27,6 +27,9 @@ const mutations = {
CLEAR_CONTENTS(state, { page }) {
page.contents = {}
},
SET_LOADING(state, { page, value }) {
page._.dataSourceContentLoading = value
},
}
const actions = {
@ -45,6 +48,7 @@ const actions = {
const serviceType = this.app.$registry.get('service', dataSource.type)
commit('SET_LOADING', { page, value: true })
try {
if (serviceType.isValid(dataSource)) {
const { data } = await DataSourceService(this.app.$client).dispatch(
@ -66,12 +70,14 @@ const actions = {
} catch (e) {
commit('SET_CONTENT', { page, dataSourceId: dataSource.id, value: null })
}
commit('SET_LOADING', { page, value: false })
},
/**
* Fetch the content for every data sources of the given page.
*/
async fetchPageDataSourceContent({ commit }, { page, data: queryData }) {
commit('SET_LOADING', { page, value: true })
try {
const { data } = await DataSourceService(this.app.$client).dispatchAll(
page.id,
@ -90,6 +96,7 @@ const actions = {
commit('CLEAR_CONTENTS', { page })
throw e
}
commit('SET_LOADING', { page, value: false })
},
debouncedFetchPageDataSourceContent({ dispatch }, { page, data: queryData }) {
@ -111,6 +118,9 @@ const getters = {
getDataSourceContents: (state) => (page) => {
return page.contents || {}
},
getLoading: (state) => (page) => {
return page._.dataSourceContentLoading
},
}
export default {

View file

@ -6,6 +6,8 @@ import { generateHash } from '@baserow/modules/core/utils/hashing'
export function populatePage(page) {
page._ = {
selected: false,
dataSourceContentLoading: false,
dataSourceLoading: false,
}
page.dataSources = []

View file

@ -133,3 +133,6 @@
@import 'theme_settings';
@import 'color_input';
@import 'group_bys';
@import 'data_explorer/node';
@import 'data_explorer/root_node';
@import 'data_explorer/data_explorer';

View file

@ -1,5 +1,6 @@
.data-source-context {
padding: 12px;
width: 560px;
}
.data-source-context__none {

View file

@ -0,0 +1,3 @@
.data-explorer {
width: 323px;
}

View file

@ -0,0 +1,46 @@
.node {
margin: 4px 0 4px 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;
}
.node__selected {
margin: auto 0 auto 4px;
color: $color-success-500;
text-align: center;
}
.node__icon {
color: $color-neutral-500;
width: 15px;
&::before {
display: flex;
justify-content: center;
}
}

View file

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

View file

@ -1,7 +1,8 @@
.formula-input-field {
font-size: 13px;
line-height: 22px;
padding: 6px 12px;
padding: 4px 12px;
min-height: 34px;
}
.formula-input-field--focused {

View file

@ -1,14 +1,24 @@
.get-formula-component {
cursor: pointer;
display: inline-block;
padding: 2px 8px;
background-color: $color-neutral-100;
font-size: 12px;
margin: 1px 0;
user-select: all;
border-radius: 3px;
user-select: none;
line-height: 18px;
@include rounded($rounded);
}
.get-formula-component--error {
background-color: $color-error-100;
}
.get-formula-component--selected {
background-color: $color-primary-100;
}
.get-formula-component__caret {
color: $color-neutral-300;
padding-left: 3px;

View file

@ -453,7 +453,7 @@ export default {
targetRect.right - contextRect.width - horizontalOffset > 0
const canLeft =
window.innerWidth -
targetRect.right -
targetRect.left -
contextRect.width -
horizontalOffset >
0

View file

@ -0,0 +1,25 @@
<template>
<div class="select__search">
<i class="select__search-icon fas fa-search"></i>
<input
:value="value"
type="text"
class="select__search-input"
v-bind="$attrs"
@input="$emit('input', $event.target.value)"
/>
</div>
</template>
<script>
export default {
name: 'SelectSearch',
props: {
value: {
type: String,
required: false,
default: null,
},
},
}
</script>

View file

@ -0,0 +1,207 @@
<template>
<Context
overflow-scroll
max-height-if-outside-viewport
:hide-on-click-outside="false"
class="data-explorer"
@shown="onShow"
>
<div v-if="loading" class="context--loading">
<div class="loading"></div>
</div>
<template v-else>
<SelectSearch
v-model="search"
:placeholder="$t('action.search')"
class="margin-bottom-1"
></SelectSearch>
<RootNode
v-for="node in matchingNodes"
:key="node.identifier"
:node="node"
:node-selected="nodeSelected"
:open-nodes="openNodes"
@node-selected="$emit('node-selected', $event)"
@toggle="toggleNode"
>
</RootNode>
<div v-if="matchingNodes.length === 0" class="context__description">
{{ $t('dataExplorer.emptyText') }}
</div>
</template>
</Context>
</template>
<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 _ from 'lodash'
export default {
name: 'DataExplorer',
components: { SelectSearch, RootNode },
mixins: [context],
props: {
nodes: {
type: Array,
required: false,
default: () => [],
},
nodeSelected: {
type: String,
required: false,
default: null,
},
loading: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
search: null,
debounceSearch: null,
debouncedSearch: null,
// A map of open node paths
openNodes: new Set(),
}
},
computed: {
isSearching() {
return Boolean(this.debouncedSearch)
},
matchingPaths() {
if (!this.isSearching) {
return new Set()
} else {
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) {
clearTimeout(this.debounceSearch)
this.debounceSearch = setTimeout(() => {
this.debouncedSearch = value
}, 300)
},
matchingPaths(value) {
this.openNodes = value
},
nodeSelected: {
handler(value) {
if (value !== null) {
this.toggleNode(value, true)
}
},
immediate: true,
},
},
methods: {
/**
* Resets state on show context
*/
onShow() {
this.search = null
this.openNodes = new Set()
},
/**
* Given a dotted path, returns a list of prefixes and the given path.
* @param {String} path the path we want the ancestors for.
*/
getPathAndParents(path) {
return _.toPath(path).map((item, index, pathParts) =>
pathParts.slice(0, index + 1).join('.')
)
},
/**
* Returns a Set of leaf nodes path that match the search term (and their parents).
* @param {Array} nodes Nodes tree.
* @param {String} parentPath Path of the current nodes.
* @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]
if (subNode.nodes) {
// It's not a leaf
const subSubNodes = this.matchesSearch(
subNode.nodes,
search,
subNodePath
)
acc = new Set([...acc, ...subSubNodes])
} else {
// It's a leaf we check if the name match the search
const nodeNameSanitised = subNode.name.trim().toLowerCase()
if (nodeNameSanitised.includes(searchSanitised)) {
// We also add the parents of the node
acc = new Set([...acc, ...this.getPathAndParents(subNodePath)])
}
}
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.
* @param {Boolean} forceOpen if we want to open the node anyway.
*/
toggleNode(path, forceOpen = false) {
const shouldOpenNode = forceOpen || !this.openNodes.has(path)
if (shouldOpenNode) {
// Open all parents as well
this.openNodes = new Set([
...this.openNodes,
...this.getPathAndParents(path),
])
} else {
const newOpenNodes = new Set(this.openNodes)
newOpenNodes.delete(path)
this.openNodes = newOpenNodes
}
this.$emit('node-toggled')
},
},
}
</script>

View file

@ -0,0 +1,94 @@
<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="`fas fa-${$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 fas fa-check-circle"
></i>
</div>
<div v-if="props.openNodes.has(props.path)">
<Node
v-for="subNode in 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 ? 'caret-down' : 'caret-right'
},
},
}
</script>

View file

@ -0,0 +1,96 @@
<template>
<div>
<div class="root-node__name">
{{ node.name }}
</div>
<div ref="nodes">
<Node
v-for="subNode in 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
)
},
},
}
</script>

View file

@ -12,12 +12,23 @@
{{ $t('action.reset') }}
</Button>
</Alert>
<EditorContent
v-else
class="input formula-input-field"
:class="classes"
:editor="editor"
/>
<div v-else>
<EditorContent
ref="editor"
class="input formula-input-field"
:class="classes"
:editor="editor"
@data-component-clicked="dataComponentClicked"
/>
<DataExplorer
ref="dataExplorer"
:nodes="nodes"
:node-selected="nodeSelected"
:loading="dataExplorerLoading"
@node-selected="dataExplorerItemSelected"
@node-toggled="editor.commands.focus()"
></DataExplorer>
</div>
</template>
<script>
@ -32,12 +43,20 @@ import { RuntimeFunctionCollection } from '@baserow/modules/core/functionCollect
import { FromTipTapVisitor } from '@baserow/modules/core/formula/tiptap/fromTipTapVisitor'
import { mergeAttributes } from '@tiptap/core'
import { HardBreak } from '@tiptap/extension-hard-break'
import DataExplorer from '@baserow/modules/core/components/dataExplorer/DataExplorer'
import { onClickOutside } from '@baserow/modules/core/utils/dom'
import { RuntimeGet } from '@baserow/modules/core/runtimeFormulaTypes'
export default {
name: 'FormulaInputField',
components: {
DataExplorer,
EditorContent,
},
provide() {
// Provide the application context to all formula components
return { applicationContext: this.applicationContext }
},
props: {
value: {
type: String,
@ -47,6 +66,20 @@ export default {
type: String,
default: null,
},
dataProviders: {
type: Array,
required: false,
default: () => [],
},
dataExplorerLoading: {
type: Boolean,
required: false,
default: false,
},
applicationContext: {
type: Object,
required: true,
},
},
data() {
return {
@ -54,6 +87,7 @@ export default {
content: null,
isFocused: false,
isFormulaInvalid: false,
dataNodeSelected: null,
}
},
computed: {
@ -113,6 +147,14 @@ export default {
wrapperContent() {
return this.editor.getJSON().content[0].content
},
nodes() {
return this.dataProviders
.map((dataProvider) => dataProvider.getNodes(this.applicationContext))
.filter((dataProviderNodes) => dataProviderNodes.nodes?.length > 0)
},
nodeSelected() {
return this.dataNodeSelected?.attrs?.path || null
},
},
watch: {
value(value) {
@ -142,12 +184,17 @@ export default {
editable: true,
onUpdate: this.onUpdate,
onFocus: this.onFocus,
onBlur: this.onBlur,
extensions: this.extensions,
parseOptions: {
preserveWhitespace: 'full',
},
editorProps: {
handleClick: this.unSelectNode,
},
})
const clickOutsideEventCancel = onClickOutside(this.$el, this.onBlur)
this.$once('hook:beforeDestroy', clickOutsideEventCancel)
},
beforeDestroy() {
this.editor?.destroy()
@ -157,18 +204,38 @@ export default {
this.isFormulaInvalid = false
this.$emit('input', '')
},
onUpdate() {
this.fixMultipleWrappers()
emitChange() {
if (!this.isFormulaInvalid) {
this.$emit('input', this.toFormula(this.wrapperContent))
}
},
onFocus() {
this.isFocused = true
onUpdate() {
this.fixMultipleWrappers()
this.unSelectNode()
this.emitChange()
},
onBlur() {
this.isFocused = false
onFocus() {
if (!this.isFocused) {
this.isFocused = true
this.unSelectNode()
this.$refs.dataExplorer.show(
this.$refs.editor.$el,
'bottom',
'left',
-100,
-330
)
}
},
onBlur(target) {
if (
!this.$refs.dataExplorer.$el.contains(target) &&
!this.$refs.editor.$el.contains(target)
) {
this.isFocused = false
this.$refs.dataExplorer.hide()
this.unSelectNode()
}
},
toContent(formula) {
if (_.isEmpty(formula)) {
@ -215,6 +282,34 @@ export default {
this.editor.commands.joinForward()
}
},
dataComponentClicked(node) {
this.selectNode(node)
this.editor.commands.blur()
},
dataExplorerItemSelected({ path }) {
const isInEditingMode = this.dataNodeSelected !== null
if (isInEditingMode) {
this.dataNodeSelected.attrs.path = path
this.emitChange()
} else {
const getNode = new RuntimeGet().toNode([{ text: path }])
this.editor.commands.insertContent(getNode)
}
this.editor.commands.focus()
},
selectNode(node) {
if (node) {
this.unSelectNode()
this.dataNodeSelected = node
this.dataNodeSelected.attrs.isSelected = true
}
},
unSelectNode() {
if (this.dataNodeSelected) {
this.dataNodeSelected.attrs.isSelected = false
this.dataNodeSelected = null
}
},
},
}
</script>

View file

@ -8,27 +8,23 @@
{{ label }}
</label>
<div class="control__elements">
<textarea
<FormulaInputField
:value="value"
:rows="nbRows"
:placeholder="placeholder"
class="input paragraph-element-form__value"
:class="{
'input--error': hasError,
}"
@input="$emit('input', $event.target.value)"
@keydown.enter.stop
@blur="$emit('blur', $event)"
:data-providers="dataProviders"
:data-explorer-loading="dataExplorerLoading"
:application-context="{ page, builder, mode }"
@input="$emit('input', $event)"
/>
</div>
<div v-if="hasError" class="error">
{{ error }}
</div>
</FormElement>
</template>
<script>
import FormulaInputField from '@baserow/modules/core/components/formula/FormulaInputField'
export default {
components: { FormulaInputField },
inject: ['page', 'builder', 'mode'],
props: {
value: {
type: String,
@ -54,18 +50,15 @@ export default {
required: false,
default: false,
},
error: {
type: String,
dataProviders: {
type: Array,
required: false,
default: '',
default: () => [],
},
},
computed: {
hasError() {
return Boolean(this.error)
},
nbRows() {
return this.value.split(/\n/).length > 1 ? 12 : 1
dataExplorerLoading: {
type: Boolean,
required: false,
default: false,
},
},
}

View file

@ -1,14 +1,28 @@
<template>
<NodeViewWrapper as="span" class="get-formula-component">
{{ pathParts.dataProvider }}
<template v-for="(part, index) in pathParts.parts">
<i :key="index" class="get-formula-component__caret fas fa-angle-right">
</i>
{{ part }}
</template>
<a class="get-formula-component__remove" @click="deleteNode">
<i class="fas fa-times"></i>
</a>
<NodeViewWrapper
as="span"
class="get-formula-component"
:class="{
'get-formula-component--error': isInvalid,
'get-formula-component--selected': isSelected,
}"
>
<div
v-tooltip="$t('getFormulaComponent.errorTooltip')"
tooltip-position="top"
:hide-tooltip="!isInvalid"
@click="emitToEditor('data-component-clicked', node)"
>
{{ pathParts.dataProvider }}
<template v-for="(part, index) in pathParts.parts">
<i :key="index" class="get-formula-component__caret fas fa-angle-right">
</i>
{{ part }}
</template>
<a class="get-formula-component__remove" @click="deleteNode">
<i class="fas fa-times"></i>
</a>
</div>
</NodeViewWrapper>
</template>
@ -23,10 +37,22 @@ export default {
NodeViewWrapper,
},
mixins: [formulaComponent],
inject: ['applicationContext'],
computed: {
availableData() {
return Object.values(this.$registry.getAll('builderDataProvider')).map(
(dataProvider) => dataProvider.getNodes(this.applicationContext)
)
},
isInvalid() {
return this.findNode(this.availableData, _.toPath(this.path)) === null
},
path() {
return this.node.attrs.path
},
isSelected() {
return this.node.attrs.isSelected
},
pathParts() {
const [dataProvider, ...parts] = _.toPath(this.path)
const dataProviderType = this.$registry.get(
@ -36,9 +62,36 @@ export default {
return {
dataProvider: dataProviderType.name,
parts,
parts: parts.map((part, index) =>
dataProviderType.pathPartToDisplay(
this.applicationContext,
part,
index + 1
)
),
}
},
},
methods: {
findNode(nodes, path) {
const [identifier, ...rest] = path
if (!nodes) {
return null
}
const nodeFound = nodes.find((node) => node.identifier === identifier)
if (!nodeFound) {
return null
}
if (rest.length > 0) {
return this.findNode(nodeFound.nodes, rest)
}
return nodeFound
},
},
}
</script>

View file

@ -5,6 +5,14 @@ import { Registerable } from '@baserow/modules/core/registry'
* the formula resolver.
*/
export class DataProviderType extends Registerable {
DATA_TYPE_TO_ICON_MAP = {
string: 'font',
number: 'hashtag',
boolean: 'check-square',
}
UNKNOWN_DATA_TYPE_ICON = 'question'
get name() {
throw new Error('`name` must be set on the dataProviderType.')
}
@ -23,24 +31,172 @@ export class DataProviderType extends Registerable {
*/
async init(r) {}
/**
* Call init step of all given data providers according to the application context
* @param {Array} dataProviders
* @param {Object} applicationContext the application context.
*/
static async initAll(dataProviders, applicationContext) {
// First we initialize providers that doesn't need a backend context
await Promise.all(
Object.values(dataProviders)
.filter((provider) => !provider.needBackendContext)
.map((dataProvider) => dataProvider.init(applicationContext))
)
// Then we initialize those that need the backend context
await Promise.all(
Object.values(dataProviders)
.filter((provider) => provider.needBackendContext)
.map((dataProvider) => dataProvider.init(applicationContext))
)
}
static getAllBackendContext(dataProviders, applicationContext) {
return Object.fromEntries(
Object.values(dataProviders).map((dataProvider) => {
return [
dataProvider.type,
dataProvider.getBackendContext(applicationContext),
]
})
)
}
/**
* Should return the context needed to be send to the backend for each dataProvider
* to be able to solve the formulas on the backend.
* @param {Object} applicationContext the application context.
* @returns An object if the dataProvider wants to send something to the backend.
*/
getBackendContext(runtimeFormulaContext) {
getBackendContext(applicationContext) {
return null
}
/**
* Returns the actual data.
* @param {object} runtimeFormulaContext the formula context instance.
* Returns the actual data for the given path.
* @param {object} applicationContext the application context.
* @param {Array<str>} path the path of the data we want to get
*/
getDataChunk(runtimeFormulaContext, path) {
getDataChunk(applicationContext, path) {
throw new Error('.getDataChunk() must be set on the dataProviderType.')
}
/**
* Return the data content for this data provider.
* @param {Object} applicationContext the application context.
* @returns {{$schema: string}}
*/
getDataContent(applicationContext) {
throw new Error('.getDataContent() must be set on the dataProviderType.')
}
/**
* Return the schema of the data provided by this data provider
* @param {Object} applicationContext the application context.
* @returns {{$schema: string}}
*/
getDataSchema(applicationContext) {
throw new Error('.getDataSchema() must be set on the dataProviderType.')
}
/**
* This function returns an object that can be read by the data explorer to display
* the data available from each data provider.
*
* Make sure to implement `getDataContent` and `getDataSchema` for every data provider
* if they should show data in the data explorer.
*
* @param {Object} applicationContext the application context.
* @returns {{identifier: string, name: string, nodes: []}}
*/
getNodes(applicationContext) {
const content = this.getDataContent(applicationContext)
const schema = this.getDataSchema(applicationContext)
return this._toNode(applicationContext, this.type, content, schema)
}
/**
* Recursive method to deeply compute the node tree for this data providers.
* @param {Object} applicationContext the application context.
* @param {string} identifier the identifier for the current node.
* @param {*} content the current node content.
* @param {$schema: string} schema the current node schema.
* @param {int} level the level of the current node in the data hierarchy.
* @returns {{identifier: string, name: string, nodes: []}}
*/
_toNode(applicationContext, identifier, content, schema, level = 0) {
const name = this.pathPartToDisplay(applicationContext, identifier, level)
if (schema.type === 'array') {
return {
name,
identifier,
icon: this.getIconForType(schema.type),
nodes: content.map((item, index) =>
this._toNode(
applicationContext,
`${index}`,
item,
schema.items,
level + 1
)
),
}
}
if (schema.type === 'object') {
return {
name,
identifier,
icon: this.getIconForType(schema.type),
nodes: Object.entries(schema.properties).map(
([identifier, subSchema]) =>
this._toNode(
applicationContext,
identifier,
content[identifier],
subSchema,
level + 1
)
),
}
}
return {
name,
type: schema.type,
icon: this.getIconForType(schema.type),
value: content,
identifier,
}
}
/**
* This function gives you the icon that can be used by the data explorer given
* the type of the data.
* @param type - The type of the data
* @returns {*|string} - The corresponding icon
*/
getIconForType(type) {
return this.DATA_TYPE_TO_ICON_MAP[type] || this.UNKNOWN_DATA_TYPE_ICON
}
/**
* This function lets you hook into the path translation. Sometimes the path uses an
* ID to reference an item, but we want to show the name of the item to the user
* instead.
* @param {Object} applicationContext the application context.
* @param {String} part - raw path part as used by the formula
* @param {String} position - index of the part in the path
* @returns {Array<String>} - modified path part as it should be displayed to the user
*/
pathPartToDisplay(applicationContext, part, position) {
if (position === 0) {
return this.name
}
return part
}
getOrder() {
return 0
}

View file

@ -24,6 +24,11 @@ export default {
}
el.tooltipMouseEnterEvent = () => {
const position = el.getAttribute('tooltip-position') || 'bottom'
const hide = el.getAttribute('hide-tooltip')
if (hide) {
return
}
if (el.tooltipElement) {
this.terminate(el)

View file

@ -567,5 +567,8 @@
},
"formulaInputField": {
"errorInvalidFormula": "The formula is invalid."
},
"dataExplorer": {
"emptyText": "No data found"
}
}

View file

@ -23,29 +23,6 @@ export class RuntimeFormulaContext {
this.applicationContext = applicationContext
}
async initAll() {
// First we initialize providers that doesn't need a backend context
await Promise.all(
Object.values(this.dataProviders)
.filter((provider) => !provider.needBackendContext)
.map((dataProvider) => dataProvider.init(this))
)
// Then we initialize those that need the backend context
await Promise.all(
Object.values(this.dataProviders)
.filter((provider) => provider.needBackendContext)
.map((dataProvider) => dataProvider.init(this))
)
}
getAllBackendContext() {
return Object.fromEntries(
Object.values(this.dataProviders).map((dataProvider) => {
return [dataProvider.type, dataProvider.getBackendContext(this)]
})
)
}
/**
* Returns the value for the given path. The first part of the path is
* the data provider type, then the remaining parts are given to the data provider.
@ -62,7 +39,7 @@ export class RuntimeFormulaContext {
}
try {
return dataProviderType.getDataChunk(this, rest)
return dataProviderType.getDataChunk(this.applicationContext, rest)
} catch (e) {
throw new UnresolvablePathError(dataProviderType.type, rest.join('.'))
}

View file

@ -180,6 +180,9 @@ export class RuntimeGet extends RuntimeFormulaFunction {
path: {
default: '',
},
isSelected: {
default: false,
},
}
},
parseHTML() {
@ -205,6 +208,7 @@ export class RuntimeGet extends RuntimeFormulaFunction {
const specificConfiguration = {
attrs: {
path: textNode.text,
isSelected: false,
},
}
return _.merge(specificConfiguration, defaultConfiguration)

View file

@ -24,16 +24,12 @@
/>
</div>
<div class="col col-4">
<FormulaInputGroup
<ApplicationBuilderFormulaInputGroup
v-model="values.row_id"
small-label
:label="$t('localBaserowGetRowForm.rowFieldLabel')"
:placeholder="$t('localBaserowGetRowForm.rowFieldPlaceHolder')"
:error="
!$v.values.row_id.validFormula
? $t('localBaserowGetRowForm.invalidFormula')
: ''
"
:data-providers-allowed="DATA_PROVIDERS_ALLOWED_DATA_SOURCES"
/>
</div>
</div>
@ -58,11 +54,11 @@
<script>
import form from '@baserow/modules/core/mixins/form'
import FormulaInputGroup from '@baserow/modules/core/components/formula/FormulaInputGroup'
import { isValidFormula } from '@baserow/modules/core/formula'
import { DATA_PROVIDERS_ALLOWED_DATA_SOURCES } from '@baserow/modules/builder/enums'
import ApplicationBuilderFormulaInputGroup from '@baserow/modules/builder/components/ApplicationBuilderFormulaInputGroup'
export default {
components: { FormulaInputGroup },
components: { ApplicationBuilderFormulaInputGroup },
mixins: [form],
props: {
builder: {
@ -81,12 +77,9 @@ export default {
},
}
},
validations() {
return {
values: {
row_id: { validFormula: isValidFormula },
},
}
computed: {
DATA_PROVIDERS_ALLOWED_DATA_SOURCES: () =>
DATA_PROVIDERS_ALLOWED_DATA_SOURCES,
},
}
</script>

View file

@ -47,6 +47,7 @@
"cookie-universal-nuxt": "2.2.2",
"cross-env": "^7.0.2",
"flush-promises": "^1.0.2",
"generate-schema": "^2.6.0",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"markdown-it": "13.0.1",

View file

@ -4096,7 +4096,7 @@ combined-stream@^1.0.8:
dependencies:
delayed-stream "~1.0.0"
commander@^2.19.0, commander@^2.20.0:
commander@^2.19.0, commander@^2.20.0, commander@^2.9.0:
version "2.20.3"
resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
@ -5958,6 +5958,14 @@ gaze@^1.0.0:
dependencies:
globule "^1.0.0"
generate-schema@^2.6.0:
version "2.6.0"
resolved "https://registry.yarnpkg.com/generate-schema/-/generate-schema-2.6.0.tgz#9ac037550fd4243783a9f7681d39bee8870bcec2"
integrity sha512-EUBKfJNzT8f91xUk5X5gKtnbdejZeE065UAJ3BCzE8VEbvwKI9Pm5jaWmqVeK1MYc1g5weAVFDTSJzN7ymtTqA==
dependencies:
commander "^2.9.0"
type-of-is "^3.4.0"
gensync@^1.0.0-beta.2:
version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
@ -8758,11 +8766,6 @@ num2fraction@^1.2.2:
resolved "https://registry.yarnpkg.com/num2fraction/-/num2fraction-1.2.2.tgz#6f682b6a027a4e9ddfa4564cd2589d1d4e669ede"
integrity sha512-Y1wZESM7VUThYY+4W+X4ySH2maqcA+p7UR+w8VWNWVAd6lwuXXWz/w/Cz43J/dI2I+PS6wD5N+bJUF+gjWvIqg==
nuxt-env@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/nuxt-env/-/nuxt-env-0.1.0.tgz#8ac50b9ff45391ad3044ea932cbd05f06a585f87"
integrity sha512-7mTao3qG0zfN0hahk3O6SuDy0KEwYmNojammWQsMwhqMn3aUjX4nMYnWDa0pua+2/rwAY9oG53jQtLgJdG7f9w==
nuxt@2.16.3:
version "2.16.3"
resolved "https://registry.yarnpkg.com/nuxt/-/nuxt-2.16.3.tgz#d0338ab9145bc60aa3d927f03d20e439b9adddf4"
@ -11842,6 +11845,11 @@ type-is@^1.6.18:
media-typer "0.3.0"
mime-types "~2.1.24"
type-of-is@^3.4.0:
version "3.5.1"
resolved "https://registry.yarnpkg.com/type-of-is/-/type-of-is-3.5.1.tgz#eec2fc89b828dbf9900eb6416eee30f4fe0fcd31"
integrity sha512-SOnx8xygcAh8lvDU2exnK2bomASfNjzB3Qz71s2tw9QnX8fkAo7aC+D0H7FV0HjRKj94CKV2Hi71kVkkO6nOxg==
typed-array-length@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.4.tgz#89d83785e5c4098bec72e08b319651f0eac9c1bb"