Merge branch 'next' of github.com:Budibase/budibase into feature/forgot-password

This commit is contained in:
mike12345567 2021-05-18 14:29:43 +01:00
commit 608c798cb9
100 changed files with 2416 additions and 665 deletions

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

@ -0,0 +1,193 @@
<p align="center">
<a href="https://www.budibase.com">
<img alt="Budibase" src="https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg" width="60" />
</a>
</p>
<h1 align="center">
Budibase
</h1>
<h3 align="center">
在您自己的设施上快速构建应用
</h3>
<p align="center">
Budibase是一个开放源代码的低代码平台可帮助开发人员和IT专业人员在几分钟内在自己的设施上构建、自动化和交付部署定制业务应用。
</p>
<h3 align="center">
🤖 🎨 🚀
</h3>
<p align="center">
<img src="https://i.imgur.com/tPQHruf.png">
</p>
<p align="center">
<a href="https://github.com/Budibase/budibase/releases">
<img alt="GitHub all releases" src="https://img.shields.io/github/downloads/Budibase/budibase/total">
</a>
<a href="https://github.com/Budibase/budibase/releases">
<img alt="GitHub release (latest by date)" src="https://img.shields.io/github/v/release/Budibase/budibase">
</a>
<a href="https://discord.gg/rCYayfe">
<img alt="Discord" src="https://img.shields.io/discord/733030666647765003">
</a>
<a href="https://twitter.com/intent/follow?screen_name=budibase">
<img src="https://img.shields.io/twitter/follow/budibase?style=social" alt="Follow @budibase" />
</a>
<img src="https://img.shields.io/badge/Contributor%20Covenant-v2.0%20adopted-ff69b4.svg" alt="Code of conduct" />
<a href="https://codecov.io/gh/Budibase/budibase">
<img src="https://codecov.io/gh/Budibase/budibase/graph/badge.svg?token=E8W2ZFXQOH"/>
</a>
</p>
<h3 align="center">
<a href="https://portal.budi.live/signup">注册</a>
<span> · </span>
<a href="https://docs.budibase.com">文档</a>
<span> · </span>
<a href="https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas">提出需求</a>
<span> · </span>
<a href="https://github.com/Budibase/budibase/issues">报告Bug</a>
<span> · </span>
支持: <a href="https://github.com/Budibase/budibase/discussions">讨论区</a>
<span> & </span>
<a href="https://discord.gg/rCYayfe">Discord</a>
</h3>
## ✨ 功能
当其他平台选择闭源路线时我们决定采用开放源代码。当其他平台选择云服务时我们决定提供本地构建器提供更好的开发体验。我们喜欢在Budibase上做不同的事情。
- **构建和发布真实的软件** 与其他平台不同使用Budibase可以构建和交付单页应用程序。Budibase应用程序具有增强的性能并且可以进行响应式设计从而为您的用户提供出色的体验。
- **开源且可扩展** Budibase是开源的。Builder使用AGPLv3许可Server使用GPLv3Client使用MPL。这会让您充满信心Budibase将永远存在。您还可以针对Budibase进行修改或对其进行分叉并根据需要进行更改以提供开发人员友好的体验。
- **连接数据库或直接开始** Budibase从多个来源包括MongoDBCouchDBPostgreSQLmySQLAirtableGoogle SheetsS3DyanmoDB或REST API提取数据。与其他平台不同使用Budibase可以从头开始创建没有数据源的业务应用程序。[新的数据源需求](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas)。
- **使用强大的预制组件设计和构建应用程序** Budibase开箱即用具有精美设计功能强大的组件您可以使用它们像构建基块来构建UI。我们还提供了许多您最喜欢的CSS样式选项因此您可以发挥更多的创意。[新的组件需求](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas)。
- **自动化流程与其他工具集成并连接到Webhooks** 通过自动化手动流程和工作流来节省时间。从连接到Webhook到自动发送电子邮件只需告诉Budibase要做些什么然后让它为您工作。您可以[在此处](https://github.com/Budibase/automations)轻松地[为Budibase创建新的自动化](https://github.com/Budibase/automations)或[在此处](https://github.com/Budibase/automations)[新的集成需求](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas)。
- **云托管和自我托管** 用户可以自行托管请参见下文或使用Budibase托管其应用。目前我们的云托管产品仅限于免费套餐但我们会在将来进行更改。对于大量使用我们建议用户进行自我托管。
<p align="center">
<img alt="Budibase design ui" src="https://imgur.com/v8m6v3q.png">
</p>
## ⌛ 状态
- [x] Alpha我们正在将Budibase演示给用户并收到反馈
- [x] 内部测试我们正在与一组封闭的客户一起测试Budibase
- [x] 公开测试:任何人都可以[注册并使用Budibase](https://portal.budi.live/signup)
- [ ] 正式发布
关注此存储库的“release”以获取主要更新的通知并给我们一个star~。
<p align="center">
<img src="https://i.imgur.com/cJpgqm8.png">
</p>
### Star时序图
[![Stargazers over time](https://starchart.cc/Budibase/budibase.svg)](https://starchart.cc/Budibase/budibase)
如果您在两个版本之间遇到问题,请使用[此处](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md#troubleshooting)的指南清理您的环境。
## 🏁 Budibase入门
Budibase构建器在MacPC和Linux上的Electron中运行。请按照以下步骤开始
- [ ] [注册Budibase](https://portal.budi.live/signup)
- [ ] 创建用户名和密码
- [ ] 复制您的API密钥
- [ ] 下载Budibase
- [ ] 打开Budibase并输入您的API密钥
如果您需要其他帮助,请[参阅以下指导教程](https://docs.budibase.com/tutorial/tutorial-signing-up)。
## 🤖 自托管
Budibase希望确保任何人都可以使用我们开发的工具并且我们知道很多人需要能够在自己的系统上托管他们制作的应用程序——这就是为什么我们决定尝试使自托管服务变得如此简单的原因
当前您可以使用Docker或Digital Ocean托管您的应用程序。可以在[此处](https://docs.budibase.com/self-hosting/introduction-to-self-hosting)找到有关自我托管的文档。
[![Deploy to DO](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/droplets/new?onboarding_origin=marketplace&i=09038e&fleetUuid=bb04f9c8-1de8-4687-b2ae-1d5177a0535b&appId=77729671&type=applications&size=s-4vcpu-8gb&region=nyc1&refcode=0caaa6085a82&image=budibase-20-04)
## 🎓 学习Budibase
Budibase[文档](https://docs.budibase.com/)位于[此处](https://docs.budibase.com/)。
您还可以按照有关[如何使用Budibase构建CRM](https://docs.budibase.com/tutorial/tutorial-introduction)的快速教程进行[操作](https://docs.budibase.com/tutorial/tutorial-introduction)
## 路线图
查看我们的[公共路线图](https://github.com/Budibase/budibase/projects/10)。如果您想讨论路线图上的某些项目,请与[Discord](https://discord.gg/rCYayfe)或通过Github进行[讨论](https://github.com/Budibase/budibase/discussions)
## ❗ 行为守则
Budibase致力于为每个人提供热情多样且无烦恼的体验。我们希望Budibase社区中的每个人都遵守我们的[**行为准则**](https://github.com/Budibase/budibase/blob/HEAD/.github/CODE_OF_CONDUCT.md)。
## 🙌 为Budibase贡献
从错误报告到PR请求每一个贡献都将受到赞赏和欢迎。如果您打算实施一项新功能或更改API请先创建一个Issue。这样我们可以确保您的工作没有白费。
### 不知道从哪里开始?
[第一次提出Issue](https://github.com/Budibase/budibase/projects/22)是一个开始做出贡献的好地方。
### 存储库的组织方式
Budibase是由lerna管理的项目。Lerna管理budibase软件包的构建和发布。在较高的层次上这里是构成Budibase的软件包。
- [packages/builder](https://github.com/Budibase/budibase/tree/HEAD/packages/builder)-包含budibase构建器客户端苗条应用程序的代码。
- [packages/client](https://github.com/Budibase/budibase/tree/HEAD/packages/client)-在浏览器中运行的模块负责读取JSON定义并从中创建生动的Web应用程序。
- [packages/server](https://github.com/Budibase/budibase/tree/HEAD/packages/server)-budibase服务器。该Koa应用程序负责为构建器和budibase应用程序提供JS服务并提供与数据库和文件系统交互的API。
有关更多信息,请参见[CONTRIBUTING.md](https://github.com/Budibase/budibase/blob/HEAD/.github/CONTRIBUTING.md)
## 📝 开源协议
Budibase是开源的。该构建器的许可证为[AGPL v3](https://www.gnu.org/licenses/agpl-3.0.en.html),服务器的许可证为[GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html),客户端的许可证为[MPL](https://directory.fsf.org/wiki/License:MPL-2.0)。
## 💬 保持联系
如果您有任何疑问或想与其他Budibase用户交谈请跳至[Github讨论](https://github.com/Budibase/budibase/discussions)或加入我们的Discord服务器
[Discord 聊天室](https://discord.gg/rCYayfe)
![Discord Shield](https://discordapp.com/api/guilds/733030666647765003/widget.png?style=shield)
## 贡献者 ✨
感谢这些出色的人 ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="http://martinmck.com"><img src="https://avatars1.githubusercontent.com/u/11256663?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Martin McKeaveney</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=shogunpurple" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=shogunpurple" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=shogunpurple" title="Tests">⚠️</a> <a href="#infra-shogunpurple" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="http://www.michaeldrury.co.uk/"><img src="https://avatars2.githubusercontent.com/u/4407001?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michael Drury</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=mike12345567" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=mike12345567" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=mike12345567" title="Tests">⚠️</a> <a href="#infra-mike12345567" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/aptkingston"><img src="https://avatars3.githubusercontent.com/u/9075550?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Andrew Kingston</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=aptkingston" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=aptkingston" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=aptkingston" title="Tests">⚠️</a> <a href="#design-aptkingston" title="Design">🎨</a></td>
<td align="center"><a href="https://budibase.com/"><img src="https://avatars3.githubusercontent.com/u/3524181?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michael Shanks</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=mjashanks" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/kevmodrome"><img src="https://avatars3.githubusercontent.com/u/534488?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Kevin Åberg Kultalahti</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=kevmodrome" title="Tests">⚠️</a></td>
<td align="center"><a href="https://www.budibase.com/"><img src="https://avatars2.githubusercontent.com/u/49767913?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Joe</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=joebudi" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=joebudi" title="Code">💻</a> <a href="#content-joebudi" title="Content">🖋</a> <a href="#design-joebudi" title="Design">🎨</a></td>
<td align="center"><a href="https://github.com/Conor-Mack"><img src="https://avatars1.githubusercontent.com/u/36074859?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Conor_Mack</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=Conor-Mack" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=Conor-Mack" title="Tests">⚠️</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/pngwn"><img src="https://avatars1.githubusercontent.com/u/12937446?v=4?s=100" width="100px;" alt=""/><br /><sub><b>pngwn</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=pngwn" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=pngwn" title="Tests">⚠️</a></td>
<td align="center"><a href="https://github.com/HugoLd"><img src="https://avatars0.githubusercontent.com/u/26521848?v=4?s=100" width="100px;" alt=""/><br /><sub><b>HugoLd</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=HugoLd" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/victoriasloan"><img src="https://avatars.githubusercontent.com/u/9913651?v=4?s=100" width="100px;" alt=""/><br /><sub><b>victoriasloan</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=victoriasloan" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/yashank09"><img src="https://avatars.githubusercontent.com/u/37672190?v=4?s=100" width="100px;" alt=""/><br /><sub><b>yashank09</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=yashank09" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/SOVLOOKUP"><img src="https://avatars.githubusercontent.com/u/53158137?v=4?s=100" width="100px;" alt=""/><br /><sub><b>SOVLOOKUP</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=SOVLOOKUP" title="Code">💻</a></td>
</tr>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
该项目遵循[所有参与者的](https://github.com/all-contributors/all-contributors)规范。欢迎任何形式的捐助!

View File

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

View File

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

View File

@ -56,6 +56,7 @@
"@spectrum-css/link": "^3.1.1",
"@spectrum-css/menu": "^3.0.1",
"@spectrum-css/modal": "^3.0.1",
"@spectrum-css/pagination": "^3.0.3",
"@spectrum-css/picker": "^1.0.1",
"@spectrum-css/popover": "^3.0.1",
"@spectrum-css/progressbar": "^1.0.2",

View File

@ -16,7 +16,10 @@
function getInitials(name) {
let parts = name.split(" ")
return parts.map(name => name[0]).join("")
if (parts.length > 0) {
return parts.map(name => name[0]).join("")
}
return name
}
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,16 @@
import CellRenderer from "./CellRenderer.svelte"
import SelectEditRenderer from "./SelectEditRenderer.svelte"
/**
* The expected schema is our normal couch schemas for our tables.
* Each field schema can be enriched with a few extra properties to customise
* the behaviour.
* All of these are optional and do not need to be added.
* displayName: Overrides the field name displayed as the column title
* sortable: Set to false to disable sorting data by a certain column
* editable: Set to false to disable editing a certain column if the
* allowEditColumns prop is true
*/
export let data = []
export let schema = {}
export let showAutoColumns = false
@ -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;

View File

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

View File

@ -51,6 +51,7 @@ export { default as TreeView } from "./TreeView/Tree.svelte"
export { default as TreeItem } from "./TreeView/Item.svelte"
export { default as Divider } from "./Divider/Divider.svelte"
export { default as Search } from "./Form/Search.svelte"
export { default as Pagination } from "./Pagination/Pagination.svelte"
// Typography
export { default as Body } from "./Typography/Body.svelte"

View File

@ -161,6 +161,11 @@
resolved "https://registry.yarnpkg.com/@spectrum-css/modal/-/modal-3.0.2.tgz#58b6621cab65f90788d310374f40df1f7090473f"
integrity sha512-YnIivJhoaao7Otu+HV7sgebPyFbO6sd/oMvTN/Rb2wwgnaMnIIuIRdGandSrcgotN2uNgs+P0knG6mv/xA1/dg==
"@spectrum-css/pagination@^3.0.3":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@spectrum-css/pagination/-/pagination-3.0.3.tgz#b204c3ada384c4af751a354bc428346d82eeea65"
integrity sha512-OJ/v9GeNXJOZ9Yr9LDBYPrR2NCiLOWP9wANT/a5sqFuugRnQbn/HYMnRp9TBxwpDY6ihaPo0T/wi7kLiAJFdDw==
"@spectrum-css/picker@^1.0.1":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@spectrum-css/picker/-/picker-1.0.2.tgz#b49429ae3c89f9c5f2c0530787ce45392c9612ff"

View File

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

View File

@ -5,6 +5,7 @@ export const gradient = (node, config = {}) => {
lightness: 0.7,
softness: 0.9,
seed: null,
version: null,
}
// Applies a gradient background
@ -15,6 +16,7 @@ export const gradient = (node, config = {}) => {
}
const { saturation, lightness, softness, points } = config
const seed = config.seed || Math.random().toString(32).substring(2)
const version = config.version ?? 0
// Hash function which returns a fixed hash between specified limits
// for a given seed and a given version
@ -69,10 +71,10 @@ export const gradient = (node, config = {}) => {
)
}
let css = `opacity:0.9;background:${randomHSL(seed, 0, 0.7)};`
let css = `opacity:0.9;background:${randomHSL(seed, version, 0.7)};`
css += "background-image:"
for (let i = 0; i < points - 1; i++) {
css += `${randomGradientPoint(seed, i)},`
css += `${randomGradientPoint(seed, version + i)},`
}
css += `${randomGradientPoint(seed, points)};`
node.style = css

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
</ModalContent>
<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
>
</ModalContent>
</Modal>

View File

@ -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}
>
<span>The changes you have made will be deleted and the application reverted back to its production state.</span>
</ModalContent>
<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
>
</ModalContent>
</Modal>

View File

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

View File

@ -3,20 +3,34 @@
import { makePropSafe } from "@budibase/string-templates"
import { currentAsset, store } from "builderStore"
import { findComponentPath } from "builderStore/storeUtils"
import { createEventDispatcher, onMount } from "svelte"
export let value
export let onChange
const dispatch = createEventDispatcher()
const getValue = component => `{{ literal ${makePropSafe(component._id)} }}`
$: path = findComponentPath($currentAsset.props, $store.selectedComponentId)
$: providers = path.filter(
component =>
component._component === "@budibase/standard-components/dataprovider"
)
// Set initial value to closest data provider
onMount(() => {
const valid = value && providers.find(x => getValue(x) === value) != null
if (!valid && providers.length) {
dispatch("change", getValue(providers[providers.length - 1]))
}
})
</script>
<Select
{value}
placeholder={null}
on:change
options={providers}
getOptionLabel={component => component._instanceName}
getOptionValue={component => `{{ literal ${makePropSafe(component._id)} }}`}
getOptionValue={getValue}
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,10 +6,6 @@ html, body {
min-height: 100%;
}
.spectrum--light {
--spectrum-alias-background-color-primary: var(--spectrum-global-color-gray-75);
}
body {
--background: var(--spectrum-alias-background-color-primary);
--background-alt: var(--spectrum-alias-background-color-secondary);

View File

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

View File

@ -0,0 +1,9 @@
export { default as fetchData } from "./fetchData"
export {
buildStyle,
convertCamel,
pipe,
capitalise,
get_name,
get_capitalised_name,
} from "./helpers"

View File

@ -0,0 +1,2 @@
export { emailValidator, requiredValidator } from "./validators"
export { createValidationStore } from "./validation"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()
})
organisation.init()
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",

View File

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

View File

@ -0,0 +1,7 @@
<script>
import { Page } from "@budibase/bbui"
</script>
<Page>
<slot />
</Page>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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>
</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>
<Divider size="S" />
@ -103,6 +131,9 @@
.file {
max-width: 30ch;
}
.logo {
align-items: start;
}
.intro {
display: grid;
}

View File

@ -1,4 +1,5 @@
export { organisation } from "./organisation"
export { users } from "./users"
export { admin } from "./admin"
export { apps } from "./apps"
export { email } from "./email"

View File

@ -1,35 +1,46 @@
import { writable } from "svelte/store"
import { writable, get } from "svelte/store"
import api from "builderStore/api"
const FALLBACK_CONFIG = {
platformUrl: "",
logoUrl: "",
docsUrl: "",
company: "http://localhost:10000",
}
export function createOrganisationStore() {
const { subscribe, set } = writable({})
const store = writable({})
const { subscribe, set } = store
async function init() {
try {
const response = await api.get(`/api/admin/configs/settings`)
const json = await response.json()
set(json)
} catch (error) {
set({
platformUrl: "",
logoUrl: "",
docsUrl: "",
company: "",
})
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,
}
}

View File

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

View File

@ -18,19 +18,37 @@ export const fetchTableData = async tableId => {
}
/**
* Perform a mango query against an internal table
* @param {String} tableId - id of the table to search
* @param {Object} search - Mango Compliant search object
* @param {Object} pagination - the pagination controls
* Searches a table using Lucene.
*/
export const searchTableData = async ({ tableId, search, pagination }) => {
const output = await API.post({
url: `/api/${tableId}/rows/search`,
export const searchTable = async ({
tableId,
query,
bookmark,
limit,
sort,
sortOrder,
sortType,
paginate,
}) => {
if (!tableId || !query) {
return {
rows: [],
}
}
const res = await API.post({
url: `/api/search/${tableId}/rows`,
body: {
query: search,
pagination,
query,
bookmark,
limit,
sort,
sortOrder,
sortType,
paginate,
},
})
output.rows = await enrichRows(output.rows, tableId)
return output
return {
...res,
rows: await enrichRows(res?.rows, tableId),
}
}

View File

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

View File

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

View File

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

View File

@ -16,7 +16,6 @@ const {
const { FieldTypes } = require("../../constants")
const { isEqual } = require("lodash")
const { cloneDeep } = require("lodash/fp")
const { QueryBuilder, search } = require("./search/utils")
const TABLE_VIEW_BEGINS_WITH = `all${SEPARATOR}${DocumentTypes.TABLE}${SEPARATOR}`
@ -248,45 +247,6 @@ exports.fetchView = async function (ctx) {
}
}
exports.search = async function (ctx) {
const appId = ctx.appId
const db = new CouchDB(appId)
const {
query,
pagination: { pageSize = 10, bookmark },
} = ctx.request.body
const tableId = ctx.params.tableId
const queryBuilder = new QueryBuilder(appId)
.setLimit(pageSize)
.addTable(tableId)
if (bookmark) {
queryBuilder.setBookmark(bookmark)
}
let searchString
if (ctx.query && ctx.query.raw && ctx.query.raw !== "") {
searchString = queryBuilder.complete(query["RAW"])
} else {
// make all strings a starts with operation rather than pure equality
for (const [key, queryVal] of Object.entries(query)) {
if (typeof queryVal === "string") {
queryBuilder.addString(key, queryVal)
} else {
queryBuilder.addEqual(key, queryVal)
}
}
searchString = queryBuilder.complete()
}
const response = await search(searchString)
const table = await db.get(tableId)
ctx.body = {
rows: await outputProcessing(appId, table, response.rows),
bookmark: response.bookmark,
}
}
exports.fetchTableRows = async function (ctx) {
const appId = ctx.appId
const db = new CouchDB(appId)

View File

@ -1,18 +1,26 @@
const { QueryBuilder, buildSearchUrl, search } = require("./utils")
const { fullSearch, paginatedSearch } = require("./utils")
const CouchDB = require("../../../db")
const { outputProcessing } = require("../../../utilities/rowProcessor")
exports.rowSearch = async ctx => {
const appId = ctx.appId
const { tableId } = ctx.params
const { bookmark, query, raw } = ctx.request.body
let url
if (query) {
url = new QueryBuilder(appId, query, bookmark).addTable(tableId).complete()
} else if (raw) {
url = buildSearchUrl({
appId,
query: raw,
bookmark,
})
const db = new CouchDB(appId)
const { paginate, query, ...params } = ctx.request.body
params.tableId = tableId
let response
if (paginate) {
response = await paginatedSearch(appId, query, params)
} else {
response = await fullSearch(appId, query, params)
}
ctx.body = await search(url)
// Enrich search results with relationships
if (response.rows && response.rows.length) {
const table = await db.get(tableId)
response.rows = await outputProcessing(appId, table, response.rows)
}
ctx.body = response
}

View File

@ -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}*`)
build(this.query.string, (key, value) => {
return value ? `${key}:${luceneEscape(value.toLowerCase())}*` : null
})
}
if (this.query.range) {
build(
this.query.range,
(key, value) => `${key}:[${value.low} TO ${value.high}]`
)
build(this.query.range, (key, value) => {
if (!value) {
return null
}
if (value.low == null || value.low === "") {
return null
}
if (value.high == null || value.high === "") {
return null
}
return `${key}:[${value.low} TO ${value.high}]`
})
}
if (this.query.fuzzy) {
build(this.query.fuzzy, (key, value) => `${key}:${value}~`)
build(this.query.fuzzy, (key, value) => {
return value ? `${key}:${luceneEscape(value.toLowerCase())}~` : null
})
}
if (this.query.equal) {
build(this.query.equal, (key, value) => `${key}:${value}`)
build(this.query.equal, (key, value) => {
return value ? `${key}:${luceneEscape(value.toLowerCase())}` : null
})
}
if (rawQuery) {
output = output.length === 0 ? rawQuery : `&${rawQuery}`
if (this.query.notEqual) {
build(this.query.notEqual, (key, value) => {
return value ? `!${key}:${luceneEscape(value.toLowerCase())}` : null
})
}
return buildSearchUrl({
appId: this.appId,
query: output,
bookmark: this.bookmark,
limit: this.limit,
})
if (this.query.empty) {
build(this.query.empty, key => `!${key}:["" TO *]`)
}
if (this.query.notEmpty) {
build(this.query.notEmpty, key => `${key}:["" TO *]`)
}
return query
}
buildSearchBody() {
let body = {
q: this.buildSearchQuery(),
limit: Math.min(this.limit, 200),
include_docs: this.includeDocs,
}
if (this.bookmark) {
body.bookmark = this.bookmark
}
if (this.sort) {
const order = this.sortOrder === "descending" ? "-" : ""
const type = `<${this.sortType}>`
body.sort = `${order}${this.sort.replace(/ /, "_")}${type}`
}
return body
}
async run() {
const url = `${env.COUCH_DB_URL}/${this.appId}/_design/database/_search/${SearchIndexes.ROWS}`
const body = this.buildSearchBody()
return await runQuery(url, body)
}
}
exports.search = async query => {
const response = await fetch(query, {
method: "GET",
/**
* Executes a lucene search query.
* @param url The query URL
* @param body The request body defining search criteria
* @returns {Promise<{rows: []}>}
*/
const runQuery = async (url, body) => {
const response = await fetch(url, {
body: JSON.stringify(body),
method: "POST",
})
const json = await response.json()
let output = {
@ -133,5 +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 }
}

View File

@ -23,6 +23,7 @@ const queryRoutes = require("./query")
const hostingRoutes = require("./hosting")
const backupRoutes = require("./backup")
const devRoutes = require("./dev")
const searchRoutes = require("./search")
exports.mainRoutes = [
authRoutes,
@ -51,6 +52,7 @@ exports.mainRoutes = [
// this could be breaking as koa may recognise other routes as this
tableRoutes,
rowRoutes,
searchRoutes,
]
exports.staticRoutes = staticRoutes

View File

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

View File

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

View File

@ -123,24 +123,6 @@ function processAutoColumn(user, table, row) {
return { table, row }
}
/**
* Given a set of rows and the table they came from this function will sort by auto ID or a custom
* method if provided (not implemented yet).
*/
function sortRows(table, rows) {
// sort based on auto ID (if found)
let autoIDColumn = Object.entries(table.schema).find(
schema => schema[1].subtype === AutoFieldSubTypes.AUTO_ID
)
// get the column name, this is the first element in the array (Object.entries)
autoIDColumn = autoIDColumn && autoIDColumn.length ? autoIDColumn[0] : null
if (autoIDColumn) {
// sort in ascending order
rows.sort((a, b) => a[autoIDColumn] - b[autoIDColumn])
}
return rows
}
/**
* Looks through the rows provided and finds formulas - which it then processes.
*/
@ -213,8 +195,6 @@ exports.outputProcessing = async (appId, table, rows) => {
rows = [rows]
wasArray = false
}
// sort by auto ID
rows = sortRows(table, rows)
// attach any linked row information
let enriched = await linkRows.attachFullLinkedDocs(appId, table, rows)

View File

@ -65,41 +65,6 @@
"type": "schema"
}
},
"search": {
"name": "Search",
"description": "A searchable list of items.",
"icon": "Search",
"styleable": true,
"hasChildren": true,
"settings": [
{
"type": "table",
"label": "Table",
"key": "table"
},
{
"type": "multifield",
"label": "Columns",
"key": "columns",
"dependsOn": "table"
},
{
"type": "number",
"label": "Rows/Page",
"defaultValue": 25,
"key": "pageSize"
},
{
"type": "text",
"label": "Empty Text",
"key": "noRowsMessage",
"defaultValue": "No rows found."
}
],
"context": {
"type": "schema"
}
},
"stackedlist": {
"name": "Stacked List",
"icon": "TaskList",
@ -1416,6 +1381,7 @@
},
"dataprovider": {
"name": "Data Provider",
"info": "Pagination is only available for data stored in internal tables.",
"icon": "Data",
"styleable": false,
"hasChildren": true,
@ -1445,7 +1411,14 @@
{
"type": "number",
"label": "Limit",
"key": "limit"
"key": "limit",
"defaultValue": 50
},
{
"type": "boolean",
"label": "Paginate",
"key": "paginate",
"defaultValue": true
}
],
"context": {
@ -1464,12 +1437,8 @@
"key": "schema"
},
{
"label": "Loading",
"key": "loading"
},
{
"label": "Loaded",
"key": "loaded"
"label": "Page Number",
"key": "pageNumber"
}
]
}

View File

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

View File

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

View File

@ -10,9 +10,9 @@
{#if options}
<div use:chart={options} use:styleable={$component.styles} />
{:else if builderStore.inBuilder}
<div use:styleable={$component.styles}>
Use the settings panel to build your chart -->
{:else if $builderStore.inBuilder}
<div class="placeholder" use:styleable={$component.styles}>
Use the settings panel to build your chart.
</div>
{/if}
@ -21,4 +21,10 @@
display: flex !important;
text-transform: capitalize;
}
div :global(.apexcharts-yaxis-label, .apexcharts-xaxis-label) {
fill: #aaa;
}
div.placeholder {
padding: 10px;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

@ -23,11 +23,24 @@ module.exports.removeNull = obj => {
return obj
}
module.exports.addConstants = obj => {
module.exports.updateContext = obj => {
if (obj.now == null) {
obj.now = new Date()
obj.now = new Date().toISOString()
}
return obj
function recurse(obj) {
for (let key of Object.keys(obj)) {
if (!obj[key]) {
continue
}
if (obj[key] instanceof Date) {
obj[key] = obj[key].toISOString()
} else if (typeof obj[key] === "object") {
obj[key] = recurse(obj[key])
}
}
return obj
}
return recurse(obj)
}
module.exports.removeHandlebarsStatements = string => {

View File

@ -107,6 +107,12 @@ describe("check the utility functions", () => {
const property = makePropSafe("thing")
expect(property).toEqual("[thing]")
})
it("should be able to handle an input date object", async () => {
const date = new Date()
const output = await processString("{{ dateObj }}", { dateObj: date })
expect(date.toISOString()).toEqual(output)
})
})
describe("check manifest", () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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