Merge branch 'next' into admin/user-management-ui

This commit is contained in:
Keviin Åberg Kultalahti 2021-05-17 10:11:25 +02:00
commit 341b632736
53 changed files with 1229 additions and 526 deletions

193
i18n/README.zh.md Normal file
View File

@ -0,0 +1,193 @@
<p align="center">
<a href="https://www.budibase.com">
<img alt="Budibase" src="https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg" width="60" />
</a>
</p>
<h1 align="center">
Budibase
</h1>
<h3 align="center">
在您自己的设施上快速构建应用
</h3>
<p align="center">
Budibase是一个开放源代码的低代码平台可帮助开发人员和IT专业人员在几分钟内在自己的设施上构建、自动化和交付部署定制业务应用。
</p>
<h3 align="center">
🤖 🎨 🚀
</h3>
<p align="center">
<img src="https://i.imgur.com/tPQHruf.png">
</p>
<p align="center">
<a href="https://github.com/Budibase/budibase/releases">
<img alt="GitHub all releases" src="https://img.shields.io/github/downloads/Budibase/budibase/total">
</a>
<a href="https://github.com/Budibase/budibase/releases">
<img alt="GitHub release (latest by date)" src="https://img.shields.io/github/v/release/Budibase/budibase">
</a>
<a href="https://discord.gg/rCYayfe">
<img alt="Discord" src="https://img.shields.io/discord/733030666647765003">
</a>
<a href="https://twitter.com/intent/follow?screen_name=budibase">
<img src="https://img.shields.io/twitter/follow/budibase?style=social" alt="Follow @budibase" />
</a>
<img src="https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg" alt="Code of conduct" />
<a href="https://codecov.io/gh/Budibase/budibase">
<img src="https://codecov.io/gh/Budibase/budibase/graph/badge.svg?token=E8W2ZFXQOH"/>
</a>
</p>
<h3 align="center">
<a href="https://portal.budi.live/signup">注册</a>
<span> · </span>
<a href="https://docs.budibase.com">文档</a>
<span> · </span>
<a href="https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas">提出需求</a>
<span> · </span>
<a href="https://github.com/Budibase/budibase/issues">报告Bug</a>
<span> · </span>
支持: <a href="https://github.com/Budibase/budibase/discussions">讨论区</a>
<span> & </span>
<a href="https://discord.gg/rCYayfe">Discord</a>
</h3>
## ✨ 功能
当其他平台选择闭源路线时我们决定采用开放源代码。当其他平台选择云服务时我们决定提供本地构建器提供更好的开发体验。我们喜欢在Budibase上做不同的事情。
- **构建和发布真实的软件** 与其他平台不同使用Budibase可以构建和交付单页应用程序。Budibase应用程序具有增强的性能并且可以进行响应式设计从而为您的用户提供出色的体验。
- **开源且可扩展** Budibase是开源的。Builder使用AGPLv3许可Server使用GPLv3Client使用MPL。这会让您充满信心Budibase将永远存在。您还可以针对Budibase进行修改或对其进行分叉并根据需要进行更改以提供开发人员友好的体验。
- **连接数据库或直接开始** Budibase从多个来源包括MongoDBCouchDBPostgreSQLmySQLAirtableGoogle SheetsS3DyanmoDB或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托管其应用。目前我们的云托管产品仅限于免费套餐但我们会在将来进行更改。对于大量使用我们建议用户进行自我托管。
<p align="center">
<img alt="Budibase design ui" src="https://imgur.com/v8m6v3q.png">
</p>
## ⌛ 状态
- [x] Alpha我们正在将Budibase演示给用户并收到反馈
- [x] 内部测试我们正在与一组封闭的客户一起测试Budibase
- [x] 公开测试:任何人都可以[注册并使用Budibase](https://portal.budi.live/signup)
- [ ] 正式发布
关注此存储库的“release”以获取主要更新的通知并给我们一个star~。
<p align="center">
<img src="https://i.imgur.com/cJpgqm8.png">
</p>
### 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构建器在MacPC和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&region=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)):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="http://martinmck.com"><img src="https://avatars1.githubusercontent.com/u/11256663?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Martin McKeaveney</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=shogunpurple" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=shogunpurple" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=shogunpurple" title="Tests">⚠️</a> <a href="#infra-shogunpurple" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="http://www.michaeldrury.co.uk/"><img src="https://avatars2.githubusercontent.com/u/4407001?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michael Drury</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=mike12345567" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=mike12345567" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=mike12345567" title="Tests">⚠️</a> <a href="#infra-mike12345567" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/aptkingston"><img src="https://avatars3.githubusercontent.com/u/9075550?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Andrew Kingston</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=aptkingston" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=aptkingston" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=aptkingston" title="Tests">⚠️</a> <a href="#design-aptkingston" title="Design">🎨</a></td>
<td align="center"><a href="https://budibase.com/"><img src="https://avatars3.githubusercontent.com/u/3524181?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michael Shanks</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/kevmodrome"><img src="https://avatars3.githubusercontent.com/u/534488?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Kevin Åberg Kultalahti</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Tests">⚠️</a></td>
<td align="center"><a href="https://www.budibase.com/"><img src="https://avatars2.githubusercontent.com/u/49767913?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=joebudi" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=joebudi" title="Code">💻</a> <a href="#content-joebudi" title="Content">🖋</a> <a href="#design-joebudi" title="Design">🎨</a></td>
<td align="center"><a href="https://github.com/Conor-Mack"><img src="https://avatars1.githubusercontent.com/u/36074859?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Conor_Mack</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=Conor-Mack" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=Conor-Mack" title="Tests">⚠️</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/pngwn"><img src="https://avatars1.githubusercontent.com/u/12937446?v=4?s=100" width="100px;" alt=""/><br /><sub><b>pngwn</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=pngwn" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=pngwn" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/HugoLd"><img src="https://avatars0.githubusercontent.com/u/26521848?v=4?s=100" width="100px;" alt=""/><br /><sub><b>HugoLd</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=HugoLd" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/victoriasloan"><img src="https://avatars.githubusercontent.com/u/9913651?v=4?s=100" width="100px;" alt=""/><br /><sub><b>victoriasloan</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=victoriasloan" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/yashank09"><img src="https://avatars.githubusercontent.com/u/37672190?v=4?s=100" width="100px;" alt=""/><br /><sub><b>yashank09</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=yashank09" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/SOVLOOKUP"><img src="https://avatars.githubusercontent.com/u/53158137?v=4?s=100" width="100px;" alt=""/><br /><sub><b>SOVLOOKUP</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=SOVLOOKUP" title="Code">💻</a></td>
</tr>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
该项目遵循[所有参与者的](https://github.com/all-contributors/all-contributors)规范。欢迎任何形式的捐助!

View File

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

View File

@ -17,6 +17,6 @@
}
</script>
<Field {label} {labelPosition} {disabled} {error}>
<Field {label} {labelPosition} {error}>
<Checkbox {error} {disabled} {text} {value} on:change={onChange} />
</Field>

View File

@ -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 @@
}
</script>
<Field {label} {labelPosition} {disabled} {error}>
<Field {label} {labelPosition} {error}>
<Combobox
{error}
{disabled}

