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/link": "^3.1.1",
"@spectrum-css/menu": "^3.0.1", "@spectrum-css/menu": "^3.0.1",
"@spectrum-css/modal": "^3.0.1", "@spectrum-css/modal": "^3.0.1",
"@spectrum-css/pagination": "^3.0.3",
"@spectrum-css/picker": "^1.0.1", "@spectrum-css/picker": "^1.0.1",
"@spectrum-css/popover": "^3.0.1", "@spectrum-css/popover": "^3.0.1",
"@spectrum-css/progressbar": "^1.0.2", "@spectrum-css/progressbar": "^1.0.2",

View File

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

View File

@ -8,7 +8,7 @@
export let disabled = false export let disabled = false
export let labelPosition = "above" export let labelPosition = "above"
export let error = null export let error = null
export let placeholder = "Choose an option" export let placeholder = "Choose an option or type"
export let options = [] export let options = []
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
@ -26,7 +26,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {disabled} {error}> <Field {label} {labelPosition} {error}>
<Combobox <Combobox
{error} {error}
{disabled} {disabled}

View File

@ -7,7 +7,7 @@
export let value = null export let value = null
export let id = 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 disabled = false
export let error = null export let error = null
export let options = [] export let options = []
@ -22,7 +22,7 @@
const getFieldText = (value, options, placeholder) => { const getFieldText = (value, options, placeholder) => {
// Always use placeholder if no value // Always use placeholder if no value
if (value == null || 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 // Wait for options to load if there is a value but no options
@ -45,10 +45,16 @@
} }
</script> </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 <div
class="spectrum-Textfield spectrum-InputGroup-textfield" class="spectrum-Textfield spectrum-InputGroup-textfield"
class:is-disabled={!!error} class:is-invalid={!!error}
class:is-disabled={disabled}
class:is-focused={open || focus} class:is-focused={open || focus}
> >
<input <input
@ -57,6 +63,7 @@
on:blur={() => (focus = false)} on:blur={() => (focus = false)}
on:change={onChange} on:change={onChange}
{value} {value}
{disabled}
{placeholder} {placeholder}
class="spectrum-Textfield-input spectrum-InputGroup-input" class="spectrum-Textfield-input spectrum-InputGroup-input"
/> />
@ -65,7 +72,7 @@
class="spectrum-Picker spectrum-Picker--sizeM spectrum-InputGroup-button" class="spectrum-Picker spectrum-Picker--sizeM spectrum-InputGroup-button"
tabindex="-1" tabindex="-1"
aria-haspopup="true" aria-haspopup="true"
disabled={!!error} {disabled}
on:click={() => (open = true)} on:click={() => (open = true)}
> >
<svg <svg
@ -116,6 +123,9 @@
min-width: 0; min-width: 0;
width: 100%; width: 100%;
} }
.spectrum-Textfield {
width: 100%;
}
.spectrum-Textfield-input { .spectrum-Textfield-input {
width: 0; width: 0;
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,6 +17,6 @@
} }
</script> </script>
<Field {label} {labelPosition} {disabled} {error}> <Field {label} {labelPosition} {error}>
<Switch {error} {disabled} {text} {value} on:change={onChange} /> <Switch {error} {disabled} {text} {value} on:change={onChange} />
</Field> </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 <div
on:click on:click
class:spectrum-ProgressBar--indeterminate={!value} class:spectrum-ProgressCircle--indeterminate={!value}
class:spectrum-ProgressCircle--overBackground={overBackground} class:spectrum-ProgressCircle--overBackground={overBackground}
class="spectrum-ProgressCircle spectrum-ProgressCircle--{convertSize(size)}" class="spectrum-ProgressCircle spectrum-ProgressCircle--{convertSize(size)}"
> >

View File

