From f4add0a180c220fa69163b21a3349357edbbcf6e Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 16 Dec 2021 10:03:59 +0000 Subject: [PATCH 001/107] Hide 'Body' tab for REST API datasources when using a GET query --- .../rest/[query]/index.svelte | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/rest/[query]/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/rest/[query]/index.svelte index 39d02d5dd8..1c0a581b93 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/rest/[query]/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/rest/[query]/index.svelte @@ -260,16 +260,18 @@ headings /> - - option.name} - getOptionValue={option => option.value} - /> - - + {#if query?.queryVerb !== "read"} + + option.name} + getOptionValue={option => option.value} + /> + + + {/if} {#if !$flags.queryTransformerBanner} From 49e813cc6cc41242404bbde54445baf41e57d9a6 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 16 Dec 2021 10:11:49 +0000 Subject: [PATCH 002/107] Revert hiding body tab for GETs since already implemented in another pending PR --- .../rest/[query]/index.svelte | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/rest/[query]/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/rest/[query]/index.svelte index 1c0a581b93..39d02d5dd8 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/rest/[query]/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/rest/[query]/index.svelte @@ -260,18 +260,16 @@ headings /> - {#if query?.queryVerb !== "read"} - - option.name} - getOptionValue={option => option.value} - /> - - - {/if} + + option.name} + getOptionValue={option => option.value} + /> + + {#if !$flags.queryTransformerBanner} From af8d55ef323a7701dac46f2c490838f3006c8aaf Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 16 Dec 2021 11:56:14 +0000 Subject: [PATCH 003/107] Use fixed width for REST query verbs --- packages/builder/src/components/common/NavItem.svelte | 1 + packages/builder/src/helpers/data/utils.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/builder/src/components/common/NavItem.svelte b/packages/builder/src/components/common/NavItem.svelte index 2d7dc745d1..d2074b69b1 100644 --- a/packages/builder/src/components/common/NavItem.svelte +++ b/packages/builder/src/components/common/NavItem.svelte @@ -133,5 +133,6 @@ .iconText { margin-top: 1px; font-size: var(--spectrum-global-dimension-font-size-50); + flex: 0 0 34px; } diff --git a/packages/builder/src/helpers/data/utils.js b/packages/builder/src/helpers/data/utils.js index 613f533043..1b8f1e04c5 100644 --- a/packages/builder/src/helpers/data/utils.js +++ b/packages/builder/src/helpers/data/utils.js @@ -84,7 +84,7 @@ export function customQueryIconText(datasource, query) { case "read": return "GET" case "delete": - return "DELETE" + return "DEL" case "patch": return "PATCH" } From 63ec10d73f717535b8113f6e16384f23893b9b03 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 17 Dec 2021 08:22:04 +0000 Subject: [PATCH 004/107] Add new core implementations for fetching data and schema from datasources --- packages/client/src/api/datasources.js | 94 ----- packages/client/src/api/index.js | 1 - .../src/components/app/DataProvider.svelte | 318 +++------------ .../src/components/app/DataProvider2.svelte | 361 ++++++++++++++++++ .../components/app/blocks/CardsBlock.svelte | 4 +- .../components/app/blocks/TableBlock.svelte | 58 +-- .../src/components/app/forms/Form.svelte | 4 +- packages/client/src/sdk.js | 2 + packages/client/src/utils/fetch/QueryFetch.js | 42 ++ .../src/utils/fetch/RelationshipFetch.js | 23 ++ packages/client/src/utils/fetch/TableFetch.js | 324 ++++++++++++++++ packages/client/src/utils/fetch/ViewFetch.js | 34 ++ packages/client/src/utils/fetch/fetchData.js | 16 + packages/client/src/utils/schema.js | 49 +++ 14 files changed, 939 insertions(+), 391 deletions(-) delete mode 100644 packages/client/src/api/datasources.js create mode 100644 packages/client/src/components/app/DataProvider2.svelte create mode 100644 packages/client/src/utils/fetch/QueryFetch.js create mode 100644 packages/client/src/utils/fetch/RelationshipFetch.js create mode 100644 packages/client/src/utils/fetch/TableFetch.js create mode 100644 packages/client/src/utils/fetch/ViewFetch.js create mode 100644 packages/client/src/utils/fetch/fetchData.js create mode 100644 packages/client/src/utils/schema.js diff --git a/packages/client/src/api/datasources.js b/packages/client/src/api/datasources.js deleted file mode 100644 index 981d8301ca..0000000000 --- a/packages/client/src/api/datasources.js +++ /dev/null @@ -1,94 +0,0 @@ -import { cloneDeep } from "lodash/fp" -import { fetchTableData, fetchTableDefinition } from "./tables" -import { fetchViewData } from "./views" -import { fetchRelationshipData } from "./relationships" -import { FieldTypes } from "../constants" -import { executeQuery, fetchQueryDefinition } from "./queries" - -/** - * Fetches all rows for a particular Budibase data source. - */ -export const fetchDatasource = async dataSource => { - if (!dataSource || !dataSource.type) { - return [] - } - - // Fetch all rows in data source - const { type, tableId, fieldName } = dataSource - let rows = [] - if (type === "table") { - rows = await fetchTableData(tableId) - } else if (type === "view") { - rows = await fetchViewData(dataSource) - } else if (type === "query") { - // Set the default query params - let parameters = cloneDeep(dataSource.queryParams || {}) - for (let param of dataSource.parameters) { - if (!parameters[param.name]) { - parameters[param.name] = param.default - } - } - rows = await executeQuery({ queryId: dataSource._id, parameters }) - } else if (type === FieldTypes.LINK) { - rows = await fetchRelationshipData({ - rowId: dataSource.rowId, - tableId: dataSource.rowTableId, - fieldName, - }) - } - - // Enrich the result is always an array - return Array.isArray(rows) ? rows : [] -} - -/** - * Fetches the schema of any kind of datasource. - */ -export const fetchDatasourceSchema = async dataSource => { - if (!dataSource) { - return null - } - const { type } = dataSource - - // Nested providers should already have exposed their own schema - if (type === "provider") { - return dataSource.value?.schema - } - - // Field sources have their schema statically defined - if (type === "field") { - if (dataSource.fieldType === "attachment") { - return { - url: { - type: "string", - }, - name: { - type: "string", - }, - } - } else if (dataSource.fieldType === "array") { - return { - value: { - type: "string", - }, - } - } - } - - // Tables, views and links can be fetched by table ID - if ( - (type === "table" || type === "view" || type === "link") && - dataSource.tableId - ) { - const table = await fetchTableDefinition(dataSource.tableId) - return table?.schema - } - - // Queries can be fetched by query ID - if (type === "query" && dataSource._id) { - const definition = await fetchQueryDefinition(dataSource._id) - return definition?.schema - } - - return null -} diff --git a/packages/client/src/api/index.js b/packages/client/src/api/index.js index 1fd5b18139..d429eb437c 100644 --- a/packages/client/src/api/index.js +++ b/packages/client/src/api/index.js @@ -1,6 +1,5 @@ export * from "./rows" export * from "./auth" -export * from "./datasources" export * from "./tables" export * from "./attachments" export * from "./views" diff --git a/packages/client/src/components/app/DataProvider.svelte b/packages/client/src/components/app/DataProvider.svelte index e9d306cc3b..a33a9f14f2 100644 --- a/packages/client/src/components/app/DataProvider.svelte +++ b/packages/client/src/components/app/DataProvider.svelte @@ -8,6 +8,8 @@ luceneLimit, } from "builder/src/helpers/lucene" import Placeholder from "./Placeholder.svelte" + import { fetchData } from "utils/fetch/fetchData.js" + import { cloneDeep } from "lodash/fp" export let dataSource export let filter @@ -16,102 +18,62 @@ export let limit export let paginate - const { API, styleable, Provider, ActionTypes } = getContext("sdk") + const { styleable, Provider, ActionTypes } = getContext("sdk") const component = getContext("component") - // Loading flag every time data is being fetched - let loading = false - - // Loading flag for the initial load - // Mark as loaded if we have no datasource so we don't stall forever - let loaded = !dataSource - let schemaLoaded = false - - // Provider state - let rows = [] - let allRows = [] - let schema = {} - let bookmarks = [null] - let pageNumber = 0 - let query = null - let queryExtensions = {} - - // Sorting can be overridden at run time, so we can't use the prop directly - let currentSortColumn = sortColumn - let currentSortOrder = sortOrder - - // Reset the current sort state to props if props change - $: currentSortColumn = sortColumn - $: currentSortOrder = sortOrder - - $: defaultQuery = buildLuceneQuery(filter) - $: extendQuery(defaultQuery, queryExtensions) - $: internalTable = dataSource?.type === "table" - $: nestedProvider = dataSource?.type === "provider" - $: hasNextPage = bookmarks[pageNumber + 1] != null - $: hasPrevPage = pageNumber > 0 - $: getSchema(dataSource) - $: sortType = getSortType(schema, currentSortColumn) - - // Wait until schema loads before loading data, so that we can determine - // the correct sort type first time - $: { - if (schemaLoaded) { - fetchData( - dataSource, - schema, - query, - limit, - currentSortColumn, - currentSortOrder, - sortType, - paginate - ) - } + const createFetch = datasource => { + return fetchData(datasource, { + filter, + sortColumn, + sortOrder, + limit, + paginate, + }) } - // Reactively filter and sort rows if required + let fetch = fetchData() + + $: id = $component.id $: { - if (internalTable) { - // Internal tables are already processed server-side - rows = allRows - } else { - // For anything else we use client-side implementations to filter, sort - // and limit - const filtered = luceneQuery(allRows, query) - const sorted = luceneSort( - filtered, - currentSortColumn, - currentSortOrder, - sortType - ) - rows = luceneLimit(sorted, limit) - } + console.log("new datasource", id, dataSource) + fetch = createFetch(dataSource) } + $: fetch.update({ + filter, + sortColumn, + sortOrder, + limit, + paginate, + }) + // Build our action context $: actions = [ { type: ActionTypes.RefreshDatasource, - callback: () => refresh(), + callback: () => fetch.refresh(), metadata: { dataSource }, }, - { - type: ActionTypes.AddDataProviderQueryExtension, - callback: addQueryExtension, - }, - { - type: ActionTypes.RemoveDataProviderQueryExtension, - callback: removeQueryExtension, - }, + // { + // type: ActionTypes.AddDataProviderQueryExtension, + // callback: addQueryExtension, + // }, + // { + // type: ActionTypes.RemoveDataProviderQueryExtension, + // callback: removeQueryExtension, + // }, { type: ActionTypes.SetDataProviderSorting, callback: ({ column, order }) => { + let newOptions = {} if (column) { - currentSortColumn = column + newOptions.sortColumn = column } if (order) { - currentSortOrder = order + newOptions.sortOrder = order + } + if (Object.keys(newOptions)?.length) { + fetch.update(newOptions) } }, }, @@ -119,199 +81,25 @@ // Build our data context $: dataContext = { - rows, - schema, - rowsLength: rows.length, + rows: $fetch.rows, + schema: $fetch.schema, + rowsLength: $fetch.rows.length, // Undocumented properties. These aren't supposed to be used in builder // bindings, but are used internally by other components id: $component?.id, state: { - query, - sortColumn: currentSortColumn, - sortOrder: currentSortOrder, + query: $fetch.query, + sortColumn: $fetch.sortColumn, + sortOrder: $fetch.sortOrder, }, - loaded, - } - - const getSortType = (schema, sortColumn) => { - if (!schema || !sortColumn || !schema[sortColumn]) { - return "string" - } - const type = schema?.[sortColumn]?.type - return type === "number" ? "number" : "string" - } - - const refresh = async () => { - if (schemaLoaded && !nestedProvider) { - fetchData( - dataSource, - schema, - query, - limit, - currentSortColumn, - currentSortOrder, - sortType, - paginate - ) - } - } - - const fetchData = async ( - dataSource, - schema, - query, - limit, - sortColumn, - sortOrder, - sortType, - paginate - ) => { - loading = true - if (dataSource?.type === "table") { - // Sanity check sort column, as using a non-existant column will prevent - // results coming back at all - const sort = schema?.[sortColumn] ? sortColumn : undefined - - // For internal tables we use server-side processing - const res = await API.searchTable({ - tableId: dataSource.tableId, - query, - limit, - sort, - sortOrder: sortOrder?.toLowerCase() ?? "ascending", - sortType, - paginate, - }) - pageNumber = 0 - allRows = res.rows - if (res.hasNextPage) { - bookmarks = [null, res.bookmark] - } else { - bookmarks = [null] - } - } else if (dataSource?.type === "provider") { - // For providers referencing another provider, just use the rows it - // provides - allRows = dataSource?.value?.rows || [] - } else if (dataSource?.type === "field") { - // Field sources will be available from context. - // Enrich non object elements into object to ensure a valid schema. - const data = dataSource?.value || [] - if (Array.isArray(data) && data[0] && typeof data[0] !== "object") { - allRows = data.map(value => ({ value })) - } else { - allRows = data - } - } else { - // For other data sources like queries or views, fetch all rows from the - // server - allRows = await API.fetchDatasource(dataSource) - } - loading = false - loaded = true - } - - const getSchema = async dataSource => { - let newSchema = (await API.fetchDatasourceSchema(dataSource)) || {} - - // Ensure there are "name" properties for all fields and that field schema - // are objects - Object.entries(newSchema).forEach(([fieldName, fieldSchema]) => { - if (typeof fieldSchema === "string") { - newSchema[fieldName] = { - type: fieldSchema, - name: fieldName, - } - } else { - newSchema[fieldName] = { - ...fieldSchema, - name: fieldName, - } - } - }) - schema = newSchema - schemaLoaded = true - } - - const nextPage = async () => { - if (!hasNextPage || !internalTable) { - return - } - const sort = schema?.[currentSortColumn] ? currentSortColumn : undefined - const res = await API.searchTable({ - tableId: dataSource?.tableId, - query, - bookmark: bookmarks[pageNumber + 1], - limit, - sort, - sortOrder: currentSortOrder?.toLowerCase() ?? "ascending", - sortType, - paginate: true, - }) - pageNumber++ - allRows = res.rows - if (res.hasNextPage) { - bookmarks[pageNumber + 1] = res.bookmark - } - } - - const prevPage = async () => { - if (!hasPrevPage || !internalTable) { - return - } - const sort = schema?.[currentSortColumn] ? currentSortColumn : undefined - const res = await API.searchTable({ - tableId: dataSource?.tableId, - query, - bookmark: bookmarks[pageNumber - 1], - limit, - sort, - sortOrder: currentSortOrder?.toLowerCase() ?? "ascending", - sortType, - paginate: true, - }) - pageNumber-- - allRows = res.rows - } - - const addQueryExtension = (key, extension) => { - if (!key || !extension) { - return - } - queryExtensions = { ...queryExtensions, [key]: extension } - } - - const removeQueryExtension = key => { - if (!key) { - return - } - const newQueryExtensions = { ...queryExtensions } - delete newQueryExtensions[key] - queryExtensions = newQueryExtensions - } - - const extendQuery = (defaultQuery, extensions) => { - const extensionValues = Object.values(extensions || {}) - let extendedQuery = { ...defaultQuery } - extensionValues.forEach(extension => { - Object.entries(extension || {}).forEach(([operator, fields]) => { - extendedQuery[operator] = { - ...extendedQuery[operator], - ...fields, - } - }) - }) - - if (JSON.stringify(query) !== JSON.stringify(extendedQuery)) { - query = extendedQuery - } + loaded: $fetch.loaded, }
- {#if !loaded} + {#if !$fetch.loaded}
@@ -321,14 +109,14 @@ {:else} {/if} - {#if paginate && internalTable} + {#if paginate && dataSource?.type === "table"} {/if} diff --git a/packages/client/src/components/app/DataProvider2.svelte b/packages/client/src/components/app/DataProvider2.svelte new file mode 100644 index 0000000000..b9f5287a49 --- /dev/null +++ b/packages/client/src/components/app/DataProvider2.svelte @@ -0,0 +1,361 @@ + + +
+ + {#if !loaded} +
+ +
+ {:else} + {#if $component.emptyState} + + {:else} + + {/if} + {#if paginate && internalTable} + + {/if} + {/if} +
+
+ + diff --git a/packages/client/src/components/app/blocks/CardsBlock.svelte b/packages/client/src/components/app/blocks/CardsBlock.svelte index ec631ede36..49c4ef5b8b 100644 --- a/packages/client/src/components/app/blocks/CardsBlock.svelte +++ b/packages/client/src/components/app/blocks/CardsBlock.svelte @@ -30,7 +30,7 @@ export let cardButtonOnClick export let linkColumn - const { API, styleable } = getContext("sdk") + const { fetchDatasourceSchema, styleable } = getContext("sdk") const context = getContext("context") const component = getContext("component") const schemaComponentMap = { @@ -111,7 +111,7 @@ // Load the datasource schema so we can determine column types const fetchSchema = async dataSource => { if (dataSource) { - schema = await API.fetchDatasourceSchema(dataSource) + schema = await fetchDatasourceSchema(dataSource) } } diff --git a/packages/client/src/components/app/blocks/TableBlock.svelte b/packages/client/src/components/app/blocks/TableBlock.svelte index 55937d1b7f..df99dee65c 100644 --- a/packages/client/src/components/app/blocks/TableBlock.svelte +++ b/packages/client/src/components/app/blocks/TableBlock.svelte @@ -26,7 +26,7 @@ export let titleButtonURL export let titleButtonPeek - const { API, styleable } = getContext("sdk") + const { fetchDatasourceSchema, styleable } = getContext("sdk") const context = getContext("context") const component = getContext("component") const schemaComponentMap = { @@ -40,6 +40,7 @@ let formId let dataProviderId let schema + let schemaLoaded = false $: fetchSchema(dataSource) $: enrichedSearchColumns = enrichSearchColumns(searchColumns, schema) @@ -89,8 +90,9 @@ // Load the datasource schema so we can determine column types const fetchSchema = async dataSource => { if (dataSource) { - schema = await API.fetchDatasourceSchema(dataSource) + schema = await fetchDatasourceSchema(dataSource) } + schemaLoaded = true } @@ -134,34 +136,36 @@
{/if} - + {#if schema} - + > + + + {/if} diff --git a/packages/client/src/components/app/forms/Form.svelte b/packages/client/src/components/app/forms/Form.svelte index 1c6083aa5c..505c45bde0 100644 --- a/packages/client/src/components/app/forms/Form.svelte +++ b/packages/client/src/components/app/forms/Form.svelte @@ -10,7 +10,7 @@ export let actionType = "Create" const context = getContext("context") - const { API } = getContext("sdk") + const { API, fetchDatasourceSchema } = getContext("sdk") let loaded = false let schema @@ -61,7 +61,7 @@ // For all other cases, just grab the normal schema else { - const dataSourceSchema = await API.fetchDatasourceSchema(dataSource) + const dataSourceSchema = await fetchDatasourceSchema(dataSource) schema = dataSourceSchema || {} } diff --git a/packages/client/src/sdk.js b/packages/client/src/sdk.js index ab1150658f..1c73361dc8 100644 --- a/packages/client/src/sdk.js +++ b/packages/client/src/sdk.js @@ -9,6 +9,7 @@ import { import { styleable } from "utils/styleable" import { linkable } from "utils/linkable" import { getAction } from "utils/getAction" +import { fetchDatasourceSchema } from "utils/schema.js" import Provider from "components/context/Provider.svelte" import { ActionTypes } from "constants" @@ -22,6 +23,7 @@ export default { styleable, linkable, getAction, + fetchDatasourceSchema, Provider, ActionTypes, } diff --git a/packages/client/src/utils/fetch/QueryFetch.js b/packages/client/src/utils/fetch/QueryFetch.js new file mode 100644 index 0000000000..80c031e7d9 --- /dev/null +++ b/packages/client/src/utils/fetch/QueryFetch.js @@ -0,0 +1,42 @@ +import TableFetch from "./TableFetch.js" +import { executeQuery, fetchQueryDefinition } from "api" +import { cloneDeep } from "lodash/fp.js" + +export default class ViewFetch extends TableFetch { + SupportsSearch = false + SupportsSort = false + SupportsPagination = false + + /** + * Fetches the schema for a view + * @param datasource the view datasource config + * @return {object} the view schema + */ + static async getSchema(datasource) { + if (!datasource?._id) { + return null + } + const definition = await fetchQueryDefinition(datasource._id) + return this.enrichSchema(definition?.schema) + } + + /** + * Fetches a single page of data from the remote resource + */ + async getData() { + const { datasource } = this.options + + // Set the default query params + let parameters = cloneDeep(datasource?.queryParams || {}) + for (let param of datasource?.parameters || {}) { + if (!parameters[param.name]) { + parameters[param.name] = param.default + } + } + + const res = await executeQuery({ queryId: datasource?._id, parameters }) + return { + rows: res || [], + } + } +} diff --git a/packages/client/src/utils/fetch/RelationshipFetch.js b/packages/client/src/utils/fetch/RelationshipFetch.js new file mode 100644 index 0000000000..d8396f7bfe --- /dev/null +++ b/packages/client/src/utils/fetch/RelationshipFetch.js @@ -0,0 +1,23 @@ +import TableFetch from "./TableFetch.js" +import { fetchRelationshipData } from "api" + +export default class ViewFetch extends TableFetch { + SupportsSearch = false + SupportsSort = false + SupportsPagination = false + + /** + * Fetches a single page of data from the remote resource + */ + async getData() { + const { datasource } = this.options + const res = await fetchRelationshipData({ + rowId: datasource?.rowId, + tableId: datasource?.rowTableId, + fieldName: datasource?.fieldName, + }) + return { + rows: res || [], + } + } +} diff --git a/packages/client/src/utils/fetch/TableFetch.js b/packages/client/src/utils/fetch/TableFetch.js new file mode 100644 index 0000000000..61fa8195a3 --- /dev/null +++ b/packages/client/src/utils/fetch/TableFetch.js @@ -0,0 +1,324 @@ +import { writable, derived, get } from "svelte/store" +import * as API from "api" +import { buildLuceneQuery } from "builder/src/helpers/lucene" +import { fetchTableDefinition } from "api" + +/** + * Parent class which handles the implementation of fetching data from an + * internal table or datasource plus. + * For other types of datasource, this class is overridden and extended. + */ +export default class TableFetch { + SupportsSearch = true + SupportsSort = true + SupportsPagination = true + + // Config + options = { + datasource: null, + schema: null, + limit: 10, + + // Search config + filter: null, + query: null, + + // Sorting config + sortColumn: null, + sortOrder: "ascending", + + // Pagination config + paginate: true, + } + + // State of the fetch + store = writable({ + rows: [], + schema: null, + loading: false, + loaded: false, + query: null, + pageNumber: 0, + cursor: null, + cursors: [], + }) + + /** + * Constructs a new DataFetch instance. + * @param opts the fetch options + */ + constructor(opts) { + this.options = { + ...this.options, + ...opts, + } + + // Bind all functions to properly scope "this" + this.getData = this.getData.bind(this) + this.getInitialData = this.getInitialData.bind(this) + this.refresh = this.refresh.bind(this) + this.update = this.update.bind(this) + this.hasNextPage = this.hasNextPage.bind(this) + this.hasPrevPage = this.hasPrevPage.bind(this) + this.nextPage = this.nextPage.bind(this) + this.prevPage = this.prevPage.bind(this) + + // Derive certain properties to return + this.derivedStore = derived(this.store, $store => { + return { + ...$store, + hasNextPage: this.hasNextPage($store), + hasPrevPage: this.hasPrevPage($store), + } + }) + + // Mark as loaded if we have no datasource + if (!this.options.datasource) { + this.store.update($store => ({ ...$store, loaded: true })) + return + } + + // Initially fetch data but don't bother waiting for the result + this.getInitialData() + } + + /** + * Extend the svelte store subscribe method to that instances of this class + * can be treated like stores + */ + get subscribe() { + return this.derivedStore.subscribe + } + + /** + * Fetches a fresh set of data from the server, resetting pagination + */ + async getInitialData() { + const { datasource, filter } = this.options + const tableId = datasource?.tableId + + // Ensure table ID exists + if (!tableId) { + return + } + + // Ensure schema exists and enrich it + let schema = this.options.schema + if (!schema) { + schema = await this.constructor.getSchema(datasource) + } + if (!schema) { + return + } + + // Build the lucene query + let query = this.options.query + if (!query) { + query = buildLuceneQuery(filter) + } + + // Update store + this.store.update($store => ({ ...$store, schema, query, loading: true })) + + // Actually fetch data + const page = await this.getData() + this.store.update($store => ({ + ...$store, + loading: false, + loaded: true, + pageNumber: 0, + rows: page.rows, + cursors: page.hasNextPage ? [null, page.cursor] : [null], + })) + } + + /** + * Fetches a single page of data from the remote resource + */ + async getData() { + const { datasource, limit, sortColumn, sortOrder, paginate } = this.options + const { tableId } = datasource + const { cursor, schema, query } = get(this.store) + + // Work out what sort type to use + const type = schema?.[sortColumn]?.type + const sortType = type === "number" ? "number" : "string" + + // Search table + const res = await API.searchTable({ + tableId, + query, + limit, + sort: sortColumn, + sortOrder: sortOrder?.toLowerCase() ?? "ascending", + sortType, + paginate, + bookmark: cursor, + }) + return { + rows: res?.rows || [], + hasNextPage: res?.hasNextPage || false, + cursor: res?.bookmark || null, + } + } + + /** + * Gets the schema definition for a table + * @param datasource the datasource definition + * @return {object} the schema + */ + static async getSchema(datasource) { + if (!datasource?.tableId) { + return null + } + const table = await fetchTableDefinition(datasource.tableId) + return this.enrichSchema(table?.schema) + } + + /** + * Enriches the schema and ensures that entries are objects with names + * @param schema the datasource schema + * @return {object} the enriched datasource schema + */ + static enrichSchema(schema) { + if (schema == null) { + return null + } + let enrichedSchema = {} + Object.entries(schema).forEach(([fieldName, fieldSchema]) => { + if (typeof fieldSchema === "string") { + enrichedSchema[fieldName] = { + type: fieldSchema, + name: fieldName, + } + } else { + enrichedSchema[fieldName] = { + ...fieldSchema, + name: fieldName, + } + } + }) + return enrichedSchema + } + + /** + * Resets the data set and updates options + * @param newOptions any new options + */ + async update(newOptions) { + // Check if any settings have actually changed + let refresh = false + const entries = Object.entries(newOptions || {}) + for (let [key, value] of entries) { + if (JSON.stringify(value) !== JSON.stringify(this.options[key])) { + refresh = true + break + } + } + if (!refresh) { + return + } + + // Assign new options and reload data + this.options = { + ...this.options, + ...newOptions, + } + await this.getInitialData() + } + + /** + * Loads the same page again + */ + async refresh() { + if (get(this.store).loading) { + return + } + const { rows } = await this.getData() + this.store.update($store => ({ ...$store, loading: true })) + this.store.update($store => ({ ...$store, rows, loading: false })) + } + + /** + * Determines whether there is a next page of data based on the state of the + * store + * @param state the current store state + * @return {boolean} whether there is a next page of data or not + */ + hasNextPage(state) { + return state.cursors[state.pageNumber + 1] != null + } + + /** + * Determines whether there is a previous page of data based on the state of + * the store + * @param state the current store state + * @return {boolean} whether there is a previous page of data or not + */ + hasPrevPage(state) { + return state.pageNumber > 0 + } + + /** + * Fetches the next page of data + */ + async nextPage() { + const state = get(this.derivedStore) + if (state.loading || !this.options.paginate || !state.hasNextPage) { + return + } + + // Fetch next page + const nextCursor = state.cursors[state.pageNumber + 1] + this.store.update($store => ({ + ...$store, + loading: true, + cursor: nextCursor, + })) + const { rows, hasNextPage, cursor } = await this.getData() + + // Update state + this.store.update($store => { + let { cursors, pageNumber } = $store + if (hasNextPage) { + cursors[pageNumber + 2] = cursor + } + return { + ...$store, + pageNumber: pageNumber + 1, + rows, + cursors, + loading: false, + } + }) + } + + /** + * Fetches the previous page of data + */ + async prevPage() { + const state = get(this.derivedStore) + if (state.loading || !this.options.paginate || !state.hasPrevPage) { + return + } + + // Fetch previous page + const prevCursor = state.cursors[state.pageNumber - 1] + this.store.update($store => ({ + ...$store, + loading: true, + cursor: prevCursor, + })) + const { rows } = await this.getData() + + // Update state + this.store.update($store => { + return { + ...$store, + pageNumber: $store.pageNumber - 1, + rows, + loading: false, + } + }) + } +} diff --git a/packages/client/src/utils/fetch/ViewFetch.js b/packages/client/src/utils/fetch/ViewFetch.js new file mode 100644 index 0000000000..beded054b5 --- /dev/null +++ b/packages/client/src/utils/fetch/ViewFetch.js @@ -0,0 +1,34 @@ +import TableFetch from "./TableFetch.js" +import { fetchTableDefinition, fetchViewData } from "api" + +export default class ViewFetch extends TableFetch { + SupportsSearch = false + SupportsSort = false + SupportsPagination = false + + /** + * Fetches the schema for a view + * @param datasource the view datasource config + * @return {object} the view schema + */ + static async getSchema(datasource) { + if (!datasource?.tableId) { + return null + } + const table = await fetchTableDefinition(datasource.tableId) + return this.enrichSchema(table?.views?.[datasource.name]?.schema) + } + + /** + * Fetches a single page of data from the remote resource + */ + async getData() { + const { datasource } = this.options + const res = await fetchViewData(datasource) + return { + rows: res || [], + } + } +} + +ViewFetch.getSchema() diff --git a/packages/client/src/utils/fetch/fetchData.js b/packages/client/src/utils/fetch/fetchData.js new file mode 100644 index 0000000000..bd14e849dc --- /dev/null +++ b/packages/client/src/utils/fetch/fetchData.js @@ -0,0 +1,16 @@ +import TableFetch from "./TableFetch.js" +import ViewFetch from "./ViewFetch.js" +import QueryFetch from "./QueryFetch.js" +import RelationshipFetch from "./RelationshipFetch.js" + +const DataFetchMap = { + table: TableFetch, + view: ViewFetch, + query: QueryFetch, + link: RelationshipFetch, +} + +export const fetchData = (datasource, options) => { + const Fetch = DataFetchMap[datasource?.type] || TableFetch + return new Fetch({ datasource, ...options }) +} diff --git a/packages/client/src/utils/schema.js b/packages/client/src/utils/schema.js new file mode 100644 index 0000000000..022ce0a38d --- /dev/null +++ b/packages/client/src/utils/schema.js @@ -0,0 +1,49 @@ +import TableFetch from "./fetch/TableFetch.js" +import ViewFetch from "./fetch/ViewFetch.js" +import QueryFetch from "./fetch/QueryFetch.js" + +/** + * Fetches the schema of any kind of datasource. + */ +export const fetchDatasourceSchema = async datasource => { + const type = datasource?.type + + // Nested providers should already have exposed their own schema + if (type === "provider") { + return datasource.value?.schema + } + + // Field sources have their schema statically defined + if (type === "field") { + if (datasource.fieldType === "attachment") { + return { + url: { + type: "string", + }, + name: { + type: "string", + }, + } + } else if (datasource.fieldType === "array") { + return { + value: { + type: "string", + }, + } + } + } + + // All normal datasource schema can use their corresponsing implementations + // in the data fetch classes + if (type === "table" || type === "link") { + return TableFetch.getSchema(datasource) + } + if (type === "view") { + return ViewFetch.getSchema(datasource) + } + if (type === "query") { + return QueryFetch.getSchema(datasource) + } + + return null +} From 86065084c1c8689d096803243dfe8d1d8f9e3edd Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 17 Dec 2021 08:22:40 +0000 Subject: [PATCH 005/107] Update core component handling to use svelte function to manually control props in order to avoid triggering additional reactive statements and improve performance --- .../client/src/components/Component.svelte | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/client/src/components/Component.svelte b/packages/client/src/components/Component.svelte index d8cdbd3f53..2ce97ee5fd 100644 --- a/packages/client/src/components/Component.svelte +++ b/packages/client/src/components/Component.svelte @@ -19,6 +19,9 @@ export let isScreen = false export let isBlock = false + // Ref to the svelte component + let ref + // Component settings are the un-enriched settings for this component that // need to be enriched at this level. // Nested settings are the un-enriched block settings that are to be passed on @@ -129,6 +132,11 @@ editing, }) + // Set initial props when the component mounts + $: if (ref?.$set) { + assignAllSettings() + } + // Extracts all settings from the component instance const getRawSettings = instance => { let validSettings = {} @@ -267,16 +275,30 @@ const cacheSettings = (enriched, nested, conditional) => { const allSettings = { ...enriched, ...nested, ...conditional } if (!cachedSettings) { - cachedSettings = allSettings + cachedSettings = { ...allSettings } } else { Object.keys(allSettings).forEach(key => { - if (!propsAreSame(allSettings[key], cachedSettings[key])) { + const same = propsAreSame(allSettings[key], cachedSettings[key]) + if (!same) { cachedSettings[key] = allSettings[key] + assignSetting(key, allSettings[key]) } }) } } + // Assigns the full set of settings to this component + const assignAllSettings = () => { + ref?.$set?.({ ...cachedSettings }) + } + + // Assigns a certain setting to this component. + // We manually use the svelte $set function to avoid triggering additional + // reactive statements. + const assignSetting = (key, value) => { + ref?.$$set?.({ [key]: value }) + } + // Generates a key used to determine when components need to fully remount. // Currently only toggling editing requires remounting. const getRenderKey = (id, editing) => { @@ -299,7 +321,7 @@ data-id={id} data-name={name} > - + {#if children.length} {#each children as child (child._id)} From 58e0daeb8c4fa89fbb045094017a9ba0f5c3be1b Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 17 Dec 2021 09:18:07 +0000 Subject: [PATCH 006/107] Remove initial render of components with undefined props by passing in initial props without triggering svelte invalidation --- .../client/src/components/Component.svelte | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/client/src/components/Component.svelte b/packages/client/src/components/Component.svelte index 2ce97ee5fd..3e79c0bb15 100644 --- a/packages/client/src/components/Component.svelte +++ b/packages/client/src/components/Component.svelte @@ -3,7 +3,7 @@ - -
- - {#if title || enrichedSearchColumns?.length || showTitleButton} -
-
- {title || ""} +{#if schemaLoaded} + +
+ + {#if title || enrichedSearchColumns?.length || showTitleButton} +
+
+ {title || ""} +
+
+ {#if enrichedSearchColumns?.length} + + {/if} + {#if showTitleButton} + + {/if} +
-
- {#if enrichedSearchColumns?.length} - - {/if} - {#if showTitleButton} - - {/if} -
-
- {/if} - + {/if} + > + + - -
- +
+ +{/if} From 78a3f807a3f46bb34c39d3def8d67407f2ab8fd6 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 17 Dec 2021 10:43:27 +0000 Subject: [PATCH 012/107] Lint --- packages/client/src/components/Component.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/src/components/Component.svelte b/packages/client/src/components/Component.svelte index 3e79c0bb15..d05f01f58a 100644 --- a/packages/client/src/components/Component.svelte +++ b/packages/client/src/components/Component.svelte @@ -3,7 +3,7 @@ @@ -270,6 +275,43 @@ /> + + + {#if !$flags.queryTransformerBanner} @@ -458,4 +500,9 @@ .auth-select { width: 200px; } + .pagination { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-m); + } From f7759f6791aac1b066f983beb781221f63ffe7e5 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 17 Dec 2021 11:38:02 +0000 Subject: [PATCH 015/107] Allow saving pagination data in queries --- packages/server/src/api/controllers/query/validation.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/server/src/api/controllers/query/validation.js b/packages/server/src/api/controllers/query/validation.js index a17a752ca5..06850878cc 100644 --- a/packages/server/src/api/controllers/query/validation.js +++ b/packages/server/src/api/controllers/query/validation.js @@ -20,6 +20,7 @@ exports.queryValidation = () => { schema: Joi.object({}).required().unknown(true), transformer: Joi.string().optional(), flags: Joi.object().optional(), + pagination: Joi.object().optional(), }) } From 882067056cc45e3b9ce54c8ff7ab551a70a0d7e6 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Fri, 17 Dec 2021 18:39:22 +0000 Subject: [PATCH 016/107] Update query pagination to be stored in fields --- .../rest/[query]/index.svelte | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/rest/[query]/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/rest/[query]/index.svelte index 37c5fce61e..0f78bfffcc 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/rest/[query]/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/rest/[query]/index.svelte @@ -215,8 +215,8 @@ if (query && !query.fields.bodyType) { query.fields.bodyType = "none" } - if (query && !query.pagination) { - query.pagination = {} + if (query && !query.fields.pagination) { + query.fields.pagination = {} } dynamicVariables = restUtils.variablesToObject(datasource) }) @@ -296,34 +296,34 @@ - + Create dynamic variables based on response body or headers - from other queries. + from this query. Date: Wed, 5 Jan 2022 10:31:06 -0500 Subject: [PATCH 060/107] Add full URL to path for imported queries --- .../controllers/query/import/sources/base/index.ts | 4 +++- .../api/controllers/query/import/sources/curl.ts | 10 +++++++--- .../controllers/query/import/sources/openapi2.ts | 14 +++++++++----- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/server/src/api/controllers/query/import/sources/base/index.ts b/packages/server/src/api/controllers/query/import/sources/base/index.ts index 06e8dcfeff..e666fdc193 100644 --- a/packages/server/src/api/controllers/query/import/sources/base/index.ts +++ b/packages/server/src/api/controllers/query/import/sources/base/index.ts @@ -1,7 +1,7 @@ import { Query, QueryParameter } from "../../../../../../definitions/datasource" +import { URL } from "url" export interface ImportInfo { - url: string name: string } @@ -23,6 +23,7 @@ export abstract class ImportSource { name: string, method: string, path: string, + url: URL, queryString: string, headers: object = {}, parameters: QueryParameter[] = [], @@ -33,6 +34,7 @@ export abstract class ImportSource { const transformer = "return data" const schema = {} path = this.processPath(path) + path = `${url.origin}/${path}` queryString = this.processQuery(queryString) const requestBody = JSON.stringify(body, null, 2) diff --git a/packages/server/src/api/controllers/query/import/sources/curl.ts b/packages/server/src/api/controllers/query/import/sources/curl.ts index b55d24403b..d72441ab12 100644 --- a/packages/server/src/api/controllers/query/import/sources/curl.ts +++ b/packages/server/src/api/controllers/query/import/sources/curl.ts @@ -60,16 +60,19 @@ export class Curl extends ImportSource { return true } + getUrl = (): URL => { + return new URL(this.curl.raw_url) + } + getInfo = async (): Promise => { - const url = new URL(this.curl.url) + const url = this.getUrl() return { - url: url.origin, name: url.hostname, } } getQueries = async (datasourceId: string): Promise => { - const url = new URL(this.curl.raw_url) + const url = this.getUrl() const name = url.pathname const path = url.pathname const method = this.curl.method @@ -87,6 +90,7 @@ export class Curl extends ImportSource { name, method, path, + url, queryString, headers, [], diff --git a/packages/server/src/api/controllers/query/import/sources/openapi2.ts b/packages/server/src/api/controllers/query/import/sources/openapi2.ts index 35dab163f6..b079db21e8 100644 --- a/packages/server/src/api/controllers/query/import/sources/openapi2.ts +++ b/packages/server/src/api/controllers/query/import/sources/openapi2.ts @@ -2,6 +2,7 @@ import { ImportInfo } from "./base" import { Query, QueryParameter } from "../../../../../definitions/datasource" import { OpenAPIV2 } from "openapi-types" import { OpenAPISource } from "./base/openapi" +import { URL } from "url" const parameterNotRef = ( param: OpenAPIV2.Parameter | OpenAPIV2.ReferenceObject @@ -55,20 +56,22 @@ export class OpenAPI2 extends OpenAPISource { } } - getInfo = async (): Promise => { + getUrl = (): URL => { const scheme = this.document.schemes?.includes("https") ? "https" : "http" const basePath = this.document.basePath || "" const host = this.document.host || "" - const url = `${scheme}://${host}${basePath}` - const name = this.document.info.title || "Swagger Import" + return new URL(`${scheme}://${host}${basePath}`) + } + getInfo = async (): Promise => { + const name = this.document.info.title || "Swagger Import" return { - url: url, - name: name, + name } } getQueries = async (datasourceId: string): Promise => { + const url = this.getUrl() const queries = [] for (let [path, pathItem] of Object.entries(this.document.paths)) { @@ -145,6 +148,7 @@ export class OpenAPI2 extends OpenAPISource { name, methodName, path, + url, queryString, headers, parameters, From e115a561c22deaac393d5286097b0bc000f86ba0 Mon Sep 17 00:00:00 2001 From: Rory Powell Date: Wed, 5 Jan 2022 10:31:44 -0500 Subject: [PATCH 061/107] Fix Add Authentication button icon --- .../rest/auth/RestAuthenticationBuilder.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/rest/auth/RestAuthenticationBuilder.svelte b/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/rest/auth/RestAuthenticationBuilder.svelte index 6c68e055e4..7bbd2402f0 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/rest/auth/RestAuthenticationBuilder.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/rest/auth/RestAuthenticationBuilder.svelte @@ -58,7 +58,7 @@ /> {/if}
- openConfigModal()} con="Add" + openConfigModal()} icon="Add" >Add authentication
From 8210ed7ee4452d82c6a874e3ae111f404f5e3a0e Mon Sep 17 00:00:00 2001 From: Rory Powell Date: Wed, 5 Jan 2022 11:54:59 -0500 Subject: [PATCH 062/107] Delete and invalidate linked dynamic variables when a query is deleted --- .../query/import/sources/openapi2.ts | 2 +- .../server/src/api/controllers/query/index.js | 21 +++++++++++++++++++ packages/server/src/threads/utils.js | 3 ++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/server/src/api/controllers/query/import/sources/openapi2.ts b/packages/server/src/api/controllers/query/import/sources/openapi2.ts index b079db21e8..c193654909 100644 --- a/packages/server/src/api/controllers/query/import/sources/openapi2.ts +++ b/packages/server/src/api/controllers/query/import/sources/openapi2.ts @@ -66,7 +66,7 @@ export class OpenAPI2 extends OpenAPISource { getInfo = async (): Promise => { const name = this.document.info.title || "Swagger Import" return { - name + name, } } diff --git a/packages/server/src/api/controllers/query/index.js b/packages/server/src/api/controllers/query/index.js index 6001b81aa9..ea2042ad49 100644 --- a/packages/server/src/api/controllers/query/index.js +++ b/packages/server/src/api/controllers/query/index.js @@ -8,6 +8,7 @@ const { BaseQueryVerbs } = require("../../../constants") const { Thread, ThreadType } = require("../../../threads") const { save: saveDatasource } = require("../datasource") const { RestImporter } = require("./import") +const { invalidateDynamicVariables } = require("../../../threads/utils") const Runner = new Thread(ThreadType.QUERY, { timeoutMs: 10000 }) @@ -166,8 +167,28 @@ exports.executeV2 = async function (ctx) { return execute(ctx, { rowsOnly: false }) } +const removeDynamicVariables = async (db, queryId) => { + const query = await db.get(queryId) + const datasource = await db.get(query.datasourceId) + const dynamicVariables = datasource.config.dynamicVariables + + if (dynamicVariables) { + // invalidate the deleted variables + const variablesToDelete = dynamicVariables.filter( + dv => dv.queryId === queryId + ) + await invalidateDynamicVariables(variablesToDelete) + + // delete dynamic variables from the datasource + const newVariables = dynamicVariables.filter(dv => dv.queryId !== queryId) + datasource.config.dynamicVariables = newVariables + await db.put(datasource) + } +} + exports.destroy = async function (ctx) { const db = new CouchDB(ctx.appId) + await removeDynamicVariables(db, ctx.params.queryId) await db.remove(ctx.params.queryId, ctx.params.revId) ctx.message = `Query deleted.` ctx.status = 200 diff --git a/packages/server/src/threads/utils.js b/packages/server/src/threads/utils.js index ffea596abd..e989d5cb63 100644 --- a/packages/server/src/threads/utils.js +++ b/packages/server/src/threads/utils.js @@ -42,10 +42,11 @@ exports.checkCacheForDynamicVariable = async (queryId, variable) => { } exports.invalidateDynamicVariables = async cachedVars => { + const cache = await getClient() let promises = [] for (let variable of cachedVars) { promises.push( - client.delete(makeVariableKey(variable.queryId, variable.name)) + cache.delete(makeVariableKey(variable.queryId, variable.name)) ) } await Promise.all(promises) From f5649b48d11a4953e8e1cce3047cbade40e6b33a Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 5 Jan 2022 17:28:57 +0000 Subject: [PATCH 063/107] Add support for cursor based pagination in query params --- packages/client/src/utils/fetch/QueryFetch.js | 7 ++++--- packages/server/src/integrations/rest.ts | 14 ++++++++++++-- packages/server/src/threads/query.js | 6 ++++-- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/client/src/utils/fetch/QueryFetch.js b/packages/client/src/utils/fetch/QueryFetch.js index 08155bb94a..5176b11c25 100644 --- a/packages/client/src/utils/fetch/QueryFetch.js +++ b/packages/client/src/utils/fetch/QueryFetch.js @@ -35,7 +35,7 @@ export default class QueryFetch extends DataFetch { // Add pagination to query if supported let queryPayload = { queryId: datasource?._id, parameters } if (supportsPagination) { - const requestCursor = type === "page" ? parseInt(cursor || 0) : cursor + const requestCursor = type === "page" ? parseInt(cursor || 1) : cursor queryPayload.pagination = { page: requestCursor, limit } } @@ -49,11 +49,12 @@ export default class QueryFetch extends DataFetch { if (type === "page") { // For "page number" pagination, increment the existing page number nextCursor = queryPayload.pagination.page + 1 + hasNextPage = data?.length === limit && limit > 0 } else { // For "cursor" pagination, the cursor should be in the response - nextCursor = pagination.cursor + nextCursor = pagination?.cursor + hasNextPage = nextCursor != null } - hasNextPage = data?.length === limit && limit > 0 } return { diff --git a/packages/server/src/integrations/rest.ts b/packages/server/src/integrations/rest.ts index ad8f46e5f5..024eee14fe 100644 --- a/packages/server/src/integrations/rest.ts +++ b/packages/server/src/integrations/rest.ts @@ -120,7 +120,7 @@ module RestModule { this.config = config } - async parseResponse(response: any) { + async parseResponse(response: any, pagination: PaginationConfig | null) { let data, raw, headers const contentType = response.headers.get("content-type") || "" try { @@ -159,6 +159,13 @@ module RestModule { for (let [key, value] of Object.entries(headers)) { headers[key] = Array.isArray(value) ? value[0] : value } + + // Check if a pagination cursor exists in the response + let nextCursor = null + if (pagination?.responseParam) { + nextCursor = data?.[pagination.responseParam] + } + return { data, info: { @@ -170,6 +177,9 @@ module RestModule { raw, headers, }, + pagination: { + cursor: nextCursor + } } } @@ -325,7 +335,7 @@ module RestModule { this.startTimeMs = performance.now() const url = this.getUrl(path, queryString, pagination, paginationValues) const response = await fetch(url, input) - return await this.parseResponse(response) + return await this.parseResponse(response, pagination) } async create(opts: RestQuery) { diff --git a/packages/server/src/threads/query.js b/packages/server/src/threads/query.js index 8232321084..ad461008df 100644 --- a/packages/server/src/threads/query.js +++ b/packages/server/src/threads/query.js @@ -47,11 +47,13 @@ class QueryRunner { let output = threadUtils.formatResponse(await integration[queryVerb](query)) let rows = output, info = undefined, - extra = undefined + extra = undefined, + pagination = undefined if (threadUtils.hasExtraData(output)) { rows = output.data info = output.info extra = output.extra + pagination = output.pagination } // transform as required @@ -90,7 +92,7 @@ class QueryRunner { integration.end() } - return { rows, keys, info, extra } + return { rows, keys, info, extra, pagination } } async runAnotherQuery(queryId, parameters) { From 4ab06c200de535abbd7b67c6c4a1443891e26954 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Wed, 5 Jan 2022 18:10:22 +0000 Subject: [PATCH 064/107] Add support for cursor and page based pagination inside REST query request body --- packages/server/src/integrations/rest.ts | 52 ++++++++++++++++++------ 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/packages/server/src/integrations/rest.ts b/packages/server/src/integrations/rest.ts index 024eee14fe..9667400400 100644 --- a/packages/server/src/integrations/rest.ts +++ b/packages/server/src/integrations/rest.ts @@ -217,20 +217,36 @@ module RestModule { return complete } - addBody(bodyType: string, body: string | any, input: any) { - let error, object, string - try { - string = typeof body !== "string" ? JSON.stringify(body) : body - object = typeof body === "object" ? body : JSON.parse(body) - } catch (err) { - error = err + addBody(bodyType: string, body: string | any, input: any, pagination: PaginationConfig | null, paginationValues: PaginationValues | null) { + if (bodyType === BodyTypes.NONE) { + return input } if (!input.headers) { input.headers = {} } + let error, object = {}, string = "" + try { + if (body) { + string = typeof body !== "string" ? JSON.stringify(body) : body + object = typeof body === "object" ? body : JSON.parse(body) + } + } catch (err) { + error = err + } + + // Util to add pagination values to a certain body type + const addPaginationToBody = (insertFn: Function) => { + if (pagination?.location === "body") { + if (pagination?.pageParam && paginationValues?.page != null) { + insertFn(pagination.pageParam, paginationValues.page) + } + if (pagination?.sizeParam && paginationValues?.limit != null) { + insertFn(pagination.sizeParam, paginationValues.limit) + } + } + } + switch (bodyType) { - case BodyTypes.NONE: - break case BodyTypes.TEXT: // content type defaults to plaintext input.body = string @@ -240,6 +256,9 @@ module RestModule { for (let [key, value] of Object.entries(object)) { params.append(key, value) } + addPaginationToBody((key: string, value: any) => { + params.append(key, value) + }) input.body = params break case BodyTypes.FORM_DATA: @@ -247,6 +266,9 @@ module RestModule { for (let [key, value] of Object.entries(object)) { form.append(key, value) } + addPaginationToBody((key: string, value: any) => { + form.append(key, value) + }) input.body = form break case BodyTypes.XML: @@ -262,7 +284,13 @@ module RestModule { if (error) { throw "Invalid JSON for request body" } - input.body = string + if (!body) { + body = {} + } + addPaginationToBody((key: string, value: any) => { + body[key] = value + }) + input.body = JSON.stringify(body) input.headers["Content-Type"] = "application/json" break } @@ -328,9 +356,7 @@ module RestModule { } let input: any = { method, headers: this.headers } - if (requestBody) { - input = this.addBody(bodyType, requestBody, input) - } + input = this.addBody(bodyType, requestBody, input, pagination, paginationValues) this.startTimeMs = performance.now() const url = this.getUrl(path, queryString, pagination, paginationValues) From 22407446eed92c630d03af972e01b7ef0e9cf2fc Mon Sep 17 00:00:00 2001 From: Rory Powell Date: Wed, 5 Jan 2022 13:27:15 -0500 Subject: [PATCH 065/107] Navigation fixes for duplicate and delete query --- .../DatasourceNavigator.svelte | 2 +- .../popovers/EditQueryPopover.svelte | 15 +++++++++++---- packages/builder/src/stores/backend/queries.js | 2 +- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte index 18143c2071..af345ddcdf 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/DatasourceNavigator.svelte @@ -137,7 +137,7 @@ selected={$queries.selected === query._id} on:click={() => onClickQuery(query)} > - + {/each} {/if} diff --git a/packages/builder/src/components/backend/DatasourceNavigator/popovers/EditQueryPopover.svelte b/packages/builder/src/components/backend/DatasourceNavigator/popovers/EditQueryPopover.svelte index 4e38dba11d..b15746735b 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/popovers/EditQueryPopover.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/popovers/EditQueryPopover.svelte @@ -5,22 +5,29 @@ import { datasources, queries } from "stores/backend" export let query + export let onClickQuery let confirmDeleteDialog async function deleteQuery() { const wasSelectedQuery = $queries.selected - const selectedDatasource = $datasources.selected + // need to calculate this before the query is deleted + const navigateToDatasource = wasSelectedQuery === query._id + await queries.delete(query) - if (wasSelectedQuery === query._id) { - $goto(`./datasource/${selectedDatasource}`) + await datasources.fetch() + + if (navigateToDatasource) { + await datasources.select(query.datasourceId) + $goto(`./datasource/${query.datasourceId}`) } notifications.success("Query deleted") } async function duplicateQuery() { try { - await queries.duplicate(query) + const newQuery = await queries.duplicate(query) + onClickQuery(newQuery) } catch (e) { notifications.error(e.message) } diff --git a/packages/builder/src/stores/backend/queries.js b/packages/builder/src/stores/backend/queries.js index 0e767fa5c0..2018933ffc 100644 --- a/packages/builder/src/stores/backend/queries.js +++ b/packages/builder/src/stores/backend/queries.js @@ -134,7 +134,7 @@ export function createQueriesStore() { list.map(q => q.name) ) - actions.save(datasourceId, newQuery) + return actions.save(datasourceId, newQuery) }, } From 6d5d301adbccec400b92ff7e5de6e0362d6fab01 Mon Sep 17 00:00:00 2001 From: Rory Powell Date: Wed, 5 Jan 2022 14:33:14 -0500 Subject: [PATCH 066/107] Support variables in URL --- .../[selectedDatasource]/rest/[query]/index.svelte | 8 ++++++-- packages/server/src/integrations/rest.ts | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/rest/[query]/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/rest/[query]/index.svelte index 3af4fd2c48..c0e3bcea5e 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/rest/[query]/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/datasource/[selectedDatasource]/rest/[query]/index.svelte @@ -232,8 +232,12 @@ const datasourceUrl = datasource?.config.url const qs = query?.fields.queryString breakQs = restUtils.breakQueryString(qs) - if (datasourceUrl && !query.fields.path?.startsWith(datasourceUrl)) { - const path = query.fields.path + const path = query.fields.path + if ( + datasourceUrl && + !path?.startsWith("http") && + !path?.startsWith("{{") // don't substitute the datasource url when query starts with a variable e.g. the upgrade path + ) { query.fields.path = `${datasource.config.url}/${path ? path : ""}` } url = buildUrl(query.fields.path, breakQs) diff --git a/packages/server/src/integrations/rest.ts b/packages/server/src/integrations/rest.ts index 63d7795a6d..06b4d327da 100644 --- a/packages/server/src/integrations/rest.ts +++ b/packages/server/src/integrations/rest.ts @@ -171,7 +171,7 @@ module RestModule { getUrl(path: string, queryString: string): string { const main = `${path}?${queryString}` let complete = main - if (this.config.url && !main.startsWith(this.config.url)) { + if (this.config.url && !main.startsWith("http")) { complete = !this.config.url ? main : `${this.config.url}/${main}` } if (!complete.startsWith("http")) { From d0c2d74eb5a73335bbb1132f4c8b940fcc9777ba Mon Sep 17 00:00:00 2001 From: Rory Powell Date: Wed, 5 Jan 2022 14:49:01 -0500 Subject: [PATCH 067/107] Fix rest import tests for url and invert dynamic variable invalidation / deletion --- .../query/import/sources/tests/curl/curl.spec.js | 5 ++--- .../import/sources/tests/openapi2/openapi2.spec.js | 13 ++++++------- .../controllers/query/import/tests/index.spec.js | 6 ------ packages/server/src/api/controllers/query/index.js | 10 +++++----- 4 files changed, 13 insertions(+), 21 deletions(-) diff --git a/packages/server/src/api/controllers/query/import/sources/tests/curl/curl.spec.js b/packages/server/src/api/controllers/query/import/sources/tests/curl/curl.spec.js index 11869862f7..2b17685f24 100644 --- a/packages/server/src/api/controllers/query/import/sources/tests/curl/curl.spec.js +++ b/packages/server/src/api/controllers/query/import/sources/tests/curl/curl.spec.js @@ -35,7 +35,6 @@ describe("Curl Import", () => { it("returns import info", async () => { await init("get") const info = await curl.getInfo() - expect(info.url).toBe("http://example.com") expect(info.name).toBe("example.com") }) @@ -67,8 +66,8 @@ describe("Curl Import", () => { } it("populates path", async () => { - await testPath("get", "") - await testPath("path", "paths/abc") + await testPath("get", "http://example.com/") + await testPath("path", "http://example.com/paths/abc") }) const testHeaders = async (file, headers) => { diff --git a/packages/server/src/api/controllers/query/import/sources/tests/openapi2/openapi2.spec.js b/packages/server/src/api/controllers/query/import/sources/tests/openapi2/openapi2.spec.js index 845c4f38f6..3c5aa89e3c 100644 --- a/packages/server/src/api/controllers/query/import/sources/tests/openapi2/openapi2.spec.js +++ b/packages/server/src/api/controllers/query/import/sources/tests/openapi2/openapi2.spec.js @@ -41,7 +41,6 @@ describe("OpenAPI2 Import", () => { const testImportInfo = async (file, extension) => { await init(file, extension) const info = await openapi2.getInfo() - expect(info.url).toBe("https://petstore.swagger.io/v2") expect(info.name).toBe("Swagger Petstore") } @@ -92,12 +91,12 @@ describe("OpenAPI2 Import", () => { it("populates path", async () => { const assertions = { - "createEntity" : "entities", - "getEntities" : "entities", - "getEntity" : "entities/{{entityId}}", - "updateEntity" : "entities/{{entityId}}", - "patchEntity" : "entities/{{entityId}}", - "deleteEntity" : "entities/{{entityId}}" + "createEntity" : "http://example.com/entities", + "getEntities" : "http://example.com/entities", + "getEntity" : "http://example.com/entities/{{entityId}}", + "updateEntity" : "http://example.com/entities/{{entityId}}", + "patchEntity" : "http://example.com/entities/{{entityId}}", + "deleteEntity" : "http://example.com/entities/{{entityId}}" } await runTests("crud", testPath, assertions) }) diff --git a/packages/server/src/api/controllers/query/import/tests/index.spec.js b/packages/server/src/api/controllers/query/import/tests/index.spec.js index 32f3b43b44..5a509d2258 100644 --- a/packages/server/src/api/controllers/query/import/tests/index.spec.js +++ b/packages/server/src/api/controllers/query/import/tests/index.spec.js @@ -51,30 +51,24 @@ describe("Rest Importer", () => { await init(data) const info = await restImporter.getInfo() expect(info.name).toBe(assertions[key].name) - expect(info.url).toBe(assertions[key].url) } it("gets info", async () => { const assertions = { "oapi2CrudJson" : { name: "CRUD", - url: "http://example.com" }, "oapi2CrudYaml" : { name: "CRUD", - url: "http://example.com" }, "oapi2PetstoreJson" : { name: "Swagger Petstore", - url: "https://petstore.swagger.io/v2" }, "oapi2PetstoreYaml" :{ name: "Swagger Petstore", - url: "https://petstore.swagger.io/v2" }, "curl": { name: "example.com", - url: "http://example.com" } } await runTest(testGetInfo, assertions) diff --git a/packages/server/src/api/controllers/query/index.js b/packages/server/src/api/controllers/query/index.js index ea2042ad49..6e5fdfb356 100644 --- a/packages/server/src/api/controllers/query/index.js +++ b/packages/server/src/api/controllers/query/index.js @@ -173,16 +173,16 @@ const removeDynamicVariables = async (db, queryId) => { const dynamicVariables = datasource.config.dynamicVariables if (dynamicVariables) { + // delete dynamic variables from the datasource + const newVariables = dynamicVariables.filter(dv => dv.queryId !== queryId) + datasource.config.dynamicVariables = newVariables + await db.put(datasource) + // invalidate the deleted variables const variablesToDelete = dynamicVariables.filter( dv => dv.queryId === queryId ) await invalidateDynamicVariables(variablesToDelete) - - // delete dynamic variables from the datasource - const newVariables = dynamicVariables.filter(dv => dv.queryId !== queryId) - datasource.config.dynamicVariables = newVariables - await db.put(datasource) } } From 157877a60444104d17efedf937bc7d48fc3006c5 Mon Sep 17 00:00:00 2001 From: Budibase Release Bot <> Date: Thu, 6 Jan 2022 08:28:55 +0000 Subject: [PATCH 068/107] v1.0.35 --- lerna.json | 2 +- packages/auth/package.json | 2 +- packages/bbui/package.json | 2 +- packages/builder/package.json | 8 ++++---- packages/cli/package.json | 2 +- packages/client/package.json | 6 +++--- packages/server/package.json | 8 ++++---- packages/string-templates/package.json | 2 +- packages/worker/package.json | 6 +++--- 9 files changed, 19 insertions(+), 19 deletions(-) diff --git a/lerna.json b/lerna.json index 4a1cc8ebbb..f3056d4dc5 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "1.0.34", + "version": "1.0.35", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/packages/auth/package.json b/packages/auth/package.json index ee71a0fd39..24c6de2f78 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/auth", - "version": "1.0.34", + "version": "1.0.35", "description": "Authentication middlewares for budibase builder and apps", "main": "src/index.js", "author": "Budibase", diff --git a/packages/bbui/package.json b/packages/bbui/package.json index 43f4c06519..7300bebb2a 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -1,7 +1,7 @@ { "name": "@budibase/bbui", "description": "A UI solution used in the different Budibase projects.", - "version": "1.0.34", + "version": "1.0.35", "license": "MPL-2.0", "svelte": "src/index.js", "module": "dist/bbui.es.js", diff --git a/packages/builder/package.json b/packages/builder/package.json index 7b4380a30e..54d3a22aa9 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/builder", - "version": "1.0.34", + "version": "1.0.35", "license": "GPL-3.0", "private": true, "scripts": { @@ -65,10 +65,10 @@ } }, "dependencies": { - "@budibase/bbui": "^1.0.34", - "@budibase/client": "^1.0.34", + "@budibase/bbui": "^1.0.35", + "@budibase/client": "^1.0.35", "@budibase/colorpicker": "1.1.2", - "@budibase/string-templates": "^1.0.34", + "@budibase/string-templates": "^1.0.35", "@sentry/browser": "5.19.1", "@spectrum-css/page": "^3.0.1", "@spectrum-css/vars": "^3.0.1", diff --git a/packages/cli/package.json b/packages/cli/package.json index 7e2215cf4a..29aa931d87 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/cli", - "version": "1.0.34", + "version": "1.0.35", "description": "Budibase CLI, for developers, self hosting and migrations.", "main": "src/index.js", "bin": { diff --git a/packages/client/package.json b/packages/client/package.json index 335732f489..6dbffb943a 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/client", - "version": "1.0.34", + "version": "1.0.35", "license": "MPL-2.0", "module": "dist/budibase-client.js", "main": "dist/budibase-client.js", @@ -19,9 +19,9 @@ "dev:builder": "rollup -cw" }, "dependencies": { - "@budibase/bbui": "^1.0.34", + "@budibase/bbui": "^1.0.35", "@budibase/standard-components": "^0.9.139", - "@budibase/string-templates": "^1.0.34", + "@budibase/string-templates": "^1.0.35", "regexparam": "^1.3.0", "shortid": "^2.2.15", "svelte-spa-router": "^3.0.5" diff --git a/packages/server/package.json b/packages/server/package.json index 0169eb1d56..4a0651da58 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "name": "@budibase/server", "email": "hi@budibase.com", - "version": "1.0.34", + "version": "1.0.35", "description": "Budibase Web Server", "main": "src/index.ts", "repository": { @@ -69,9 +69,9 @@ "author": "Budibase", "license": "GPL-3.0", "dependencies": { - "@budibase/auth": "^1.0.34", - "@budibase/client": "^1.0.34", - "@budibase/string-templates": "^1.0.34", + "@budibase/auth": "^1.0.35", + "@budibase/client": "^1.0.35", + "@budibase/string-templates": "^1.0.35", "@bull-board/api": "^3.7.0", "@bull-board/koa": "^3.7.0", "@elastic/elasticsearch": "7.10.0", diff --git a/packages/string-templates/package.json b/packages/string-templates/package.json index 31e93caa08..6bf816633a 100644 --- a/packages/string-templates/package.json +++ b/packages/string-templates/package.json @@ -1,6 +1,6 @@ { "name": "@budibase/string-templates", - "version": "1.0.34", + "version": "1.0.35", "description": "Handlebars wrapper for Budibase templating.", "main": "src/index.cjs", "module": "dist/bundle.mjs", diff --git a/packages/worker/package.json b/packages/worker/package.json index 7eb3a61ca8..627016d10b 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -1,7 +1,7 @@ { "name": "@budibase/worker", "email": "hi@budibase.com", - "version": "1.0.34", + "version": "1.0.35", "description": "Budibase background service", "main": "src/index.js", "repository": { @@ -29,8 +29,8 @@ "author": "Budibase", "license": "GPL-3.0", "dependencies": { - "@budibase/auth": "^1.0.34", - "@budibase/string-templates": "^1.0.34", + "@budibase/auth": "^1.0.35", + "@budibase/string-templates": "^1.0.35", "@koa/router": "^8.0.0", "@sentry/node": "^6.0.0", "@techpass/passport-openidconnect": "^0.3.0", From 422c0a30336d8178a119b03313b3fb4f65388a5e Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 6 Jan 2022 14:05:32 +0000 Subject: [PATCH 069/107] Comment builder implementation of lucene searching to highlight that it will be replaced --- packages/builder/src/helpers/fetchTableData.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/builder/src/helpers/fetchTableData.js b/packages/builder/src/helpers/fetchTableData.js index 8b413f3611..6d61ec813e 100644 --- a/packages/builder/src/helpers/fetchTableData.js +++ b/packages/builder/src/helpers/fetchTableData.js @@ -1,5 +1,7 @@ // Do not use any aliased imports in common files, as these will be bundled -// by multiple bundlers which may not be able to resolve them +// by multiple bundlers which may not be able to resolve them. +// This will eventually be replaced by the new client implementation when we +// add a core package. import { writable, derived, get } from "svelte/store" import * as API from "../builderStore/api" import { buildLuceneQuery } from "./lucene" From 657f55f0dac9400fde9576efcd4ab3297ce73973 Mon Sep 17 00:00:00 2001 From: mike12345567 Date: Thu, 6 Jan 2022 15:28:38 +0000 Subject: [PATCH 070/107] Adding a skip button for fetching plus datasource tables incase working with very large data sets and still want to perform queries. --- .../builder/src/builderStore/datasource.js | 4 ++-- .../PlusConfigForm.svelte | 24 +++++++++---------- .../modals/DatasourceConfigModal.svelte | 17 +++++++++++-- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/packages/builder/src/builderStore/datasource.js b/packages/builder/src/builderStore/datasource.js index 61cead2e16..cfdeeac23e 100644 --- a/packages/builder/src/builderStore/datasource.js +++ b/packages/builder/src/builderStore/datasource.js @@ -23,10 +23,10 @@ function prepareData(config) { return datasource } -export async function saveDatasource(config) { +export async function saveDatasource(config, skipFetch = false) { const datasource = prepareData(config) // Create datasource - const resp = await datasources.save(datasource, datasource.plus) + const resp = await datasources.save(datasource, !skipFetch && datasource.plus) // update the tables incase data source plus await tables.fetch() diff --git a/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte b/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte index b97f23d7a3..819fb32e45 100644 --- a/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte +++ b/packages/builder/src/components/backend/DatasourceNavigator/TableIntegrationMenu/PlusConfigForm.svelte @@ -199,18 +199,18 @@ Tell budibase how your tables are related to get even more smart features. -{/if} -{#if relationshipInfo && relationshipInfo.length > 0} - openRelationshipModal(detail.from, detail.to)} - schema={relationshipSchema} - data={relationshipInfo} - allowEditColumns={false} - allowEditRows={false} - allowSelectRows={false} - /> -{:else} - No relationships configured. + {#if relationshipInfo && relationshipInfo.length > 0} +
openRelationshipModal(detail.from, detail.to)} + schema={relationshipSchema} + data={relationshipInfo} + allowEditColumns={false} + allowEditRows={false} + allowSelectRows={false} + /> + {:else} + No relationships configured. + {/if} {/if}