diff --git a/packages/bbui/package.json b/packages/bbui/package.json index 056f4229cb..c4c7e14b4b 100644 --- a/packages/bbui/package.json +++ b/packages/bbui/package.json @@ -56,6 +56,7 @@ "@spectrum-css/link": "^3.1.1", "@spectrum-css/menu": "^3.0.1", "@spectrum-css/modal": "^3.0.1", + "@spectrum-css/pagination": "^3.0.3", "@spectrum-css/picker": "^1.0.1", "@spectrum-css/popover": "^3.0.1", "@spectrum-css/progressbar": "^1.0.2", diff --git a/packages/bbui/src/Form/Checkbox.svelte b/packages/bbui/src/Form/Checkbox.svelte index 1f3e439c2a..90a2cddda5 100644 --- a/packages/bbui/src/Form/Checkbox.svelte +++ b/packages/bbui/src/Form/Checkbox.svelte @@ -17,6 +17,6 @@ } - + diff --git a/packages/bbui/src/Form/Combobox.svelte b/packages/bbui/src/Form/Combobox.svelte index e08e609732..b718921325 100644 --- a/packages/bbui/src/Form/Combobox.svelte +++ b/packages/bbui/src/Form/Combobox.svelte @@ -8,7 +8,7 @@ export let disabled = false export let labelPosition = "above" export let error = null - export let placeholder = "Choose an option" + export let placeholder = "Choose an option or type" export let options = [] export let getOptionLabel = option => extractProperty(option, "label") export let getOptionValue = option => extractProperty(option, "value") @@ -26,7 +26,7 @@ } - + { // Always use placeholder if no value if (value == null || value === "") { - return placeholder || "Choose an option" + return placeholder || "Choose an option or type" } // Wait for options to load if there is a value but no options @@ -45,10 +45,16 @@ } -
+
(focus = false)} on:change={onChange} {value} + {disabled} {placeholder} class="spectrum-Textfield-input spectrum-InputGroup-input" /> @@ -65,7 +72,7 @@ class="spectrum-Picker spectrum-Picker--sizeM spectrum-InputGroup-button" tabindex="-1" aria-haspopup="true" - disabled={!!error} + {disabled} on:click={() => (open = true)} > - + - + diff --git a/packages/bbui/src/Form/Input.svelte b/packages/bbui/src/Form/Input.svelte index 327fefa32c..07ebf4f4db 100644 --- a/packages/bbui/src/Form/Input.svelte +++ b/packages/bbui/src/Form/Input.svelte @@ -19,7 +19,7 @@ } - + - + - + - + - +
+ +
+ + Page {page} + +
+ +
+ + + diff --git a/packages/bbui/src/ProgressCircle/ProgressCircle.svelte b/packages/bbui/src/ProgressCircle/ProgressCircle.svelte index 711517ec7b..9c8181ec7c 100644 --- a/packages/bbui/src/ProgressCircle/ProgressCircle.svelte +++ b/packages/bbui/src/ProgressCircle/ProgressCircle.svelte @@ -42,7 +42,7 @@
diff --git a/packages/bbui/src/Table/Table.svelte b/packages/bbui/src/Table/Table.svelte index 048ded2b5b..07ba7fa6e9 100644 --- a/packages/bbui/src/Table/Table.svelte +++ b/packages/bbui/src/Table/Table.svelte @@ -4,6 +4,16 @@ import CellRenderer from "./CellRenderer.svelte" import SelectEditRenderer from "./SelectEditRenderer.svelte" + /** + * The expected schema is our normal couch schemas for our tables. + * Each field schema can be enriched with a few extra properties to customise + * the behaviour. + * All of these are optional and do not need to be added. + * displayName: Overrides the field name displayed as the column title + * sortable: Set to false to disable sorting data by a certain column + * editable: Set to false to disable editing a certain column if the + * allowEditColumns prop is true + */ export let data = [] export let schema = {} export let showAutoColumns = false @@ -462,10 +472,6 @@ tbody tr.hidden { height: calc(var(--row-height) + 1px); } - tbody tr.offset { - background-color: red; - display: block; - } td { padding-top: 0; padding-bottom: 0; diff --git a/packages/bbui/src/index.js b/packages/bbui/src/index.js index 9d47958461..8ed1dadf1f 100644 --- a/packages/bbui/src/index.js +++ b/packages/bbui/src/index.js @@ -51,6 +51,7 @@ export { default as TreeView } from "./TreeView/Tree.svelte" export { default as TreeItem } from "./TreeView/Item.svelte" export { default as Divider } from "./Divider/Divider.svelte" export { default as Search } from "./Form/Search.svelte" +export { default as Pagination } from "./Pagination/Pagination.svelte" // Typography export { default as Body } from "./Typography/Body.svelte" diff --git a/packages/bbui/yarn.lock b/packages/bbui/yarn.lock index e9bad2e162..36e26b0ea6 100644 --- a/packages/bbui/yarn.lock +++ b/packages/bbui/yarn.lock @@ -161,6 +161,11 @@ resolved "https://registry.yarnpkg.com/@spectrum-css/modal/-/modal-3.0.2.tgz#58b6621cab65f90788d310374f40df1f7090473f" integrity sha512-YnIivJhoaao7Otu+HV7sgebPyFbO6sd/oMvTN/Rb2wwgnaMnIIuIRdGandSrcgotN2uNgs+P0knG6mv/xA1/dg== +"@spectrum-css/pagination@^3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@spectrum-css/pagination/-/pagination-3.0.3.tgz#b204c3ada384c4af751a354bc428346d82eeea65" + integrity sha512-OJ/v9GeNXJOZ9Yr9LDBYPrR2NCiLOWP9wANT/a5sqFuugRnQbn/HYMnRp9TBxwpDY6ihaPo0T/wi7kLiAJFdDw== + "@spectrum-css/picker@^1.0.1": version "1.0.2" resolved "https://registry.yarnpkg.com/@spectrum-css/picker/-/picker-1.0.2.tgz#b49429ae3c89f9c5f2c0530787ce45392c9612ff" diff --git a/packages/builder/src/actions.js b/packages/builder/src/actions.js index c39f004af0..22c89ca15a 100644 --- a/packages/builder/src/actions.js +++ b/packages/builder/src/actions.js @@ -5,6 +5,7 @@ export const gradient = (node, config = {}) => { lightness: 0.7, softness: 0.9, seed: null, + version: null, } // Applies a gradient background @@ -15,6 +16,7 @@ export const gradient = (node, config = {}) => { } const { saturation, lightness, softness, points } = config const seed = config.seed || Math.random().toString(32).substring(2) + const version = config.version ?? 0 // Hash function which returns a fixed hash between specified limits // for a given seed and a given version @@ -69,10 +71,10 @@ export const gradient = (node, config = {}) => { ) } - let css = `opacity:0.9;background:${randomHSL(seed, 0, 0.7)};` + let css = `opacity:0.9;background:${randomHSL(seed, version, 0.7)};` css += "background-image:" for (let i = 0; i < points - 1; i++) { - css += `${randomGradientPoint(seed, i)},` + css += `${randomGradientPoint(seed, version + i)},` } css += `${randomGradientPoint(seed, points)};` node.style = css diff --git a/packages/builder/src/builderStore/store/screenTemplates/rowDetailScreen.js b/packages/builder/src/builderStore/store/screenTemplates/rowDetailScreen.js index 16480b199e..d06cb4000e 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/rowDetailScreen.js +++ b/packages/builder/src/builderStore/store/screenTemplates/rowDetailScreen.js @@ -90,10 +90,17 @@ const createScreen = table => { tableId: table._id, type: "table", }, - filter: { - _id: `{{ ${makePropSafe("url")}.${makePropSafe("id")} }}`, - }, + filter: [ + { + field: "_id", + operator: "equal", + type: "string", + value: `{{ ${makePropSafe("url")}.${makePropSafe("id")} }}`, + valueType: "Binding", + }, + ], limit: 1, + paginate: false, }) const repeater = new Component("@budibase/standard-components/repeater") diff --git a/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js b/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js index 37f1a8f04a..7318b3a13d 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js +++ b/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js @@ -80,6 +80,7 @@ const createScreen = table => { tableId: table._id, type: "table", }, + paginate: false, }) const spectrumTable = new Component("@budibase/standard-components/table") diff --git a/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte b/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte index 2d419f18aa..6cae231be9 100644 --- a/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte +++ b/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte @@ -40,13 +40,11 @@ if (wasSelectedTable._id === table._id) { $goto("./table") } - editorModal.hide() } async function save() { await tables.save(table) notifications.success("Table renamed successfully") - editorModal.hide() } function checkValid(evt) { diff --git a/packages/builder/src/components/common/ValuesList.svelte b/packages/builder/src/components/common/ValuesList.svelte index ff8cdb5479..eba81dac7c 100644 --- a/packages/builder/src/components/common/ValuesList.svelte +++ b/packages/builder/src/components/common/ValuesList.svelte @@ -7,7 +7,7 @@ const inputChanged = ev => { try { - values = ev.target.value.split("\n") + values = ev.detail.split("\n") } catch (_) { values = [] } diff --git a/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte b/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte index cc8182cb2e..800de89ea4 100644 --- a/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte +++ b/packages/builder/src/components/common/bindings/DrawerBindableInput.svelte @@ -13,10 +13,11 @@ export let title = "Bindings" export let placeholder export let label + export let disabled = false const dispatch = createEventDispatcher() let bindingDrawer - $: tempValue = value + $: tempValue = Array.isArray(value) ? value : [] $: readableValue = runtimeToReadableBinding(bindings, value) const handleClose = () => { @@ -32,13 +33,16 @@
onChange(event.detail)} {placeholder} /> -
- -
+ {#if !disabled} +
+ +
+ {/if}
diff --git a/packages/builder/src/components/design/AppPreview/componentStructure.json b/packages/builder/src/components/design/AppPreview/componentStructure.json index 91f039fe68..9bc6be093e 100644 --- a/packages/builder/src/components/design/AppPreview/componentStructure.json +++ b/packages/builder/src/components/design/AppPreview/componentStructure.json @@ -4,7 +4,6 @@ "table", "repeater", "button", - "search", { "name": "Form", "icon": "Form", diff --git a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/DataProviderSelect.svelte b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/DataProviderSelect.svelte index 9be8feab57..f21cc7fcf9 100644 --- a/packages/builder/src/components/design/PropertiesPanel/PropertyControls/DataProviderSelect.svelte +++ b/packages/builder/src/components/design/PropertiesPanel/PropertyControls/DataProviderSelect.svelte @@ -3,20 +3,34 @@ import { makePropSafe } from "@budibase/string-templates" import { currentAsset, store } from "builderStore" import { findComponentPath } from "builderStore/storeUtils" + import { createEventDispatcher, onMount } from "svelte" export let value + export let onChange + + const dispatch = createEventDispatcher() + const getValue = component => `{{ literal ${makePropSafe(component._id)} }}` $: path = findComponentPath($currentAsset.props, $store.selectedComponentId) $: providers = path.filter( component => component._component === "@budibase/standard-components/dataprovider" ) + + // Set initial value to closest data provider + onMount(() => { + const valid = value && providers.find(x => getValue(x) === value) != null + if (!valid && providers.length) { + dispatch("change", getValue(providers[providers.length - 1])) + } + }) onFieldChange(expression, e.detail)} + placeholder="Column" + /> + + {#if expression.valueType === "Binding"} + (expression.value = event.detail)} + /> + {:else if ["string", "longform", "number"].includes(expression.type)} + + {:else if expression.type === "options"} + + {:else if expression.type === "boolean"} + + {:else if expression.type === "datetime"} + + {:else} + + {/if} + + removeField(expression.id)} + /> + {/each} +
+{/if} +
+ +
+ + diff --git a/packages/builder/src/components/design/PropertiesPanel/SettingsView.svelte b/packages/builder/src/components/design/PropertiesPanel/SettingsView.svelte index d316affb44..eeb4fe1bb3 100644 --- a/packages/builder/src/components/design/PropertiesPanel/SettingsView.svelte +++ b/packages/builder/src/components/design/PropertiesPanel/SettingsView.svelte @@ -16,7 +16,7 @@ import MultiFieldSelect from "./PropertyControls/MultiFieldSelect.svelte" import SchemaSelect from "./PropertyControls/SchemaSelect.svelte" import EventsEditor from "./PropertyControls/EventsEditor" - import FilterEditor from "./PropertyControls/FilterEditor.svelte" + import FilterEditor from "./PropertyControls/FilterEditor/FilterEditor.svelte" import { IconSelect } from "./PropertyControls/IconSelect" import ColorPicker from "./PropertyControls/ColorPicker.svelte" import StringFieldSelect from "./PropertyControls/StringFieldSelect.svelte" @@ -156,8 +156,11 @@ {/if} {/each} {:else} -
- This component doesn't have any additional settings. +
This component doesn't have any additional settings.
+ {/if} + {#if componentDefinition?.info} +
+ {@html componentDefinition?.info}
{/if} @@ -185,7 +188,7 @@ height: 100%; gap: var(--spacing-s); } - .empty { + .text { font-size: var(--spectrum-global-dimension-font-size-75); margin-top: var(--spacing-m); color: var(--grey-6); diff --git a/packages/builder/src/global.css b/packages/builder/src/global.css index 726f422a48..ed80f10b1c 100644 --- a/packages/builder/src/global.css +++ b/packages/builder/src/global.css @@ -6,10 +6,6 @@ html, body { min-height: 100%; } -.spectrum--light { - --spectrum-alias-background-color-primary: var(--spectrum-global-color-gray-75); -} - body { --background: var(--spectrum-alias-background-color-primary); --background-alt: var(--spectrum-alias-background-color-secondary); diff --git a/packages/client/src/api/tables.js b/packages/client/src/api/tables.js index 248e1516c2..59381e35bf 100644 --- a/packages/client/src/api/tables.js +++ b/packages/client/src/api/tables.js @@ -18,19 +18,37 @@ export const fetchTableData = async tableId => { } /** - * Perform a mango query against an internal table - * @param {String} tableId - id of the table to search - * @param {Object} search - Mango Compliant search object - * @param {Object} pagination - the pagination controls + * Searches a table using Lucene. */ -export const searchTableData = async ({ tableId, search, pagination }) => { - const output = await API.post({ - url: `/api/${tableId}/rows/search`, +export const searchTable = async ({ + tableId, + query, + bookmark, + limit, + sort, + sortOrder, + sortType, + paginate, +}) => { + if (!tableId || !query) { + return { + rows: [], + } + } + const res = await API.post({ + url: `/api/search/${tableId}/rows`, body: { - query: search, - pagination, + query, + bookmark, + limit, + sort, + sortOrder, + sortType, + paginate, }, }) - output.rows = await enrichRows(output.rows, tableId) - return output + return { + ...res, + rows: await enrichRows(res?.rows, tableId), + } } diff --git a/packages/client/src/components/ClientApp.svelte b/packages/client/src/components/ClientApp.svelte index 8fb1b83d4c..5d07754f49 100644 --- a/packages/client/src/components/ClientApp.svelte +++ b/packages/client/src/components/ClientApp.svelte @@ -39,8 +39,18 @@ {#if loaded && $screenStore.activeLayout} - - - - +
+ + + + +
{/if} + + diff --git a/packages/server/src/api/controllers/row.js b/packages/server/src/api/controllers/row.js index 6d0cc08548..1d73abea47 100644 --- a/packages/server/src/api/controllers/row.js +++ b/packages/server/src/api/controllers/row.js @@ -16,7 +16,6 @@ const { const { FieldTypes } = require("../../constants") const { isEqual } = require("lodash") const { cloneDeep } = require("lodash/fp") -const { QueryBuilder, search } = require("./search/utils") const TABLE_VIEW_BEGINS_WITH = `all${SEPARATOR}${DocumentTypes.TABLE}${SEPARATOR}` @@ -248,45 +247,6 @@ exports.fetchView = async function (ctx) { } } -exports.search = async function (ctx) { - const appId = ctx.appId - const db = new CouchDB(appId) - const { - query, - pagination: { pageSize = 10, bookmark }, - } = ctx.request.body - const tableId = ctx.params.tableId - - const queryBuilder = new QueryBuilder(appId) - .setLimit(pageSize) - .addTable(tableId) - if (bookmark) { - queryBuilder.setBookmark(bookmark) - } - - let searchString - if (ctx.query && ctx.query.raw && ctx.query.raw !== "") { - searchString = queryBuilder.complete(query["RAW"]) - } else { - // make all strings a starts with operation rather than pure equality - for (const [key, queryVal] of Object.entries(query)) { - if (typeof queryVal === "string") { - queryBuilder.addString(key, queryVal) - } else { - queryBuilder.addEqual(key, queryVal) - } - } - searchString = queryBuilder.complete() - } - - const response = await search(searchString) - const table = await db.get(tableId) - ctx.body = { - rows: await outputProcessing(appId, table, response.rows), - bookmark: response.bookmark, - } -} - exports.fetchTableRows = async function (ctx) { const appId = ctx.appId const db = new CouchDB(appId) diff --git a/packages/server/src/api/controllers/search/index.js b/packages/server/src/api/controllers/search/index.js index 234c7eb258..ede0556e18 100644 --- a/packages/server/src/api/controllers/search/index.js +++ b/packages/server/src/api/controllers/search/index.js @@ -1,18 +1,26 @@ -const { QueryBuilder, buildSearchUrl, search } = require("./utils") +const { fullSearch, paginatedSearch } = require("./utils") +const CouchDB = require("../../../db") +const { outputProcessing } = require("../../../utilities/rowProcessor") exports.rowSearch = async ctx => { const appId = ctx.appId const { tableId } = ctx.params - const { bookmark, query, raw } = ctx.request.body - let url - if (query) { - url = new QueryBuilder(appId, query, bookmark).addTable(tableId).complete() - } else if (raw) { - url = buildSearchUrl({ - appId, - query: raw, - bookmark, - }) + const db = new CouchDB(appId) + const { paginate, query, ...params } = ctx.request.body + params.tableId = tableId + + let response + if (paginate) { + response = await paginatedSearch(appId, query, params) + } else { + response = await fullSearch(appId, query, params) } - ctx.body = await search(url) + + // Enrich search results with relationships + if (response.rows && response.rows.length) { + const table = await db.get(tableId) + response.rows = await outputProcessing(appId, table, response.rows) + } + + ctx.body = response } diff --git a/packages/server/src/api/controllers/search/utils.js b/packages/server/src/api/controllers/search/utils.js index d3ffb26be7..9fc10dabf9 100644 --- a/packages/server/src/api/controllers/search/utils.js +++ b/packages/server/src/api/controllers/search/utils.js @@ -4,28 +4,19 @@ const env = require("../../../environment") const fetch = require("node-fetch") /** - * Given a set of inputs this will generate the URL which is to be sent to the search proxy in CouchDB. - * @param {string} appId The ID of the app which we will be searching within. - * @param {string} query The lucene query string which is to be used for searching. - * @param {string|null} bookmark If there were more than the limit specified can send the bookmark that was - * returned with query for next set of search results. - * @param {number} limit The number of entries to return per query. - * @param {boolean} excludeDocs By default full rows are returned, if required this can be disabled. - * @return {string} The URL which a GET can be performed on to receive results. + * Escapes any characters in a string which lucene searches require to be + * escaped. + * @param value The value to escape + * @returns {string} */ -function buildSearchUrl({ appId, query, bookmark, excludeDocs, limit = 50 }) { - let url = `${env.COUCH_DB_URL}/${appId}/_design/database/_search` - url += `/${SearchIndexes.ROWS}?q=${query}` - url += `&limit=${limit}` - if (!excludeDocs) { - url += "&include_docs=true" - } - if (bookmark) { - url += `&bookmark=${bookmark}` - } - return checkSlashesInUrl(url) +const luceneEscape = value => { + return `${value}`.replace(/[ #+\-&|!(){}\[\]^"~*?:\\]/g, "\\$&") } +/** + * Class to build lucene query URLs. + * Optionally takes a base lucene query object. + */ class QueryBuilder { constructor(appId, base) { this.appId = appId @@ -34,10 +25,20 @@ class QueryBuilder { fuzzy: {}, range: {}, equal: {}, + notEqual: {}, + empty: {}, + notEmpty: {}, ...base, } this.limit = 50 - this.bookmark = null + this.sortOrder = "ascending" + this.sortType = "string" + this.includeDocs = true + } + + setTable(tableId) { + this.query.equal.tableId = tableId + return this } setLimit(limit) { @@ -45,11 +46,31 @@ class QueryBuilder { return this } + setSort(sort) { + this.sort = sort + return this + } + + setSortOrder(sortOrder) { + this.sortOrder = sortOrder + return this + } + + setSortType(sortType) { + this.sortType = sortType + return this + } + setBookmark(bookmark) { this.bookmark = bookmark return this } + excludeDocs() { + this.includeDocs = false + return this + } + addString(key, partial) { this.query.string[key] = partial return this @@ -73,52 +94,113 @@ class QueryBuilder { return this } - addTable(tableId) { - this.query.equal.tableId = tableId + addNotEqual(key, value) { + this.query.notEqual[key] = value return this } - complete(rawQuery = null) { - let output = "" + addEmpty(key, value) { + this.query.empty[key] = value + return this + } + + addNotEmpty(key, value) { + this.query.notEmpty[key] = value + return this + } + + buildSearchQuery() { + let query = "*:*" + function build(structure, queryFn) { for (let [key, value] of Object.entries(structure)) { - if (output.length !== 0) { - output += " AND " + const expression = queryFn(luceneEscape(key.replace(/ /, "_")), value) + if (expression == null) { + continue } - output += queryFn(key, value) + query += ` AND ${expression}` } } + // Construct the actual lucene search query string from JSON structure if (this.query.string) { - build(this.query.string, (key, value) => `${key}:${value}*`) + build(this.query.string, (key, value) => { + return value ? `${key}:${luceneEscape(value.toLowerCase())}*` : null + }) } if (this.query.range) { - build( - this.query.range, - (key, value) => `${key}:[${value.low} TO ${value.high}]` - ) + build(this.query.range, (key, value) => { + if (!value) { + return null + } + if (value.low == null || value.low === "") { + return null + } + if (value.high == null || value.high === "") { + return null + } + return `${key}:[${value.low} TO ${value.high}]` + }) } if (this.query.fuzzy) { - build(this.query.fuzzy, (key, value) => `${key}:${value}~`) + build(this.query.fuzzy, (key, value) => { + return value ? `${key}:${luceneEscape(value.toLowerCase())}~` : null + }) } if (this.query.equal) { - build(this.query.equal, (key, value) => `${key}:${value}`) + build(this.query.equal, (key, value) => { + return value ? `${key}:${luceneEscape(value.toLowerCase())}` : null + }) } - if (rawQuery) { - output = output.length === 0 ? rawQuery : `&${rawQuery}` + if (this.query.notEqual) { + build(this.query.notEqual, (key, value) => { + return value ? `!${key}:${luceneEscape(value.toLowerCase())}` : null + }) } - return buildSearchUrl({ - appId: this.appId, - query: output, - bookmark: this.bookmark, - limit: this.limit, - }) + if (this.query.empty) { + build(this.query.empty, key => `!${key}:["" TO *]`) + } + if (this.query.notEmpty) { + build(this.query.notEmpty, key => `${key}:["" TO *]`) + } + + return query + } + + buildSearchBody() { + let body = { + q: this.buildSearchQuery(), + limit: Math.min(this.limit, 200), + include_docs: this.includeDocs, + } + if (this.bookmark) { + body.bookmark = this.bookmark + } + if (this.sort) { + const order = this.sortOrder === "descending" ? "-" : "" + const type = `<${this.sortType}>` + body.sort = `${order}${this.sort.replace(/ /, "_")}${type}` + } + return body + } + + async run() { + const url = `${env.COUCH_DB_URL}/${this.appId}/_design/database/_search/${SearchIndexes.ROWS}` + const body = this.buildSearchBody() + return await runQuery(url, body) } } -exports.search = async query => { - const response = await fetch(query, { - method: "GET", +/** + * Executes a lucene search query. + * @param url The query URL + * @param body The request body defining search criteria + * @returns {Promise<{rows: []}>} + */ +const runQuery = async (url, body) => { + const response = await fetch(url, { + body: JSON.stringify(body), + method: "POST", }) const json = await response.json() let output = { @@ -133,5 +215,122 @@ exports.search = async query => { return output } -exports.QueryBuilder = QueryBuilder -exports.buildSearchUrl = buildSearchUrl +/** + * Gets round the fixed limit of 200 results from a query by fetching as many + * pages as required and concatenating the results. This recursively operates + * until enough results have been found. + * @param appId {string} The app ID to search + * @param query {object} The JSON query structure + * @param params {object} The search params including: + * tableId {string} The table ID to search + * sort {string} The sort column + * sortOrder {string} The sort order ("ascending" or "descending") + * sortType {string} Whether to treat sortable values as strings or + * numbers. ("string" or "number") + * limit {number} The number of results to fetch + * bookmark {string|null} Current bookmark in the recursive search + * rows {array|null} Current results in the recursive search + * @returns {Promise<*[]|*>} + */ +const recursiveSearch = async (appId, query, params) => { + const bookmark = params.bookmark + const rows = params.rows || [] + if (rows.length >= params.limit) { + return rows + } + let pageSize = 200 + if (rows.length > params.limit - 200) { + pageSize = params.limit - rows.length + } + const page = await new QueryBuilder(appId, query) + .setTable(params.tableId) + .setBookmark(bookmark) + .setLimit(pageSize) + .setSort(params.sort) + .setSortOrder(params.sortOrder) + .setSortType(params.sortType) + .run() + if (!page.rows.length) { + return rows + } + if (page.rows.length < 200) { + return [...rows, ...page.rows] + } + const newParams = { + ...params, + bookmark: page.bookmark, + rows: [...rows, ...page.rows], + } + return await recursiveSearch(appId, query, newParams) +} + +/** + * Performs a paginated search. A bookmark will be returned to allow the next + * page to be fetched. There is a max limit off 200 results per page in a + * paginated search. + * @param appId {string} The app ID to search + * @param query {object} The JSON query structure + * @param params {object} The search params including: + * tableId {string} The table ID to search + * sort {string} The sort column + * sortOrder {string} The sort order ("ascending" or "descending") + * sortType {string} Whether to treat sortable values as strings or + * numbers. ("string" or "number") + * limit {number} The desired page size + * bookmark {string} The bookmark to resume from + * @returns {Promise<{hasNextPage: boolean, rows: *[]}>} + */ +exports.paginatedSearch = async (appId, query, params) => { + let limit = params.limit + if (limit == null || isNaN(limit) || limit < 0) { + limit = 50 + } + limit = Math.min(limit, 200) + const search = new QueryBuilder(appId, query) + .setTable(params.tableId) + .setSort(params.sort) + .setSortOrder(params.sortOrder) + .setSortType(params.sortType) + const searchResults = await search + .setBookmark(params.bookmark) + .setLimit(limit) + .run() + + // Try fetching 1 row in the next page to see if another page of results + // exists or not + const nextResults = await search + .setBookmark(searchResults.bookmark) + .setLimit(1) + .run() + + return { + ...searchResults, + hasNextPage: nextResults.rows && nextResults.rows.length > 0, + } +} + +/** + * Performs a full search, fetching multiple pages if required to return the + * desired amount of results. There is a limit of 1000 results to avoid + * heavy performance hits, and to avoid client components breaking from + * handling too much data. + * @param appId {string} The app ID to search + * @param query {object} The JSON query structure + * @param params {object} The search params including: + * tableId {string} The table ID to search + * sort {string} The sort column + * sortOrder {string} The sort order ("ascending" or "descending") + * sortType {string} Whether to treat sortable values as strings or + * numbers. ("string" or "number") + * limit {number} The desired number of results + * @returns {Promise<{rows: *}>} + */ +exports.fullSearch = async (appId, query, params) => { + let limit = params.limit + if (limit == null || isNaN(limit) || limit < 0) { + limit = 1000 + } + params.limit = Math.min(limit, 1000) + const rows = await recursiveSearch(appId, query, params) + return { rows } +} diff --git a/packages/server/src/api/routes/index.js b/packages/server/src/api/routes/index.js index 0b09a78bb8..5ea3ddacef 100644 --- a/packages/server/src/api/routes/index.js +++ b/packages/server/src/api/routes/index.js @@ -23,6 +23,7 @@ const queryRoutes = require("./query") const hostingRoutes = require("./hosting") const backupRoutes = require("./backup") const devRoutes = require("./dev") +const searchRoutes = require("./search") exports.mainRoutes = [ authRoutes, @@ -51,6 +52,7 @@ exports.mainRoutes = [ // this could be breaking as koa may recognise other routes as this tableRoutes, rowRoutes, + searchRoutes, ] exports.staticRoutes = staticRoutes diff --git a/packages/server/src/api/routes/row.js b/packages/server/src/api/routes/row.js index be14910f3e..494ea61608 100644 --- a/packages/server/src/api/routes/row.js +++ b/packages/server/src/api/routes/row.js @@ -39,12 +39,6 @@ router usage, rowController.save ) - .post( - "/api/:tableId/rows/search", - paramResource("tableId"), - authorized(PermissionTypes.TABLE, PermissionLevels.READ), - rowController.search - ) .patch( "/api/:tableId/rows/:rowId", paramSubResource("tableId", "rowId"), diff --git a/packages/server/src/db/views/staticViews.js b/packages/server/src/db/views/staticViews.js index 5f5bc7db14..23f320d7eb 100644 --- a/packages/server/src/db/views/staticViews.js +++ b/packages/server/src/db/views/staticViews.js @@ -84,6 +84,7 @@ async function searchIndex(appId, indexName, fnString) { designDoc.indexes = { [indexName]: { index: fnString, + analyzer: "keyword", }, } await db.put(designDoc) @@ -96,11 +97,15 @@ exports.createAllSearchIndex = async appId => { function (doc) { function idx(input, prev) { for (let key of Object.keys(input)) { - const idxKey = prev != null ? `${prev}.${key}` : key - if (key === "_id" || key === "_rev") { + let idxKey = prev != null ? `${prev}.${key}` : key + idxKey = idxKey.replace(/ /, "_") + if (key === "_id" || key === "_rev" || input[key] == null) { continue } - if (typeof input[key] !== "object") { + if (typeof input[key] === "string") { + // eslint-disable-next-line no-undef + index(idxKey, input[key].toLowerCase(), { store: true }) + } else if (typeof input[key] !== "object") { // eslint-disable-next-line no-undef index(idxKey, input[key], { store: true }) } else { diff --git a/packages/server/src/utilities/rowProcessor.js b/packages/server/src/utilities/rowProcessor.js index fd79751c3e..2267c9e986 100644 --- a/packages/server/src/utilities/rowProcessor.js +++ b/packages/server/src/utilities/rowProcessor.js @@ -123,24 +123,6 @@ function processAutoColumn(user, table, row) { return { table, row } } -/** - * Given a set of rows and the table they came from this function will sort by auto ID or a custom - * method if provided (not implemented yet). - */ -function sortRows(table, rows) { - // sort based on auto ID (if found) - let autoIDColumn = Object.entries(table.schema).find( - schema => schema[1].subtype === AutoFieldSubTypes.AUTO_ID - ) - // get the column name, this is the first element in the array (Object.entries) - autoIDColumn = autoIDColumn && autoIDColumn.length ? autoIDColumn[0] : null - if (autoIDColumn) { - // sort in ascending order - rows.sort((a, b) => a[autoIDColumn] - b[autoIDColumn]) - } - return rows -} - /** * Looks through the rows provided and finds formulas - which it then processes. */ @@ -213,8 +195,6 @@ exports.outputProcessing = async (appId, table, rows) => { rows = [rows] wasArray = false } - // sort by auto ID - rows = sortRows(table, rows) // attach any linked row information let enriched = await linkRows.attachFullLinkedDocs(appId, table, rows) diff --git a/packages/standard-components/manifest.json b/packages/standard-components/manifest.json index 163caa3dbc..a3fbc5aa52 100644 --- a/packages/standard-components/manifest.json +++ b/packages/standard-components/manifest.json @@ -65,41 +65,6 @@ "type": "schema" } }, - "search": { - "name": "Search", - "description": "A searchable list of items.", - "icon": "Search", - "styleable": true, - "hasChildren": true, - "settings": [ - { - "type": "table", - "label": "Table", - "key": "table" - }, - { - "type": "multifield", - "label": "Columns", - "key": "columns", - "dependsOn": "table" - }, - { - "type": "number", - "label": "Rows/Page", - "defaultValue": 25, - "key": "pageSize" - }, - { - "type": "text", - "label": "Empty Text", - "key": "noRowsMessage", - "defaultValue": "No rows found." - } - ], - "context": { - "type": "schema" - } - }, "stackedlist": { "name": "Stacked List", "icon": "TaskList", @@ -1416,6 +1381,7 @@ }, "dataprovider": { "name": "Data Provider", + "info": "Pagination is only available for data stored in internal tables.", "icon": "Data", "styleable": false, "hasChildren": true, @@ -1445,7 +1411,14 @@ { "type": "number", "label": "Limit", - "key": "limit" + "key": "limit", + "defaultValue": 50 + }, + { + "type": "boolean", + "label": "Paginate", + "key": "paginate", + "defaultValue": true } ], "context": { @@ -1464,12 +1437,8 @@ "key": "schema" }, { - "label": "Loading", - "key": "loading" - }, - { - "label": "Loaded", - "key": "loaded" + "label": "Page Number", + "key": "pageNumber" } ] } diff --git a/packages/standard-components/src/DataProvider.svelte b/packages/standard-components/src/DataProvider.svelte index a8da4925d3..e0b2ad859a 100644 --- a/packages/standard-components/src/DataProvider.svelte +++ b/packages/standard-components/src/DataProvider.svelte @@ -1,11 +1,13 @@ -
+
- + {#if !loaded} +
+ +
+ {:else} + + {#if paginate && internalTable} + + {/if} + {/if}
+ + diff --git a/packages/standard-components/src/Search.svelte b/packages/standard-components/src/Search.svelte deleted file mode 100644 index 29ca3b011b..0000000000 --- a/packages/standard-components/src/Search.svelte +++ /dev/null @@ -1,195 +0,0 @@ - - - -
-
- {#if schema} - {#each columns as field} -
- - {#if schema[field].type === "options"} - - {:else if schema[field].type === "datetime"} - - {:else if schema[field].type === "boolean"} - - {:else if schema[field].type === "number"} - - {:else if schema[field].type === "string"} - - {/if} -
- {/each} - {/if} -
- - -
-
- {#if loaded} - {#if rows.length > 0} - {#if $component.children === 0 && $builderStore.inBuilder} -

Add some components to display.

- {:else} - {#each rows as row} - - - - {/each} - {/if} - {:else if noRowsMessage} -

{noRowsMessage}

- {/if} - {/if} - -
-
- - diff --git a/packages/standard-components/src/charts/ApexChart.svelte b/packages/standard-components/src/charts/ApexChart.svelte index cf9cade436..a7e25514e0 100644 --- a/packages/standard-components/src/charts/ApexChart.svelte +++ b/packages/standard-components/src/charts/ApexChart.svelte @@ -10,9 +10,9 @@ {#if options}
-{:else if builderStore.inBuilder} -
- Use the settings panel to build your chart --> +{:else if $builderStore.inBuilder} +
+ Use the settings panel to build your chart.
{/if} @@ -21,4 +21,10 @@ display: flex !important; text-transform: capitalize; } + div :global(.apexcharts-yaxis-label, .apexcharts-xaxis-label) { + fill: #aaa; + } + div.placeholder { + padding: 10px; + } diff --git a/packages/standard-components/src/forms/Form.svelte b/packages/standard-components/src/forms/Form.svelte index ccc61fab9e..afa4aeeeb4 100644 --- a/packages/standard-components/src/forms/Form.svelte +++ b/packages/standard-components/src/forms/Form.svelte @@ -187,5 +187,6 @@ div { padding: 20px; position: relative; + background-color: var(--spectrum-alias-background-color-secondary); } diff --git a/packages/standard-components/src/index.js b/packages/standard-components/src/index.js index 2ad685033a..7b4d492fa9 100644 --- a/packages/standard-components/src/index.js +++ b/packages/standard-components/src/index.js @@ -27,7 +27,6 @@ export { default as embed } from "./Embed.svelte" export { default as cardhorizontal } from "./CardHorizontal.svelte" export { default as cardstat } from "./CardStat.svelte" export { default as icon } from "./Icon.svelte" -export { default as search } from "./Search.svelte" export { default as backgroundimage } from "./BackgroundImage.svelte" export * from "./charts" export * from "./forms" diff --git a/packages/standard-components/src/table/Table.svelte b/packages/standard-components/src/table/Table.svelte index f68835d75b..35839e4722 100644 --- a/packages/standard-components/src/table/Table.svelte +++ b/packages/standard-components/src/table/Table.svelte @@ -94,3 +94,9 @@
+ + diff --git a/packages/string-templates/src/index.cjs b/packages/string-templates/src/index.cjs index e662f253c6..b62521942a 100644 --- a/packages/string-templates/src/index.cjs +++ b/packages/string-templates/src/index.cjs @@ -4,7 +4,7 @@ const processors = require("./processors") const { cloneDeep } = require("lodash/fp") const { removeNull, - addConstants, + updateContext, removeHandlebarsStatements, } = require("./utilities") const manifest = require("../manifest.json") @@ -92,8 +92,7 @@ module.exports.processStringSync = (string, context) => { } // take a copy of input incase error const input = string - let clonedContext = removeNull(cloneDeep(context)) - clonedContext = addConstants(clonedContext) + const clonedContext = removeNull(updateContext(cloneDeep(context))) // remove any null/undefined properties if (typeof string !== "string") { throw "Cannot process non-string types." diff --git a/packages/string-templates/src/utilities.js b/packages/string-templates/src/utilities.js index 38496b04b4..e94b7f8ee7 100644 --- a/packages/string-templates/src/utilities.js +++ b/packages/string-templates/src/utilities.js @@ -23,11 +23,24 @@ module.exports.removeNull = obj => { return obj } -module.exports.addConstants = obj => { +module.exports.updateContext = obj => { if (obj.now == null) { - obj.now = new Date() + obj.now = new Date().toISOString() } - return obj + function recurse(obj) { + for (let key of Object.keys(obj)) { + if (!obj[key]) { + continue + } + if (obj[key] instanceof Date) { + obj[key] = obj[key].toISOString() + } else if (typeof obj[key] === "object") { + obj[key] = recurse(obj[key]) + } + } + return obj + } + return recurse(obj) } module.exports.removeHandlebarsStatements = string => { diff --git a/packages/string-templates/test/basic.spec.js b/packages/string-templates/test/basic.spec.js index 5732181b13..f5c7c8be75 100644 --- a/packages/string-templates/test/basic.spec.js +++ b/packages/string-templates/test/basic.spec.js @@ -107,6 +107,12 @@ describe("check the utility functions", () => { const property = makePropSafe("thing") expect(property).toEqual("[thing]") }) + + it("should be able to handle an input date object", async () => { + const date = new Date() + const output = await processString("{{ dateObj }}", { dateObj: date }) + expect(date.toISOString()).toEqual(output) + }) }) describe("check manifest", () => { diff --git a/yarn.lock b/yarn.lock index c8c4430e09..f344bd8338 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4559,7 +4559,7 @@ supports-color@^7.1.0: dependencies: has-flag "^4.0.0" -svelte@^3.37.0: +svelte@^3.38.2: version "3.38.2" resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.38.2.tgz#55e5c681f793ae349b5cc2fe58e5782af4275ef5" integrity sha512-q5Dq0/QHh4BLJyEVWGe7Cej5NWs040LWjMbicBGZ+3qpFWJ1YObRmUDZKbbovddLC9WW7THTj3kYbTOFmU9fbg==