@ -4,6 +4,16 @@
import CellRenderer from "./CellRenderer.svelte" import CellRenderer from "./CellRenderer.svelte"
import SelectEditRenderer from "./SelectEditRenderer.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 data = []
export let schema = {} export let schema = {}
export let showAutoColumns = false export let showAutoColumns = false
@ -463,10 +473,6 @@
tbody tr.hidden { tbody tr.hidden {
height: calc(var(--row-height) + 1px); height: calc(var(--row-height) + 1px);
} }
tbody tr.offset {
background-color: red;
display: block;
}
td { td {
padding-top: 0; padding-top: 0;
padding-bottom: 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 TreeItem } from "./TreeView/Item.svelte"
export { default as Divider } from "./Divider/Divider.svelte" export { default as Divider } from "./Divider/Divider.svelte"
export { default as Search } from "./Form/Search.svelte" export { default as Search } from "./Form/Search.svelte"
export { default as Pagination } from "./Pagination/Pagination.svelte"
// Typography // Typography
export { default as Body } from "./Typography/Body.svelte" 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" resolved "https://registry.yarnpkg.com/@spectrum-css/modal/-/modal-3.0.2.tgz#58b6621cab65f90788d310374f40df1f7090473f"
integrity sha512-YnIivJhoaao7Otu+HV7sgebPyFbO6sd/oMvTN/Rb2wwgnaMnIIuIRdGandSrcgotN2uNgs+P0knG6mv/xA1/dg== 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": "@spectrum-css/picker@^1.0.1":
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/@spectrum-css/picker/-/picker-1.0.2.tgz#b49429ae3c89f9c5f2c0530787ce45392c9612ff" 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, lightness: 0.7,
softness: 0.9, softness: 0.9,
seed: null, seed: null,
version: null,
} }
// Applies a gradient background // Applies a gradient background
@ -15,6 +16,7 @@ export const gradient = (node, config = {}) => {
} }
const { saturation, lightness, softness, points } = config const { saturation, lightness, softness, points } = config
const seed = config.seed || Math.random().toString(32).substring(2) 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 // Hash function which returns a fixed hash between specified limits
// for a given seed and a given version // 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:" css += "background-image:"
for (let i = 0; i < points - 1; i++) { for (let i = 0; i < points - 1; i++) {
css += `${randomGradientPoint(seed, i)},` css += `${randomGradientPoint(seed, version + i)},`
} }
css += `${randomGradientPoint(seed, points)};` css += `${randomGradientPoint(seed, points)};`
node.style = css node.style = css

View File

@ -90,10 +90,17 @@ const createScreen = table => {
tableId: table._id, tableId: table._id,
type: "table", type: "table",
}, },
filter: { filter: [
_id: `{{ ${makePropSafe("url")}.${makePropSafe("id")} }}`, {
field: "_id",
operator: "equal",
type: "string",
value: `{{ ${makePropSafe("url")}.${makePropSafe("id")} }}`,
valueType: "Binding",
}, },
],
limit: 1, limit: 1,
paginate: false,
}) })
const repeater = new Component("@budibase/standard-components/repeater") const repeater = new Component("@budibase/standard-components/repeater")

View File

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

View File

