Merge branch 'next' of github.com:Budibase/budibase into feature/forgot-password
This commit is contained in:
commit
608c798cb9
|
@ -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使用GPLv3,Client使用MPL。这会让您充满信心,Budibase将永远存在。您还可以针对Budibase进行修改或对其进行分叉,并根据需要进行更改,以提供开发人员友好的体验。
|
||||
- **连接数据库或直接开始** Budibase从多个来源(包括MongoDB,CouchDB,PostgreSQL,mySQL,Airtable,Google Sheets,S3,DyanmoDB或REST API)提取数据。与其他平台不同,使用Budibase可以从头开始,创建没有数据源的业务应用程序。[新的数据源需求](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas)。
|
||||
- **使用强大的预制组件设计和构建应用程序** Budibase开箱即用,具有精美设计,功能强大的组件,您可以使用它们像构建基块来构建UI。我们还提供了许多您最喜欢的CSS样式选项,因此您可以发挥更多的创意。[新的组件需求](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas)。
|
||||
- **自动化流程,与其他工具集成,并连接到Webhooks** 通过自动化手动流程和工作流来节省时间。从连接到Webhook,到自动发送电子邮件,只需告诉Budibase要做些什么,然后让它为您工作。您可以[在此处](https://github.com/Budibase/automations)轻松地[为Budibase创建新的自动化,](https://github.com/Budibase/automations)或[在此处](https://github.com/Budibase/automations)[新的集成需求](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas)。
|
||||
- **云托管和自我托管** 用户可以自行托管(请参见下文),或使用Budibase托管其应用。目前,我们的云托管产品仅限于免费套餐,但我们会在将来进行更改。对于大量使用,我们建议用户进行自我托管。
|
||||
|
||||
<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构建器在Mac,PC和Linux上的Electron中运行。请按照以下步骤开始:
|
||||
|
||||
- [ ] [注册Budibase](https://portal.budi.live/signup)
|
||||
- [ ] 创建用户名和密码
|
||||
- [ ] 复制您的API密钥
|
||||
- [ ] 下载Budibase
|
||||
- [ ] 打开Budibase并输入您的API密钥
|
||||
|
||||
如果您需要其他帮助,请[参阅以下指导教程](https://docs.budibase.com/tutorial/tutorial-signing-up)。
|
||||
|
||||
|
||||
## 🤖 自托管
|
||||
|
||||
Budibase希望确保任何人都可以使用我们开发的工具,并且我们知道很多人需要能够在自己的系统上托管他们制作的应用程序——这就是为什么我们决定尝试使自托管服务变得如此简单的原因!
|
||||
|
||||
当前,您可以使用Docker或Digital Ocean托管您的应用程序。可以在[此处](https://docs.budibase.com/self-hosting/introduction-to-self-hosting)找到有关自我托管的文档。
|
||||
|
||||
[![Deploy to DO](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/droplets/new?onboarding_origin=marketplace&i=09038e&fleetUuid=bb04f9c8-1de8-4687-b2ae-1d5177a0535b&appId=77729671&type=applications&size=s-4vcpu-8gb®ion=nyc1&refcode=0caaa6085a82&image=budibase-20-04)
|
||||
|
||||
|
||||
## 🎓 学习Budibase
|
||||
|
||||
Budibase[文档](https://docs.budibase.com/)位于[此处](https://docs.budibase.com/)。
|
||||
|
||||
您还可以按照有关[如何使用Budibase构建CRM](https://docs.budibase.com/tutorial/tutorial-introduction)的快速教程进行[操作](https://docs.budibase.com/tutorial/tutorial-introduction)
|
||||
|
||||
|
||||
## 路线图
|
||||
查看我们的[公共路线图](https://github.com/Budibase/budibase/projects/10)。如果您想讨论路线图上的某些项目,请与[Discord](https://discord.gg/rCYayfe)或通过Github进行[讨论](https://github.com/Budibase/budibase/discussions)
|
||||
|
||||
|
||||
## ❗ 行为守则
|
||||
|
||||
Budibase致力于为每个人提供热情,多样且无烦恼的体验。我们希望Budibase社区中的每个人都遵守我们的[**行为准则**](https://github.com/Budibase/budibase/blob/HEAD/.github/CODE_OF_CONDUCT.md)。
|
||||
|
||||
## 🙌 为Budibase贡献
|
||||
|
||||
从错误报告到PR请求:每一个贡献都将受到赞赏和欢迎。如果您打算实施一项新功能或更改API,请先创建一个Issue。这样我们可以确保您的工作没有白费。
|
||||
|
||||
### 不知道从哪里开始?
|
||||
|
||||
[第一次提出Issue](https://github.com/Budibase/budibase/projects/22)是一个开始做出贡献的好地方。
|
||||
|
||||
### 存储库的组织方式
|
||||
|
||||
Budibase是由lerna管理的项目。Lerna管理budibase软件包的构建和发布。在较高的层次上,这里是构成Budibase的软件包。
|
||||
|
||||
- [packages/builder](https://github.com/Budibase/budibase/tree/HEAD/packages/builder)-包含budibase构建器客户端苗条应用程序的代码。
|
||||
- [packages/client](https://github.com/Budibase/budibase/tree/HEAD/packages/client)-在浏览器中运行的模块,负责读取JSON定义并从中创建生动的Web应用程序。
|
||||
- [packages/server](https://github.com/Budibase/budibase/tree/HEAD/packages/server)-budibase服务器。该Koa应用程序负责为构建器和budibase应用程序提供JS服务,并提供与数据库和文件系统交互的API。
|
||||
|
||||
有关更多信息,请参见[CONTRIBUTING.md](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md)
|
||||
|
||||
## 📝 开源协议
|
||||
|
||||
Budibase是开源的。该构建器的许可证为[AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html),服务器的许可证为[GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html),客户端的许可证为[MPL](https://directory.fsf.org/wiki/License:MPL-2.0)。
|
||||
|
||||
## 💬 保持联系
|
||||
|
||||
如果您有任何疑问或想与其他Budibase用户交谈,请跳至[Github讨论](https://github.com/Budibase/budibase/discussions)或加入我们的Discord服务器:
|
||||
|
||||
[Discord 聊天室](https://discord.gg/rCYayfe)
|
||||
|
||||
![Discord Shield](https://discordapp.com/api/guilds/733030666647765003/widget.png?style=shield)
|
||||
|
||||
|
||||
## 贡献者 ✨
|
||||
|
||||
感谢这些出色的人 ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
|
||||
|
||||
<!-- 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)规范。欢迎任何形式的捐助!
|
|
@ -166,7 +166,9 @@ exports.getAllApps = async (devApps = false) => {
|
|||
const appDbNames = allDbs.filter(dbName =>
|
||||
dbName.startsWith(exports.APP_PREFIX)
|
||||
)
|
||||
const appPromises = appDbNames.map(db => new CouchDB(db).get(DocumentTypes.APP_METADATA))
|
||||
const appPromises = appDbNames.map(db =>
|
||||
new CouchDB(db).get(DocumentTypes.APP_METADATA)
|
||||
)
|
||||
if (appPromises.length === 0) {
|
||||
return []
|
||||
} else {
|
||||
|
|
|
@ -143,7 +143,6 @@ class RedisWrapper {
|
|||
}
|
||||
|
||||
async clear() {
|
||||
const db = this._db
|
||||
let items = await this.scan()
|
||||
await Promise.all(items.map(obj => this.delete(obj.key)))
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -16,8 +16,11 @@
|
|||
|
||||
function getInitials(name) {
|
||||
let parts = name.split(" ")
|
||||
if (parts.length > 0) {
|
||||
return parts.map(name => name[0]).join("")
|
||||
}
|
||||
return name
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if url}
|
||||
|
|
|
@ -17,6 +17,6 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<Field {label} {labelPosition} {disabled} {error}>
|
||||
<Field {label} {labelPosition} {error}>
|
||||
<Checkbox {error} {disabled} {text} {value} on:change={onChange} />
|
||||
</Field>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<Field {label} {labelPosition} {disabled} {error}>
|
||||
<Field {label} {labelPosition} {error}>
|
||||
<DatePicker
|
||||
{error}
|
||||
{disabled}
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<Field {label} {labelPosition} {disabled} {error}>
|
||||
<Field {label} {labelPosition} {error}>
|
||||
<CoreDropzone
|
||||
{error}
|
||||
{disabled}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<Field {label} {labelPosition} {disabled} {error}>
|
||||
<Field {label} {labelPosition} {error}>
|
||||
<TextField
|
||||
{error}
|
||||
{disabled}
|
||||
|
|
|
@ -21,7 +21,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<Field {label} {labelPosition} {disabled} {error}>
|
||||
<Field {label} {labelPosition} {error}>
|
||||
<Multiselect
|
||||
{error}
|
||||
{disabled}
|
||||
|
|
|
@ -25,7 +25,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<Field {label} {labelPosition} {disabled} {error}>
|
||||
<Field {label} {labelPosition} {error}>
|
||||
<RadioGroup
|
||||
{error}
|
||||
{disabled}
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<Field {label} {labelPosition} {disabled}>
|
||||
<Field {label} {labelPosition}>
|
||||
<Search
|
||||
{disabled}
|
||||
{value}
|
||||
|
|
|
@ -28,7 +28,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<Field {label} {labelPosition} {disabled} {error}>
|
||||
<Field {label} {labelPosition} {error}>
|
||||
<Select
|
||||
{quiet}
|
||||
{error}
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<Field {label} {labelPosition} {disabled} {error}>
|
||||
<Field {label} {labelPosition} {error}>
|
||||
<TextArea
|
||||
bind:getCaretPosition
|
||||
{error}
|
||||
|
|
|
@ -17,6 +17,6 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
<Field {label} {labelPosition} {disabled} {error}>
|
||||
<Field {label} {labelPosition} {error}>
|
||||
<Switch {error} {disabled} {text} {value} on:change={onChange} />
|
||||
</Field>
|
||||
|
|
|
@ -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>
|
|
@ -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)}"
|
||||
>
|
||||
|
|
|
@ -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
|
||||
|
@ -272,6 +282,7 @@
|
|||
{#if sortedRows?.length && fields.length}
|
||||
{#each sortedRows as row, idx}
|
||||
<tr
|
||||
on:click={() => dispatch("click", row)}
|
||||
on:click={() => toggleSelectRow(row)}
|
||||
class="spectrum-Table-row"
|
||||
class:hidden={idx < firstVisibleRow || idx > lastVisibleRow}
|
||||
|
@ -462,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;
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<script>
|
||||
export let selected = false
|
||||
export let open = false
|
||||
export let href = false
|
||||
export let title
|
||||
export let icon
|
||||
</script>
|
||||
|
@ -10,7 +11,7 @@
|
|||
class:is-open={open}
|
||||
class="spectrum-TreeView-item"
|
||||
>
|
||||
<a on:click class="spectrum-TreeView-itemLink" href="#">
|
||||
<a on:click class="spectrum-TreeView-itemLink" {href}>
|
||||
{#if $$slots.default}
|
||||
<svg
|
||||
class="spectrum-Icon spectrum-UIIcon-ChevronRight100 spectrum-TreeView-itemIndicator"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -4,16 +4,17 @@
|
|||
import { routes } from "../.routify/routes"
|
||||
import { initialise } from "builderStore"
|
||||
import { NotificationDisplay } from "@budibase/bbui"
|
||||
import { parse, stringify } from "qs"
|
||||
|
||||
onMount(async () => {
|
||||
await initialise()
|
||||
})
|
||||
|
||||
const config = {}
|
||||
const queryHandler = { parse, stringify }
|
||||
</script>
|
||||
|
||||
<NotificationDisplay />
|
||||
<Router {routes} {config} />
|
||||
<Router {routes} config={{ queryHandler }} />
|
||||
<div class="modal-container" />
|
||||
|
||||
<style>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -80,6 +80,7 @@ const createScreen = table => {
|
|||
tableId: table._id,
|
||||
type: "table",
|
||||
},
|
||||
paginate: false,
|
||||
})
|
||||
|
||||
const spectrumTable = new Component("@budibase/standard-components/table")
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
|
||||
const inputChanged = ev => {
|
||||
try {
|
||||
values = ev.target.value.split("\n")
|
||||
values = ev.detail.split("\n")
|
||||
} catch (_) {
|
||||
values = []
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
{#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">
|
||||
|
|
|
@ -14,7 +14,6 @@
|
|||
|
||||
const POLL_INTERVAL = 1000
|
||||
|
||||
|
||||
let loading = false
|
||||
let feedbackModal
|
||||
let deployments = []
|
||||
|
@ -62,8 +61,12 @@
|
|||
|
||||
// Required to check any updated deployment statuses between polls
|
||||
function checkIncomingDeploymentStatus(current, incoming) {
|
||||
console.log(current, incoming)
|
||||
for (let incomingDeployment of incoming) {
|
||||
if (incomingDeployment.status === DeploymentStatus.FAILURE || incomingDeployment.status === DeploymentStatus.SUCCESS) {
|
||||
if (
|
||||
incomingDeployment.status === DeploymentStatus.FAILURE ||
|
||||
incomingDeployment.status === DeploymentStatus.SUCCESS
|
||||
) {
|
||||
const currentDeployment = current.find(
|
||||
deployment => deployment._id === incomingDeployment._id
|
||||
)
|
||||
|
@ -76,14 +79,17 @@
|
|||
if (incomingDeployment.status === DeploymentStatus.FAILURE) {
|
||||
notifications.error(incomingDeployment.err)
|
||||
} else {
|
||||
notifications.send("Published to Production.", "success", "CheckmarkCircle")
|
||||
notifications.send(
|
||||
"Published to Production.",
|
||||
"success",
|
||||
"CheckmarkCircle"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
onMount(() => {
|
||||
fetchDeployments()
|
||||
poll = setInterval(fetchDeployments, POLL_INTERVAL)
|
||||
|
@ -92,19 +98,16 @@
|
|||
onDestroy(() => clearInterval(poll))
|
||||
</script>
|
||||
|
||||
|
||||
<Button
|
||||
secondary
|
||||
on:click={publishModal.show}
|
||||
>
|
||||
Publish
|
||||
</Button>
|
||||
<Button secondary on:click={publishModal.show}>Publish</Button>
|
||||
<Modal bind:this={publishModal}>
|
||||
<ModalContent
|
||||
title="Publish to Production"
|
||||
confirmText="Publish"
|
||||
onConfirm={deployApp}
|
||||
>
|
||||
<span>The changes you have made will be published to the production version of the application.</span>
|
||||
<span
|
||||
>The changes you have made will be published to the production version of
|
||||
the application.</span
|
||||
>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
|
|
@ -1,6 +1,12 @@
|
|||
<script>
|
||||
import { onMount, onDestroy } from "svelte"
|
||||
import { Button, Icon, Modal, notifications, ModalContent } from "@budibase/bbui"
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Modal,
|
||||
notifications,
|
||||
ModalContent,
|
||||
} from "@budibase/bbui"
|
||||
import { store } from "builderStore"
|
||||
import { apps } from "stores/portal"
|
||||
import api from "builderStore/api"
|
||||
|
@ -16,7 +22,9 @@
|
|||
if (response.status !== 200) throw json.message
|
||||
|
||||
// Reset frontend state after revert
|
||||
const applicationPkg = await api.get(`/api/applications/${appId}/appPackage`)
|
||||
const applicationPkg = await api.get(
|
||||
`/api/applications/${appId}/appPackage`
|
||||
)
|
||||
const pkg = await applicationPkg.json()
|
||||
if (applicationPkg.ok) {
|
||||
await store.actions.initialise(pkg)
|
||||
|
@ -31,14 +39,12 @@
|
|||
}
|
||||
</script>
|
||||
|
||||
|
||||
<Icon name="Revert" hoverable on:click={revertModal.show} />
|
||||
<Modal bind:this={revertModal}>
|
||||
<ModalContent
|
||||
title="Revert Changes"
|
||||
confirmText="Revert"
|
||||
onConfirm={revert}
|
||||
<ModalContent title="Revert Changes" confirmText="Revert" onConfirm={revert}>
|
||||
<span
|
||||
>The changes you have made will be deleted and the application reverted
|
||||
back to its production state.</span
|
||||
>
|
||||
<span>The changes you have made will be deleted and the application reverted back to its production state.</span>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
"table",
|
||||
"repeater",
|
||||
"button",
|
||||
"search",
|
||||
{
|
||||
"name": "Form",
|
||||
"icon": "Form",
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
import { writable } from "svelte/store"
|
||||
import api from "builderStore/api"
|
||||
|
||||
export default function (url) {
|
||||
const store = writable({ status: "LOADING", data: {}, error: {} })
|
||||
|
||||
async function get() {
|
||||
store.update(u => ({ ...u, status: "LOADING" }))
|
||||
try {
|
||||
const response = await api.get(url)
|
||||
store.set({ data: await response.json(), status: "SUCCESS" })
|
||||
} catch (e) {
|
||||
store.set({ data: {}, error: e, status: "ERROR" })
|
||||
}
|
||||
}
|
||||
|
||||
get()
|
||||
|
||||
return { subscribe: store.subscribe, refresh: get }
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
export { default as fetchData } from "./fetchData"
|
||||
export {
|
||||
buildStyle,
|
||||
convertCamel,
|
||||
pipe,
|
||||
capitalise,
|
||||
get_name,
|
||||
get_capitalised_name,
|
||||
} from "./helpers"
|
|
@ -0,0 +1,2 @@
|
|||
export { emailValidator, requiredValidator } from "./validators"
|
||||
export { createValidationStore } from "./validation"
|
|
@ -0,0 +1,23 @@
|
|||
import { writable, derived } from "svelte/store"
|
||||
|
||||
export function createValidationStore(initialValue, ...validators) {
|
||||
let touched = false
|
||||
|
||||
const value = writable(initialValue || "")
|
||||
const error = derived(value, $v => validate($v, validators))
|
||||
const touchedStore = derived(value, () => {
|
||||
if (!touched) {
|
||||
touched = true
|
||||
return false
|
||||
}
|
||||
return touched
|
||||
})
|
||||
|
||||
return [value, error, touchedStore]
|
||||
}
|
||||
|
||||
function validate(value, validators) {
|
||||
const failing = validators.find(v => v(value) !== true)
|
||||
|
||||
return failing && failing(value)
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
export function emailValidator(value) {
|
||||
return (
|
||||
(value &&
|
||||
!!value.match(
|
||||
/^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
|
||||
)) ||
|
||||
"Please enter a valid email"
|
||||
)
|
||||
}
|
||||
|
||||
export function requiredValidator(value) {
|
||||
return (
|
||||
(value !== undefined && value !== null && value !== "") ||
|
||||
"This field is required"
|
||||
)
|
||||
}
|
|
@ -22,7 +22,13 @@
|
|||
|
||||
// Redirect to log in at any time if the user isn't authenticated
|
||||
$: {
|
||||
if (loaded && hasAdminUser && !$auth.user && !$isActive("./auth")) {
|
||||
if (
|
||||
loaded &&
|
||||
hasAdminUser &&
|
||||
!$auth.user &&
|
||||
!$isActive("./auth") &&
|
||||
!$isActive("./invite")
|
||||
) {
|
||||
$goto("./auth/login")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,16 @@
|
|||
<script>
|
||||
import { store, automationStore } from "builderStore"
|
||||
import { roles } from "stores/backend"
|
||||
import { Button, Icon, Modal, ModalContent, ActionGroup, ActionButton, Tabs, Tab } from "@budibase/bbui"
|
||||
import {
|
||||
Button,
|
||||
Icon,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ActionGroup,
|
||||
ActionButton,
|
||||
Tabs,
|
||||
Tab,
|
||||
} from "@budibase/bbui"
|
||||
import SettingsLink from "components/settings/Link.svelte"
|
||||
import ThemeEditorDropdown from "components/settings/ThemeEditorDropdown.svelte"
|
||||
import FeedbackNavLink from "components/feedback/FeedbackNavLink.svelte"
|
||||
|
@ -84,7 +93,9 @@
|
|||
</div>
|
||||
<div class="toprightnav">
|
||||
<RevertModal />
|
||||
<Icon name="Play" hoverable
|
||||
<Icon
|
||||
name="Play"
|
||||
hoverable
|
||||
on:click={() => {
|
||||
window.open(`/${application}`)
|
||||
}}
|
||||
|
|
|
@ -0,0 +1,97 @@
|
|||
<script>
|
||||
import {
|
||||
Layout,
|
||||
Heading,
|
||||
Body,
|
||||
Input,
|
||||
Button,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { goto, params } from "@roxi/routify"
|
||||
import { createValidationStore, requiredValidator } from "helpers/validation"
|
||||
import { users } from "stores/portal"
|
||||
|
||||
const [password, passwordError, passwordTouched] = createValidationStore(
|
||||
"",
|
||||
requiredValidator
|
||||
)
|
||||
const [repeat, _, repeatTouched] = createValidationStore(
|
||||
"",
|
||||
requiredValidator
|
||||
)
|
||||
const inviteCode = $params["?code"]
|
||||
|
||||
async function acceptInvite() {
|
||||
try {
|
||||
const res = await users.acceptInvite(inviteCode, $password)
|
||||
if (!res) {
|
||||
throw new Error(res.message)
|
||||
}
|
||||
notifications.success(`User created.`)
|
||||
$goto("../auth/login")
|
||||
} catch (err) {
|
||||
notifications.error(err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section>
|
||||
<div class="container">
|
||||
<Layout gap="XS">
|
||||
<img src="https://i.imgur.com/ZKyklgF.png" />
|
||||
</Layout>
|
||||
<div class="center">
|
||||
<Layout gap="XS">
|
||||
<Heading size="M">Accept Invitation</Heading>
|
||||
<Body size="M">Please enter a password to setup your user.</Body>
|
||||
</Layout>
|
||||
</div>
|
||||
<Layout gap="XS">
|
||||
<Input
|
||||
label="Password"
|
||||
type="password"
|
||||
error={$passwordTouched && $passwordError}
|
||||
bind:value={$password}
|
||||
/>
|
||||
<Input
|
||||
label="Repeat Password"
|
||||
type="password"
|
||||
error={$repeatTouched &&
|
||||
$password !== $repeat &&
|
||||
"Passwords must match"}
|
||||
bind:value={$repeat}
|
||||
/>
|
||||
</Layout>
|
||||
<Layout gap="S">
|
||||
<Button
|
||||
disabled={!$passwordTouched || !$repeatTouched || $password !== $repeat}
|
||||
cta
|
||||
on:click={acceptInvite}>Accept invite</Button
|
||||
>
|
||||
</Layout>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
.container {
|
||||
margin: 0 auto;
|
||||
width: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
}
|
||||
.center {
|
||||
text-align: center;
|
||||
}
|
||||
img {
|
||||
width: 40px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
|
@ -1,6 +1,5 @@
|
|||
<script>
|
||||
import { isActive, goto } from "@roxi/routify"
|
||||
import { onMount } from "svelte"
|
||||
import {
|
||||
Icon,
|
||||
Avatar,
|
||||
|
@ -13,34 +12,21 @@
|
|||
Modal,
|
||||
} from "@budibase/bbui"
|
||||
import ConfigChecklist from "components/common/ConfigChecklist.svelte"
|
||||
import { organisation, apps } from "stores/portal"
|
||||
import { organisation } from "stores/portal"
|
||||
import { auth } from "stores/backend"
|
||||
import BuilderSettingsModal from "components/start/BuilderSettingsModal.svelte"
|
||||
|
||||
let orgName
|
||||
let orgLogo
|
||||
let user
|
||||
let oldSettingsModal
|
||||
|
||||
async function getInfo() {
|
||||
// fetch orgInfo
|
||||
orgName = "ACME Inc."
|
||||
orgLogo = "https://via.placeholder.com/150"
|
||||
user = { name: "John Doe" }
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
organisation.init()
|
||||
getInfo()
|
||||
})
|
||||
|
||||
let menu = [
|
||||
{ title: "Apps", href: "/builder/portal/apps" },
|
||||
{ title: "Drafts", href: "/builder/portal/drafts" },
|
||||
{ title: "Users", href: "/builder/portal/users", heading: "Manage" },
|
||||
{ title: "Groups", href: "/builder/portal/groups" },
|
||||
{ title: "Auth", href: "/builder/portal/oauth" },
|
||||
{ title: "Email", href: "/builder/portal/email" },
|
||||
{ title: "Users", href: "/builder/portal/manage/users", heading: "Manage" },
|
||||
{ title: "Groups", href: "/builder/portal/manage/groups" },
|
||||
{ title: "Auth", href: "/builder/portal/manage/auth" },
|
||||
{ title: "Email", href: "/builder/portal/manage/email" },
|
||||
{
|
||||
title: "General",
|
||||
href: "/builder/portal/settings/general",
|
||||
|
|
|
@ -62,15 +62,17 @@
|
|||
}
|
||||
|
||||
const openApp = app => {
|
||||
if (app.lockedBy && app.lockedBy?.email === $auth.user?.email) {
|
||||
notifications.error(`App locked by ${app.lockedBy.email}. Please allow lock to expire or have them unlock this app.`)
|
||||
if (app.lockedBy && app.lockedBy?.email !== $auth.user?.email) {
|
||||
notifications.error(
|
||||
`App locked by ${app.lockedBy.email}. Please allow lock to expire or have them unlock this app.`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (appStatus === AppStatus.DEV) {
|
||||
$goto(`../../app/${app.appId}`)
|
||||
} else {
|
||||
window.open(`/${app.appId}`, '_blank');
|
||||
window.open(`/${app.appId}`, "_blank")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
<script>
|
||||
import { Page } from "@budibase/bbui"
|
||||
</script>
|
||||
|
||||
<Page>
|
||||
<slot />
|
||||
</Page>
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
@ -0,0 +1,114 @@
|
|||
<script>
|
||||
import GoogleLogo from "./_logos/Google.svelte"
|
||||
import {
|
||||
Button,
|
||||
Heading,
|
||||
Divider,
|
||||
Page,
|
||||
Label,
|
||||
notifications,
|
||||
Layout,
|
||||
Input,
|
||||
Body,
|
||||
} from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
import api from "builderStore/api"
|
||||
|
||||
const ConfigTypes = {
|
||||
Google: "google",
|
||||
// Github: "github",
|
||||
// AzureAD: "ad",
|
||||
}
|
||||
|
||||
const ConfigFields = {
|
||||
Google: ["clientID", "clientSecret", "callbackURL"],
|
||||
}
|
||||
|
||||
let google
|
||||
|
||||
async function save(doc) {
|
||||
try {
|
||||
// Save an oauth config
|
||||
const response = await api.post(`/api/admin/configs`, doc)
|
||||
const json = await response.json()
|
||||
if (response.status !== 200) throw new Error(json.message)
|
||||
google._rev = json._rev
|
||||
google._id = json._id
|
||||
|
||||
notifications.success(`Settings saved.`)
|
||||
} catch (err) {
|
||||
notifications.error(`Failed to update OAuth settings. ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// fetch the configs for oauth
|
||||
const googleResponse = await api.get(
|
||||
`/api/admin/configs/${ConfigTypes.Google}`
|
||||
)
|
||||
const googleDoc = await googleResponse.json()
|
||||
|
||||
if (!googleDoc._id) {
|
||||
google = {
|
||||
type: ConfigTypes.Google,
|
||||
config: {},
|
||||
}
|
||||
} else {
|
||||
google = googleDoc
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Page>
|
||||
<Layout noPadding>
|
||||
<div>
|
||||
<Heading size="M">OAuth</Heading>
|
||||
<Body>
|
||||
Every budibase app comes with basic authentication (email/password)
|
||||
included. You can add additional authentication methods from the options
|
||||
below.
|
||||
</Body>
|
||||
</div>
|
||||
<Divider />
|
||||
{#if google}
|
||||
<div>
|
||||
<Heading size="S">
|
||||
<span>
|
||||
<GoogleLogo />
|
||||
Google
|
||||
</span>
|
||||
</Heading>
|
||||
<Body>
|
||||
To allow users to authenticate using their Google accounts, fill out
|
||||
the fields below.
|
||||
</Body>
|
||||
</div>
|
||||
|
||||
{#each ConfigFields.Google as field}
|
||||
<div class="form-row">
|
||||
<Label size="L">{field}</Label>
|
||||
<Input bind:value={google.config[field]} />
|
||||
</div>
|
||||
{/each}
|
||||
<div>
|
||||
<Button primary on:click={() => save(google)}>Save</Button>
|
||||
</div>
|
||||
<Divider />
|
||||
{/if}
|
||||
</Layout>
|
||||
</Page>
|
||||
|
||||
<style>
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20% 1fr;
|
||||
grid-gap: var(--spacing-l);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
</style>
|
|
@ -1,32 +1,19 @@
|
|||
<script>
|
||||
import {
|
||||
Menu,
|
||||
MenuItem,
|
||||
Button,
|
||||
Detail,
|
||||
Heading,
|
||||
Divider,
|
||||
Label,
|
||||
Modal,
|
||||
ModalContent,
|
||||
notifications,
|
||||
Layout,
|
||||
Icon,
|
||||
Body,
|
||||
Page,
|
||||
Select,
|
||||
Tabs,
|
||||
Tab,
|
||||
MenuSection,
|
||||
MenuSeparator,
|
||||
} from "@budibase/bbui"
|
||||
import { goto } from "@roxi/routify"
|
||||
import { onMount } from "svelte"
|
||||
import { fade } from "svelte/transition"
|
||||
import { email } from "stores/portal"
|
||||
import Editor from "components/integration/QueryEditor.svelte"
|
||||
import TemplateBindings from "./TemplateBindings.svelte"
|
||||
import api from "builderStore/api"
|
||||
import TemplateBindings from "./_components/TemplateBindings.svelte"
|
||||
|
||||
const ConfigTypes = {
|
||||
SMTP: "smtp",
|
|
@ -23,8 +23,8 @@
|
|||
import { onMount } from "svelte"
|
||||
import { email } from "stores/portal"
|
||||
import Editor from "components/integration/QueryEditor.svelte"
|
||||
import TemplateBindings from "./TemplateBindings.svelte"
|
||||
import TemplateLink from "./TemplateLink.svelte"
|
||||
import TemplateBindings from "./_components/TemplateBindings.svelte"
|
||||
import TemplateLink from "./_components/TemplateLink.svelte"
|
||||
import api from "builderStore/api"
|
||||
|
||||
const ConfigTypes = {
|
|
@ -0,0 +1,43 @@
|
|||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 268 268"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0)">
|
||||
<path
|
||||
d="M58.8037 109.043C64.0284 93.2355 74.1116 79.4822 87.615 69.7447C101.118
|
||||
60.0073 117.352 54.783 134 54.8172C152.872 54.8172 169.934 61.5172 183.334
|
||||
72.4828L222.328 33.5C198.566 12.7858 168.114 0 134 0C81.1817 0 35.711
|
||||
30.1277 13.8467 74.2583L58.8037 109.043Z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
<path
|
||||
d="M179.113 201.145C166.942 208.995 151.487 213.183 134 213.183C117.418
|
||||
213.217 101.246 208.034 87.7727 198.369C74.2993 188.703 64.2077 175.044
|
||||
58.9265 159.326L13.8132 193.574C24.8821 215.978 42.012 234.828 63.2572
|
||||
247.984C84.5024 261.14 109.011 268.075 134 268C166.752 268 198.041 256.353
|
||||
221.48 234.5L179.125 201.145H179.113Z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M221.48 234.5C245.991 211.631 261.903 177.595 261.903 134C261.903
|
||||
126.072 260.686 117.552 258.866 109.634H134V161.414H205.869C202.329
|
||||
178.823 192.804 192.301 179.125 201.145L221.48 234.5Z"
|
||||
fill="#4A90E2"
|
||||
/>
|
||||
<path
|
||||
d="M58.9265 159.326C56.1947 151.162 54.8068 142.609 54.8172 134C54.8172
|
||||
125.268 56.213 116.882 58.8037 109.043L13.8467 74.2584C4.64957 92.825
|
||||
-0.0915078 113.28 1.86708e-05 134C1.86708e-05 155.44 4.96919 175.652
|
||||
13.8132 193.574L58.9265 159.326Z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="268" height="268" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
|
@ -0,0 +1,115 @@
|
|||
<script>
|
||||
import GoogleLogo from "./_logos/Google.svelte"
|
||||
import {
|
||||
Button,
|
||||
Heading,
|
||||
Divider,
|
||||
Label,
|
||||
notifications,
|
||||
Layout,
|
||||
Input,
|
||||
Body,
|
||||
Page,
|
||||
} from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
import api from "builderStore/api"
|
||||
|
||||
const ConfigTypes = {
|
||||
Google: "google",
|
||||
// Github: "github",
|
||||
// AzureAD: "ad",
|
||||
}
|
||||
|
||||
const ConfigFields = {
|
||||
Google: ["clientID", "clientSecret", "callbackURL"],
|
||||
}
|
||||
|
||||
let google
|
||||
|
||||
async function save(doc) {
|
||||
try {
|
||||
// Save an oauth config
|
||||
const response = await api.post(`/api/admin/configs`, doc)
|
||||
const json = await response.json()
|
||||
if (response.status !== 200) throw new Error(json.message)
|
||||
google._rev = json._rev
|
||||
google._id = json._id
|
||||
|
||||
notifications.success(`Settings saved.`)
|
||||
} catch (err) {
|
||||
notifications.error(`Failed to update OAuth settings. ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// fetch the configs for oauth
|
||||
const googleResponse = await api.get(
|
||||
`/api/admin/configs/${ConfigTypes.Google}`
|
||||
)
|
||||
const googleDoc = await googleResponse.json()
|
||||
|
||||
if (!googleDoc._id) {
|
||||
google = {
|
||||
type: ConfigTypes.Google,
|
||||
config: {},
|
||||
}
|
||||
} else {
|
||||
google = googleDoc
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<Page>
|
||||
<header>
|
||||
<Heading size="M">OAuth</Heading>
|
||||
<Body size="S">
|
||||
Every budibase app comes with basic authentication (email/password)
|
||||
included. You can add additional authentication methods from the options
|
||||
below.
|
||||
</Body>
|
||||
</header>
|
||||
<Divider />
|
||||
{#if google}
|
||||
<div class="config-form">
|
||||
<Layout gap="S">
|
||||
<Heading size="S">
|
||||
<span>
|
||||
<GoogleLogo />
|
||||
Google
|
||||
</span>
|
||||
</Heading>
|
||||
{#each ConfigFields.Google as field}
|
||||
<div class="form-row">
|
||||
<Label>{field}</Label>
|
||||
<Input bind:value={google.config[field]} />
|
||||
</div>
|
||||
{/each}
|
||||
</Layout>
|
||||
<Button primary on:click={() => save(google)}>Save</Button>
|
||||
</div>
|
||||
<Divider />
|
||||
{/if}
|
||||
</Page>
|
||||
|
||||
<style>
|
||||
.config-form {
|
||||
margin-top: 42px;
|
||||
margin-bottom: 42px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 20% 1fr;
|
||||
grid-gap: var(--spacing-l);
|
||||
}
|
||||
|
||||
span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 42px;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,168 @@
|
|||
<script>
|
||||
import { goto } from "@roxi/routify"
|
||||
import {
|
||||
ActionButton,
|
||||
Button,
|
||||
Layout,
|
||||
Heading,
|
||||
Body,
|
||||
Divider,
|
||||
Label,
|
||||
Input,
|
||||
Modal,
|
||||
Table,
|
||||
ModalContent,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { fetchData } from "helpers"
|
||||
import { users } from "stores/portal"
|
||||
|
||||
import TagsRenderer from "./_components/TagsTableRenderer.svelte"
|
||||
import UpdateRolesModal from "./_components/UpdateRolesModal.svelte"
|
||||
|
||||
export let userId
|
||||
let deleteUserModal
|
||||
let editRolesModal
|
||||
|
||||
const roleSchema = {
|
||||
name: { displayName: "App" },
|
||||
role: {},
|
||||
}
|
||||
|
||||
// Merge the Apps list and the roles response to get something that makes sense for the table
|
||||
$: appList = Object.keys($apps?.data).map(id => ({
|
||||
...$apps?.data?.[id],
|
||||
_id: id,
|
||||
role: [$roleFetch?.data?.roles?.[id]],
|
||||
}))
|
||||
let selectedApp
|
||||
|
||||
const roleFetch = fetchData(`/api/admin/users/${userId}`)
|
||||
const apps = fetchData(`/api/admin/roles`)
|
||||
|
||||
async function deleteUser() {
|
||||
const res = await users.del(userId)
|
||||
if (res.message) {
|
||||
notifications.success(`User ${$roleFetch?.data?.email} deleted.`)
|
||||
$goto("./")
|
||||
} else {
|
||||
notifications.error("Failed to delete user.")
|
||||
}
|
||||
}
|
||||
|
||||
async function openUpdateRolesModal({ detail }) {
|
||||
console.log(detail)
|
||||
selectedApp = detail
|
||||
editRolesModal.show()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Layout noPadding gap="XS">
|
||||
<div class="back">
|
||||
<ActionButton on:click={() => $goto("./")} quiet size="S" icon="BackAndroid"
|
||||
>Back to users</ActionButton
|
||||
>
|
||||
</div>
|
||||
<div class="heading">
|
||||
<Layout noPadding gap="XS">
|
||||
<Heading>User: {$roleFetch?.data?.email}</Heading>
|
||||
<Body
|
||||
>Lorem ipsum dolor sit amet consectetur adipisicing elit. Debitis porro
|
||||
ut nesciunt ipsam perspiciatis aliquam et hic minus alias beatae. Odit
|
||||
veritatis quos quas laborum magnam tenetur perspiciatis ex hic.
|
||||
</Body>
|
||||
</Layout>
|
||||
</div>
|
||||
<Divider size="S" />
|
||||
<div class="general">
|
||||
<Heading size="S">General</Heading>
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<Label size="L">Email</Label>
|
||||
<Input disabled thin value={$roleFetch?.data?.email} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="regenerate">
|
||||
<ActionButton size="S" icon="Refresh" quiet
|
||||
>Regenerate password</ActionButton
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<Divider size="S" />
|
||||
<div class="roles">
|
||||
<Heading size="S">Configure roles</Heading>
|
||||
<Table
|
||||
on:click={openUpdateRolesModal}
|
||||
schema={roleSchema}
|
||||
data={appList}
|
||||
allowEditColumns={false}
|
||||
allowEditRows={false}
|
||||
allowSelectRows={false}
|
||||
customRenderers={[{ column: "role", component: TagsRenderer }]}
|
||||
/>
|
||||
</div>
|
||||
<Divider size="S" />
|
||||
<div class="delete">
|
||||
<Layout gap="S" noPadding
|
||||
><Heading size="S">Delete user</Heading>
|
||||
<Body>Deleting a user completely removes them from your account.</Body>
|
||||
<div class="delete-button">
|
||||
<Button warning on:click={deleteUserModal.show}>Delete user</Button>
|
||||
</div></Layout
|
||||
>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<Modal bind:this={deleteUserModal}>
|
||||
<ModalContent
|
||||
warning
|
||||
onConfirm={deleteUser}
|
||||
title="Delete User"
|
||||
confirmText="Delete user"
|
||||
cancelText="Cancel"
|
||||
showCloseIcon={false}
|
||||
>
|
||||
<Body
|
||||
>Are you sure you want to delete <strong>{$roleFetch?.data?.email}</strong
|
||||
></Body
|
||||
>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<Modal bind:this={editRolesModal}>
|
||||
<UpdateRolesModal
|
||||
app={selectedApp}
|
||||
user={$roleFetch.data}
|
||||
on:update={roleFetch.refresh}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.fields {
|
||||
display: grid;
|
||||
grid-gap: var(--spacing-m);
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
.field {
|
||||
display: grid;
|
||||
grid-template-columns: 32% 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
.heading {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
.general {
|
||||
position: relative;
|
||||
margin: var(--spacing-xl) 0;
|
||||
}
|
||||
.roles {
|
||||
margin: var(--spacing-xl) 0;
|
||||
}
|
||||
.delete {
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
.regenerate {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,59 @@
|
|||
<script>
|
||||
import {
|
||||
Body,
|
||||
Input,
|
||||
Select,
|
||||
ModalContent,
|
||||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { createValidationStore, emailValidator } from "helpers/validation"
|
||||
import { users } from "stores/portal"
|
||||
|
||||
export let disabled
|
||||
|
||||
const options = ["Email onboarding", "Basic onboarding"]
|
||||
let selected = options[0]
|
||||
|
||||
const [email, error, touched] = createValidationStore("", emailValidator)
|
||||
|
||||
async function createUserFlow() {
|
||||
const res = await users.invite($email)
|
||||
console.log(res)
|
||||
if (res.status) {
|
||||
notifications.error(res.message)
|
||||
} else {
|
||||
notifications.success(res.message)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ModalContent
|
||||
onConfirm={createUserFlow}
|
||||
size="M"
|
||||
title="Add new user options"
|
||||
confirmText="Add user"
|
||||
confirmDisabled={disabled}
|
||||
cancelText="Cancel"
|
||||
disabled={$error}
|
||||
showCloseIcon={false}
|
||||
>
|
||||
<Body noPadding
|
||||
>If you have SMTP configured and an email for the new user, you can use the
|
||||
automated email onboarding flow. Otherwise, use our basic onboarding process
|
||||
with autogenerated passwords.</Body
|
||||
>
|
||||
<Select
|
||||
placeholder={null}
|
||||
bind:value={selected}
|
||||
on:change
|
||||
{options}
|
||||
label="Add new user via:"
|
||||
/>
|
||||
<Input
|
||||
type="email"
|
||||
bind:value={$email}
|
||||
error={$touched && $error}
|
||||
placeholder="john@doe.com"
|
||||
label="Email"
|
||||
/>
|
||||
</ModalContent>
|
|
@ -0,0 +1,40 @@
|
|||
<script>
|
||||
import { ModalContent, Body, Input, notifications } from "@budibase/bbui"
|
||||
import { createValidationStore, emailValidator } from "helpers/validation"
|
||||
import { users } from "stores/portal"
|
||||
|
||||
const [email, error, touched] = createValidationStore("", emailValidator)
|
||||
const password = Math.random().toString(36).substr(2, 20)
|
||||
|
||||
async function createUser() {
|
||||
const res = await users.create({ email: $email, password })
|
||||
if (res.status) {
|
||||
notifications.error(res.message)
|
||||
} else {
|
||||
notifications.success("Succesfully created user")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ModalContent
|
||||
onConfirm={createUser}
|
||||
size="M"
|
||||
title="Basic user onboarding"
|
||||
confirmText="Continue"
|
||||
cancelText="Cancel"
|
||||
disabled={$error}
|
||||
error={$touched && $error}
|
||||
showCloseIcon={false}
|
||||
>
|
||||
<Body noPadding
|
||||
>Below you will find the user’s username and password. The password will not
|
||||
be accessible from this point. Please download the credentials.</Body
|
||||
>
|
||||
<Input
|
||||
type="email"
|
||||
label="Username"
|
||||
bind:value={$email}
|
||||
error={$touched && $error}
|
||||
/>
|
||||
<Input disabled label="Password" value={password} />
|
||||
</ModalContent>
|
|
@ -0,0 +1,20 @@
|
|||
<script>
|
||||
import { Tag, Tags } from "@budibase/bbui"
|
||||
export let value
|
||||
|
||||
const displayLimit = 5
|
||||
|
||||
$: tags = value?.slice(0, displayLimit) ?? []
|
||||
$: leftover = (value?.length ?? 0) - tags.length
|
||||
</script>
|
||||
|
||||
<Tags>
|
||||
{#each tags as tag}
|
||||
<Tag>
|
||||
{tag}
|
||||
</Tag>
|
||||
{/each}
|
||||
{#if leftover}
|
||||
<Tag>+{leftover} more</Tag>
|
||||
{/if}
|
||||
</Tags>
|
|
@ -0,0 +1,51 @@
|
|||
<script>
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { Body, Select, ModalContent, notifications } from "@budibase/bbui"
|
||||
import { fetchData } from "helpers"
|
||||
import { users } from "stores/portal"
|
||||
|
||||
export let app
|
||||
export let user
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const roles = app.roles
|
||||
let options = roles.map(role => role._id)
|
||||
let selectedRole
|
||||
|
||||
async function updateUserRoles() {
|
||||
const res = await users.updateRoles({
|
||||
...user,
|
||||
roles: {
|
||||
...user.roles,
|
||||
[app._id]: selectedRole,
|
||||
},
|
||||
})
|
||||
if (res.status === 400) {
|
||||
notifications.error("Failed to update role")
|
||||
} else {
|
||||
notifications.success("Roles updated")
|
||||
dispatch("update")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<ModalContent
|
||||
onConfirm={updateUserRoles}
|
||||
title="Update App Roles"
|
||||
confirmText="Update roles"
|
||||
cancelText="Cancel"
|
||||
size="M"
|
||||
showCloseIcon={false}
|
||||
>
|
||||
<Body noPadding
|
||||
>Update {user.email}'s roles for <strong>{app.name}</strong>.</Body
|
||||
>
|
||||
<Select
|
||||
placeholder={null}
|
||||
bind:value={selectedRole}
|
||||
on:change
|
||||
{options}
|
||||
label="Select roles:"
|
||||
/>
|
||||
</ModalContent>
|
|
@ -0,0 +1,105 @@
|
|||
<script>
|
||||
import { goto } from "@roxi/routify"
|
||||
import {
|
||||
Heading,
|
||||
Body,
|
||||
Divider,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Search,
|
||||
Table,
|
||||
Label,
|
||||
Layout,
|
||||
Modal,
|
||||
} from "@budibase/bbui"
|
||||
import TagsRenderer from "./_components/TagsTableRenderer.svelte"
|
||||
import AddUserModal from "./_components/AddUserModal.svelte"
|
||||
import BasicOnboardingModal from "./_components/BasicOnboardingModal.svelte"
|
||||
import { users } from "stores/portal"
|
||||
|
||||
users.init()
|
||||
|
||||
const schema = {
|
||||
email: {},
|
||||
status: { displayName: "Development Access", type: "boolean" },
|
||||
// role: { type: "options" },
|
||||
group: {},
|
||||
// access: {},
|
||||
// group: {}
|
||||
}
|
||||
|
||||
let search
|
||||
let email
|
||||
$: filteredUsers = $users
|
||||
.filter(user => user.email.includes(search || ""))
|
||||
.map(user => ({ ...user, group: ["All"] }))
|
||||
|
||||
let createUserModal
|
||||
let basicOnboardingModal
|
||||
|
||||
function openBasicOnoboardingModal() {
|
||||
createUserModal.hide()
|
||||
basicOnboardingModal.show()
|
||||
}
|
||||
</script>
|
||||
|
||||
<Layout>
|
||||
<div class="heading">
|
||||
<Heading>Users</Heading>
|
||||
<Body
|
||||
>Users are the common denominator in Budibase. Each user is assigned to a
|
||||
group that contains apps and permissions. In this section, you can add
|
||||
users, or edit and delete an existing user.</Body
|
||||
>
|
||||
</div>
|
||||
<Divider size="S" />
|
||||
|
||||
<div class="users">
|
||||
<Heading size="S">Users</Heading>
|
||||
<div class="field">
|
||||
<Label size="L">Search / filter</Label>
|
||||
<Search bind:value={search} placeholder="" />
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<ButtonGroup>
|
||||
<Button disabled secondary>Import users</Button>
|
||||
<Button overBackground on:click={createUserModal.show}>Add user</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
<Table
|
||||
on:click={({ detail }) => $goto(`./${detail._id}`)}
|
||||
{schema}
|
||||
data={filteredUsers || $users}
|
||||
allowEditColumns={false}
|
||||
allowEditRows={false}
|
||||
allowSelectRows={false}
|
||||
customRenderers={[{ column: "group", component: TagsRenderer }]}
|
||||
/>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
<Modal bind:this={createUserModal}
|
||||
><AddUserModal on:change={openBasicOnoboardingModal} /></Modal
|
||||
>
|
||||
<Modal bind:this={basicOnboardingModal}><BasicOnboardingModal {email} /></Modal>
|
||||
|
||||
<style>
|
||||
.users {
|
||||
position: relative;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
grid-gap: var(--spacing-m);
|
||||
margin: var(--spacing-xl) 0;
|
||||
}
|
||||
.field > :global(*) + :global(*) {
|
||||
margin-left: var(--spacing-m);
|
||||
}
|
||||
.buttons {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,43 @@
|
|||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 268 268"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0)">
|
||||
<path
|
||||
d="M58.8037 109.043C64.0284 93.2355 74.1116 79.4822 87.615 69.7447C101.118
|
||||
60.0073 117.352 54.783 134 54.8172C152.872 54.8172 169.934 61.5172 183.334
|
||||
72.4828L222.328 33.5C198.566 12.7858 168.114 0 134 0C81.1817 0 35.711
|
||||
30.1277 13.8467 74.2583L58.8037 109.043Z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
<path
|
||||
d="M179.113 201.145C166.942 208.995 151.487 213.183 134 213.183C117.418
|
||||
213.217 101.246 208.034 87.7727 198.369C74.2993 188.703 64.2077 175.044
|
||||
58.9265 159.326L13.8132 193.574C24.8821 215.978 42.012 234.828 63.2572
|
||||
247.984C84.5024 261.14 109.011 268.075 134 268C166.752 268 198.041 256.353
|
||||
221.48 234.5L179.125 201.145H179.113Z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M221.48 234.5C245.991 211.631 261.903 177.595 261.903 134C261.903
|
||||
126.072 260.686 117.552 258.866 109.634H134V161.414H205.869C202.329
|
||||
178.823 192.804 192.301 179.125 201.145L221.48 234.5Z"
|
||||
fill="#4A90E2"
|
||||
/>
|
||||
<path
|
||||
d="M58.9265 159.326C56.1947 151.162 54.8068 142.609 54.8172 134C54.8172
|
||||
125.268 56.213 116.882 58.8037 109.043L13.8467 74.2584C4.64957 92.825
|
||||
-0.0915078 113.28 1.86708e-05 134C1.86708e-05 155.44 4.96919 175.652
|
||||
13.8132 193.574L58.9265 159.326Z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0">
|
||||
<rect width="268" height="268" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 1.5 KiB |
|
@ -1,7 +1,8 @@
|
|||
<script>
|
||||
import GoogleLogo from "./logos/Google.svelte"
|
||||
import GoogleLogo from "./_logos/Google.svelte"
|
||||
import {
|
||||
Button,
|
||||
Page,
|
||||
Heading,
|
||||
Divider,
|
||||
Label,
|
||||
|
@ -9,7 +10,6 @@
|
|||
Layout,
|
||||
Input,
|
||||
Body,
|
||||
Page,
|
||||
} from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
import api from "builderStore/api"
|
||||
|
|
|
@ -12,6 +12,7 @@
|
|||
notifications,
|
||||
} from "@budibase/bbui"
|
||||
import { organisation } from "stores/portal"
|
||||
import { post } from "builderStore/api"
|
||||
import analytics from "analytics"
|
||||
let analyticsDisabled = analytics.disabled()
|
||||
|
||||
|
@ -24,18 +25,30 @@
|
|||
}
|
||||
|
||||
let loading = false
|
||||
let file
|
||||
|
||||
$: company = $organisation?.company
|
||||
$: logoUrl = $organisation.logoUrl
|
||||
async function uploadLogo() {
|
||||
let data = new FormData()
|
||||
data.append("file", file)
|
||||
|
||||
const res = await post("/api/admin/configs/upload/settings/logo", data, {})
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
loading = true
|
||||
await toggleAnalytics()
|
||||
const res = await organisation.save({ ...$organisation, company })
|
||||
if (file) {
|
||||
await uploadLogo()
|
||||
}
|
||||
const res = await organisation.save({
|
||||
company: $organisation.company,
|
||||
platformUrl: $organisation.platformUrl,
|
||||
})
|
||||
if (res.status === 200) {
|
||||
notifications.success("General settings saved.")
|
||||
notifications.success("Settings saved.")
|
||||
} else {
|
||||
notifications.danger("Error when saving settings.")
|
||||
notifications.error(res.message)
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
@ -46,10 +59,9 @@
|
|||
<div class="intro">
|
||||
<Heading size="M">General</Heading>
|
||||
<Body>
|
||||
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Hic vero, aut
|
||||
culpa provident sunt ratione! Voluptas doloremque, dicta nisi velit
|
||||
perspiciatis, ratione vel blanditiis totam, nam voluptate repellat
|
||||
aperiam fuga!
|
||||
General is the place where you edit your organisation name, logo. You
|
||||
can also configure your platform URL as well as turn on or off
|
||||
analytics.
|
||||
</Body>
|
||||
</div>
|
||||
<Divider size="S" />
|
||||
|
@ -59,14 +71,30 @@
|
|||
<div class="fields">
|
||||
<div class="field">
|
||||
<Label size="L">Organization name</Label>
|
||||
<Input thin bind:value={company} />
|
||||
<Input thin bind:value={$organisation.company} />
|
||||
</div>
|
||||
<!-- <div class="field">
|
||||
<Label>Logo</Label>
|
||||
<div class="field logo">
|
||||
<Label size="L">Logo</Label>
|
||||
<div class="file">
|
||||
<Dropzone />
|
||||
<Dropzone
|
||||
value={[file]}
|
||||
on:change={e => {
|
||||
file = e.detail?.[0]
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Divider size="S" />
|
||||
<div class="analytics">
|
||||
<Heading size="S">Platform</Heading>
|
||||
<Body>Here you can set up general platform settings.</Body>
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<Label size="L">Platform URL</Label>
|
||||
<Input thin bind:value={$organisation.platformUrl} />
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
<Divider size="S" />
|
||||
|
@ -103,6 +131,9 @@
|
|||
.file {
|
||||
max-width: 30ch;
|
||||
}
|
||||
.logo {
|
||||
align-items: start;
|
||||
}
|
||||
.intro {
|
||||
display: grid;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
export { organisation } from "./organisation"
|
||||
export { users } from "./users"
|
||||
export { admin } from "./admin"
|
||||
export { apps } from "./apps"
|
||||
export { email } from "./email"
|
|
@ -1,35 +1,46 @@
|
|||
import { writable } from "svelte/store"
|
||||
import { writable, get } from "svelte/store"
|
||||
import api from "builderStore/api"
|
||||
|
||||
export function createOrganisationStore() {
|
||||
const { subscribe, set } = writable({})
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const response = await api.get(`/api/admin/configs/settings`)
|
||||
const json = await response.json()
|
||||
set(json)
|
||||
} catch (error) {
|
||||
set({
|
||||
const FALLBACK_CONFIG = {
|
||||
platformUrl: "",
|
||||
logoUrl: "",
|
||||
docsUrl: "",
|
||||
company: "",
|
||||
})
|
||||
company: "http://localhost:10000",
|
||||
}
|
||||
|
||||
export function createOrganisationStore() {
|
||||
const store = writable({})
|
||||
const { subscribe, set } = store
|
||||
|
||||
async function init() {
|
||||
const res = await api.get(`/api/admin/configs/settings`)
|
||||
const json = await res.json()
|
||||
|
||||
if (json.status === 400) {
|
||||
set(FALLBACK_CONFIG)
|
||||
} else {
|
||||
set({ ...json.config, _rev: json._rev })
|
||||
}
|
||||
}
|
||||
|
||||
async function save(config) {
|
||||
const res = await api.post("/api/admin/configs", {
|
||||
type: "settings",
|
||||
config,
|
||||
_rev: get(store)._rev,
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.status) {
|
||||
return json
|
||||
}
|
||||
await init()
|
||||
return { status: 200 }
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
save: async config => {
|
||||
try {
|
||||
await api.post("/api/admin/configs", { type: "settings", config })
|
||||
await init()
|
||||
return { status: 200 }
|
||||
} catch (error) {
|
||||
return { error }
|
||||
}
|
||||
},
|
||||
set,
|
||||
save,
|
||||
init,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
import { writable } from "svelte/store"
|
||||
import api, { post } from "builderStore/api"
|
||||
import { update } from "lodash"
|
||||
|
||||
export function createUsersStore() {
|
||||
const { subscribe, set } = writable([])
|
||||
|
||||
async function init() {
|
||||
const response = await api.get(`/api/admin/users`)
|
||||
const json = await response.json()
|
||||
set(json)
|
||||
}
|
||||
|
||||
async function invite(email) {
|
||||
const response = await api.post(`/api/admin/users/invite`, { email })
|
||||
return await response.json()
|
||||
}
|
||||
async function acceptInvite(inviteCode, password) {
|
||||
const response = await api.post("/api/admin/users/invite/accept", {
|
||||
inviteCode,
|
||||
password,
|
||||
})
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async function create({ email, password }) {
|
||||
const response = await api.post("/api/admin/users", {
|
||||
email,
|
||||
password,
|
||||
builder: { global: true },
|
||||
roles: {},
|
||||
})
|
||||
init()
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async function del(id) {
|
||||
const response = await api.delete(`/api/admin/users/${id}`)
|
||||
update(users => users.filter(user => user._id !== id))
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
async function updateRoles(data) {
|
||||
try {
|
||||
const res = await post(`/api/admin/users`, data)
|
||||
const json = await res.json()
|
||||
return json
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
init,
|
||||
invite,
|
||||
acceptInvite,
|
||||
create,
|
||||
updateRoles,
|
||||
del,
|
||||
}
|
||||
}
|
||||
|
||||
export const users = createUsersStore()
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -39,8 +39,18 @@
|
|||
</script>
|
||||
|
||||
{#if loaded && $screenStore.activeLayout}
|
||||
<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>
|
||||
|
|
|
@ -244,7 +244,7 @@ exports.delete = async function (ctx) {
|
|||
}
|
||||
|
||||
const createEmptyAppPackage = async (ctx, app) => {
|
||||
const db = new CouchDB(app.instance._id)
|
||||
const db = new CouchDB(app.appId)
|
||||
|
||||
let screensAndLayouts = []
|
||||
for (let layout of BASE_LAYOUTS) {
|
||||
|
|
|
@ -1,15 +1,12 @@
|
|||
const CouchDB = require("../../db")
|
||||
const {
|
||||
getBuiltinRoles,
|
||||
Role,
|
||||
getRole,
|
||||
isBuiltin,
|
||||
getExternalRoleID,
|
||||
getAllRoles,
|
||||
} = require("@budibase/auth/roles")
|
||||
const {
|
||||
generateRoleID,
|
||||
getRoleParams,
|
||||
getUserMetadataParams,
|
||||
InternalTables,
|
||||
} = require("../../db/utils")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -1,31 +1,21 @@
|
|||
const { SearchIndexes } = require("../../../db/utils")
|
||||
const { checkSlashesInUrl } = require("../../../utilities")
|
||||
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 +24,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 +45,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 +93,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}*`)
|
||||
}
|
||||
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,
|
||||
build(this.query.string, (key, value) => {
|
||||
return value ? `${key}:${luceneEscape(value.toLowerCase())}*` : null
|
||||
})
|
||||
}
|
||||
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 => {
|
||||
const response = await fetch(query, {
|
||||
method: "GET",
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +214,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 }
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"),
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -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}>
|
||||
{#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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -187,5 +187,6 @@
|
|||
div {
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
background-color: var(--spectrum-alias-background-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -94,3 +94,9 @@
|
|||
<slot />
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
background-color: var(--spectrum-alias-background-color-secondary);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -23,12 +23,25 @@ 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()
|
||||
}
|
||||
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 => {
|
||||
let regexp = new RegExp(exports.FIND_HBS_REGEX)
|
||||
|
|
|
@ -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", () => {
|
||||
|
|
|
@ -118,16 +118,17 @@ exports.upload = async function (ctx) {
|
|||
// add to configuration structure
|
||||
// TODO: right now this only does a global level
|
||||
const db = new CouchDB(GLOBAL_DB)
|
||||
let config = await getScopedFullConfig(db, { type })
|
||||
if (!config) {
|
||||
config = {
|
||||
let cfgStructure = await getScopedFullConfig(db, { type })
|
||||
if (!cfgStructure) {
|
||||
cfgStructure = {
|
||||
_id: generateConfigID({ type }),
|
||||
config: {},
|
||||
}
|
||||
}
|
||||
const url = `/${bucket}/${key}`
|
||||
config[`${name}Url`] = url
|
||||
cfgStructure.config[`${name}Url`] = url
|
||||
// write back to db with url updated
|
||||
await db.put(config)
|
||||
await db.put(cfgStructure)
|
||||
|
||||
ctx.body = {
|
||||
message: "File has been uploaded and url stored to config.",
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
const { getAllRoles } = require("@budibase/auth/roles")
|
||||
const { getAllApps, getDeployedAppID, DocumentTypes } = require("@budibase/auth/db")
|
||||
const {
|
||||
getAllApps,
|
||||
getDeployedAppID,
|
||||
DocumentTypes,
|
||||
} = require("@budibase/auth/db")
|
||||
const CouchDB = require("../../../db")
|
||||
|
||||
exports.fetch = async ctx => {
|
||||
|
|
|
@ -29,7 +29,7 @@ exports.getApps = async ctx => {
|
|||
let url = app.url || encodeURI(`${app.name}`)
|
||||
url = `/${url.replace(URL_REGEX_SLASH, "")}`
|
||||
body[url] = {
|
||||
appId: app.instance._id,
|
||||
appId: app.appId,
|
||||
name: app.name,
|
||||
url,
|
||||
}
|
||||
|
|
|
@ -25,9 +25,9 @@ function smtpValidation() {
|
|||
function settingValidation() {
|
||||
// prettier-ignore
|
||||
return Joi.object({
|
||||
platformUrl: Joi.string().valid("", null),
|
||||
logoUrl: Joi.string().valid("", null),
|
||||
docsUrl: Joi.string().valid("", null),
|
||||
platformUrl: Joi.string().optional(),
|
||||
logoUrl: Joi.string().optional(),
|
||||
docsUrl: Joi.string().optional(),
|
||||
company: Joi.string().required(),
|
||||
}).unknown(true)
|
||||
}
|
||||
|
@ -44,9 +44,9 @@ function googleValidation() {
|
|||
function buildConfigSaveValidation() {
|
||||
// prettier-ignore
|
||||
return joiValidator.body(Joi.object({
|
||||
_id: Joi.string(),
|
||||
_rev: Joi.string(),
|
||||
group: Joi.string(),
|
||||
_id: Joi.string().optional(),
|
||||
_rev: Joi.string().optional(),
|
||||
group: Joi.string().optional(),
|
||||
type: Joi.string().valid(...Object.values(Configs)).required(),
|
||||
config: Joi.alternatives()
|
||||
.conditional("type", {
|
||||
|
|
|
@ -7,9 +7,8 @@ const {
|
|||
EmailTemplatePurpose,
|
||||
} = require("../constants")
|
||||
const { checkSlashesInUrl } = require("./index")
|
||||
const env = require("../environment")
|
||||
|
||||
const LOCAL_URL = `http://localhost:${env.PORT}`
|
||||
const LOCAL_URL = `http://localhost:10000`
|
||||
const BASE_COMPANY = "Budibase"
|
||||
|
||||
exports.getSettingsTemplateContext = async (purpose, code = null) => {
|
||||
|
@ -42,7 +41,7 @@ exports.getSettingsTemplateContext = async (purpose, code = null) => {
|
|||
case EmailTemplatePurpose.INVITATION:
|
||||
context[InternalTemplateBindings.INVITE_CODE] = code
|
||||
context[InternalTemplateBindings.INVITE_URL] = checkSlashesInUrl(
|
||||
`${URL}/invite?code=${code}`
|
||||
`${URL}/builder/invite?code=${code}`
|
||||
)
|
||||
break
|
||||
}
|
||||
|
|
|
@ -4559,7 +4559,7 @@ supports-color@^7.1.0:
|
|||
dependencies:
|
||||
has-flag "^4.0.0"
|
||||
|
||||
svelte@^3.37.0:
|
||||
svelte@^3.38.2:
|
||||
version "3.38.2"
|
||||
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.38.2.tgz#55e5c681f793ae349b5cc2fe58e5782af4275ef5"
|
||||
integrity sha512-q5Dq0/QHh4BLJyEVWGe7Cej5NWs040LWjMbicBGZ+3qpFWJ1YObRmUDZKbbovddLC9WW7THTj3kYbTOFmU9fbg==
|
||||
|
|
Loading…
Reference in New Issue