diff --git a/i18n/README.zh.md b/i18n/README.zh.md new file mode 100644 index 0000000000..7e4dffd387 --- /dev/null +++ b/i18n/README.zh.md @@ -0,0 +1,193 @@ +

+ + Budibase + +

+

+ Budibase +

+

+ 在您自己的设施上快速构建应用 +

+

+ Budibase是一个开放源代码的低代码平台,可帮助开发人员和IT专业人员在几分钟内在自己的设施上构建、自动化和交付部署定制业务应用。 +

+ + +

+ 🤖 🎨 🚀 +

+ + +

+ +

+ +

+ + GitHub all releases + + + GitHub release (latest by date) + + + Discord + + + Follow @budibase + + Code of conduct + + + +

+

+ 注册 + · + 文档 + · + 提出需求 + · + 报告Bug + · + 支持: 讨论区 + & + Discord +

+ + + +## ✨ 功能 +当其他平台选择闭源路线时,我们决定采用开放源代码。当其他平台选择云服务时,我们决定提供本地构建器提供更好的开发体验。我们喜欢在Budibase上做不同的事情。 + +- **构建和发布真实的软件** 与其他平台不同,使用Budibase可以构建和交付单页应用程序。Budibase应用程序具有增强的性能,并且可以进行响应式设计,从而为您的用户提供出色的体验。 +- **开源且可扩展** Budibase是开源的。Builder使用AGPLv3许可,Server使用GPLv3,Client使用MPL。这会让您充满信心,Budibase将永远存在。您还可以针对Budibase进行修改或对其进行分叉,并根据需要进行更改,以提供开发人员友好的体验。 +- **连接数据库或直接开始** Budibase从多个来源(包括MongoDB,CouchDB,PostgreSQL,mySQL,Airtable,Google Sheets,S3,DyanmoDB或REST API)提取数据。与其他平台不同,使用Budibase可以从头开始,创建没有数据源的业务应用程序。[新的数据源需求](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas)。 +- **使用强大的预制组件设计和构建应用程序** Budibase开箱即用,具有精美设计,功能强大的组件,您可以使用它们像构建基块来构建UI。我们还提供了许多您最喜欢的CSS样式选项,因此您可以发挥更多的创意。[新的组件需求](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas)。 +- **自动化流程,与其他工具集成,并连接到Webhooks** 通过自动化手动流程和工作流来节省时间。从连接到Webhook,到自动发送电子邮件,只需告诉Budibase要做些什么,然后让它为您工作。您可以[在此处](https://github.com/Budibase/automations)轻松地[为Budibase创建新的自动化,](https://github.com/Budibase/automations)或[在此处](https://github.com/Budibase/automations)[新的集成需求](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas)。 +- **云托管和自我托管** 用户可以自行托管(请参见下文),或使用Budibase托管其应用。目前,我们的云托管产品仅限于免费套餐,但我们会在将来进行更改。对于大量使用,我们建议用户进行自我托管。 + +

+ Budibase design ui +

+ + +## ⌛ 状态 +- [x] Alpha:我们正在将Budibase演示给用户并收到反馈 +- [x] 内部测试:我们正在与一组封闭的客户一起测试Budibase +- [x] 公开测试:任何人都可以[注册并使用Budibase](https://portal.budi.live/signup) +- [ ] 正式发布 + +关注此存储库的“release”以获取主要更新的通知,并给我们一个star~。 + +

+ +