View File

@ -7,7 +7,7 @@
export let value = null
export let id = null
export let placeholder = "Choose an option"
export let placeholder = "Choose an option or type"
export let disabled = false
export let error = null
export let options = []
@ -22,7 +22,7 @@
const getFieldText = (value, options, placeholder) => {
// 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 @@
}
</script>
<div class="spectrum-InputGroup" class:is-focused={open || focus}>
<div
class="spectrum-InputGroup"
class:is-focused={open || focus}
class:is-invalid={!!error}
class:is-disabled={disabled}
>
<div
class="spectrum-Textfield spectrum-InputGroup-textfield"
class:is-disabled={!!error}
class:is-invalid={!!error}
class:is-disabled={disabled}
class:is-focused={open || focus}
>
<input
@ -57,6 +63,7 @@
on:blur={() => (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)}
>
<svg
@ -116,6 +123,9 @@
min-width: 0;
width: 100%;
}
.spectrum-Textfield {
width: 100%;
}
.spectrum-Textfield-input {
width: 0;
}

View File

@ -18,7 +18,7 @@
}
</script>
<Field {label} {labelPosition} {disabled} {error}>
<Field {label} {labelPosition} {error}>
<DatePicker
{error}
{disabled}

View File

@ -20,7 +20,7 @@
}
</script>
<Field {label} {labelPosition} {disabled} {error}>
<Field {label} {labelPosition} {error}>
<CoreDropzone
{error}
{disabled}