@ -40,13 +40,11 @@
if (wasSelectedTable._id === table._id) { if (wasSelectedTable._id === table._id) {
$goto("./table") $goto("./table")
} }
editorModal.hide()
} }
async function save() { async function save() {
await tables.save(table) await tables.save(table)
notifications.success("Table renamed successfully") notifications.success("Table renamed successfully")
editorModal.hide()
} }
function checkValid(evt) { function checkValid(evt) {

View File

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

View File

@ -13,10 +13,11 @@
export let title = "Bindings" export let title = "Bindings"
export let placeholder export let placeholder
export let label export let label
export let disabled = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let bindingDrawer let bindingDrawer
$: tempValue = value $: tempValue = Array.isArray(value) ? value : []
$: readableValue = runtimeToReadableBinding(bindings, value) $: readableValue = runtimeToReadableBinding(bindings, value)
const handleClose = () => { const handleClose = () => {
@ -32,13 +33,16 @@
<div class="control"> <div class="control">
<Input <Input
{label} {label}
{disabled}
value={readableValue} value={readableValue}
on:change={event => onChange(event.detail)} on:change={event => onChange(event.detail)}
{placeholder} {placeholder}
/> />
{#if !disabled}
<div class="icon" on:click={bindingDrawer.show}> <div class="icon" on:click={bindingDrawer.show}>
<Icon size="S" name="FlashOn" /> <Icon size="S" name="FlashOn" />
</div> </div>
{/if}
</div> </div>
<Drawer bind:this={bindingDrawer} {title}> <Drawer bind:this={bindingDrawer} {title}>
<svelte:fragment slot="description"> <svelte:fragment slot="description">

View File

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

View File

@ -3,20 +3,34 @@
import { makePropSafe } from "@budibase/string-templates" import { makePropSafe } from "@budibase/string-templates"
import { currentAsset, store } from "builderStore" import { currentAsset, store } from "builderStore"
import { findComponentPath } from "builderStore/storeUtils" import { findComponentPath } from "builderStore/storeUtils"
import { createEventDispatcher, onMount } from "svelte"
export let value export let value
export let onChange
const dispatch = createEventDispatcher()
const getValue = component => `{{ literal ${makePropSafe(component._id)} }}`
$: path = findComponentPath($currentAsset.props, $store.selectedComponentId) $: path = findComponentPath($currentAsset.props, $store.selectedComponentId)
$: providers = path.filter( $: providers = path.filter(
component => component =>
component._component === "@budibase/standard-components/dataprovider" 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> </script>
<Select <Select
{value} {value}
placeholder={null}
on:change on:change
options={providers} options={providers}
getOptionLabel={component => component._instanceName} getOptionLabel={component => component._instanceName}
getOptionValue={component => `{{ literal ${makePropSafe(component._id)} }}`} getOptionValue={getValue}
/> />

View File

@ -80,7 +80,7 @@
/> />
{/each} {/each}
<div> <div>
<Button icon="AddCircle" size="S" cta on:click={addField}> <Button icon="AddCircle" secondary on:click={addField}>
Add Add
{fieldLabel} {fieldLabel}
</Button> </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 MultiFieldSelect from "./PropertyControls/MultiFieldSelect.svelte"
import SchemaSelect from "./PropertyControls/SchemaSelect.svelte" import SchemaSelect from "./PropertyControls/SchemaSelect.svelte"
import EventsEditor from "./PropertyControls/EventsEditor" import EventsEditor from "./PropertyControls/EventsEditor"
import FilterEditor from "./PropertyControls/FilterEditor.svelte" import FilterEditor from "./PropertyControls/FilterEditor/FilterEditor.svelte"
import { IconSelect } from "./PropertyControls/IconSelect" import { IconSelect } from "./PropertyControls/IconSelect"
import ColorPicker from "./PropertyControls/ColorPicker.svelte" import ColorPicker from "./PropertyControls/ColorPicker.svelte"
import StringFieldSelect from "./PropertyControls/StringFieldSelect.svelte" import StringFieldSelect from "./PropertyControls/StringFieldSelect.svelte"
@ -156,8 +156,11 @@
{/if} {/if}
{/each} {/each}
{:else} {:else}
<div class="empty"> <div class="text">This component doesn't have any additional settings.</div>
This component doesn't have any additional settings. {/if}
{#if componentDefinition?.info}
<div class="text">
{@html componentDefinition?.info}
</div> </div>
{/if} {/if}
@ -185,7 +188,7 @@
height: 100%; height: 100%;
gap: var(--spacing-s); gap: var(--spacing-s);
} }
.empty { .text {
font-size: var(--spectrum-global-dimension-font-size-75); font-size: var(--spectrum-global-dimension-font-size-75);
margin-top: var(--spacing-m); margin-top: var(--spacing-m);
color: var(--grey-6); color: var(--grey-6);

View File

@ -6,10 +6,6 @@ html, body {
min-height: 100%; min-height: 100%;
} }
.spectrum--light {
--spectrum-alias-background-color-primary: var(--spectrum-global-color-gray-75);
}
body { body {
--background: var(--spectrum-alias-background-color-primary); --background: var(--spectrum-alias-background-color-primary);
--background-alt: var(--spectrum-alias-background-color-secondary); --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 * Searches a table using Lucene.
* @param {String} tableId - id of the table to search
* @param {Object} search - Mango Compliant search object
* @param {Object} pagination - the pagination controls
*/ */
export const searchTableData = async ({ tableId, search, pagination }) => { export const searchTable = async ({
const output = await API.post({ tableId,
url: `/api/${tableId}/rows/search`, query,
bookmark,
limit,
sort,
sortOrder,
sortType,
paginate,
}) => {
if (!tableId || !query) {
return {
rows: [],
}
}
const res = await API.post({
url: `/api/search/${tableId}/rows`,
body: { body: {
query: search, query,
pagination, bookmark,
limit,
sort,
sortOrder,
sortType,
paginate,
}, },
}) })
output.rows = await enrichRows(output.rows, tableId) return {
return output ...res,
rows: await enrichRows(res?.rows, tableId),
}
} }

View File

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

View File

@ -16,7 +16,6 @@ const {
const { FieldTypes } = require("../../constants") const { FieldTypes } = require("../../constants")
const { isEqual } = require("lodash") const { isEqual } = require("lodash")
const { cloneDeep } = require("lodash/fp") const { cloneDeep } = require("lodash/fp")
const { QueryBuilder, search } = require("./search/utils")
const TABLE_VIEW_BEGINS_WITH = `all${SEPARATOR}${DocumentTypes.TABLE}${SEPARATOR}` 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) { exports.fetchTableRows = async function (ctx) {
const appId = ctx.appId const appId = ctx.appId
const db = new CouchDB(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 => { exports.rowSearch = async ctx => {
const appId = ctx.appId const appId = ctx.appId
const { tableId } = ctx.params const { tableId } = ctx.params
const { bookmark, query, raw } = ctx.request.body const db = new CouchDB(appId)
let url const { paginate, query, ...params } = ctx.request.body
if (query) { params.tableId = tableId
url = new QueryBuilder(appId, query, bookmark).addTable(tableId).complete()
} else if (raw) { let response
url = buildSearchUrl({ if (paginate) {
appId, response = await paginatedSearch(appId, query, params)
query: raw, } else {
bookmark, 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") 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. * Escapes any characters in a string which lucene searches require to be
* @param {string} appId The ID of the app which we will be searching within. * escaped.
* @param {string} query The lucene query string which is to be used for searching. * @param value The value to escape
* @param {string|null} bookmark If there were more than the limit specified can send the bookmark that was * @returns {string}
* 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.
*/ */
function buildSearchUrl({ appId, query, bookmark, excludeDocs, limit = 50 }) { const luceneEscape = value => {
let url = `${env.COUCH_DB_URL}/${appId}/_design/database/_search` return `${value}`.replace(/[ #+\-&|!(){}\[\]^"~*?:\\]/g, "\\$&")
url += `/${SearchIndexes.ROWS}?q=${query}`
url += `&limit=${limit}`
if (!excludeDocs) {
url += "&include_docs=true"
}
if (bookmark) {
url += `&bookmark=${bookmark}`
}
return checkSlashesInUrl(url)
} }
/**
* Class to build lucene query URLs.
* Optionally takes a base lucene query object.
*/
class QueryBuilder { class QueryBuilder {
constructor(appId, base) { constructor(appId, base) {
this.appId = appId this.appId = appId
@ -34,10 +25,20 @@ class QueryBuilder {
fuzzy: {}, fuzzy: {},
range: {}, range: {},
equal: {}, equal: {},
notEqual: {},
empty: {},
notEmpty: {},
...base, ...base,
} }
this.limit = 50 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) { setLimit(limit) {
@ -45,11 +46,31 @@ class QueryBuilder {
return this 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) { setBookmark(bookmark) {
this.bookmark = bookmark this.bookmark = bookmark
return this return this
} }
excludeDocs() {
this.includeDocs = false
return this
}
addString(key, partial) { addString(key, partial) {
this.query.string[key] = partial this.query.string[key] = partial
return this return this
@ -73,52 +94,113 @@ class QueryBuilder {
return this return this
} }
addTable(tableId) { addNotEqual(key, value) {
this.query.equal.tableId = tableId this.query.notEqual[key] = value
return this return this
} }
complete(rawQuery = null) { addEmpty(key, value) {
let output = "" this.query.empty[key] = value
return this
}
addNotEmpty(key, value) {
this.query.notEmpty[key] = value
return this
}
buildSearchQuery() {
let query = "*:*"
function build(structure, queryFn) { function build(structure, queryFn) {
for (let [key, value] of Object.entries(structure)) { for (let [key, value] of Object.entries(structure)) {
if (output.length !== 0) { const expression = queryFn(luceneEscape(key.replace(/ /, "_")), value)
output += " AND " 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) { 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}]`
)
}
if (this.query.fuzzy) {
build(this.query.fuzzy, (key, value) => `${key}:${value}~`)
}
if (this.query.equal) {
build(this.query.equal, (key, value) => `${key}:${value}`)
}
if (rawQuery) {
output = output.length === 0 ? rawQuery : `&${rawQuery}`
}
return buildSearchUrl({
appId: this.appId,
query: output,
bookmark: this.bookmark,
limit: this.limit,
}) })
} }
if (this.query.range) {
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) => {
return value ? `${key}:${luceneEscape(value.toLowerCase())}~` : null
})
}
if (this.query.equal) {
build(this.query.equal, (key, value) => {
return value ? `${key}:${luceneEscape(value.toLowerCase())}` : null
})
}
if (this.query.notEqual) {
build(this.query.notEqual, (key, value) => {
return value ? `!${key}:${luceneEscape(value.toLowerCase())}` : null
})
}
if (this.query.empty) {
build(this.query.empty, key => `!${key}:["" TO *]`)
}
if (this.query.notEmpty) {
build(this.query.notEmpty, key => `${key}:["" TO *]`)
} }
exports.search = async query => { return query
const response = await fetch(query, { }
method: "GET",
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)
}
}
/**
* 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() const json = await response.json()
let output = { let output = {
@ -133,5 +215,122 @@ exports.search = async query => {
return output 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 hostingRoutes = require("./hosting")
const backupRoutes = require("./backup") const backupRoutes = require("./backup")
const devRoutes = require("./dev") const devRoutes = require("./dev")
const searchRoutes = require("./search")
exports.mainRoutes = [ exports.mainRoutes = [
authRoutes, authRoutes,
@ -51,6 +52,7 @@ exports.mainRoutes = [
// this could be breaking as koa may recognise other routes as this // this could be breaking as koa may recognise other routes as this
tableRoutes, tableRoutes,
rowRoutes, rowRoutes,
searchRoutes,
] ]
exports.staticRoutes = staticRoutes exports.staticRoutes = staticRoutes

View File

@ -39,12 +39,6 @@ router
usage, usage,
rowController.save rowController.save
) )
.post(
"/api/:tableId/rows/search",
paramResource("tableId"),
authorized(PermissionTypes.TABLE, PermissionLevels.READ),
rowController.search
)
.patch( .patch(
"/api/:tableId/rows/:rowId", "/api/:tableId/rows/:rowId",
paramSubResource("tableId", "rowId"), paramSubResource("tableId", "rowId"),

View File

@ -84,6 +84,7 @@ async function searchIndex(appId, indexName, fnString) {
designDoc.indexes = { designDoc.indexes = {
[indexName]: { [indexName]: {
index: fnString, index: fnString,
analyzer: "keyword",
}, },
} }
await db.put(designDoc) await db.put(designDoc)
@ -96,11 +97,15 @@ exports.createAllSearchIndex = async appId => {
function (doc) { function (doc) {
function idx(input, prev) { function idx(input, prev) {
for (let key of Object.keys(input)) { for (let key of Object.keys(input)) {
const idxKey = prev != null ? `${prev}.${key}` : key let idxKey = prev != null ? `${prev}.${key}` : key
if (key === "_id" || key === "_rev") { idxKey = idxKey.replace(/ /, "_")
if (key === "_id" || key === "_rev" || input[key] == null) {
continue 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 // eslint-disable-next-line no-undef
index(idxKey, input[key], { store: true }) index(idxKey, input[key], { store: true })
} else { } else {

View File

@ -123,24 +123,6 @@ function processAutoColumn(user, table, row) {
return { 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. * Looks through the rows provided and finds formulas - which it then processes.
*/ */
@ -213,8 +195,6 @@ exports.outputProcessing = async (appId, table, rows) => {
rows = [rows] rows = [rows]
wasArray = false wasArray = false
} }
// sort by auto ID
rows = sortRows(table, rows)
// attach any linked row information // attach any linked row information
let enriched = await linkRows.attachFullLinkedDocs(appId, table, rows) let enriched = await linkRows.attachFullLinkedDocs(appId, table, rows)

View File

@ -65,41 +65,6 @@
"type": "schema" "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": { "stackedlist": {
"name": "Stacked List", "name": "Stacked List",
"icon": "TaskList", "icon": "TaskList",
@ -1416,6 +1381,7 @@
}, },
"dataprovider": { "dataprovider": {
"name": "Data Provider", "name": "Data Provider",
"info": "Pagination is only available for data stored in internal tables.",
"icon": "Data", "icon": "Data",
"styleable": false, "styleable": false,
"hasChildren": true, "hasChildren": true,
@ -1445,7 +1411,14 @@
{ {
"type": "number", "type": "number",
"label": "Limit", "label": "Limit",
"key": "limit" "key": "limit",
"defaultValue": 50
},
{
"type": "boolean",
"label": "Paginate",
"key": "paginate",
"defaultValue": true
} }
], ],
"context": { "context": {
@ -1464,12 +1437,8 @@
"key": "schema" "key": "schema"
}, },
{ {
"label": "Loading", "label": "Page Number",
"key": "loading" "key": "pageNumber"
},
{
"label": "Loaded",
"key": "loaded"
} }
] ]
} }

View File

@ -1,11 +1,13 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { ProgressCircle, Pagination } from "@budibase/bbui"
export let dataSource export let dataSource
export let filter export let filter
export let sortColumn export let sortColumn
export let sortOrder export let sortOrder
export let limit export let limit
export let paginate
const { API, styleable, Provider, ActionTypes } = getContext("sdk") const { API, styleable, Provider, ActionTypes } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
@ -15,15 +17,45 @@
// Loading flag for the initial load // Loading flag for the initial load
let loaded = false let loaded = false
let schemaLoaded = false
// Provider state
let rows = []
let allRows = [] let allRows = []
let schema = {} let schema = {}
let bookmarks = [null]
let pageNumber = 0
$: fetchData(dataSource) $: internalTable = dataSource?.type === "table"
$: filteredRows = filterRows(allRows, filter) $: query = internalTable ? buildLuceneQuery(filter) : null
$: sortedRows = sortRows(filteredRows, sortColumn, sortOrder) $: hasNextPage = bookmarks[pageNumber + 1] != null
$: rows = limitRows(sortedRows, limit) $: hasPrevPage = pageNumber > 0
$: getSchema(dataSource) $: 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 = [ $: actions = [
{ {
type: ActionTypes.RefreshDatasource, type: ActionTypes.RefreshDatasource,
@ -31,27 +63,86 @@
metadata: { dataSource }, metadata: { dataSource },
}, },
] ]
$: dataContext = { $: dataContext = { rows, schema, rowsLength: rows.length }
rows,
schema, const getSortType = (schema, sortColumn) => {
rowsLength: rows.length, if (!schema || !sortColumn || !schema[sortColumn]) {
loading, return "string"
loaded, }
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 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 loading = false
loaded = true loaded = true
} }
const filterRows = (rows, filter) => { const inMemoryFilterRows = (rows, filter) => {
if (!Object.keys(filter || {}).length) {
return rows
}
let filteredData = [...rows] let filteredData = [...rows]
Object.entries(filter).forEach(([field, value]) => { Object.entries(filter || {}).forEach(([field, value]) => {
if (value != null && value !== "") { if (value != null && value !== "") {
filteredData = filteredData.filter(row => { filteredData = filteredData.filter(row => {
return row[field] === value return row[field] === value
@ -111,11 +202,91 @@
} }
}) })
schema = fixedSchema 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> </script>
<div use:styleable={$component.styles}> <div use:styleable={$component.styles} class="container">
<Provider {actions} data={dataContext}> <Provider {actions} data={dataContext}>
{#if !loaded}
<div class="loading">
<ProgressCircle />
</div>
{:else}
<slot /> <slot />
{#if paginate && internalTable}
<div class="pagination">
<Pagination
page={pageNumber + 1}
{hasPrevPage}
{hasNextPage}
goToPrevPage={prevPage}
goToNextPage={nextPage}
/>
</div>
{/if}
{/if}
</Provider> </Provider>
</div> </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} {#if options}
<div use:chart={options} use:styleable={$component.styles} /> <div use:chart={options} use:styleable={$component.styles} />
{:else if builderStore.inBuilder} {:else if $builderStore.inBuilder}
<div use:styleable={$component.styles}> <div class="placeholder" use:styleable={$component.styles}>
Use the settings panel to build your chart --> Use the settings panel to build your chart.
</div> </div>
{/if} {/if}
@ -21,4 +21,10 @@
display: flex !important; display: flex !important;
text-transform: capitalize; text-transform: capitalize;
} }
div :global(.apexcharts-yaxis-label, .apexcharts-xaxis-label) {
fill: #aaa;
}
div.placeholder {
padding: 10px;
}
</style> </style>

View File

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

View File

@ -27,7 +27,6 @@ export { default as embed } from "./Embed.svelte"
export { default as cardhorizontal } from "./CardHorizontal.svelte" export { default as cardhorizontal } from "./CardHorizontal.svelte"
export { default as cardstat } from "./CardStat.svelte" export { default as cardstat } from "./CardStat.svelte"
export { default as icon } from "./Icon.svelte" export { default as icon } from "./Icon.svelte"
export { default as search } from "./Search.svelte"
export { default as backgroundimage } from "./BackgroundImage.svelte" export { default as backgroundimage } from "./BackgroundImage.svelte"
export * from "./charts" export * from "./charts"
export * from "./forms" export * from "./forms"

View File

@ -94,3 +94,9 @@
<slot /> <slot />
</Table> </Table>
</div> </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 { cloneDeep } = require("lodash/fp")
const { const {
removeNull, removeNull,
addConstants, updateContext,
removeHandlebarsStatements, removeHandlebarsStatements,
} = require("./utilities") } = require("./utilities")
const manifest = require("../manifest.json") const manifest = require("../manifest.json")
@ -92,8 +92,7 @@ module.exports.processStringSync = (string, context) => {
} }
// take a copy of input incase error // take a copy of input incase error
const input = string const input = string
let clonedContext = removeNull(cloneDeep(context)) const clonedContext = removeNull(updateContext(cloneDeep(context)))
clonedContext = addConstants(clonedContext)
// remove any null/undefined properties // remove any null/undefined properties
if (typeof string !== "string") { if (typeof string !== "string") {
throw "Cannot process non-string types." throw "Cannot process non-string types."

View File

@ -23,12 +23,25 @@ module.exports.removeNull = obj => {
return obj return obj
} }
module.exports.addConstants = obj => { module.exports.updateContext = obj => {
if (obj.now == null) { if (obj.now == null) {
obj.now = new Date() obj.now = new Date().toISOString()
}
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 obj
} }
return recurse(obj)
}
module.exports.removeHandlebarsStatements = string => { module.exports.removeHandlebarsStatements = string => {
let regexp = new RegExp(exports.FIND_HBS_REGEX) let regexp = new RegExp(exports.FIND_HBS_REGEX)

View File

@ -107,6 +107,12 @@ describe("check the utility functions", () => {
const property = makePropSafe("thing") const property = makePropSafe("thing")
expect(property).toEqual("[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", () => { describe("check manifest", () => {