+ +### Star时序图 + +[![Stargazers over time](https://starchart.cc/Budibase/budibase.svg)](https://starchart.cc/Budibase/budibase) + +如果您在两个版本之间遇到问题,请使用[此处](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md#troubleshooting)的指南清理您的环境。 + + +## 🏁 Budibase入门 + +Budibase构建器在Mac,PC和Linux上的Electron中运行。请按照以下步骤开始: + +- [ ] [注册Budibase](https://portal.budi.live/signup) +- [ ] 创建用户名和密码 +- [ ] 复制您的API密钥 +- [ ] 下载Budibase +- [ ] 打开Budibase并输入您的API密钥 + +如果您需要其他帮助,请[参阅以下指导教程](https://docs.budibase.com/tutorial/tutorial-signing-up)。 + + +## 🤖 自托管 + +Budibase希望确保任何人都可以使用我们开发的工具,并且我们知道很多人需要能够在自己的系统上托管他们制作的应用程序——这就是为什么我们决定尝试使自托管服务变得如此简单的原因! + +当前,您可以使用Docker或Digital Ocean托管您的应用程序。可以在[此处](https://docs.budibase.com/self-hosting/introduction-to-self-hosting)找到有关自我托管的文档。 + +[![Deploy to DO](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/droplets/new?onboarding_origin=marketplace&i=09038e&fleetUuid=bb04f9c8-1de8-4687-b2ae-1d5177a0535b&appId=77729671&type=applications&size=s-4vcpu-8gb®ion=nyc1&refcode=0caaa6085a82&image=budibase-20-04) + + +## 🎓 学习Budibase + +Budibase[文档](https://docs.budibase.com/)位于[此处](https://docs.budibase.com/)。 + +您还可以按照有关[如何使用Budibase构建CRM](https://docs.budibase.com/tutorial/tutorial-introduction)的快速教程进行[操作](https://docs.budibase.com/tutorial/tutorial-introduction) + + +## 路线图 +查看我们的[公共路线图](https://github.com/Budibase/budibase/projects/10)。如果您想讨论路线图上的某些项目,请与[Discord](https://discord.gg/rCYayfe)或通过Github进行[讨论](https://github.com/Budibase/budibase/discussions) + + +## ❗ 行为守则 + +Budibase致力于为每个人提供热情,多样且无烦恼的体验。我们希望Budibase社区中的每个人都遵守我们的[**行为准则**](https://github.com/Budibase/budibase/blob/HEAD/.github/CODE_OF_CONDUCT.md)。 + +## 🙌 为Budibase贡献 + +从错误报告到PR请求:每一个贡献都将受到赞赏和欢迎。如果您打算实施一项新功能或更改API,请先创建一个Issue。这样我们可以确保您的工作没有白费。 + +### 不知道从哪里开始? + +[第一次提出Issue](https://github.com/Budibase/budibase/projects/22)是一个开始做出贡献的好地方。 + +### 存储库的组织方式 + +Budibase是由lerna管理的项目。Lerna管理budibase软件包的构建和发布。在较高的层次上,这里是构成Budibase的软件包。 + +- [packages/builder](https://github.com/Budibase/budibase/tree/HEAD/packages/builder)-包含budibase构建器客户端苗条应用程序的代码。 +- [packages/client](https://github.com/Budibase/budibase/tree/HEAD/packages/client)-在浏览器中运行的模块,负责读取JSON定义并从中创建生动的Web应用程序。 +- [packages/server](https://github.com/Budibase/budibase/tree/HEAD/packages/server)-budibase服务器。该Koa应用程序负责为构建器和budibase应用程序提供JS服务,并提供与数据库和文件系统交互的API。 + +有关更多信息,请参见[CONTRIBUTING.md](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md) + +## 📝 开源协议 + +Budibase是开源的。该构建器的许可证为[AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html),服务器的许可证为[GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html),客户端的许可证为[MPL](https://directory.fsf.org/wiki/License:MPL-2.0)。 + +## 💬 保持联系 + +如果您有任何疑问或想与其他Budibase用户交谈,请跳至[Github讨论](https://github.com/Budibase/budibase/discussions)或加入我们的Discord服务器: + +[Discord 聊天室](https://discord.gg/rCYayfe) + +![Discord Shield](https://discordapp.com/api/guilds/733030666647765003/widget.png?style=shield) + + +## 贡献者 ✨ + +感谢这些出色的人 ([emoji key](https://allcontributors.org/docs/en/emoji-key)): + + + + + + + + + + + + + + + + + + + + + +

Martin McKeaveney

💻 📖 ⚠️ 🚇

Michael Drury

📖 💻 ⚠️ 🚇

Andrew Kingston

📖 💻 ⚠️ 🎨

Michael Shanks

📖 💻 ⚠️

Kevin Åberg Kultalahti

📖 💻 ⚠️

Joe

📖 💻 🖋 🎨

Conor_Mack

💻 ⚠️

pngwn

💻 ⚠️

HugoLd

💻

victoriasloan

💻

yashank09

💻

SOVLOOKUP

💻
+ + + + + + +该项目遵循[所有参与者的](https://github.com/all-contributors/all-contributors)规范。欢迎任何形式的捐助! 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 79dc251221..74bbda278c 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 @@ -463,10 +473,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 e0e3c5ab81..ca1e170754 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", () => {