View File

@ -5,7 +5,6 @@
export let id = null
export let label = null
export let labelPosition = "above"
export let disabled = false
export let error = null
</script>

View File

@ -19,7 +19,7 @@
}
</script>
<Field {label} {labelPosition} {disabled} {error}>
<Field {label} {labelPosition} {error}>
<TextField
{error}
{disabled}

View File

@ -21,7 +21,7 @@
}
</script>
<Field {label} {labelPosition} {disabled} {error}>
<Field {label} {labelPosition} {error}>
<Multiselect
{error}
{disabled}

View File

@ -25,7 +25,7 @@
}
</script>
<Field {label} {labelPosition} {disabled} {error}>
<Field {label} {labelPosition} {error}>
<RadioGroup
{error}
{disabled}

View File

@ -16,7 +16,7 @@
}
</script>
<Field {label} {labelPosition} {disabled}>
<Field {label} {labelPosition}>
<Search
{disabled}
{value}

View File

@ -28,7 +28,7 @@
}
</script>
<Field {label} {labelPosition} {disabled} {error}>
<Field {label} {labelPosition} {error}>
<Select
{quiet}
{error}

View File

@ -18,7 +18,7 @@
}
</script>
<Field {label} {labelPosition} {disabled} {error}>
<Field {label} {labelPosition} {error}>
<TextArea
bind:getCaretPosition
{error}

View File

@ -17,6 +17,6 @@
}
</script>
<Field {label} {labelPosition} {disabled} {error}>
<Field {label} {labelPosition} {error}>
<Switch {error} {disabled} {text} {value} on:change={onChange} />
</Field>

View File

@ -0,0 +1,57 @@
<script>
import "@spectrum-css/pagination/dist/index-vars.css"
import "@spectrum-css/actionbutton/dist/index-vars.css"
import "@spectrum-css/typography/dist/index-vars.css"
export let page
export let goToPrevPage
export let goToNextPage
export let hasPrevPage = true
export let hasNextPage = true
</script>
<nav class="spectrum-Pagination spectrum-Pagination--explicit">
<div
href="#"
class="spectrum-ActionButton spectrum-ActionButton--sizeM spectrum-ActionButton--quiet spectrum-Pagination-prevButton"
on:click={hasPrevPage ? goToPrevPage : null}
class:is-disabled={!hasPrevPage}
>
<svg
class="spectrum-Icon spectrum-UIIcon-ChevronLeft100"
focusable="false"
aria-hidden="true"
aria-label="ChevronLeft"
>
<use xlink:href="#spectrum-css-icon-Chevron100" />
</svg>
</div>
<span class="spectrum-Body--secondary spectrum-Pagination-counter">
Page {page}
</span>
<div
href="#"
class="spectrum-ActionButton spectrum-ActionButton--sizeM spectrum-ActionButton--quiet spectrum-Pagination-nextButton"
on:click={hasNextPage ? goToNextPage : null}
class:is-disabled={!hasNextPage}
>
<svg
class="spectrum-Icon spectrum-UIIcon-ChevronRight100"
focusable="false"
aria-hidden="true"
aria-label="ChevronLeft"
>
<use xlink:href="#spectrum-css-icon-Chevron100" />
</svg>
</div>
</nav>
<style>
.spectrum-Pagination-counter {
margin-left: 0;
user-select: none;
}
.is-disabled:hover {
cursor: initial;
}
</style>

View File

@ -42,7 +42,7 @@
<div
on:click
class:spectrum-ProgressBar--indeterminate={!value}
class:spectrum-ProgressCircle--indeterminate={!value}
class:spectrum-ProgressCircle--overBackground={overBackground}
class="spectrum-ProgressCircle spectrum-ProgressCircle--{convertSize(size)}"
>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -80,6 +80,7 @@ const createScreen = table => {
tableId: table._id,
type: "table",
},
paginate: false,
})
const spectrumTable = new Component("@budibase/standard-components/table")

View File

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

View File

@ -7,7 +7,7 @@
const inputChanged = ev => {
try {
values = ev.target.value.split("\n")
values = ev.detail.split("\n")
} catch (_) {
values = []
}

View File

@ -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 @@
<div class="control">
<Input
{label}
{disabled}
value={readableValue}
on:change={event => onChange(event.detail)}
{placeholder}
/>
<div class="icon" on:click={bindingDrawer.show}>
<Icon size="S" name="FlashOn" />
</div>
{#if !disabled}
<div class="icon" on:click={bindingDrawer.show}>
<Icon size="S" name="FlashOn" />
</div>
{/if}
</div>
<Drawer bind:this={bindingDrawer} {title}>
<svelte:fragment slot="description">

View File

@ -4,7 +4,6 @@
"table",
"repeater",
"button",
"search",
{
"name": "Form",
"icon": "Form",

View File

@ -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]))
}
})
</script>
<Select
{value}
placeholder={null}
on:change
options={providers}
getOptionLabel={component => component._instanceName}
getOptionValue={component => `{{ literal ${makePropSafe(component._id)} }}`}
getOptionValue={getValue}
/>

View File

@ -80,7 +80,7 @@
/>
{/each}
<div>
<Button icon="AddCircle" size="S" cta on:click={addField}>
<Button icon="AddCircle" secondary on:click={addField}>
Add
{fieldLabel}
</Button>

View File

@ -1,71 +0,0 @@
<script>
import { Button, Drawer, Body, DrawerContent, Layout } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { notifications } from "@budibase/bbui"
import {
getDatasourceForProvider,
getSchemaForDatasource,
} from "builderStore/dataBinding"
import SaveFields from "./EventsEditor/actions/SaveFields.svelte"
import { currentAsset } from "builderStore"
const dispatch = createEventDispatcher()
export let value = {}
export let componentInstance
let drawer
let tempValue = value
$: schemaFields = getSchemaFields(componentInstance)
const getSchemaFields = component => {
const datasource = getDatasourceForProvider($currentAsset, component)
const { schema } = getSchemaForDatasource(datasource)
return Object.values(schema || {})
}
const saveFilter = async () => {
dispatch("change", tempValue)
notifications.success("Filters saved.")
drawer.hide()
}
const onFieldsChanged = event => {
tempValue = event.detail
}
</script>
<Button secondary on:click={drawer.show}>Define Filters</Button>
<Drawer bind:this={drawer} title="Filtering">
<Button cta slot="buttons" on:click={saveFilter}>Save</Button>
<DrawerContent slot="body">
<Layout>
<Body size="S">
{#if !Object.keys(tempValue || {}).length}
Add your first filter column.
{:else}
Results are filtered to only those which match all of the following
constaints.
{/if}
</Body>
<div class="fields">
<SaveFields
parameterFields={value}
{schemaFields}
valueLabel="Equals"
on:change={onFieldsChanged}
/>
</div>
</Layout>
</DrawerContent>
</Drawer>
<style>
.fields {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
align-items: center;
grid-template-columns: auto 1fr auto 1fr auto;
}
</style>

View File

@ -0,0 +1,90 @@
<script>
import {
notifications,
Button,
Drawer,
Body,
DrawerContent,
Layout,
} from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import {
getDatasourceForProvider,
getSchemaForDatasource,
} from "builderStore/dataBinding"
import LuceneFilterBuilder from "./LuceneFilterBuilder.svelte"
import { currentAsset } from "builderStore"
import SaveFields from "../EventsEditor/actions/SaveFields.svelte"
const dispatch = createEventDispatcher()
export let value = []
export let componentInstance
let drawer
let tempValue = value
$: numFilters = Array.isArray(tempValue)
? tempValue.length
: Object.keys(tempValue || {}).length
$: dataSource = getDatasourceForProvider($currentAsset, componentInstance)
$: schema = getSchemaForDatasource(dataSource)?.schema
$: schemaFields = Object.values(schema || {})
$: internalTable = dataSource?.type === "table"
// Reset value if value is wrong type for the datasource.
// Lucene editor needs an array, and simple editor needs an object.
$: {
if (internalTable && !Array.isArray(value)) {
tempValue = []
dispatch("change", [])
} else if (!internalTable && Array.isArray(value)) {
tempValue = {}
dispatch("change", {})
}
}
const saveFilter = async () => {
dispatch("change", tempValue)
notifications.success("Filters saved.")
drawer.hide()
}
</script>
<Button secondary on:click={drawer.show}>Define Filters</Button>
<Drawer bind:this={drawer} title="Filtering">
<Button cta slot="buttons" on:click={saveFilter}>Save</Button>
<DrawerContent slot="body">
<Layout>
<Body size="S">
{#if !numFilters}
Add your first filter column.
{:else}
Results are filtered to only those which match all of the following
constaints.
{/if}
</Body>
{#if internalTable}
<LuceneFilterBuilder bind:value={tempValue} {schemaFields} />
{:else}
<div class="fields">
<SaveFields
parameterFields={Array.isArray(value) ? {} : value}
{schemaFields}
valueLabel="Equals"
on:change={e => (tempValue = e.detail)}
/>
</div>
{/if}
</Layout>
</DrawerContent>
</Drawer>
<style>
.fields {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
align-items: center;
grid-template-columns: auto 1fr auto 1fr auto;
}
</style>

View File

@ -0,0 +1,237 @@
<script>
import {
DatePicker,
ActionButton,
Button,
Select,
Combobox,
Input,
} from "@budibase/bbui"
import { store, currentAsset } from "builderStore"
import { getBindableProperties } from "builderStore/dataBinding"
import { createEventDispatcher } from "svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { generate } from "shortid"
const dispatch = createEventDispatcher()
export let schemaFields
export let value
const OperatorOptions = {
Equals: {
value: "equal",
label: "Equals",
},
NotEquals: {
value: "notEqual",
label: "Not equals",
},
Empty: {
value: "empty",
label: "Is empty",
},
NotEmpty: {
value: "notEmpty",
label: "Is not empty",
},
StartsWith: {
value: "string",
label: "Starts with",
},
Like: {
value: "fuzzy",
label: "Like",
},
MoreThan: {
value: "rangeLow",
label: "More than",
},
LessThan: {
value: "rangeHigh",
label: "Less than",
},
}
const BannedTypes = ["link", "attachment"]
$: bindableProperties = getBindableProperties(
$currentAsset,
$store.selectedComponentId
)
$: fieldOptions = (schemaFields ?? [])
.filter(field => !BannedTypes.includes(field.type))
.map(field => field.name)
const addField = () => {
value = [
...value,
{
id: generate(),
field: null,
operator: OperatorOptions.Equals.value,
value: null,
valueType: "Value",
},
]
}
const removeField = id => {
value = value.filter(field => field.id !== id)
}
const getValidOperatorsForType = type => {
const Op = OperatorOptions
if (type === "string") {
return [
Op.Equals,
Op.NotEquals,
Op.StartsWith,
Op.Like,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "number") {
return [
Op.Equals,
Op.NotEquals,
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "options") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "boolean") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "longform") {
return [
Op.Equals,
Op.NotEquals,
Op.StartsWith,
Op.Like,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "datetime") {
return [
Op.Equals,
Op.NotEquals,
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
}
return []
}
const onFieldChange = (expression, field) => {
// Update the field type
expression.type = schemaFields.find(x => x.name === field)?.type
// Ensure a valid operator is set
const validOperators = getValidOperatorsForType(expression.type)
if (!validOperators.includes(expression.operator)) {
expression.operator =
validOperators[0]?.value ?? OperatorOptions.Equals.value
onOperatorChange(expression, expression.operator)
}
}
const onOperatorChange = (expression, operator) => {
const noValueOptions = [
OperatorOptions.Empty.value,
OperatorOptions.NotEmpty.value,
]
expression.noValue = noValueOptions.includes(operator)
if (expression.noValue) {
expression.value = null
}
}
const getFieldOptions = field => {
const schema = schemaFields.find(x => x.name === field)
return schema?.constraints?.inclusion || []
}
</script>
{#if value?.length}
<div class="fields">
{#each value as expression, idx}
<Select
bind:value={expression.field}
options={fieldOptions}
on:change={e => onFieldChange(expression, e.detail)}
placeholder="Column"
/>
<Select
disabled={!expression.field}
options={getValidOperatorsForType(expression.type)}
bind:value={expression.operator}
on:change={e => onOperatorChange(expression, e.detail)}
placeholder={null}
/>
<Select
disabled={expression.noValue || !expression.field}
options={["Value", "Binding"]}
bind:value={expression.valueType}
placeholder={null}
/>
{#if expression.valueType === "Binding"}
<DrawerBindableInput
disabled={expression.noValue}
title={`Value for "${expression.field}"`}
value={expression.value}
placeholder="Value"
bindings={bindableProperties}
on:change={event => (expression.value = event.detail)}
/>
{:else if ["string", "longform", "number"].includes(expression.type)}
<Input disabled={expression.noValue} bind:value={expression.value} />
{:else if expression.type === "options"}
<Combobox
disabled={expression.noValue}
options={getFieldOptions(expression.field)}
bind:value={expression.value}
/>
{:else if expression.type === "boolean"}
<Combobox
disabled
options={[
{ label: "True", value: true },
{ label: "False", value: false },
]}
bind:value={expression.value}
/>
{:else if expression.type === "datetime"}
<DatePicker
disabled={expression.noValue}
bind:value={expression.value}
/>
{:else}
<DrawerBindableInput disabled />
{/if}
<ActionButton
size="S"
quiet
icon="Close"
on:click={() => removeField(expression.id)}
/>
{/each}
</div>
{/if}
<div>
<Button icon="AddCircle" size="M" secondary on:click={addField}>
Add expression
</Button>
</div>
<style>
.fields {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
align-items: center;
grid-template-columns: 1fr 120px 120px 1fr auto;
}
</style>

View File

@ -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}
<div class="empty">
This component doesn't have any additional settings.
<div class="text">This component doesn't have any additional settings.</div>
{/if}
{#if componentDefinition?.info}
<div class="text">
{@html componentDefinition?.info}
</div>
{/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);

View File

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

View File

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

View File

@ -39,8 +39,18 @@
</script>
{#if loaded && $screenStore.activeLayout}
<Provider key="user" data={$authStore} {actions}>
<Component definition={$screenStore.activeLayout.props} />
<NotificationDisplay />
</Provider>
<div lang="en" dir="ltr" class="spectrum spectrum--medium spectrum--light">
<Provider key="user" data={$authStore} {actions}>
<Component definition={$screenStore.activeLayout.props} />
<NotificationDisplay />
</Provider>
</div>
{/if}
<style>
div {
background: transparent;
height: 100%;
position: relative;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,13 @@
<script>
import { getContext } from "svelte"
import { ProgressCircle, Pagination } from "@budibase/bbui"
export let dataSource
export let filter
export let sortColumn
export let sortOrder
export let limit
export let paginate
const { API, styleable, Provider, ActionTypes } = getContext("sdk")
const component = getContext("component")
@ -15,15 +17,45 @@
// Loading flag for the initial load
let loaded = false
let schemaLoaded = false
// Provider state
let rows = []
let allRows = []
let schema = {}
let bookmarks = [null]
let pageNumber = 0
$: fetchData(dataSource)
$: filteredRows = filterRows(allRows, filter)
$: sortedRows = sortRows(filteredRows, sortColumn, sortOrder)
$: rows = limitRows(sortedRows, limit)
$: internalTable = dataSource?.type === "table"
$: query = internalTable ? buildLuceneQuery(filter) : null
$: hasNextPage = bookmarks[pageNumber + 1] != null
$: hasPrevPage = pageNumber > 0
$: getSchema(dataSource)
$: sortType = getSortType(schema, sortColumn)
$: {
// Wait until schema loads before loading data, so that we can determine
// the correct sort type first time
if (schemaLoaded) {
fetchData(
dataSource,
query,
limit,
sortColumn,
sortOrder,
sortType,
paginate
)
}
}
$: {
// Sort and limit rows in memory when we aren't searching internal tables
if (internalTable) {
rows = allRows
} else {
const sortedRows = sortRows(allRows, sortColumn, sortOrder)
rows = limitRows(sortedRows, limit)
}
}
$: actions = [
{
type: ActionTypes.RefreshDatasource,
@ -31,27 +63,86 @@
metadata: { dataSource },
},
]
$: dataContext = {
rows,
schema,
rowsLength: rows.length,
loading,
loaded,
$: dataContext = { rows, schema, rowsLength: rows.length }
const getSortType = (schema, sortColumn) => {
if (!schema || !sortColumn || !schema[sortColumn]) {
return "string"
}
const type = schema?.[sortColumn]?.type
return type === "number" ? "number" : "string"
}
const fetchData = async dataSource => {
const buildLuceneQuery = filter => {
let query = {
string: {},
fuzzy: {},
range: {},
equal: {},
notEqual: {},
empty: {},
notEmpty: {},
}
if (Array.isArray(filter)) {
filter.forEach(({ operator, field, type, value }) => {
if (operator.startsWith("range")) {
if (!query.range[field]) {
query.range[field] = {
low: type === "number" ? Number.MIN_SAFE_INTEGER : "0000",
high: type === "number" ? Number.MAX_SAFE_INTEGER : "9999",
}
}
if (operator === "rangeLow") {
query.range[field].low = value
} else if (operator === "rangeHigh") {
query.range[field].high = value
}
} else if (query[operator]) {
query[operator][field] = value
}
})
}
return query
}
const fetchData = async (
dataSource,
query,
limit,
sortColumn,
sortOrder,
sortType,
paginate
) => {
loading = true
allRows = await API.fetchDatasource(dataSource)
if (dataSource?.type === "table") {
const res = await API.searchTable({
tableId: dataSource.tableId,
query,
limit,
sort: sortColumn,
sortOrder: sortOrder?.toLowerCase() ?? "ascending",
sortType,
paginate,
})
pageNumber = 0
allRows = res.rows
if (res.hasNextPage) {
bookmarks = [null, res.bookmark]
} else {
bookmarks = [null]
}
} else {
const rows = await API.fetchDatasource(dataSource)
allRows = inMemoryFilterRows(rows, filter)
}
loading = false
loaded = true
}
const filterRows = (rows, filter) => {
if (!Object.keys(filter || {}).length) {
return rows
}
const inMemoryFilterRows = (rows, filter) => {
let filteredData = [...rows]
Object.entries(filter).forEach(([field, value]) => {
Object.entries(filter || {}).forEach(([field, value]) => {
if (value != null && value !== "") {
filteredData = filteredData.filter(row => {
return row[field] === value
@ -111,11 +202,91 @@
}
})
schema = fixedSchema
schemaLoaded = true
}
const nextPage = async () => {
if (!hasNextPage || !internalTable) {
return
}
const res = await API.searchTable({
tableId: dataSource?.tableId,
query,
bookmark: bookmarks[pageNumber + 1],
limit,
sort: sortColumn,
sortOrder: sortOrder?.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 res = await API.searchTable({
tableId: dataSource?.tableId,
query,
bookmark: bookmarks[pageNumber - 1],
limit,
sort: sortColumn,
sortOrder: sortOrder?.toLowerCase() ?? "ascending",
sortType,
paginate: true,
})
pageNumber--
allRows = res.rows
}
</script>
<div use:styleable={$component.styles}>
<div use:styleable={$component.styles} class="container">
<Provider {actions} data={dataContext}>
<slot />
{#if !loaded}
<div class="loading">
<ProgressCircle />
</div>
{:else}
<slot />
{#if paginate && internalTable}
<div class="pagination">
<Pagination
page={pageNumber + 1}
{hasPrevPage}
{hasNextPage}
goToPrevPage={prevPage}
goToNextPage={nextPage}
/>
</div>
{/if}
{/if}
</Provider>
</div>
<style>
.container {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
.loading {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
height: 100px;
}
.pagination {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
margin-top: var(--spacing-xl);
}
</style>

View File

@ -1,195 +0,0 @@
<script>
import { getContext } from "svelte"
import {
Button,
DatePicker,
Label,
Select,
Toggle,
Input,
} from "@budibase/bbui"
const { API, styleable, Provider, builderStore, ActionTypes } = getContext(
"sdk"
)
const component = getContext("component")
export let table
export let columns = []
export let pageSize
export let noRowsMessage
let rows = []
let loaded = false
let search = {}
let tableDefinition
let schema
let nextBookmark = null
let bookmark = null
let lastBookmark = null
$: fetchData(table, bookmark)
// omit empty strings
$: parsedSearch = Object.keys(search).reduce(
(acc, next) =>
search[next] === "" ? acc : { ...acc, [next]: search[next] },
{}
)
$: actions = [
{
type: ActionTypes.RefreshDatasource,
callback: () => fetchData(table, bookmark),
metadata: { datasource: { type: "table", tableId: table } },
},
]
async function fetchData(table, mark) {
if (table) {
const tableDef = await API.fetchTableDefinition(table)
schema = tableDef.schema
const output = await API.searchTableData({
tableId: table,
search: parsedSearch,
pagination: {
pageSize,
bookmark: mark,
},
})
rows = output.rows
nextBookmark = output.bookmark
}
loaded = true
}
function nextPage() {
lastBookmark = bookmark
bookmark = nextBookmark
}
function previousPage() {
nextBookmark = bookmark
if (lastBookmark !== bookmark) {
bookmark = lastBookmark
} else {
// special case for going back to beginning
bookmark = null
lastBookmark = null
}
}
</script>
<Provider {actions}>
<div use:styleable={$component.styles}>
<div class="query-builder">
{#if schema}
{#each columns as field}
<div class="form-field">
<Label extraSmall grey>{schema[field].name}</Label>
{#if schema[field].type === "options"}
<Select secondary bind:value={search[field]}>
<option value="">Choose an option</option>
{#each schema[field].constraints.inclusion as opt}
<option>{opt}</option>
{/each}
</Select>
{:else if schema[field].type === "datetime"}
<DatePicker bind:value={search[field]} />
{:else if schema[field].type === "boolean"}
<Toggle text={schema[field].name} bind:checked={search[field]} />
{:else if schema[field].type === "number"}
<Input type="number" bind:value={search[field]} />
{:else if schema[field].type === "string"}
<Input bind:value={search[field]} />
{/if}
</div>
{/each}
{/if}
<div class="actions">
<Button
secondary
on:click={() => {
search = {}
bookmark = null
}}
>
Reset
</Button>
<Button
primary
on:click={() => {
bookmark = null
fetchData(table, bookmark)
}}
>
Search
</Button>
</div>
</div>
{#if loaded}
{#if rows.length > 0}
{#if $component.children === 0 && $builderStore.inBuilder}
<p><i class="ri-image-line" />Add some components to display.</p>
{:else}
{#each rows as row}
<Provider data={row}>
<slot />
</Provider>
{/each}
{/if}
{:else if noRowsMessage}
<p><i class="ri-search-2-line" />{noRowsMessage}</p>
{/if}
{/if}
<div class="pagination">
{#if lastBookmark != null || bookmark != null}
<Button primary on:click={previousPage}>Back</Button>
{/if}
{#if nextBookmark != null && rows.length !== 0}
<Button primary on:click={nextPage}>Next</Button>
{/if}
</div>
</div>
</Provider>
<style>
p {
margin: 0 var(--spacing-m);
background-color: var(--grey-2);
color: var(--grey-6);
font-size: var(--font-size-s);
padding: var(--spacing-l);
border-radius: var(--border-radius-s);
display: grid;
place-items: center;
}
p i {
margin-bottom: var(--spacing-m);
font-size: 1.5rem;
color: var(--grey-5);
}
.query-builder {
padding: var(--spacing-m);
border-radius: var(--border-radius-s);
}
.actions {
display: grid;
grid-gap: var(--spacing-s);
justify-content: flex-end;
grid-auto-flow: column;
}
.form-field {
margin-bottom: var(--spacing-m);
}
.pagination {
display: grid;
grid-gap: var(--spacing-s);
justify-content: flex-end;
margin-top: var(--spacing-m);
grid-auto-flow: column;
}
</style>

View File

@ -10,9 +10,9 @@
{#if options}
<div use:chart={options} use:styleable={$component.styles} />
{:else if builderStore.inBuilder}
<div use:styleable={$component.styles}>
Use the settings panel to build your chart -->
{:else if $builderStore.inBuilder}
<div class="placeholder" use:styleable={$component.styles}>
Use the settings panel to build your chart.
</div>
{/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;
}
</style>

View File

@ -187,5 +187,6 @@
div {
padding: 20px;
position: relative;
background-color: var(--spectrum-alias-background-color-secondary);
}
</style>

View File

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

View File

@ -94,3 +94,9 @@
<slot />
</Table>
</div>
<style>
div {
background-color: var(--spectrum-alias-background-color-secondary);
}
</style>

View File

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

View File

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

View File

@ -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", () => {