Merge branch 'feat/BUDI-8046' into display_scim_sync_per_row

This commit is contained in:
Adria Navarro 2024-03-07 13:33:59 +01:00
commit 73c141c0fb
213 changed files with 6427 additions and 2127 deletions

View File

@ -44,7 +44,8 @@
"no-undef": "off", "no-undef": "off",
"no-prototype-builtins": "off", "no-prototype-builtins": "off",
"local-rules/no-budibase-imports": "error", "local-rules/no-budibase-imports": "error",
"local-rules/no-test-com": "error" "local-rules/no-test-com": "error",
"local-rules/email-domain-example-com": "error"
} }
}, },
{ {

View File

@ -51,4 +51,41 @@ module.exports = {
} }
}, },
}, },
"email-domain-example-com": {
meta: {
type: "problem",
docs: {
description:
"enforce using the example.com domain for generator.email calls",
category: "Possible Errors",
recommended: false,
},
fixable: "code",
schema: [],
},
create: function (context) {
return {
CallExpression(node) {
if (
node.callee.type === "MemberExpression" &&
node.callee.object.name === "generator" &&
node.callee.property.name === "email" &&
node.arguments.length === 0
) {
context.report({
node,
message:
"Prefer using generator.email with the domain \"{ domain: 'example.com' }\".",
fix: function (fixer) {
return fixer.replaceText(
node,
'generator.email({ domain: "example.com" })'
)
},
})
}
},
}
},
},
} }

221
i18n/README.kr.md Normal file
View File

@ -0,0 +1,221 @@
<p align="center">
<a href="https://www.budibase.com">
<img alt="Budibase" src="https://res.cloudinary.com/daog6scxm/image/upload/v1696515725/Branding/Assets/Symbol/RGB/Full%20Colour/Budibase_Symbol_RGB_FullColour_cbqvha_1_z5cwq2.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 alt="Budibase design ui" src="https://res.cloudinary.com/daog6scxm/image/upload/v1633524049/ui/design-ui-wide-mobile_gdaveq.jpg">
</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://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://docs.budibase.com/getting-started">소개</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">버그 보고</a>
<span> · </span>
지원: <a href="https://github.com/Budibase/budibase/discussions">토론</a>
</h3>
<br /><br />
## ✨ 특징
### "실제" 소프트웨어를 구축할 수 있습니다.
Budibase를 사용하면 고성능 단일 페이지 애플리케이션을 구축할 수 있습니다. 또한 반응형 디자인으로 제작하여 사용자에게 멋진 경험을 제공할 수 있습니다.
<br /><br />
### 오픈 소스 및 확장성
Budibase는 오픈소스이며, GPL v3 라이선스에 따라 공개되어 있습니다. 이는 Budibase가 항상 당신 곁에 있다는 안도감을 줄 것입니다. 그리고 우리는 개발자 친화적인 환경을 제공하고 있기 때문에, 당신은 원하는 만큼 소스 코드를 포크하여 수정하거나 Budibase에 직접 기여할 수 있습니다.
<br /><br />
### 기존 데이터 또는 처음부터 시작
Budibase를 사용하면 다음과 같은 여러 소스에서 데이터를 가져올 수 있습니다: MondoDB, CouchDB, PostgreSQL, MySQL, Airtable, S3, DynamoDB 또는 REST API.
또는 원하는 경우 외부 도구 없이도 Budibase를 사용하여 처음부터 시작하여 자체 애플리케이션을 구축할 수 있습니다.[데이터 소스 제안](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
<p align="center">
<img alt="Budibase data" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970242/Out%20of%20beta%20launch/data_n1tlhf.png">
</p>
<br /><br />
### 강력한 내장 구성 요소로 애플리케이션을 설계하고 구축할 수 있습니다.
Budibase에는 아름답게 디자인된 강력한 컴포넌트들이 제공되며, 이를 사용하여 UI를 쉽게 구축할 수 있습니다. 또한, CSS를 통한 스타일링 옵션도 풍부하게 제공되어 보다 창의적인 표현도 가능하다.
[Request new component](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
<p align="center">
<img alt="Budibase design" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970243/Out%20of%20beta%20launch/design-like-a-pro_qhlfeu.gif">
</p>
<br /><br />
### 프로세스를 자동화하고, 다른 도구와 연동하고, 웹훅으로 연결하세요!
워크플로우와 수동 프로세스를 자동화하여 시간을 절약하세요. 웹훅 이벤트 연결부터 이메일 자동화까지, Budibase에 수행할 작업을 지시하기만 하면 자동으로 처리됩니다. [새로운 자동화 만들기](https://github.com/Budibase/automations)또는[새로운 자동화를 요청할 수 있습니다](https://github.com/Budibase/budibase/discussions?discussions_q=category%3AIdeas).
<p align="center">
<img alt="Budibase automations" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970486/Out%20of%20beta%20launch/automation_riro7u.png">
</p>
<br /><br />
### 선호하는 도구
Budibase는 사용자의 선호도에 따라 애플리케이션을 구축할 수 있는 다양한 도구를 통합하고 있습니다.
<p align="center">
<img alt="Budibase integrations" src="https://res.cloudinary.com/daog6scxm/image/upload/v1636970242/Out%20of%20beta%20launch/integrations_kc7dqt.png">
</p>
<br /><br />
### 관리자의 천국
Budibase는 어떤 규모의 프로젝트에도 유연하게 대응할 수 있으며, Budibase를 사용하면 개인 또는 조직의 서버에서 자체 호스팅하고 사용자, 온보딩, SMTP, 앱, 그룹, 테마 등을 한꺼번에 관리할 수 있습니다. 또한, 사용자나 그룹에 앱 포털을 제공하고 그룹 관리자에게 사용자 관리를 맡길 수도 있다.
- 프로모션 비디오: https://youtu.be/xoljVpty_Kw
<br /><br /><br />
## 🏁 시작
Docker, Kubernetes 또는 Digital Ocean을 사용하여 자체 인프라에서 Budibase를 호스팅하거나, 걱정 없이 빠르게 애플리케이션을 구축하려는 경우 클라우드에서 Budibase를 사용할 수 있습니다.
### [Budibase 셀프 호스팅으로 시작하기](https://docs.budibase.com/docs/hosting-methods)
- [Docker - single ARM compatible image](https://docs.budibase.com/docs/docker)
- [Docker Compose](https://docs.budibase.com/docs/docker-compose)
- [Kubernetes](https://docs.budibase.com/docs/kubernetes-k8s)
- [Digital Ocean](https://docs.budibase.com/docs/digitalocean)
- [Portainer](https://docs.budibase.com/docs/portainer)
### [클라우드에서 Budibase 시작하기](https://budibase.com)
<br /><br />
## 🎓 Budibase 알아보기
문서 [documentacion de Budibase](https://docs.budibase.com/docs).
<br />
<br /><br />
## 💬 커뮤니티
질문하고, 다른 사람을 돕고, 다른 Budibase 사용자와 즐거운 대화를 나눌 수 있는 Budibase 커뮤니티에 여러분을 초대합니다.
[깃허브 토론](https://github.com/Budibase/budibase/discussions)
<br /><br /><br />
## ❗ 행동강령
Budibase 는 모든 계층의 사람들을 환영하고 상호 존중하는 환경을 제공하는 데 특별한 주의를 기울이고 있습니다. 저희는 커뮤니티에도 같은 기대를 가지고 있습니다.
[**행동 강령**](https://github.com/Budibase/budibase/blob/HEAD/.github/CODE_OF_CONDUCT.md).
<br />
<br /><br />
## 🙌 Contribuir en Budibase
버그 신고부터 코드의 버그 수정에 이르기까지 모든 기여를 감사하고 환영합니다. 새로운 기능을 구현하거나 API를 변경할 계획이 있다면 [여기에 새 메시지](https://github.com/Budibase/budibase/issues),
이렇게 하면 여러분의 노력이 헛되지 않도록 보장할 수 있습니다.
여기에는 다음을 위해 Budibase 환경을 설정하는 방법에 대한 지침이 나와 있습니다. [여기를 클릭하세요](https://github.com/Budibase/budibase/tree/HEAD/docs/CONTRIBUTING.md).
### 어디서부터 시작해야 할지 혼란스러우신가요?
이곳은 기여를 시작하기에 최적의 장소입니다! [First time issues project](https://github.com/Budibase/budibase/projects/22).
### 리포지토리 구성
Budibase는 Lerna에서 관리하는 단일 리포지토리입니다. Lerna는 변경 사항이 있을 때마다 이를 동기화하여 Budibase 패키지를 빌드하고 게시합니다. 크게 보면 이러한 패키지가 Budibase를 구성하는 패키지입니다:
- [packages/builder](https://github.com/Budibase/budibase/tree/HEAD/packages/builder) - budibase builder 클라이언트 측의 svelte 애플리케이션 코드가 포함되어 있습니다.
- [packages/client](https://github.com/Budibase/budibase/tree/HEAD/packages/client) - budibase builder 클라이언트 측의 svelte 애플리케이션 코드가 포함되어 있습니다.
- [packages/server](https://github.com/Budibase/budibase/tree/HEAD/packages/server) - Budibase의 서버 부분입니다. 이 Koa 애플리케이션은 빌더에게 Budibase 애플리케이션을 생성하는 데 필요한 것을 제공하는 역할을 합니다. 또한 데이터베이스 및 파일 저장소와 상호 작용할 수 있는 API를 제공합니다.
자세한 내용은 다음 문서를 참조하세요. [CONTRIBUTING.md](https://github.com/Budibase/budibase/blob/HEAD/docs/CONTRIBUTING.md)
<br /><br />
## 📝 라이선스
Budibase는 오픈 소스이며, 라이선스는 다음과 같습니다 [GPL v3](https://www.gnu.org/licenses/gpl-3.0.en.html). 클라이언트 및 컴포넌트 라이브러리는 다음과 같이 라이선스가 부여됩니다. [MPL](https://directory.fsf.org/wiki/License:MPL-2.0) - 이렇게 하면 빌드한 애플리케이션에 원하는 대로 라이선스를 부여할 수 있습니다.
<br /><br />
## ⭐ 스타 수의 역사
[![Stargazers over time](https://starchart.cc/Budibase/budibase.svg)](https://starchart.cc/Budibase/budibase)
빌더 업데이트 중 문제가 발생하는 경우 [여기](https://github.com/Budibase/budibase/blob/HEAD/docs/CONTRIBUTING.md#troubleshooting) 를 참고하여 환경을 정리해 주세요.
<br /><br />
## Contributors ✨
훌륭한 여러분께 감사할 따름입니다. ([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/Rory-Powell"><img src="https://avatars.githubusercontent.com/u/8755148?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Rory Powell</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=Rory-Powell" title="Tests">⚠️</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/PClmnt"><img src="https://avatars.githubusercontent.com/u/5665926?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Peter Clement</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=PClmnt" title="Code">💻</a> <a href="https://github.com/Budibase/budibase/commits?author=PClmnt" title="Documentation">📖</a> <a href="https://github.com/Budibase/budibase/commits?author=PClmnt" title="Tests">⚠️</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>
<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>
<td align="center"><a href="https://github.com/seoulaja"><img src="https://avatars.githubusercontent.com/u/15101654?v=4?s=100" width="100px;" alt=""/><br /><sub><b>seoulaja</b></sub></a><br /><a href="#translation-seoulaja" title="Translation">🌍</a></td>
<td align="center"><a href="https://github.com/mslourens"><img src="https://avatars.githubusercontent.com/u/1907152?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Maurits Lourens</b></sub></a><br /><a href="https://github.com/Budibase/budibase/commits?author=mslourens" title="Tests">⚠️</a> <a href="https://github.com/Budibase/budibase/commits?author=mslourens" title="Code">💻</a></td>
</tr>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
이 프로젝트는 다음 사양을 따릅니다. [all-contributors](https://github.com/all-contributors/all-contributors).
모든 종류의 기여를 환영합니다!

View File

@ -1,5 +1,5 @@
{ {
"version": "2.20.11", "version": "2.21.3",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

@ -1 +1 @@
Subproject commit de6d44c372a7f48ca0ce8c6c0c19311d4bc21646 Subproject commit 0c050591c21d3b67dc0c9225d60cc9e2324c8dac

View File

@ -67,7 +67,7 @@
"@types/lodash": "4.14.200", "@types/lodash": "4.14.200",
"@types/node-fetch": "2.6.4", "@types/node-fetch": "2.6.4",
"@types/pouchdb": "6.4.0", "@types/pouchdb": "6.4.0",
"@types/redlock": "4.0.3", "@types/redlock": "4.0.7",
"@types/semver": "7.3.7", "@types/semver": "7.3.7",
"@types/tar-fs": "2.0.1", "@types/tar-fs": "2.0.1",
"@types/uuid": "8.3.4", "@types/uuid": "8.3.4",
@ -78,6 +78,7 @@
"jest-serial-runner": "1.2.1", "jest-serial-runner": "1.2.1",
"pino-pretty": "10.0.0", "pino-pretty": "10.0.0",
"pouchdb-adapter-memory": "7.2.2", "pouchdb-adapter-memory": "7.2.2",
"testcontainers": "^10.7.2",
"timekeeper": "2.2.0", "timekeeper": "2.2.0",
"typescript": "5.2.2" "typescript": "5.2.2"
}, },

View File

@ -6,7 +6,7 @@ import env from "../environment"
import * as accounts from "../accounts" import * as accounts from "../accounts"
import { UserDB } from "../users" import { UserDB } from "../users"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { User } from "@budibase/types" import { User, UserMetadata } from "@budibase/types"
const EXPIRY_SECONDS = 3600 const EXPIRY_SECONDS = 3600
@ -15,7 +15,7 @@ const EXPIRY_SECONDS = 3600
*/ */
async function populateFromDB(userId: string, tenantId: string) { async function populateFromDB(userId: string, tenantId: string) {
const db = tenancy.getTenantDB(tenantId) const db = tenancy.getTenantDB(tenantId)
const user = await db.get<any>(userId) const user = await db.get<UserMetadata>(userId)
user.budibaseAccess = true user.budibaseAccess = true
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) { if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const account = await accounts.getAccount(user.email) const account = await accounts.getAccount(user.email)

View File

@ -1,66 +1,57 @@
import PouchDB from "pouchdb"
import { getPouchDB, closePouchDB } from "./couch" import { getPouchDB, closePouchDB } from "./couch"
import { DocumentType } from "../constants" import { DocumentType } from "../constants"
class Replication { class Replication {
source: any source: PouchDB.Database
target: any target: PouchDB.Database
replication: any
/** constructor({ source, target }: { source: string; target: string }) {
*
* @param source - the DB you want to replicate or rollback to
* @param target - the DB you want to replicate to, or rollback from
*/
constructor({ source, target }: any) {
this.source = getPouchDB(source) this.source = getPouchDB(source)
this.target = getPouchDB(target) this.target = getPouchDB(target)
} }
close() { async close() {
return Promise.all([closePouchDB(this.source), closePouchDB(this.target)]) await Promise.all([closePouchDB(this.source), closePouchDB(this.target)])
} }
promisify(operation: any, opts = {}) { replicate(opts: PouchDB.Replication.ReplicateOptions = {}) {
return new Promise(resolve => { return new Promise<PouchDB.Replication.ReplicationResult<{}>>(resolve => {
operation(this.target, opts) this.source.replicate
.on("denied", function (err: any) { .to(this.target, opts)
.on("denied", function (err) {
// a document failed to replicate (e.g. due to permissions) // a document failed to replicate (e.g. due to permissions)
throw new Error(`Denied: Document failed to replicate ${err}`) throw new Error(`Denied: Document failed to replicate ${err}`)
}) })
.on("complete", function (info: any) { .on("complete", function (info) {
return resolve(info) return resolve(info)
}) })
.on("error", function (err: any) { .on("error", function (err) {
throw new Error(`Replication Error: ${err}`) throw new Error(`Replication Error: ${err}`)
}) })
}) })
} }
/** appReplicateOpts(
* Two way replication operation, intended to be promise based. opts: PouchDB.Replication.ReplicateOptions = {}
* @param opts - PouchDB replication options ): PouchDB.Replication.ReplicateOptions {
*/ if (typeof opts.filter === "string") {
sync(opts = {}) { return opts
this.replication = this.promisify(this.source.sync, opts) }
return this.replication
}
/** const filter = opts.filter
* One way replication operation, intended to be promise based. delete opts.filter
* @param opts - PouchDB replication options
*/
replicate(opts = {}) {
this.replication = this.promisify(this.source.replicate.to, opts)
return this.replication
}
appReplicateOpts() {
return { return {
filter: (doc: any) => { ...opts,
filter: (doc: any, params: any) => {
if (doc._id && doc._id.startsWith(DocumentType.AUTOMATION_LOG)) { if (doc._id && doc._id.startsWith(DocumentType.AUTOMATION_LOG)) {
return false return false
} }
return doc._id !== DocumentType.APP_METADATA if (doc._id === DocumentType.APP_METADATA) {
return false
}
return filter ? filter(doc, params) : true
}, },
} }
} }
@ -75,10 +66,6 @@ class Replication {
// take the opportunity to remove deleted tombstones // take the opportunity to remove deleted tombstones
await this.replicate() await this.replicate()
} }
cancel() {
this.replication.cancel()
}
} }
export default Replication export default Replication

View File

@ -11,6 +11,7 @@ import {
Document, Document,
isDocument, isDocument,
RowResponse, RowResponse,
RowValue,
} from "@budibase/types" } from "@budibase/types"
import { getCouchInfo } from "./connections" import { getCouchInfo } from "./connections"
import { directCouchUrlCall } from "./utils" import { directCouchUrlCall } from "./utils"
@ -221,7 +222,7 @@ export class DatabaseImpl implements Database {
}) })
} }
async allDocs<T extends Document>( async allDocs<T extends Document | RowValue>(
params: DatabaseQueryOpts params: DatabaseQueryOpts
): Promise<AllDocsResponse<T>> { ): Promise<AllDocsResponse<T>> {
return this.performCall(db => { return this.performCall(db => {

View File

@ -1,5 +1,4 @@
import { import {
DocumentScope,
DocumentDestroyResponse, DocumentDestroyResponse,
DocumentInsertResponse, DocumentInsertResponse,
DocumentBulkResponse, DocumentBulkResponse,
@ -13,6 +12,7 @@ import {
DatabasePutOpts, DatabasePutOpts,
DatabaseQueryOpts, DatabaseQueryOpts,
Document, Document,
RowValue,
} from "@budibase/types" } from "@budibase/types"
import tracer from "dd-trace" import tracer from "dd-trace"
import { Writable } from "stream" import { Writable } from "stream"
@ -79,7 +79,7 @@ export class DDInstrumentedDatabase implements Database {
}) })
} }
allDocs<T extends Document>( allDocs<T extends Document | RowValue>(
params: DatabaseQueryOpts params: DatabaseQueryOpts
): Promise<AllDocsResponse<T>> { ): Promise<AllDocsResponse<T>> {
return tracer.trace("db.allDocs", span => { return tracer.trace("db.allDocs", span => {

View File

@ -74,7 +74,7 @@ export function getGlobalIDFromUserMetadataID(id: string) {
* Generates a template ID. * Generates a template ID.
* @param ownerId The owner/user of the template, this could be global or a workspace level. * @param ownerId The owner/user of the template, this could be global or a workspace level.
*/ */
export function generateTemplateID(ownerId: any) { export function generateTemplateID(ownerId: string) {
return `${DocumentType.TEMPLATE}${SEPARATOR}${ownerId}${SEPARATOR}${newid()}` return `${DocumentType.TEMPLATE}${SEPARATOR}${ownerId}${SEPARATOR}${newid()}`
} }
@ -105,7 +105,7 @@ export function prefixRoleID(name: string) {
* Generates a new dev info document ID - this is scoped to a user. * Generates a new dev info document ID - this is scoped to a user.
* @returns The new dev info ID which info for dev (like api key) can be stored under. * @returns The new dev info ID which info for dev (like api key) can be stored under.
*/ */
export const generateDevInfoID = (userId: any) => { export const generateDevInfoID = (userId: string) => {
return `${DocumentType.DEV_INFO}${SEPARATOR}${userId}` return `${DocumentType.DEV_INFO}${SEPARATOR}${userId}`
} }

View File

@ -1,5 +1,6 @@
import { APIError } from "@budibase/types" import { APIError } from "@budibase/types"
import * as errors from "../errors" import * as errors from "../errors"
import environment from "../environment"
export async function errorHandling(ctx: any, next: any) { export async function errorHandling(ctx: any, next: any) {
try { try {
@ -14,15 +15,19 @@ export async function errorHandling(ctx: any, next: any) {
console.error(err) console.error(err)
} }
const error = errors.getPublicError(err) let error: APIError = {
const body: APIError = {
message: err.message, message: err.message,
status: status, status: status,
validationErrors: err.validation, validationErrors: err.validation,
error, error: errors.getPublicError(err),
} }
ctx.body = body if (environment.isTest() && ctx.headers["x-budibase-include-stacktrace"]) {
// @ts-ignore
error.stack = err.stack
}
ctx.body = error
} }
} }

View File

@ -1,5 +1,5 @@
import env from "../environment" import env from "../environment"
import Redis from "ioredis" import Redis, { Cluster } from "ioredis"
// mock-redis doesn't have any typing // mock-redis doesn't have any typing
let MockRedis: any | undefined let MockRedis: any | undefined
if (env.MOCK_REDIS) { if (env.MOCK_REDIS) {
@ -28,7 +28,7 @@ const DEFAULT_SELECT_DB = SelectableDatabase.DEFAULT
// for testing just generate the client once // for testing just generate the client once
let CLOSED = false let CLOSED = false
let CLIENTS: { [key: number]: any } = {} const CLIENTS: Record<number, Redis> = {}
let CONNECTED = false let CONNECTED = false
// mock redis always connected // mock redis always connected
@ -36,7 +36,7 @@ if (env.MOCK_REDIS) {
CONNECTED = true CONNECTED = true
} }
function pickClient(selectDb: number): any { function pickClient(selectDb: number) {
return CLIENTS[selectDb] return CLIENTS[selectDb]
} }
@ -201,12 +201,15 @@ class RedisWrapper {
key = `${db}${SEPARATOR}${key}` key = `${db}${SEPARATOR}${key}`
let stream let stream
if (CLUSTERED) { if (CLUSTERED) {
let node = this.getClient().nodes("master") let node = (this.getClient() as never as Cluster).nodes("master")
stream = node[0].scanStream({ match: key + "*", count: 100 }) stream = node[0].scanStream({ match: key + "*", count: 100 })
} else { } else {
stream = this.getClient().scanStream({ match: key + "*", count: 100 }) stream = (this.getClient() as Redis).scanStream({
match: key + "*",
count: 100,
})
} }
return promisifyStream(stream, this.getClient()) return promisifyStream(stream, this.getClient() as any)
} }
async keys(pattern: string) { async keys(pattern: string) {
@ -221,14 +224,16 @@ class RedisWrapper {
async get(key: string) { async get(key: string) {
const db = this._db const db = this._db
let response = await this.getClient().get(addDbPrefix(db, key)) const response = await this.getClient().get(addDbPrefix(db, key))
// overwrite the prefixed key // overwrite the prefixed key
// @ts-ignore
if (response != null && response.key) { if (response != null && response.key) {
// @ts-ignore
response.key = key response.key = key
} }
// if its not an object just return the response // if its not an object just return the response
try { try {
return JSON.parse(response) return JSON.parse(response!)
} catch (err) { } catch (err) {
return response return response
} }
@ -274,13 +279,37 @@ class RedisWrapper {
} }
} }
async bulkStore(
data: Record<string, any>,
expirySeconds: number | null = null
) {
const client = this.getClient()
const dataToStore = Object.entries(data).reduce((acc, [key, value]) => {
acc[addDbPrefix(this._db, key)] =
typeof value === "object" ? JSON.stringify(value) : value
return acc
}, {} as Record<string, any>)
const pipeline = client.pipeline()
pipeline.mset(dataToStore)
if (expirySeconds !== null) {
for (const key of Object.keys(dataToStore)) {
pipeline.expire(key, expirySeconds)
}
}
await pipeline.exec()
}
async getTTL(key: string) { async getTTL(key: string) {
const db = this._db const db = this._db
const prefixedKey = addDbPrefix(db, key) const prefixedKey = addDbPrefix(db, key)
return this.getClient().ttl(prefixedKey) return this.getClient().ttl(prefixedKey)
} }
async setExpiry(key: string, expirySeconds: number | null) { async setExpiry(key: string, expirySeconds: number) {
const db = this._db const db = this._db
const prefixedKey = addDbPrefix(db, key) const prefixedKey = addDbPrefix(db, key)
await this.getClient().expire(prefixedKey, expirySeconds) await this.getClient().expire(prefixedKey, expirySeconds)
@ -295,6 +324,26 @@ class RedisWrapper {
let items = await this.scan() let items = await this.scan()
await Promise.all(items.map((obj: any) => this.delete(obj.key))) await Promise.all(items.map((obj: any) => this.delete(obj.key)))
} }
async increment(key: string) {
const result = await this.getClient().incr(addDbPrefix(this._db, key))
if (isNaN(result)) {
throw new Error(`Redis ${key} does not contain a number`)
}
return result
}
async deleteIfValue(key: string, value: any) {
const client = this.getClient()
const luaScript = `
if redis.call('GET', KEYS[1]) == ARGV[1] then
redis.call('DEL', KEYS[1])
end
`
await client.eval(luaScript, 1, addDbPrefix(this._db, key), value)
}
} }
export default RedisWrapper export default RedisWrapper

View File

@ -72,7 +72,7 @@ const OPTIONS: Record<keyof typeof LockType, Redlock.Options> = {
export async function newRedlock(opts: Redlock.Options = {}) { export async function newRedlock(opts: Redlock.Options = {}) {
const options = { ...OPTIONS.DEFAULT, ...opts } const options = { ...OPTIONS.DEFAULT, ...opts }
const redisWrapper = await getLockClient() const redisWrapper = await getLockClient()
const client = redisWrapper.getClient() const client = redisWrapper.getClient() as any
return new Redlock([client], options) return new Redlock([client], options)
} }

View File

@ -0,0 +1,214 @@
import { GenericContainer, StartedTestContainer } from "testcontainers"
import { generator, structures } from "../../../tests"
import RedisWrapper from "../redis"
import { env } from "../.."
jest.setTimeout(30000)
describe("redis", () => {
let redis: RedisWrapper
let container: StartedTestContainer
beforeAll(async () => {
const container = await new GenericContainer("redis")
.withExposedPorts(6379)
.start()
env._set(
"REDIS_URL",
`${container.getHost()}:${container.getMappedPort(6379)}`
)
env._set("MOCK_REDIS", 0)
env._set("REDIS_PASSWORD", 0)
})
afterAll(() => container?.stop())
beforeEach(async () => {
redis = new RedisWrapper(structures.db.id())
await redis.init()
})
describe("store", () => {
it("a basic value can be persisted", async () => {
const key = structures.uuid()
const value = generator.word()
await redis.store(key, value)
expect(await redis.get(key)).toEqual(value)
})
it("objects can be persisted", async () => {
const key = structures.uuid()
const value = { [generator.word()]: generator.word() }
await redis.store(key, value)
expect(await redis.get(key)).toEqual(value)
})
})
describe("bulkStore", () => {
function createRandomObject(
keyLength: number,
valueGenerator: () => any = () => generator.word()
) {
return generator
.unique(() => generator.word(), keyLength)
.reduce((acc, key) => {
acc[key] = valueGenerator()
return acc
}, {} as Record<string, string>)
}
it("a basic object can be persisted", async () => {
const data = createRandomObject(10)
await redis.bulkStore(data)
for (const [key, value] of Object.entries(data)) {
expect(await redis.get(key)).toEqual(value)
}
expect(await redis.keys("*")).toHaveLength(10)
})
it("a complex object can be persisted", async () => {
const data = {
...createRandomObject(10, () => createRandomObject(5)),
...createRandomObject(5),
}
await redis.bulkStore(data)
for (const [key, value] of Object.entries(data)) {
expect(await redis.get(key)).toEqual(value)
}
expect(await redis.keys("*")).toHaveLength(15)
})
it("no TTL is set by default", async () => {
const data = createRandomObject(10)
await redis.bulkStore(data)
for (const [key, value] of Object.entries(data)) {
expect(await redis.get(key)).toEqual(value)
expect(await redis.getTTL(key)).toEqual(-1)
}
})
it("a bulk store can be persisted with TTL", async () => {
const ttl = 500
const data = createRandomObject(8)
await redis.bulkStore(data, ttl)
for (const [key, value] of Object.entries(data)) {
expect(await redis.get(key)).toEqual(value)
expect(await redis.getTTL(key)).toEqual(ttl)
}
expect(await redis.keys("*")).toHaveLength(8)
})
it("setting a TTL of -1 will not persist the key", async () => {
const ttl = -1
const data = createRandomObject(5)
await redis.bulkStore(data, ttl)
for (const [key, value] of Object.entries(data)) {
expect(await redis.get(key)).toBe(null)
}
expect(await redis.keys("*")).toHaveLength(0)
})
})
describe("increment", () => {
it("can increment on a new key", async () => {
const key = structures.uuid()
const result = await redis.increment(key)
expect(result).toBe(1)
})
it("can increment multiple times", async () => {
const key = structures.uuid()
const results = [
await redis.increment(key),
await redis.increment(key),
await redis.increment(key),
await redis.increment(key),
await redis.increment(key),
]
expect(results).toEqual([1, 2, 3, 4, 5])
})
it("can increment on a new key", async () => {
const key1 = structures.uuid()
const key2 = structures.uuid()
const result1 = await redis.increment(key1)
expect(result1).toBe(1)
const result2 = await redis.increment(key2)
expect(result2).toBe(1)
})
it("can increment multiple times in parallel", async () => {
const key = structures.uuid()
const results = await Promise.all(
Array.from({ length: 100 }).map(() => redis.increment(key))
)
expect(results).toHaveLength(100)
expect(results).toEqual(Array.from({ length: 100 }).map((_, i) => i + 1))
})
it("can increment existing set keys", async () => {
const key = structures.uuid()
await redis.store(key, 70)
await redis.increment(key)
const result = await redis.increment(key)
expect(result).toBe(72)
})
it.each([
generator.word(),
generator.bool(),
{ [generator.word()]: generator.word() },
])("cannot increment if the store value is not a number", async value => {
const key = structures.uuid()
await redis.store(key, value)
await expect(redis.increment(key)).rejects.toThrowError(
"ERR value is not an integer or out of range"
)
})
})
describe("deleteIfValue", () => {
it("can delete if the value matches", async () => {
const key = structures.uuid()
const value = generator.word()
await redis.store(key, value)
await redis.deleteIfValue(key, value)
expect(await redis.get(key)).toBeNull()
})
it("will not delete if the value does not matches", async () => {
const key = structures.uuid()
const value = generator.word()
await redis.store(key, value)
await redis.deleteIfValue(key, generator.word())
expect(await redis.get(key)).toEqual(value)
})
})
})

View File

@ -84,25 +84,24 @@ export function getBuiltinRoles(): { [key: string]: RoleDoc } {
return cloneDeep(BUILTIN_ROLES) return cloneDeep(BUILTIN_ROLES)
} }
export const BUILTIN_ROLE_ID_ARRAY = Object.values(BUILTIN_ROLES).map( export function isBuiltin(role: string) {
role => role._id return getBuiltinRole(role) !== undefined
) }
export const BUILTIN_ROLE_NAME_ARRAY = Object.values(BUILTIN_ROLES).map( export function getBuiltinRole(roleId: string): Role | undefined {
role => role.name const role = Object.values(BUILTIN_ROLES).find(role =>
) roleId.includes(role._id)
)
export function isBuiltin(role?: string) { if (!role) {
return BUILTIN_ROLE_ID_ARRAY.some(builtin => role?.includes(builtin)) return undefined
}
return cloneDeep(role)
} }
/** /**
* Works through the inheritance ranks to see how far up the builtin stack this ID is. * Works through the inheritance ranks to see how far up the builtin stack this ID is.
*/ */
export function builtinRoleToNumber(id?: string) { export function builtinRoleToNumber(id: string) {
if (!id) {
return 0
}
const builtins = getBuiltinRoles() const builtins = getBuiltinRoles()
const MAX = Object.values(builtins).length + 1 const MAX = Object.values(builtins).length + 1
if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) { if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) {
@ -123,7 +122,7 @@ export function builtinRoleToNumber(id?: string) {
/** /**
* Converts any role to a number, but has to be async to get the roles from db. * Converts any role to a number, but has to be async to get the roles from db.
*/ */
export async function roleToNumber(id?: string) { export async function roleToNumber(id: string) {
if (isBuiltin(id)) { if (isBuiltin(id)) {
return builtinRoleToNumber(id) return builtinRoleToNumber(id)
} }
@ -131,7 +130,7 @@ export async function roleToNumber(id?: string) {
defaultPublic: true, defaultPublic: true,
})) as RoleDoc[] })) as RoleDoc[]
for (let role of hierarchy) { for (let role of hierarchy) {
if (isBuiltin(role?.inherits)) { if (role?.inherits && isBuiltin(role.inherits)) {
return builtinRoleToNumber(role.inherits) + 1 return builtinRoleToNumber(role.inherits) + 1
} }
} }
@ -161,35 +160,28 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string {
* @returns The role object, which may contain an "inherits" property. * @returns The role object, which may contain an "inherits" property.
*/ */
export async function getRole( export async function getRole(
roleId?: string, roleId: string,
opts?: { defaultPublic?: boolean } opts?: { defaultPublic?: boolean }
): Promise<RoleDoc | undefined> { ): Promise<RoleDoc> {
if (!roleId) {
return undefined
}
let role: any = {}
// built in roles mostly come from the in-code implementation, // built in roles mostly come from the in-code implementation,
// but can be extended by a doc stored about them (e.g. permissions) // but can be extended by a doc stored about them (e.g. permissions)
if (isBuiltin(roleId)) { let role: RoleDoc | undefined = getBuiltinRole(roleId)
role = cloneDeep( if (!role) {
Object.values(BUILTIN_ROLES).find(role => role._id === roleId)
)
} else {
// make sure has the prefix (if it has it then it won't be added) // make sure has the prefix (if it has it then it won't be added)
roleId = prefixRoleID(roleId) roleId = prefixRoleID(roleId)
} }
try { try {
const db = getAppDB() const db = getAppDB()
const dbRole = await db.get(getDBRoleID(roleId)) const dbRole = await db.get<RoleDoc>(getDBRoleID(roleId))
role = Object.assign(role, dbRole) role = Object.assign(role || {}, dbRole)
// finalise the ID // finalise the ID
role._id = getExternalRoleID(role._id, role.version) role._id = getExternalRoleID(role._id!, role.version)
} catch (err) { } catch (err) {
if (!isBuiltin(roleId) && opts?.defaultPublic) { if (!isBuiltin(roleId) && opts?.defaultPublic) {
return cloneDeep(BUILTIN_ROLES.PUBLIC) return cloneDeep(BUILTIN_ROLES.PUBLIC)
} }
// only throw an error if there is no role at all // only throw an error if there is no role at all
if (Object.keys(role).length === 0) { if (!role || Object.keys(role).length === 0) {
throw err throw err
} }
} }
@ -200,7 +192,7 @@ export async function getRole(
* Simple function to get all the roles based on the top level user role ID. * Simple function to get all the roles based on the top level user role ID.
*/ */
async function getAllUserRoles( async function getAllUserRoles(
userRoleId?: string, userRoleId: string,
opts?: { defaultPublic?: boolean } opts?: { defaultPublic?: boolean }
): Promise<RoleDoc[]> { ): Promise<RoleDoc[]> {
// admins have access to all roles // admins have access to all roles
@ -226,7 +218,7 @@ async function getAllUserRoles(
} }
export async function getUserRoleIdHierarchy( export async function getUserRoleIdHierarchy(
userRoleId?: string userRoleId: string
): Promise<string[]> { ): Promise<string[]> {
const roles = await getUserRoleHierarchy(userRoleId) const roles = await getUserRoleHierarchy(userRoleId)
return roles.map(role => role._id!) return roles.map(role => role._id!)
@ -241,7 +233,7 @@ export async function getUserRoleIdHierarchy(
* highest level of access and the last being the lowest level. * highest level of access and the last being the lowest level.
*/ */
export async function getUserRoleHierarchy( export async function getUserRoleHierarchy(
userRoleId?: string, userRoleId: string,
opts?: { defaultPublic?: boolean } opts?: { defaultPublic?: boolean }
) { ) {
// special case, if they don't have a role then they are a public user // special case, if they don't have a role then they are a public user
@ -265,9 +257,9 @@ export function checkForRoleResourceArray(
return rolePerms return rolePerms
} }
export async function getAllRoleIds(appId?: string) { export async function getAllRoleIds(appId: string): Promise<string[]> {
const roles = await getAllRoles(appId) const roles = await getAllRoles(appId)
return roles.map(role => role._id) return roles.map(role => role._id!)
} }
/** /**

View File

@ -18,7 +18,7 @@ export const account = (partial: Partial<Account> = {}): Account => {
return { return {
accountId: uuid(), accountId: uuid(),
tenantId: generator.word(), tenantId: generator.word(),
email: generator.email(), email: generator.email({ domain: "example.com" }),
tenantName: generator.word(), tenantName: generator.word(),
hosting: Hosting.SELF, hosting: Hosting.SELF,
createdAt: Date.now(), createdAt: Date.now(),

View File

@ -13,7 +13,7 @@ interface CreateUserRequestFields {
export function createUserRequest(userData?: Partial<CreateUserRequestFields>) { export function createUserRequest(userData?: Partial<CreateUserRequestFields>) {
const defaultValues = { const defaultValues = {
externalId: uuid(), externalId: uuid(),
email: generator.email(), email: `${uuid()}@example.com`,
firstName: generator.first(), firstName: generator.first(),
lastName: generator.last(), lastName: generator.last(),
username: generator.name(), username: generator.name(),

View File

@ -35,7 +35,10 @@ export default function positionDropdown(element, opts) {
} }
if (typeof customUpdate === "function") { if (typeof customUpdate === "function") {
styles = customUpdate(anchorBounds, elementBounds, styles) styles = customUpdate(anchorBounds, elementBounds, {
...styles,
offset: opts.offset,
})
} else { } else {
// Determine vertical styles // Determine vertical styles
if (align === "right-outside") { if (align === "right-outside") {

View File

@ -9,13 +9,17 @@ const intercom = new IntercomClient(process.env.INTERCOM_TOKEN)
class AnalyticsHub { class AnalyticsHub {
constructor() { constructor() {
this.clients = [posthog, intercom] this.clients = [posthog, intercom]
this.initialised = false
} }
async activate() { async activate() {
// Check analytics are enabled // Check analytics are enabled
const analyticsStatus = await API.getAnalyticsStatus() const analyticsStatus = await API.getAnalyticsStatus()
if (analyticsStatus.enabled) { if (analyticsStatus.enabled && !this.initialised) {
this.clients.forEach(client => client.init()) this.clients.forEach(client => {
client.init()
})
this.initialised = true
} }
} }

View File

@ -40,8 +40,15 @@
part2: PrettyRelationshipDefinitions.MANY, part2: PrettyRelationshipDefinitions.MANY,
}, },
} }
let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions) $: relationshipOpts1 =
let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions) relationshipPart2 === PrettyRelationshipDefinitions.ONE
? [PrettyRelationshipDefinitions.MANY]
: Object.values(PrettyRelationshipDefinitions)
$: relationshipOpts2 =
relationshipPart1 === PrettyRelationshipDefinitions.ONE
? [PrettyRelationshipDefinitions.MANY]
: Object.values(PrettyRelationshipDefinitions)
let relationshipPart1 = PrettyRelationshipDefinitions.ONE let relationshipPart1 = PrettyRelationshipDefinitions.ONE
let relationshipPart2 = PrettyRelationshipDefinitions.MANY let relationshipPart2 = PrettyRelationshipDefinitions.MANY

View File

@ -164,9 +164,10 @@
</div> </div>
{/if} {/if}
<TourWrap <TourWrap
tourStepKey={$builderStore.onboarding stepKeys={[
? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT,
: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT} TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT,
]}
> >
<div class="app-action-button users"> <div class="app-action-button users">
<div class="app-action" id="builder-app-users-button"> <div class="app-action" id="builder-app-users-button">
@ -209,7 +210,7 @@
<div bind:this={appActionPopoverAnchor}> <div bind:this={appActionPopoverAnchor}>
<div class="app-action"> <div class="app-action">
<Icon name={isPublished ? "GlobeCheck" : "GlobeStrike"} /> <Icon name={isPublished ? "GlobeCheck" : "GlobeStrike"} />
<TourWrap tourStepKey={TOUR_STEP_KEYS.BUILDER_APP_PUBLISH}> <TourWrap stepKeys={[TOUR_STEP_KEYS.BUILDER_APP_PUBLISH]}>
<span class="publish-open" id="builder-app-publish-button"> <span class="publish-open" id="builder-app-publish-button">
Publish Publish
<Icon <Icon

View File

@ -45,7 +45,10 @@
<Checkbox text="Require confirmation" bind:value={parameters.confirm} /> <Checkbox text="Require confirmation" bind:value={parameters.confirm} />
{#if parameters.confirm} {#if parameters.confirm}
<Label small>Confirm text</Label> <Label small>Title</Label>
<Input placeholder="Delete Row" bind:value={parameters.customTitleText} />
<Label small>Text</Label>
<Input <Input
placeholder="Are you sure you want to delete?" placeholder="Are you sure you want to delete?"
bind:value={parameters.confirmText} bind:value={parameters.confirmText}

View File

@ -72,7 +72,13 @@
<Checkbox text="Require confirmation" bind:value={parameters.confirm} /> <Checkbox text="Require confirmation" bind:value={parameters.confirm} />
{#if parameters.confirm} {#if parameters.confirm}
<Label small>Confirm text</Label> <Label small>Title</Label>
<Input
placeholder="Duplicate Row"
bind:value={parameters.customTitleText}
/>
<Label small>Text</Label>
<Input <Input
placeholder="Are you sure you want to duplicate this row?" placeholder="Are you sure you want to duplicate this row?"
bind:value={parameters.confirmText} bind:value={parameters.confirmText}

View File

@ -64,7 +64,13 @@
{#if parameters.confirm} {#if parameters.confirm}
<Input <Input
label="Confirm text" label="Title"
placeholder="Execute Query"
bind:value={parameters.customTitleText}
/>
<Input
label="Text"
placeholder="Are you sure you want to execute this query?" placeholder="Are you sure you want to execute this query?"
bind:value={parameters.confirmText} bind:value={parameters.confirmText}
/> />

View File

@ -106,11 +106,12 @@
/> />
<Label small>Export columns</Label> <Label small>Export columns</Label>
<ColumnEditor <ColumnEditor
bind:value={parameters.columns} value={parameters.columns}
allowCellEditing={false} allowCellEditing={false}
componentInstance={selectedTable} componentInstance={selectedTable}
on:change={e => { on:change={e => {
const columns = e.detail const columns = e.detail
parameters.columns = columns
parameters.customHeaders = columns.reduce((headerMap, column) => { parameters.customHeaders = columns.reduce((headerMap, column) => {
return { return {
[column.name]: column.displayName, [column.name]: column.displayName,

View File

@ -72,7 +72,10 @@
<Checkbox text="Require confirmation" bind:value={parameters.confirm} /> <Checkbox text="Require confirmation" bind:value={parameters.confirm} />
{#if parameters.confirm} {#if parameters.confirm}
<Label small>Confirm text</Label> <Label small>Title</Label>
<Input placeholder="Save Row" bind:value={parameters.customTitleText} />
<Label small>Text</Label>
<Input <Input
placeholder="Are you sure you want to save this row?" placeholder="Are you sure you want to save this row?"
bind:value={parameters.confirmText} bind:value={parameters.confirmText}

View File

@ -1,5 +1,5 @@
<script> <script>
import EditComponentPopover from "../EditComponentPopover.svelte" import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte"
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { runtimeToReadableBinding } from "dataBinding" import { runtimeToReadableBinding } from "dataBinding"
import { isJSBinding } from "@budibase/string-templates" import { isJSBinding } from "@budibase/string-templates"

View File

@ -3,6 +3,7 @@
import { componentStore } from "stores/builder" import { componentStore } from "stores/builder"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { createEventDispatcher, getContext } from "svelte" import { createEventDispatcher, getContext } from "svelte"
import { customPositionHandler } from "."
import ComponentSettingsSection from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte" import ComponentSettingsSection from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
export let anchor export let anchor
@ -54,25 +55,6 @@
dispatch("change", nestedComponentInstance) dispatch("change", nestedComponentInstance)
} }
const customPositionHandler = (anchorBounds, eleBounds, cfg) => {
let { left, top } = cfg
let percentageOffset = 30
// left-outside
left = anchorBounds.left - eleBounds.width - 18
// shift up from the anchor, if space allows
let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
let defaultTop = anchorBounds.top - offsetPos
if (window.innerHeight - defaultTop < eleBounds.height) {
top = window.innerHeight - eleBounds.height - 5
} else {
top = anchorBounds.top - offsetPos
}
return { ...cfg, left, top }
}
</script> </script>
<Icon <Icon
@ -104,6 +86,7 @@
showPopover={drawers.length === 0} showPopover={drawers.length === 0}
clickOutsideOverride={drawers.length > 0} clickOutsideOverride={drawers.length > 0}
maxHeight={600} maxHeight={600}
offset={18}
handlePostionUpdate={customPositionHandler} handlePostionUpdate={customPositionHandler}
> >
<span class="popover-wrap"> <span class="popover-wrap">

View File

@ -0,0 +1,18 @@
export const customPositionHandler = (anchorBounds, eleBounds, cfg) => {
let { left, top, offset } = cfg
let percentageOffset = 30
// left-outside
left = anchorBounds.left - eleBounds.width - (offset || 5)
// shift up from the anchor, if space allows
let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
let defaultTop = anchorBounds.top - offsetPos
if (window.innerHeight - defaultTop < eleBounds.height) {
top = window.innerHeight - eleBounds.height - 5
} else {
top = anchorBounds.top - offsetPos
}
return { ...cfg, left, top }
}

View File

@ -1,5 +1,5 @@
<script> <script>
import EditComponentPopover from "../EditComponentPopover.svelte" import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte"
import { Toggle, Icon } from "@budibase/bbui" import { Toggle, Icon } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"

View File

@ -52,8 +52,8 @@
_id: Helpers.uuid(), _id: Helpers.uuid(),
_component: componentType, _component: componentType,
_instanceName: `Step ${currentStep + 1}`, _instanceName: `Step ${currentStep + 1}`,
title: stepSettings.title ?? defaults.title, title: stepSettings.title ?? defaults?.title,
buttons: stepSettings.buttons || defaults.buttons, buttons: stepSettings.buttons || defaults?.buttons,
fields: stepSettings.fields, fields: stepSettings.fields,
desc: stepSettings.desc, desc: stepSettings.desc,

View File

@ -1,5 +1,5 @@
<script> <script>
import EditComponentPopover from "../EditComponentPopover.svelte" import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte"
import { Toggle, Icon } from "@budibase/bbui" import { Toggle, Icon } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"

View File

@ -1,5 +1,5 @@
<script> <script>
import EditComponentPopover from "../EditComponentPopover.svelte" import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte"
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { setContext } from "svelte" import { setContext } from "svelte"
import { writable } from "svelte/store" import { writable } from "svelte/store"

View File

@ -116,7 +116,6 @@
$: pagerText = `Page ${currentPage} of ${totalPages}` $: pagerText = `Page ${currentPage} of ${totalPages}`
</script> </script>
a11y-click-events-have-key-events
<div bind:this={buttonAnchor}> <div bind:this={buttonAnchor}>
<ActionButton on:click={dropdown.show}> <ActionButton on:click={dropdown.show}>
{displayValue} {displayValue}
@ -140,10 +139,22 @@ a11y-click-events-have-key-events
{/each} {/each}
</div> </div>
<div class="search-input"> <div class="search-input">
<div class="input-wrapper"> <div class="input-wrapper" style={`width: ${value ? "425" : "510"}px`}>
<Input bind:value={searchTerm} thin placeholder="Search Icon" /> <Input
bind:value={searchTerm}
on:keyup={event => {
if (event.key === "Enter") {
searchForIcon()
}
}}
thin
placeholder="Search Icon"
/>
</div> </div>
<Button secondary on:click={searchForIcon}>Search</Button> <Button secondary on:click={searchForIcon}>Search</Button>
{#if value}
<Button primary on:click={() => (value = null)}>Clear</Button>
{/if}
</div> </div>
<div class="page-area"> <div class="page-area">
<div class="pager"> <div class="pager">
@ -240,6 +251,7 @@ a11y-click-events-have-key-events
flex-flow: row nowrap; flex-flow: row nowrap;
width: 100%; width: 100%;
padding-right: 15px; padding-right: 15px;
gap: 10px;
} }
.input-wrapper { .input-wrapper {
width: 510px; width: 510px;

View File

@ -20,17 +20,23 @@
export let bindings = [] export let bindings = []
export let componentBindings = [] export let componentBindings = []
export let nested = false export let nested = false
export let highlighted = false
export let propertyFocus = false export let propertyFocus = false
export let info = null export let info = null
export let disableBindings = false export let disableBindings = false
export let wide export let wide
$: nullishValue = value == null || value === "" let highlightType
$: highlightedProp = $builderStore.highlightedSetting
$: allBindings = getAllBindings(bindings, componentBindings, nested) $: allBindings = getAllBindings(bindings, componentBindings, nested)
$: safeValue = getSafeValue(value, defaultValue, allBindings) $: safeValue = getSafeValue(value, defaultValue, allBindings)
$: replaceBindings = val => readableToRuntimeBinding(allBindings, val) $: replaceBindings = val => readableToRuntimeBinding(allBindings, val)
$: if (!Array.isArray(value)) {
highlightType =
highlightedProp?.key === key ? `highlighted-${highlightedProp?.type}` : ""
}
const getAllBindings = (bindings, componentBindings, nested) => { const getAllBindings = (bindings, componentBindings, nested) => {
if (!nested) { if (!nested) {
return bindings return bindings
@ -71,16 +77,17 @@
} }
onDestroy(() => { onDestroy(() => {
if (highlighted) { if (highlightedProp) {
builderStore.highlightSetting(null) builderStore.highlightSetting(null)
} }
}) })
</script> </script>
<div <div
class="property-control" id={`${key}-prop-control-wrap`}
class={`property-control ${highlightType}`}
class:wide={!label || labelHidden || wide === true} class:wide={!label || labelHidden || wide === true}
class:highlighted={highlighted && nullishValue} class:highlighted={highlightType}
class:property-focus={propertyFocus} class:property-focus={propertyFocus}
> >
{#if label && !labelHidden} {#if label && !labelHidden}
@ -115,6 +122,16 @@
</div> </div>
<style> <style>
.property-control.highlighted.highlighted-info {
border-color: var(--spectrum-semantic-informative-color-background);
}
.property-control.highlighted.highlighted-error {
border-color: var(--spectrum-global-color-static-red-600);
}
.property-control.highlighted.highlighted-warning {
border-color: var(--spectrum-global-color-static-orange-700);
}
.property-control { .property-control {
position: relative; position: relative;
display: grid; display: grid;
@ -132,6 +149,10 @@
.property-control.highlighted { .property-control.highlighted {
background: var(--spectrum-global-color-gray-300); background: var(--spectrum-global-color-gray-300);
border-color: var(--spectrum-global-color-static-red-600); border-color: var(--spectrum-global-color-static-red-600);
margin-top: -3.5px;
margin-bottom: -3.5px;
padding-bottom: 3.5px;
padding-top: 3.5px;
} }
.property-control.property-focus :global(input) { .property-control.property-focus :global(input) {

View File

@ -1,6 +1,6 @@
<script> <script>
import { Popover, Layout, Heading, Body, Button, Link } from "@budibase/bbui" import { Popover, Layout, Heading, Body, Button, Link } from "@budibase/bbui"
import { TOURS } from "./tours.js" import { TOURS, getCurrentStepIdx } from "./tours.js"
import { goto, layout, isActive } from "@roxi/routify" import { goto, layout, isActive } from "@roxi/routify"
import { builderStore } from "stores/builder" import { builderStore } from "stores/builder"
@ -20,6 +20,13 @@
const updateTourStep = (targetStepKey, tourKey) => { const updateTourStep = (targetStepKey, tourKey) => {
if (!tourKey) { if (!tourKey) {
tourSteps = null
tourStepIdx = null
lastStep = null
tourStep = null
popoverAnchor = null
popover = null
skipping = false
return return
} }
if (!tourSteps?.length) { if (!tourSteps?.length) {
@ -78,16 +85,6 @@
} }
} }
} }
const getCurrentStepIdx = (steps, tourStepKey) => {
if (!steps?.length) {
return
}
if (steps?.length && !tourStepKey) {
return 0
}
return steps.findIndex(step => step.id === tourStepKey)
}
</script> </script>
{#if tourKey} {#if tourKey}
@ -98,7 +95,9 @@
anchor={popoverAnchor} anchor={popoverAnchor}
maxWidth={300} maxWidth={300}
dismissible={false} dismissible={false}
offset={15} offset={12}
handlePostionUpdate={tourStep?.positionHandler}
customZindex={3}
> >
<div class="tour-content"> <div class="tour-content">
<Layout noPadding gap="M"> <Layout noPadding gap="M">
@ -119,7 +118,7 @@
</Body> </Body>
<div class="tour-footer"> <div class="tour-footer">
<div class="tour-navigation"> <div class="tour-navigation">
{#if typeof tourOnSkip === "function"} {#if typeof tourOnSkip === "function" && !lastStep}
<Link <Link
secondary secondary
quiet quiet

View File

@ -1,39 +1,63 @@
<script> <script>
import { tourHandler } from "./tourHandler" import { tourHandler } from "./tourHandler"
import { TOURS } from "./tours" import { TOURSBYSTEP, TOURS, getCurrentStepIdx } from "./tours"
import { onMount, onDestroy } from "svelte" import { onMount, onDestroy } from "svelte"
import { builderStore } from "stores/builder" import { builderStore } from "stores/builder"
export let tourStepKey export let stepKeys = []
let currentTourStep
let ready = false let ready = false
let registered = false let registered = {}
let handler
const registerTourNode = (tourKey, stepKey) => { const registerTourNode = (tourKey, stepKey) => {
if (ready && !registered && tourKey) { const step = TOURSBYSTEP[stepKey]
currentTourStep = TOURS[tourKey].steps.find(step => step.id === stepKey) if (ready && step && !registered[stepKey] && step?.tour === tourKey) {
if (!currentTourStep) { const elem = document.querySelector(step.query)
return registered[stepKey] = tourHandler(elem, stepKey)
}
}
const scrollToStep = () => {
let tourStepIdx = getCurrentStepIdx(
TOURS[tourKeyWatch]?.steps,
tourStepKeyWatch
)
let currentStep = TOURS[tourKeyWatch]?.steps?.[tourStepIdx]
if (currentStep?.scrollIntoView) {
let currentNode = $builderStore.tourNodes?.[currentStep.id]
if (currentNode) {
currentNode.scrollIntoView({ behavior: "smooth", block: "center" })
} }
const elem = document.querySelector(currentTourStep.query)
handler = tourHandler(elem, stepKey)
registered = true
} }
} }
$: tourKeyWatch = $builderStore.tourKey $: tourKeyWatch = $builderStore.tourKey
$: registerTourNode(tourKeyWatch, tourStepKey, ready) $: tourStepKeyWatch = $builderStore.tourStepKey
$: if (tourKeyWatch || stepKeys || ready) {
stepKeys.forEach(tourStepKey => {
registerTourNode(tourKeyWatch, tourStepKey)
})
}
$: scrollToStep(tourKeyWatch, tourStepKeyWatch)
onMount(() => { onMount(() => {
ready = true ready = true
}) })
onDestroy(() => { onDestroy(() => {
if (handler) { Object.entries(registered).forEach(entry => {
const handler = entry[1]
const stepKey = entry[0]
// Run step destroy, de-register nodes in the builderStore and local cache
handler.destroy() handler.destroy()
} delete registered[stepKey]
// Check if the step is part of an active tour. End the tour if that is the case
const step = TOURSBYSTEP[stepKey]
if (step.tour === tourKeyWatch) {
builderStore.setTour()
}
})
}) })
</script> </script>

View File

@ -0,0 +1,9 @@
<div>
When faced with a sizable form, consider implementing a multi-step approach to
enhance user experience.
<p>
Breaking the form into multiple steps can significantly improve usability by
making the process more digestible for your users.
</p>
</div>

View File

@ -0,0 +1,17 @@
<div>
You can use bindings to set the Row ID on your form.
<p>
This will allow you to pull the correct information into your form and allow
you to update!
</p>
<a href="https://docs.budibase.com/docs/form-block" target="_blank">
How to pass a row ID using bindings
</a>
</div>
<style>
a {
color: inherit;
text-decoration: underline;
}
</style>

View File

@ -1,3 +1,5 @@
export { default as OnboardingData } from "./OnboardingData.svelte" export { default as OnboardingData } from "./OnboardingData.svelte"
export { default as OnboardingDesign } from "./OnboardingDesign.svelte" export { default as OnboardingDesign } from "./OnboardingDesign.svelte"
export { default as OnboardingPublish } from "./OnboardingPublish.svelte" export { default as OnboardingPublish } from "./OnboardingPublish.svelte"
export { default as NewViewUpdateFormRowId } from "./NewViewUpdateFormRowId.svelte"
export { default as NewFormSteps } from "./NewFormSteps.svelte"

View File

@ -2,8 +2,15 @@ import { get } from "svelte/store"
import { builderStore } from "stores/builder" import { builderStore } from "stores/builder"
import { auth } from "stores/portal" import { auth } from "stores/portal"
import analytics from "analytics" import analytics from "analytics"
import { OnboardingData, OnboardingDesign, OnboardingPublish } from "./steps" import {
OnboardingData,
OnboardingDesign,
OnboardingPublish,
NewViewUpdateFormRowId,
NewFormSteps,
} from "./steps"
import { API } from "api" import { API } from "api"
import { customPositionHandler } from "components/design/settings/controls/EditComponentPopover"
const ONBOARDING_EVENT_PREFIX = "onboarding" const ONBOARDING_EVENT_PREFIX = "onboarding"
@ -14,11 +21,26 @@ export const TOUR_STEP_KEYS = {
BUILDER_USER_MANAGEMENT: "builder-user-management", BUILDER_USER_MANAGEMENT: "builder-user-management",
BUILDER_AUTOMATION_SECTION: "builder-automation-section", BUILDER_AUTOMATION_SECTION: "builder-automation-section",
FEATURE_USER_MANAGEMENT: "feature-user-management", FEATURE_USER_MANAGEMENT: "feature-user-management",
BUILDER_FORM_CREATE_STEPS: "builder-form-create-steps",
BUILDER_FORM_VIEW_UPDATE_STEPS: "builder-form-view-update-steps",
BUILDER_FORM_ROW_ID: "builder-form-row-id",
} }
export const TOUR_KEYS = { export const TOUR_KEYS = {
TOUR_BUILDER_ONBOARDING: "builder-onboarding", TOUR_BUILDER_ONBOARDING: "builder-onboarding",
FEATURE_ONBOARDING: "feature-onboarding", FEATURE_ONBOARDING: "feature-onboarding",
BUILDER_FORM_CREATE: "builder-form-create",
BUILDER_FORM_VIEW_UPDATE: "builder-form-view-update",
}
export const getCurrentStepIdx = (steps, tourStepKey) => {
if (!steps?.length) {
return
}
if (steps?.length && !tourStepKey) {
return 0
}
return steps.findIndex(step => step.id === tourStepKey)
} }
const endUserOnboarding = async ({ skipped = false } = {}) => { const endUserOnboarding = async ({ skipped = false } = {}) => {
@ -37,13 +59,8 @@ const endUserOnboarding = async ({ skipped = false } = {}) => {
// Update the cached user // Update the cached user
await auth.getSelf() await auth.getSelf()
builderStore.update(state => ({ builderStore.endBuilderOnboarding()
...state, builderStore.setTour()
tourNodes: null,
tourKey: null,
tourStepKey: null,
onboarding: false,
}))
} catch (e) { } catch (e) {
console.error("Onboarding failed", e) console.error("Onboarding failed", e)
return false return false
@ -52,9 +69,29 @@ const endUserOnboarding = async ({ skipped = false } = {}) => {
} }
} }
const tourEvent = eventKey => { const endTour = async ({ key, skipped = false } = {}) => {
const { tours = {} } = get(auth).user
tours[key] = new Date().toISOString()
await API.updateSelf({
tours,
})
if (skipped) {
tourEvent(key, skipped)
}
// Update the cached user
await auth.getSelf()
// Reset tour state
builderStore.setTour()
}
const tourEvent = (eventKey, skipped) => {
analytics.captureEvent(`${ONBOARDING_EVENT_PREFIX}:${eventKey}`, { analytics.captureEvent(`${ONBOARDING_EVENT_PREFIX}:${eventKey}`, {
eventSource: EventSource.PORTAL, eventSource: EventSource.PORTAL,
skipped,
}) })
} }
@ -135,7 +172,71 @@ const getTours = () => {
}, },
], ],
}, },
[TOUR_KEYS.BUILDER_FORM_CREATE]: {
steps: [
{
id: TOUR_STEP_KEYS.BUILDER_FORM_CREATE_STEPS,
title: "Add multiple steps",
layout: NewFormSteps,
query: "#steps-prop-control-wrap",
onComplete: () => {
builderStore.highlightSetting()
endTour({ key: TOUR_KEYS.BUILDER_FORM_CREATE })
},
onLoad: () => {
tourEvent(TOUR_STEP_KEYS.BUILDER_FORM_CREATE_STEPS)
builderStore.highlightSetting("steps", "info")
},
positionHandler: customPositionHandler,
align: "left-outside",
},
],
},
[TOUR_KEYS.BUILDER_FORM_VIEW_UPDATE]: {
steps: [
{
id: TOUR_STEP_KEYS.BUILDER_FORM_ROW_ID,
title: "Add row ID to update a row",
layout: NewViewUpdateFormRowId,
query: "#rowId-prop-control-wrap",
onLoad: () => {
tourEvent(TOUR_STEP_KEYS.BUILDER_FORM_ROW_ID)
builderStore.highlightSetting("rowId", "info")
},
positionHandler: customPositionHandler,
align: "left-outside",
},
{
id: TOUR_STEP_KEYS.BUILDER_FORM_VIEW_UPDATE_STEPS,
title: "Add multiple steps",
layout: NewFormSteps,
query: "#steps-prop-control-wrap",
onComplete: () => {
builderStore.highlightSetting()
endTour({ key: TOUR_KEYS.BUILDER_FORM_VIEW_UPDATE })
},
onLoad: () => {
tourEvent(TOUR_STEP_KEYS.BUILDER_FORM_VIEW_UPDATE_STEPS)
builderStore.highlightSetting("steps", "info")
},
positionHandler: customPositionHandler,
align: "left-outside",
scrollIntoView: true,
},
],
onSkip: async () => {
builderStore.highlightSetting()
endTour({ key: TOUR_KEYS.BUILDER_FORM_VIEW_UPDATE, skipped: true })
},
},
} }
} }
export const TOURS = getTours() export const TOURS = getTours()
export const TOURSBYSTEP = Object.keys(TOURS).reduce((acc, tour) => {
TOURS[tour].steps.forEach(element => {
acc[element.id] = element
acc[element.id]["tour"] = tour
})
return acc
}, {})

View File

@ -1146,7 +1146,7 @@ export const getAllStateVariables = () => {
"@budibase/standard-components/multistepformblockstep" "@budibase/standard-components/multistepformblockstep"
) )
steps.forEach(step => { steps?.forEach(step => {
parseComponentSettings(stepDefinition, step) parseComponentSettings(stepDefinition, step)
}) })
}) })

View File

@ -17,6 +17,10 @@ export function breakQueryString(qs) {
return paramObj return paramObj
} }
function isEncoded(str) {
return typeof str == "string" && decodeURIComponent(str) !== str
}
export function buildQueryString(obj) { export function buildQueryString(obj) {
let str = "" let str = ""
if (obj) { if (obj) {
@ -35,7 +39,7 @@ export function buildQueryString(obj) {
value = value.replace(binding, marker) value = value.replace(binding, marker)
bindingMarkers[marker] = binding bindingMarkers[marker] = binding
}) })
let encoded = encodeURIComponent(value || "") let encoded = isEncoded(value) ? value : encodeURIComponent(value || "")
Object.entries(bindingMarkers).forEach(([marker, binding]) => { Object.entries(bindingMarkers).forEach(([marker, binding]) => {
encoded = encoded.replace(marker, binding) encoded = encoded.replace(marker, binding)
}) })

View File

@ -39,4 +39,11 @@ describe("check query string utils", () => {
expect(broken.key1).toBe(obj2.key1) expect(broken.key1).toBe(obj2.key1)
expect(broken.key2).toBe(obj2.key2) expect(broken.key2).toBe(obj2.key2)
}) })
it("should not encode a URL more than once when building the query string", () => {
const queryString = buildQueryString({
values: "a%2Cb%2Cc",
})
expect(queryString).toBe("values=a%2Cb%2Cc")
})
}) })

View File

@ -69,11 +69,12 @@
// brought back to the same screen. // brought back to the same screen.
const topItemNavigate = path => () => { const topItemNavigate = path => () => {
const activeTopNav = $layout.children.find(c => $isActive(c.path)) const activeTopNav = $layout.children.find(c => $isActive(c.path))
if (!activeTopNav) return if (activeTopNav) {
builderStore.setPreviousTopNavPath( builderStore.setPreviousTopNavPath(
activeTopNav.path, activeTopNav.path,
window.location.pathname window.location.pathname
) )
}
$goto($builderStore.previousTopNavPath[path] || path) $goto($builderStore.previousTopNavPath[path] || path)
} }
@ -95,7 +96,7 @@
const release_date = new Date("2023-03-01T00:00:00.000Z") const release_date = new Date("2023-03-01T00:00:00.000Z")
const onboarded = new Date($auth.user?.onboardedAt) const onboarded = new Date($auth.user?.onboardedAt)
if (onboarded < release_date) { if (onboarded < release_date) {
builderStore.startTour(TOUR_KEYS.FEATURE_ONBOARDING) builderStore.setTour(TOUR_KEYS.FEATURE_ONBOARDING)
} }
} }
} }
@ -143,7 +144,7 @@
</span> </span>
<Tabs {selected} size="M"> <Tabs {selected} size="M">
{#each $layout.children as { path, title }} {#each $layout.children as { path, title }}
<TourWrap tourStepKey={`builder-${title}-section`}> <TourWrap stepKeys={[`builder-${title}-section`]}>
<Tab <Tab
quiet quiet
selected={$isActive(path)} selected={$isActive(path)}

View File

@ -16,6 +16,14 @@
} from "dataBinding" } from "dataBinding"
import { ActionButton, notifications } from "@budibase/bbui" import { ActionButton, notifications } from "@budibase/bbui"
import { capitalise } from "helpers" import { capitalise } from "helpers"
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
import { TOUR_STEP_KEYS } from "components/portal/onboarding/tours.js"
const {
BUILDER_FORM_CREATE_STEPS,
BUILDER_FORM_VIEW_UPDATE_STEPS,
BUILDER_FORM_ROW_ID,
} = TOUR_STEP_KEYS
const onUpdateName = async value => { const onUpdateName = async value => {
try { try {
@ -46,7 +54,6 @@
$: id = $selectedComponent?._id $: id = $selectedComponent?._id
$: id, (section = tabs[0]) $: id, (section = tabs[0])
$: componentName = getComponentName(componentInstance) $: componentName = getComponentName(componentInstance)
</script> </script>
@ -92,13 +99,21 @@
</div> </div>
</span> </span>
{#if section == "settings"} {#if section == "settings"}
<ComponentSettingsSection <TourWrap
{componentInstance} stepKeys={[
{componentDefinition} BUILDER_FORM_CREATE_STEPS,
{bindings} BUILDER_FORM_VIEW_UPDATE_STEPS,
{componentBindings} BUILDER_FORM_ROW_ID,
{isScreen} ]}
/> >
<ComponentSettingsSection
{componentInstance}
{componentDefinition}
{bindings}
{componentBindings}
{isScreen}
/>
</TourWrap>
{/if} {/if}
{#if section == "styles"} {#if section == "styles"}
<DesignSection <DesignSection

View File

@ -1,7 +1,7 @@
<script> <script>
import { helpers } from "@budibase/shared-core" import { helpers } from "@budibase/shared-core"
import { DetailSummary, notifications } from "@budibase/bbui" import { DetailSummary, notifications } from "@budibase/bbui"
import { componentStore } from "stores/builder" import { componentStore, builderStore } from "stores/builder"
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte" import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte" import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte"
import EjectBlockButton from "components/design/settings/controls/EjectBlockButton.svelte" import EjectBlockButton from "components/design/settings/controls/EjectBlockButton.svelte"
@ -177,9 +177,7 @@
defaultValue={setting.defaultValue} defaultValue={setting.defaultValue}
nested={setting.nested} nested={setting.nested}
onChange={val => updateSetting(setting, val)} onChange={val => updateSetting(setting, val)}
highlighted={$componentStore.highlightedSettingKey === propertyFocus={$builderStore.propertyFocus === setting.key}
setting.key}
propertyFocus={$componentStore.propertyFocus === setting.key}
info={setting.info} info={setting.info}
disableBindings={setting.disableBindings} disableBindings={setting.disableBindings}
props={{ props={{

View File

@ -174,7 +174,7 @@
} else if (type === "request-add-component") { } else if (type === "request-add-component") {
toggleAddComponent() toggleAddComponent()
} else if (type === "highlight-setting") { } else if (type === "highlight-setting") {
builderStore.highlightSetting(data.setting) builderStore.highlightSetting(data.setting, "error")
// Also scroll setting into view // Also scroll setting into view
const selector = `#${data.setting}-prop-control` const selector = `#${data.setting}-prop-control`

View File

@ -3,13 +3,23 @@
import DatasourceModal from "./DatasourceModal.svelte" import DatasourceModal from "./DatasourceModal.svelte"
import ScreenRoleModal from "./ScreenRoleModal.svelte" import ScreenRoleModal from "./ScreenRoleModal.svelte"
import sanitizeUrl from "helpers/sanitizeUrl" import sanitizeUrl from "helpers/sanitizeUrl"
import FormTypeModal from "./FormTypeModal.svelte"
import { Modal, notifications } from "@budibase/bbui" import { Modal, notifications } from "@budibase/bbui"
import { screenStore, navigationStore, tables } from "stores/builder" import {
screenStore,
navigationStore,
tables,
builderStore,
} from "stores/builder"
import { auth } from "stores/portal"
import { get } from "svelte/store" import { get } from "svelte/store"
import getTemplates from "templates" import getTemplates from "templates"
import { Roles } from "constants/backend" import { Roles } from "constants/backend"
import { capitalise } from "helpers" import { capitalise } from "helpers"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { TOUR_KEYS } from "components/portal/onboarding/tours.js"
import formScreen from "templates/formScreen"
import rowListScreen from "templates/rowListScreen"
let mode let mode
let pendingScreen let pendingScreen
@ -18,12 +28,18 @@
let screenDetailsModal let screenDetailsModal
let datasourceModal let datasourceModal
let screenAccessRoleModal let screenAccessRoleModal
let formTypeModal
// Cache variables for workflow // Cache variables for workflow
let screenAccessRole = Roles.BASIC let screenAccessRole = Roles.BASIC
let selectedTemplates = null
let templates = null
let screens = null
let selectedDatasources = null
let blankScreenUrl = null let blankScreenUrl = null
let screenMode = null let screenMode = null
let formType = null
// Creates an array of screens, checking and sanitising their URLs // Creates an array of screens, checking and sanitising their URLs
const createScreens = async ({ screens, screenAccessRole }) => { const createScreens = async ({ screens, screenAccessRole }) => {
@ -32,7 +48,7 @@
} }
try { try {
let screenId let createdScreens = []
for (let screen of screens) { for (let screen of screens) {
// Check we aren't clashing with an existing URL // Check we aren't clashing with an existing URL
@ -56,7 +72,7 @@
// Create the screen // Create the screen
const response = await screenStore.save(screen) const response = await screenStore.save(screen)
screenId = response._id createdScreens.push(response)
// Add link in layout. We only ever actually create 1 screen now, even // Add link in layout. We only ever actually create 1 screen now, even
// for autoscreens, so it's always safe to do this. // for autoscreens, so it's always safe to do this.
@ -66,9 +82,7 @@
) )
} }
// Go to new screen return createdScreens
$goto(`./${screenId}`)
screenStore.select(screenId)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
notifications.error("Error creating screens") notifications.error("Error creating screens")
@ -104,13 +118,16 @@
// Handler for NewScreenModal // Handler for NewScreenModal
export const show = newMode => { export const show = newMode => {
mode = newMode mode = newMode
selectedTemplates = null templates = null
screens = null
selectedDatasources = null
blankScreenUrl = null blankScreenUrl = null
screenMode = mode screenMode = mode
pendingScreen = null pendingScreen = null
screenAccessRole = Roles.BASIC screenAccessRole = Roles.BASIC
formType = null
if (mode === "table" || mode === "grid") { if (mode === "table" || mode === "grid" || mode === "form") {
datasourceModal.show() datasourceModal.show()
} else if (mode === "blank") { } else if (mode === "blank") {
let templates = getTemplates($tables.list) let templates = getTemplates($tables.list)
@ -125,19 +142,26 @@
} }
// Handler for DatasourceModal confirmation, move to screen access select // Handler for DatasourceModal confirmation, move to screen access select
const confirmScreenDatasources = async ({ templates }) => { const confirmScreenDatasources = async ({ datasources }) => {
selectedTemplates = templates selectedDatasources = datasources
screenAccessRoleModal.show() if (screenMode === "form") {
formTypeModal.show()
} else {
screenAccessRoleModal.show()
}
} }
// Handler for Datasource Screen Creation // Handler for Datasource Screen Creation
const completeDatasourceScreenCreation = async () => { const completeDatasourceScreenCreation = async () => {
const screens = selectedTemplates.map(template => { templates = rowListScreen(selectedDatasources, mode)
const screens = templates.map(template => {
let screenTemplate = template.create() let screenTemplate = template.create()
screenTemplate.autoTableId = template.resourceId screenTemplate.autoTableId = template.resourceId
return screenTemplate return screenTemplate
}) })
await createScreens({ screens, screenAccessRole }) const createdScreens = await createScreens({ screens, screenAccessRole })
loadNewScreen(createdScreens)
} }
const confirmScreenBlank = async ({ screenUrl }) => { const confirmScreenBlank = async ({ screenUrl }) => {
@ -154,7 +178,54 @@
return return
} }
pendingScreen.routing.route = screenUrl pendingScreen.routing.route = screenUrl
await createScreens({ screens: [pendingScreen], screenAccessRole }) const createdScreens = await createScreens({
screens: [pendingScreen],
screenAccessRole,
})
loadNewScreen(createdScreens)
}
const onConfirmFormType = () => {
screenAccessRoleModal.show()
}
const loadNewScreen = createdScreens => {
const lastScreen = createdScreens.slice(-1)[0]
// Go to new screen
if (lastScreen?.props?._children.length) {
// Focus on the main component for the streen type
const mainComponent = lastScreen?.props?._children?.[0]._id
$goto(`./${lastScreen._id}/${mainComponent}`)
} else {
$goto(`./${lastScreen._id}`)
}
screenStore.select(lastScreen._id)
}
const confirmFormScreenCreation = async () => {
templates = formScreen(selectedDatasources, { actionType: formType })
screens = templates.map(template => {
let screenTemplate = template.create()
return screenTemplate
})
const createdScreens = await createScreens({ screens, screenAccessRole })
if (formType === "Update" || formType === "Create") {
const associatedTour =
formType === "Update"
? TOUR_KEYS.BUILDER_FORM_VIEW_UPDATE
: TOUR_KEYS.BUILDER_FORM_CREATE
const tourRequired = !$auth?.user?.tours?.[associatedTour]
if (tourRequired) {
builderStore.setTour(associatedTour)
}
}
// Go to new screen
loadNewScreen(createdScreens)
} }
// Submit screen config for creation. // Submit screen config for creation.
@ -164,6 +235,8 @@
screenUrl: blankScreenUrl, screenUrl: blankScreenUrl,
screenAccessRole, screenAccessRole,
}) })
} else if (screenMode === "form") {
confirmFormScreenCreation()
} else { } else {
completeDatasourceScreenCreation() completeDatasourceScreenCreation()
} }
@ -179,19 +252,18 @@
</script> </script>
<Modal bind:this={datasourceModal} autoFocus={false}> <Modal bind:this={datasourceModal} autoFocus={false}>
<DatasourceModal <DatasourceModal {mode} onConfirm={confirmScreenDatasources} />
{mode}
onConfirm={confirmScreenDatasources}
initialScreens={!selectedTemplates ? [] : [...selectedTemplates]}
/>
</Modal> </Modal>
<Modal bind:this={screenAccessRoleModal}> <Modal bind:this={screenAccessRoleModal}>
<ScreenRoleModal <ScreenRoleModal
onConfirm={confirmScreenCreation} onConfirm={() => {
onCancel={roleSelectBack} confirmScreenCreation()
}}
bind:screenAccessRole bind:screenAccessRole
onCancel={roleSelectBack}
screenUrl={blankScreenUrl} screenUrl={blankScreenUrl}
confirmText={screenMode === "form" ? "Confirm" : "Done"}
/> />
</Modal> </Modal>
@ -201,3 +273,17 @@
initialUrl={blankScreenUrl} initialUrl={blankScreenUrl}
/> />
</Modal> </Modal>
<Modal bind:this={formTypeModal}>
<FormTypeModal
onConfirm={onConfirmFormType}
onCancel={() => {
formTypeModal.hide()
datasourceModal.show()
}}
on:select={e => {
formType = e.detail
}}
type={formType}
/>
</Modal>

View File

@ -4,37 +4,33 @@
import ICONS from "components/backend/DatasourceNavigator/icons" import ICONS from "components/backend/DatasourceNavigator/icons"
import { IntegrationNames } from "constants" import { IntegrationNames } from "constants"
import { onMount } from "svelte" import { onMount } from "svelte"
import rowListScreen from "templates/rowListScreen"
import DatasourceTemplateRow from "./DatasourceTemplateRow.svelte" import DatasourceTemplateRow from "./DatasourceTemplateRow.svelte"
export let mode
export let onCancel export let onCancel
export let onConfirm export let onConfirm
export let initialScreens = []
let selectedScreens = [...initialScreens] let selectedSources = []
$: filteredSources = $datasources.list?.filter(datasource => { $: filteredSources = $datasources.list?.filter(datasource => {
return datasource.source !== IntegrationNames.REST && datasource["entities"] return datasource.source !== IntegrationNames.REST && datasource["entities"]
}) })
const toggleSelection = datasource => { const toggleSelection = datasource => {
const { resourceId } = datasource const exists = selectedSources.find(
if (selectedScreens.find(s => s.resourceId === resourceId)) { d => d.resourceId === datasource.resourceId
selectedScreens = selectedScreens.filter( )
screen => screen.resourceId !== resourceId if (exists) {
selectedSources = selectedSources.filter(
d => d.resourceId === datasource.resourceId
) )
} else { } else {
selectedScreens = [ selectedSources = [...selectedSources, datasource]
...selectedScreens,
rowListScreen([datasource], mode)[0],
]
} }
} }
const confirmDatasourceSelection = async () => { const confirmDatasourceSelection = async () => {
await onConfirm({ await onConfirm({
templates: selectedScreens, datasources: selectedSources,
}) })
} }
@ -54,7 +50,7 @@
cancelText="Back" cancelText="Back"
onConfirm={confirmDatasourceSelection} onConfirm={confirmDatasourceSelection}
{onCancel} {onCancel}
disabled={!selectedScreens.length} disabled={!selectedSources.length}
size="L" size="L"
> >
<Body size="S"> <Body size="S">
@ -85,8 +81,8 @@
resourceId: table._id, resourceId: table._id,
type: "table", type: "table",
}} }}
{@const selected = selectedScreens.find( {@const selected = selectedSources.find(
screen => screen.resourceId === tableDS.resourceId datasource => datasource.resourceId === tableDS.resourceId
)} )}
<DatasourceTemplateRow <DatasourceTemplateRow
on:click={() => toggleSelection(tableDS)} on:click={() => toggleSelection(tableDS)}
@ -103,7 +99,7 @@
tableId: view.tableId, tableId: view.tableId,
type: "viewV2", type: "viewV2",
}} }}
{@const selected = selectedScreens.find( {@const selected = selectedSources.find(
x => x.resourceId === viewDS.resourceId x => x.resourceId === viewDS.resourceId
)} )}
<DatasourceTemplateRow <DatasourceTemplateRow

View File

@ -0,0 +1,123 @@
<script>
import { ModalContent, Layout, Body, Icon } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
export let onCancel = () => {}
export let onConfirm = () => {}
export let type
const dispatch = createEventDispatcher()
</script>
<span>
<ModalContent
title="Select form type"
confirmText="Done"
cancelText="Back"
{onConfirm}
{onCancel}
disabled={!type}
size="L"
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<Layout noPadding gap="S">
<div
class="form-type"
class:selected={type === "Create"}
on:click={() => {
dispatch("select", "Create")
}}
>
<div class="form-type-wrap">
<div class="form-type-content">
<Body noPadding>Create a new row</Body>
<Body size="S">
For capturing and storing new data from your users
</Body>
</div>
{#if type === "Create"}
<span class="form-type-check">
<Icon size="S" name="CheckmarkCircle" />
</span>
{/if}
</div>
</div>
<div
class="form-type"
class:selected={type === "Update"}
on:click={() => {
dispatch("select", "Update")
}}
>
<div class="form-type-wrap">
<div class="form-type-content">
<Body noPadding>Update an existing row</Body>
<Body size="S">For viewing and updating existing data</Body>
</div>
{#if type === "Update"}
<span class="form-type-check">
<Icon size="S" name="CheckmarkCircle" />
</span>
{/if}
</div>
</div>
<div
class="form-type"
class:selected={type === "View"}
on:click={() => {
dispatch("select", "View")
}}
>
<div class="form-type-wrap">
<div class="form-type-content">
<Body noPadding>View an existing row</Body>
<Body size="S">For a read only view of your data</Body>
</div>
{#if type === "View"}
<span class="form-type-check">
<Icon size="S" name="CheckmarkCircle" />
</span>
{/if}
</div>
</div>
</Layout>
</ModalContent>
</span>
<style>
.form-type {
cursor: pointer;
gap: var(--spacing-s);
padding: var(--spacing-m) var(--spacing-xl);
background: var(--spectrum-alias-background-color-secondary);
transition: 0.3s all;
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px;
display: flex;
flex-direction: column;
}
.selected,
.form-type:hover {
background: var(--spectrum-alias-background-color-tertiary);
}
.form-type-wrap {
display: flex;
align-items: center;
justify-content: space-between;
}
.form-type :global(p:nth-child(2)) {
color: var(--grey-6);
}
.form-type-check {
margin-left: auto;
}
.form-type-check :global(.spectrum-Icon) {
color: var(--spectrum-global-color-green-600);
}
.form-type-content {
gap: var(--spacing-s);
display: flex;
flex-direction: column;
}
</style>

View File

@ -9,6 +9,7 @@
export let onCancel export let onCancel
export let screenUrl export let screenUrl
export let screenAccessRole export let screenAccessRole
export let confirmText = "Done"
let error let error
@ -40,7 +41,7 @@
<ModalContent <ModalContent
title="Access" title="Access"
confirmText="Done" {confirmText}
cancelText="Back" cancelText="Back"
{onConfirm} {onConfirm}
{onCancel} {onCancel}

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@ -1,9 +1,10 @@
<script> <script>
import { Body } from "@budibase/bbui" import { Body } from "@budibase/bbui"
import CreationPage from "components/common/CreationPage.svelte" import CreationPage from "components/common/CreationPage.svelte"
import blankImage from "./blank.png" import blankImage from "./images/blank.png"
import tableImage from "./table.png" import tableImage from "./images/table.png"
import gridImage from "./grid.png" import gridImage from "./images/grid.png"
import formImage from "./images/form.png"
import CreateScreenModal from "./CreateScreenModal.svelte" import CreateScreenModal from "./CreateScreenModal.svelte"
import { screenStore } from "stores/builder" import { screenStore } from "stores/builder"
@ -56,6 +57,16 @@
<Body size="XS">View and manipulate rows on a grid</Body> <Body size="XS">View and manipulate rows on a grid</Body>
</div> </div>
</div> </div>
<div class="card" on:click={() => createScreenModal.show("form")}>
<div class="image">
<img alt="" src={formImage} />
</div>
<div class="text">
<Body size="S">Form</Body>
<Body size="XS">Capture data from your users</Body>
</div>
</div>
</div> </div>
</CreationPage> </CreationPage>
</div> </div>

View File

@ -29,6 +29,7 @@ export const INITIAL_APP_META_STATE = {
initialised: false, initialised: false,
hasAppPackage: false, hasAppPackage: false,
usedPlugins: null, usedPlugins: null,
automations: {},
routes: {}, routes: {},
} }
@ -63,6 +64,7 @@ export class AppMetaStore extends BudiStore {
...app.features, ...app.features,
}, },
initialised: true, initialised: true,
automations: app.automations || {},
hasAppPackage: true, hasAppPackage: true,
})) }))
} }

View File

@ -2,12 +2,11 @@ import { get } from "svelte/store"
import { createBuilderWebsocket } from "./websocket.js" import { createBuilderWebsocket } from "./websocket.js"
import { BuilderSocketEvent } from "@budibase/shared-core" import { BuilderSocketEvent } from "@budibase/shared-core"
import BudiStore from "./BudiStore" import BudiStore from "./BudiStore"
import { previewStore } from "./preview.js"
import { TOUR_KEYS } from "components/portal/onboarding/tours.js" import { TOUR_KEYS } from "components/portal/onboarding/tours.js"
export const INITIAL_BUILDER_STATE = { export const INITIAL_BUILDER_STATE = {
previousTopNavPath: {}, previousTopNavPath: {},
highlightedSettingKey: null, highlightedSetting: null,
propertyFocus: null, propertyFocus: null,
builderSidePanel: false, builderSidePanel: false,
onboarding: false, onboarding: false,
@ -26,7 +25,6 @@ export class BuilderStore extends BudiStore {
this.reset = this.reset.bind(this) this.reset = this.reset.bind(this)
this.highlightSetting = this.highlightSetting.bind(this) this.highlightSetting = this.highlightSetting.bind(this)
this.propertyFocus = this.propertyFocus.bind(this) this.propertyFocus = this.propertyFocus.bind(this)
this.hover = this.hover.bind(this)
this.hideBuilderSidePanel = this.hideBuilderSidePanel.bind(this) this.hideBuilderSidePanel = this.hideBuilderSidePanel.bind(this)
this.showBuilderSidePanel = this.showBuilderSidePanel.bind(this) this.showBuilderSidePanel = this.showBuilderSidePanel.bind(this)
this.setPreviousTopNavPath = this.setPreviousTopNavPath.bind(this) this.setPreviousTopNavPath = this.setPreviousTopNavPath.bind(this)
@ -58,10 +56,10 @@ export class BuilderStore extends BudiStore {
this.websocket = null this.websocket = null
} }
highlightSetting(key) { highlightSetting(key, type) {
this.update(state => ({ this.update(state => ({
...state, ...state,
highlightedSettingKey: key, highlightedSetting: key ? { key, type: type || "info" } : null,
})) }))
} }
@ -135,25 +133,20 @@ export class BuilderStore extends BudiStore {
})) }))
} }
startTour(tourKey) { endBuilderOnboarding() {
this.update(state => ({ this.update(state => ({
...state, ...state,
tourKey: tourKey, onboarding: false,
})) }))
} }
hover(componentId, notifyClient = true) { setTour(tourKey) {
const store = get(this.store) this.update(state => ({
if (componentId === store.hoveredComponentId) { ...state,
return tourStepKey: null,
} tourNodes: null,
this.update(state => { tourKey: tourKey,
state.hoveredComponentId = componentId }))
return state
})
if (notifyClient) {
previewStore.sendEvent("hover-component", componentId)
}
} }
} }

View File

@ -147,6 +147,12 @@ export function createTablesStore() {
if (indexes) { if (indexes) {
draft.indexes = indexes draft.indexes = indexes
} }
// Add object to indicate if column is being added
if (draft.schema[field.name] === undefined) {
draft._add = {
name: field.name,
}
}
draft.schema = { draft.schema = {
...draft.schema, ...draft.schema,
[field.name]: cloneDeep(field), [field.name]: cloneDeep(field),

View File

@ -88,14 +88,42 @@ describe("Builder store", () => {
) )
}) })
it("Sync a highlighted setting key to state", ctx => { it("Sync a highlighted setting key to state. Default to info type", ctx => {
expect(ctx.test.store.highlightedSettingKey).toBeNull() expect(ctx.test.store.highlightedSetting).toBeNull()
ctx.test.builderStore.highlightSetting("testing") ctx.test.builderStore.highlightSetting("testing")
expect(ctx.test.store).toStrictEqual({ expect(ctx.test.store).toStrictEqual({
...INITIAL_BUILDER_STATE, ...INITIAL_BUILDER_STATE,
highlightedSettingKey: "testing", highlightedSetting: {
key: "testing",
type: "info",
},
})
})
it("Sync a highlighted setting key to state. Use provided type", ctx => {
expect(ctx.test.store.highlightedSetting).toBeNull()
ctx.test.builderStore.highlightSetting("testing", "error")
expect(ctx.test.store).toStrictEqual({
...INITIAL_BUILDER_STATE,
highlightedSetting: {
key: "testing",
type: "error",
},
})
})
it("Sync a highlighted setting key to state. Unset when no value is passed", ctx => {
expect(ctx.test.store.highlightedSetting).toBeNull()
ctx.test.builderStore.highlightSetting("testing", "error")
ctx.test.builderStore.highlightSetting()
expect(ctx.test.store).toStrictEqual({
...INITIAL_BUILDER_STATE,
}) })
}) })

View File

@ -0,0 +1,43 @@
import { Screen } from "./Screen"
import { Component } from "./Component"
import sanitizeUrl from "helpers/sanitizeUrl"
export const FORM_TEMPLATE = "FORM_TEMPLATE"
export const formUrl = datasource => sanitizeUrl(`/${datasource.label}-form`)
// Mode not really necessary
export default function (datasources, config) {
if (!Array.isArray(datasources)) {
return []
}
return datasources.map(datasource => {
return {
name: `${datasource.label} - Form`,
create: () => createScreen(datasource, config),
id: FORM_TEMPLATE,
resourceId: datasource.resourceId,
}
})
}
const generateMultistepFormBlock = (dataSource, { actionType } = {}) => {
const multistepFormBlock = new Component(
"@budibase/standard-components/multistepformblock"
)
multistepFormBlock
.customProps({
actionType,
dataSource,
steps: [{}],
})
.instanceName(`${dataSource.label} - Multistep Form block`)
return multistepFormBlock
}
const createScreen = (datasource, config) => {
return new Screen()
.route(formUrl(datasource))
.instanceName(`${datasource.label} - Form`)
.addChild(generateMultistepFormBlock(datasource, config))
.json()
}

View File

@ -1,7 +1,11 @@
import rowListScreen from "./rowListScreen" import rowListScreen from "./rowListScreen"
import createFromScratchScreen from "./createFromScratchScreen" import createFromScratchScreen from "./createFromScratchScreen"
import formScreen from "./formScreen"
const allTemplates = datasources => [...rowListScreen(datasources)] const allTemplates = datasources => [
...rowListScreen(datasources),
...formScreen(datasources),
]
// Allows us to apply common behaviour to all create() functions // Allows us to apply common behaviour to all create() functions
const createTemplateOverride = template => () => { const createTemplateOverride = template => () => {
@ -19,6 +23,7 @@ export default datasources => {
}) })
const fromScratch = enrichTemplate(createFromScratchScreen) const fromScratch = enrichTemplate(createFromScratchScreen)
const tableTemplates = allTemplates(datasources).map(enrichTemplate) const tableTemplates = allTemplates(datasources).map(enrichTemplate)
return [ return [
fromScratch, fromScratch,
...tableTemplates.sort((templateA, templateB) => { ...tableTemplates.sort((templateA, templateB) => {

View File

@ -525,6 +525,38 @@
"barTitle": "Disable button", "barTitle": "Disable button",
"key": "disabled" "key": "disabled"
}, },
{
"type": "icon",
"label": "Icon",
"key": "icon"
},
{
"type": "select",
"label": "Gap",
"key": "gap",
"showInBar": true,
"barStyle": "picker",
"dependsOn": "icon",
"options": [
{
"label": "None",
"value": "N"
},
{
"label": "Small",
"value": "S"
},
{
"label": "Medium",
"value": "M"
},
{
"label": "Large",
"value": "L"
}
],
"defaultValue": "M"
},
{ {
"type": "event", "type": "event",
"label": "On click", "label": "On click",

View File

@ -13,9 +13,10 @@
export let size = "M" export let size = "M"
export let type = "cta" export let type = "cta"
export let quiet = false export let quiet = false
export let icon = null
export let gap = "M"
// For internal use only for now - not defined in the manifest // For internal use only for now - not defined in the manifest
export let icon = null
export let active = false export let active = false
const handleOnClick = async () => { const handleOnClick = async () => {
@ -47,7 +48,7 @@
{#key $component.editing} {#key $component.editing}
<button <button
class={`spectrum-Button spectrum-Button--size${size} spectrum-Button--${type}`} class={`spectrum-Button spectrum-Button--size${size} spectrum-Button--${type} gap-${gap}`}
class:spectrum-Button--quiet={quiet} class:spectrum-Button--quiet={quiet}
disabled={disabled || handlingOnClick} disabled={disabled || handlingOnClick}
use:styleable={$component.styles} use:styleable={$component.styles}
@ -58,15 +59,7 @@
class:active class:active
> >
{#if icon} {#if icon}
<svg <i class="{icon} {size}" />
class:hasText={componentText?.length > 0}
class="spectrum-Icon spectrum-Icon--size{size.toUpperCase()}"
focusable="false"
aria-hidden="true"
aria-label={icon}
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
{/if} {/if}
{componentText} {componentText}
</button> </button>
@ -92,4 +85,13 @@
.active { .active {
color: var(--spectrum-global-color-blue-600); color: var(--spectrum-global-color-blue-600);
} }
.gap-S {
gap: 8px;
}
.gap-M {
gap: 16px;
}
.gap-L {
gap: 32px;
}
</style> </style>

View File

@ -20,7 +20,7 @@
wrap: true, wrap: true,
}} }}
> >
{#each buttons as { text, type, quiet, disabled, onClick, size }} {#each buttons as { text, type, quiet, disabled, onClick, size, icon, gap }}
<BlockComponent <BlockComponent
type="button" type="button"
props={{ props={{
@ -29,6 +29,8 @@
type, type,
quiet, quiet,
disabled, disabled,
icon,
gap,
size: size || "M", size: size || "M",
}} }}
/> />

View File

@ -92,9 +92,9 @@
{#if schemaLoaded} {#if schemaLoaded}
<Button <Button
onClick={openEditor} onClick={openEditor}
icon="Properties" icon="ri-filter-3-line"
text="Filter" text="Filter"
{size} size="XL"
type="secondary" type="secondary"
quiet quiet
active={filters?.length > 0} active={filters?.length > 0}

View File

@ -1,7 +1,7 @@
<script> <script>
import { CoreSelect, CoreMultiselect } from "@budibase/bbui" import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
import { fetchData, Utils } from "@budibase/frontend-core" import { fetchData, Utils } from "@budibase/frontend-core"
import { getContext } from "svelte" import { getContext, onMount } from "svelte"
import Field from "./Field.svelte" import Field from "./Field.svelte"
import { FieldTypes } from "../../../constants" import { FieldTypes } from "../../../constants"
@ -28,6 +28,7 @@
let tableDefinition let tableDefinition
let searchTerm let searchTerm
let open let open
let initialValue
$: type = $: type =
datasourceType === "table" ? FieldTypes.LINK : FieldTypes.BB_REFERENCE datasourceType === "table" ? FieldTypes.LINK : FieldTypes.BB_REFERENCE
@ -109,7 +110,11 @@
} }
$: forceFetchRows(filter) $: forceFetchRows(filter)
$: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue) $: debouncedFetchRows(
searchTerm,
primaryDisplay,
initialValue || defaultValue
)
const forceFetchRows = async () => { const forceFetchRows = async () => {
// if the filter has changed, then we need to reset the options, clear the selection, and re-fetch // if the filter has changed, then we need to reset the options, clear the selection, and re-fetch
@ -127,9 +132,13 @@
if (allRowsFetched || !primaryDisplay) { if (allRowsFetched || !primaryDisplay) {
return return
} }
if (defaultVal && !optionsObj[defaultVal]) { // must be an array
if (defaultVal && !Array.isArray(defaultVal)) {
defaultVal = defaultVal.split(",")
}
if (defaultVal && defaultVal.some(val => !optionsObj[val])) {
await fetch.update({ await fetch.update({
query: { equal: { _id: defaultVal } }, query: { oneOf: { _id: defaultVal } },
}) })
} }
@ -202,6 +211,16 @@
fetch.nextPage() fetch.nextPage()
} }
} }
onMount(() => {
// if the form is in 'Update' mode, then we need to fetch the matching row so that the value is correctly set
if (fieldState?.value) {
initialValue =
fieldSchema?.relationshipType !== "one-to-many"
? flatten(fieldState?.value) ?? []
: flatten(fieldState?.value)?.[0]
}
})
</script> </script>
<Field <Field

View File

@ -59,13 +59,13 @@
isReadonly: () => readonly, isReadonly: () => readonly,
getType: () => column.schema.type, getType: () => column.schema.type,
getValue: () => row[column.name], getValue: () => row[column.name],
setValue: (value, options = { save: true }) => { setValue: (value, options = { apply: true }) => {
validation.actions.setError(cellId, null) validation.actions.setError(cellId, null)
updateValue({ updateValue({
rowId: row._id, rowId: row._id,
column: column.name, column: column.name,
value, value,
save: options?.save, apply: options?.apply,
}) })
}, },
} }

View File

@ -217,14 +217,14 @@
const type = $focusedCellAPI.getType() const type = $focusedCellAPI.getType()
if (type === "number" && keyCodeIsNumber(keyCode)) { if (type === "number" && keyCodeIsNumber(keyCode)) {
// Update the value locally but don't save it yet // Update the value locally but don't save it yet
$focusedCellAPI.setValue(parseInt(key), { save: false }) $focusedCellAPI.setValue(parseInt(key), { apply: false })
$focusedCellAPI.focus() $focusedCellAPI.focus()
} else if ( } else if (
["string", "barcodeqr", "longform"].includes(type) && ["string", "barcodeqr", "longform"].includes(type) &&
(keyCodeIsLetter(keyCode) || keyCodeIsNumber(keyCode)) (keyCodeIsLetter(keyCode) || keyCodeIsNumber(keyCode))
) { ) {
// Update the value locally but don't save it yet // Update the value locally but don't save it yet
$focusedCellAPI.setValue(key, { save: false }) $focusedCellAPI.setValue(key, { apply: false })
$focusedCellAPI.focus() $focusedCellAPI.focus()
} }
} }

View File

@ -327,29 +327,31 @@ export const createActions = context => {
get(fetch)?.getInitialData() get(fetch)?.getInitialData()
} }
// Patches a row with some changes // Checks if a changeset for a row actually mutates the row or not
const updateRow = async (rowId, changes, options = { save: true }) => { const changesAreValid = (row, changes) => {
const columns = Object.keys(changes || {})
if (!row || !columns.length) {
return false
}
// Ensure there is at least 1 column that creates a difference
return columns.some(column => row[column] !== changes[column])
}
// Patches a row with some changes in local state, and returns whether a
// valid pending change was made or not
const stashRowChanges = (rowId, changes) => {
const $rows = get(rows) const $rows = get(rows)
const $rowLookupMap = get(rowLookupMap) const $rowLookupMap = get(rowLookupMap)
const index = $rowLookupMap[rowId] const index = $rowLookupMap[rowId]
const row = $rows[index] const row = $rows[index]
if (index == null || !Object.keys(changes || {}).length) {
return // Check this is a valid change
if (!row || !changesAreValid(row, changes)) {
return false
} }
// Abandon if no changes // Add change to cache
let same = true
for (let column of Object.keys(changes)) {
if (row[column] !== changes[column]) {
same = false
break
}
}
if (same) {
return
}
// Immediately update state so that the change is reflected
rowChangeCache.update(state => ({ rowChangeCache.update(state => ({
...state, ...state,
[rowId]: { [rowId]: {
@ -357,26 +359,30 @@ export const createActions = context => {
...changes, ...changes,
}, },
})) }))
return true
}
// Stop here if we don't want to persist the change // Saves any pending changes to a row
if (!options?.save) { const applyRowChanges = async rowId => {
const $rows = get(rows)
const $rowLookupMap = get(rowLookupMap)
const index = $rowLookupMap[rowId]
const row = $rows[index]
if (row == null) {
return return
} }
// Save change // Save change
try { try {
inProgressChanges.update(state => ({ // Mark as in progress
...state, inProgressChanges.update(state => ({ ...state, [rowId]: true }))
[rowId]: true,
}))
// Update row // Update row
const saved = await datasource.actions.updateRow({ const changes = get(rowChangeCache)[rowId]
...cleanRow(row), const newRow = { ...cleanRow(row), ...changes }
...get(rowChangeCache)[rowId], const saved = await datasource.actions.updateRow(newRow)
})
// Update state after a successful change // Update row state after a successful change
if (saved?._id) { if (saved?._id) {
rows.update(state => { rows.update(state => {
state[index] = saved state[index] = saved
@ -386,6 +392,8 @@ export const createActions = context => {
// Handle users table edge case // Handle users table edge case
await refreshRow(saved.id) await refreshRow(saved.id)
} }
// Wipe row change cache now that we've saved the row
rowChangeCache.update(state => { rowChangeCache.update(state => {
delete state[rowId] delete state[rowId]
return state return state
@ -393,15 +401,17 @@ export const createActions = context => {
} catch (error) { } catch (error) {
handleValidationError(rowId, error) handleValidationError(rowId, error)
} }
inProgressChanges.update(state => ({
...state, // Mark as completed
[rowId]: false, inProgressChanges.update(state => ({ ...state, [rowId]: false }))
}))
} }
// Updates a value of a row // Updates a value of a row
const updateValue = async ({ rowId, column, value, save = true }) => { const updateValue = async ({ rowId, column, value, apply = true }) => {
return await updateRow(rowId, { [column]: value }, { save }) const success = stashRowChanges(rowId, { [column]: value })
if (success && apply) {
await applyRowChanges(rowId)
}
} }
// Deletes an array of rows // Deletes an array of rows
@ -411,9 +421,7 @@ export const createActions = context => {
} }
// Actually delete rows // Actually delete rows
rowsToDelete.forEach(row => { rowsToDelete.forEach(row => delete row.__idx)
delete row.__idx
})
await datasource.actions.deleteRows(rowsToDelete) await datasource.actions.deleteRows(rowsToDelete)
// Update state // Update state
@ -433,7 +441,7 @@ export const createActions = context => {
newRow = newRows[i] newRow = newRows[i]
// Ensure we have a unique _id. // Ensure we have a unique _id.
// This means generating one for non DS+, overriting any that may already // This means generating one for non DS+, overwriting any that may already
// exist as we cannot allow duplicates. // exist as we cannot allow duplicates.
if (!$isDatasourcePlus) { if (!$isDatasourcePlus) {
newRow._id = Helpers.uuid() newRow._id = Helpers.uuid()
@ -494,7 +502,7 @@ export const createActions = context => {
duplicateRow, duplicateRow,
getRow, getRow,
updateValue, updateValue,
updateRow, applyRowChanges,
deleteRows, deleteRows,
hasRow, hasRow,
loadNextPage, loadNextPage,
@ -508,7 +516,14 @@ export const createActions = context => {
} }
export const initialise = context => { export const initialise = context => {
const { rowChangeCache, inProgressChanges, previousFocusedRowId } = context const {
rowChangeCache,
inProgressChanges,
previousFocusedRowId,
previousFocusedCellId,
rows,
validation,
} = context
// Wipe the row change cache when changing row // Wipe the row change cache when changing row
previousFocusedRowId.subscribe(id => { previousFocusedRowId.subscribe(id => {
@ -519,4 +534,15 @@ export const initialise = context => {
}) })
} }
}) })
// Ensure any unsaved changes are saved when changing cell
previousFocusedCellId.subscribe(async id => {
const rowId = id?.split("-")[0]
const hasErrors = validation.actions.rowHasErrors(rowId)
const hasChanges = Object.keys(get(rowChangeCache)[rowId] || {}).length > 0
const isSavingChanges = get(inProgressChanges)[rowId]
if (rowId && !hasErrors && hasChanges && !isSavingChanges) {
await rows.actions.applyRowChanges(rowId)
}
})
} }

View File

@ -16,6 +16,7 @@ export const createStores = context => {
const hoveredRowId = writable(null) const hoveredRowId = writable(null)
const rowHeight = writable(get(props).fixedRowHeight || DefaultRowHeight) const rowHeight = writable(get(props).fixedRowHeight || DefaultRowHeight)
const previousFocusedRowId = writable(null) const previousFocusedRowId = writable(null)
const previousFocusedCellId = writable(null)
const gridFocused = writable(false) const gridFocused = writable(false)
const isDragging = writable(false) const isDragging = writable(false)
const buttonColumnWidth = writable(0) const buttonColumnWidth = writable(0)
@ -48,6 +49,7 @@ export const createStores = context => {
focusedCellAPI, focusedCellAPI,
focusedRowId, focusedRowId,
previousFocusedRowId, previousFocusedRowId,
previousFocusedCellId,
hoveredRowId, hoveredRowId,
rowHeight, rowHeight,
gridFocused, gridFocused,
@ -129,6 +131,7 @@ export const initialise = context => {
const { const {
focusedRowId, focusedRowId,
previousFocusedRowId, previousFocusedRowId,
previousFocusedCellId,
rows, rows,
focusedCellId, focusedCellId,
selectedRows, selectedRows,
@ -181,6 +184,13 @@ export const initialise = context => {
lastFocusedRowId = id lastFocusedRowId = id
}) })
// Remember the last focused cell ID so that we can store the previous one
let lastFocusedCellId = null
focusedCellId.subscribe(id => {
previousFocusedCellId.set(lastFocusedCellId)
lastFocusedCellId = id
})
// Remove hovered row when a cell is selected // Remove hovered row when a cell is selected
focusedCellId.subscribe(cell => { focusedCellId.subscribe(cell => {
if (cell && get(hoveredRowId)) { if (cell && get(hoveredRowId)) {

View File

@ -1,8 +1,23 @@
import { writable, get } from "svelte/store" import { writable, get, derived } from "svelte/store"
// Normally we would break out actions into the explicit "createActions"
// function, but for validation all these actions are pure so can go into
// "createStores" instead to make dependency ordering simpler
export const createStores = () => { export const createStores = () => {
const validation = writable({}) const validation = writable({})
// Derive which rows have errors so that we can use that info later
const rowErrorMap = derived(validation, $validation => {
let map = {}
Object.entries($validation).forEach(([key, error]) => {
// Extract row ID from all errored cell IDs
if (error) {
map[key.split("-")[0]] = true
}
})
return map
})
const setError = (cellId, error) => { const setError = (cellId, error) => {
if (!cellId) { if (!cellId) {
return return
@ -13,11 +28,16 @@ export const createStores = () => {
})) }))
} }
const rowHasErrors = rowId => {
return get(rowErrorMap)[rowId]
}
return { return {
validation: { validation: {
...validation, ...validation,
actions: { actions: {
setError, setError,
rowHasErrors,
}, },
}, },
} }

@ -1 +1 @@
Subproject commit 1b6964d047ffa1dd628758e4215c0242a06a5780 Subproject commit 6504ee7451b0a4b21e7e061a0f49add143bb483e

View File

@ -8,6 +8,7 @@ module FetchMock {
let mockSearch = false let mockSearch = false
const func = async (url: any, opts: any) => { const func = async (url: any, opts: any) => {
const { host, pathname } = new URL(url)
function json(body: any, status = 200) { function json(body: any, status = 200) {
return { return {
status, status,
@ -34,7 +35,7 @@ module FetchMock {
} }
} }
if (url.includes("/api/global")) { if (pathname.includes("/api/global")) {
const user = { const user = {
email: "test@example.com", email: "test@example.com",
_id: "us_test@example.com", _id: "us_test@example.com",
@ -47,31 +48,31 @@ module FetchMock {
global: false, global: false,
}, },
} }
return url.endsWith("/users") && opts.method === "GET" return pathname.endsWith("/users") && opts.method === "GET"
? json([user]) ? json([user])
: json(user) : json(user)
} }
// mocked data based on url // mocked data based on url
else if (url.includes("api/apps")) { else if (pathname.includes("api/apps")) {
return json({ return json({
app1: { app1: {
url: "/app1", url: "/app1",
}, },
}) })
} else if (url.includes("example.com")) { } else if (host.includes("example.com")) {
return json({ return json({
body: opts.body, body: opts.body,
url, url,
method: opts.method, method: opts.method,
}) })
} else if (url.includes("invalid.com")) { } else if (host.includes("invalid.com")) {
return json( return json(
{ {
invalid: true, invalid: true,
}, },
404 404
) )
} else if (mockSearch && url.includes("_search")) { } else if (mockSearch && pathname.includes("_search")) {
const body = opts.body const body = opts.body
const parts = body.split("tableId:") const parts = body.split("tableId:")
let tableId let tableId
@ -90,7 +91,7 @@ module FetchMock {
], ],
bookmark: "test", bookmark: "test",
}) })
} else if (url.includes("google.com")) { } else if (host.includes("google.com")) {
return json({ return json({
url, url,
opts, opts,
@ -177,7 +178,7 @@ module FetchMock {
} else if (url === "https://www.googleapis.com/oauth2/v4/token") { } else if (url === "https://www.googleapis.com/oauth2/v4/token") {
// any valid response // any valid response
return json({}) return json({})
} else if (url.includes("failonce.com")) { } else if (host.includes("failonce.com")) {
failCount++ failCount++
if (failCount === 1) { if (failCount === 1) {
return json({ message: "error" }, 500) return json({ message: "error" }, 500)

View File

@ -149,7 +149,7 @@
"rimraf": "3.0.2", "rimraf": "3.0.2",
"supertest": "6.3.3", "supertest": "6.3.3",
"swagger-jsdoc": "6.1.0", "swagger-jsdoc": "6.1.0",
"testcontainers": "10.6.0", "testcontainers": "10.7.2",
"timekeeper": "2.2.0", "timekeeper": "2.2.0",
"ts-node": "10.8.1", "ts-node": "10.8.1",
"tsconfig-paths": "4.0.0", "tsconfig-paths": "4.0.0",

View File

@ -10,6 +10,11 @@ CREATE TABLE Persons (
City varchar(255), City varchar(255),
PRIMARY KEY (PersonID) PRIMARY KEY (PersonID)
); );
CREATE TABLE Person (
PersonID int NOT NULL AUTO_INCREMENT,
Name varchar(255),
PRIMARY KEY (PersonID)
);
CREATE TABLE Tasks ( CREATE TABLE Tasks (
TaskID int NOT NULL AUTO_INCREMENT, TaskID int NOT NULL AUTO_INCREMENT,
PersonID INT, PersonID INT,
@ -27,6 +32,7 @@ CREATE TABLE Products (
); );
INSERT INTO Persons (FirstName, LastName, Age, Address, City, CreatedAt) VALUES ('Mike', 'Hughes', 28.2, '123 Fake Street', 'Belfast', '2021-01-19 03:14:07'); INSERT INTO Persons (FirstName, LastName, Age, Address, City, CreatedAt) VALUES ('Mike', 'Hughes', 28.2, '123 Fake Street', 'Belfast', '2021-01-19 03:14:07');
INSERT INTO Persons (FirstName, LastName, Age, Address, City, CreatedAt) VALUES ('Dave', 'Johnson', 29, '124 Fake Street', 'Belfast', '2022-04-01 00:11:11'); INSERT INTO Persons (FirstName, LastName, Age, Address, City, CreatedAt) VALUES ('Dave', 'Johnson', 29, '124 Fake Street', 'Belfast', '2022-04-01 00:11:11');
INSERT INTO Person (Name) VALUES ('Elf');
INSERT INTO Tasks (PersonID, TaskName, CreatedAt) VALUES (1, 'assembling', '2020-01-01'); INSERT INTO Tasks (PersonID, TaskName, CreatedAt) VALUES (1, 'assembling', '2020-01-01');
INSERT INTO Tasks (PersonID, TaskName, CreatedAt) VALUES (2, 'processing', '2019-12-31'); INSERT INTO Tasks (PersonID, TaskName, CreatedAt) VALUES (2, 'processing', '2019-12-31');
INSERT INTO Products (name, updated) VALUES ('Meat', '11:00:22'), ('Fruit', '10:00:00'); INSERT INTO Products (name, updated) VALUES ('Meat', '11:00:22'), ('Fruit', '10:00:00');

View File

@ -1,3 +1,3 @@
#!/bin/bash #!/bin/bash
docker-compose down docker-compose down -v
docker volume prune -f docker volume prune -f

View File

@ -20,6 +20,7 @@ import {
AutomationActionStepId, AutomationActionStepId,
AutomationResults, AutomationResults,
UserCtx, UserCtx,
DeleteAutomationResponse,
} from "@budibase/types" } from "@budibase/types"
import { getActionDefinitions as actionDefs } from "../../automations/actions" import { getActionDefinitions as actionDefs } from "../../automations/actions"
import sdk from "../../sdk" import sdk from "../../sdk"
@ -72,7 +73,9 @@ function cleanAutomationInputs(automation: Automation) {
return automation return automation
} }
export async function create(ctx: UserCtx) { export async function create(
ctx: UserCtx<Automation, { message: string; automation: Automation }>
) {
const db = context.getAppDB() const db = context.getAppDB()
let automation = ctx.request.body let automation = ctx.request.body
automation.appId = ctx.appId automation.appId = ctx.appId
@ -207,7 +210,7 @@ export async function find(ctx: UserCtx) {
ctx.body = await db.get(ctx.params.id) ctx.body = await db.get(ctx.params.id)
} }
export async function destroy(ctx: UserCtx) { export async function destroy(ctx: UserCtx<void, DeleteAutomationResponse>) {
const db = context.getAppDB() const db = context.getAppDB()
const automationId = ctx.params.id const automationId = ctx.params.id
const oldAutomation = await db.get<Automation>(automationId) const oldAutomation = await db.get<Automation>(automationId)

View File

@ -15,10 +15,14 @@ import {
FieldType, FieldType,
RelationshipFieldMetadata, RelationshipFieldMetadata,
SourceName, SourceName,
UpdateDatasourceRequest,
UpdateDatasourceResponse, UpdateDatasourceResponse,
UserCtx, UserCtx,
VerifyDatasourceRequest, VerifyDatasourceRequest,
VerifyDatasourceResponse, VerifyDatasourceResponse,
Table,
RowValue,
DynamicVariable,
} from "@budibase/types" } from "@budibase/types"
import sdk from "../../sdk" import sdk from "../../sdk"
import { builderSocket } from "../../websockets" import { builderSocket } from "../../websockets"
@ -90,8 +94,10 @@ async function invalidateVariables(
existingDatasource: Datasource, existingDatasource: Datasource,
updatedDatasource: Datasource updatedDatasource: Datasource
) { ) {
const existingVariables: any = existingDatasource.config?.dynamicVariables const existingVariables: DynamicVariable[] =
const updatedVariables: any = updatedDatasource.config?.dynamicVariables existingDatasource.config?.dynamicVariables || []
const updatedVariables: DynamicVariable[] =
updatedDatasource.config?.dynamicVariables || []
const toInvalidate = [] const toInvalidate = []
if (!existingVariables) { if (!existingVariables) {
@ -103,9 +109,9 @@ async function invalidateVariables(
toInvalidate.push(...existingVariables) toInvalidate.push(...existingVariables)
} else { } else {
// invaldate changed / removed // invaldate changed / removed
existingVariables.forEach((existing: any) => { existingVariables.forEach(existing => {
const unchanged = updatedVariables.find( const unchanged = updatedVariables.find(
(updated: any) => updated =>
existing.name === updated.name && existing.name === updated.name &&
existing.queryId === updated.queryId && existing.queryId === updated.queryId &&
existing.value === updated.value existing.value === updated.value
@ -118,24 +124,32 @@ async function invalidateVariables(
await invalidateDynamicVariables(toInvalidate) await invalidateDynamicVariables(toInvalidate)
} }
export async function update(ctx: UserCtx<any, UpdateDatasourceResponse>) { export async function update(
ctx: UserCtx<UpdateDatasourceRequest, UpdateDatasourceResponse>
) {
const db = context.getAppDB() const db = context.getAppDB()
const datasourceId = ctx.params.datasourceId const datasourceId = ctx.params.datasourceId
const baseDatasource = await sdk.datasources.get(datasourceId) const baseDatasource = await sdk.datasources.get(datasourceId)
const auth = baseDatasource.config?.auth
await invalidateVariables(baseDatasource, ctx.request.body) await invalidateVariables(baseDatasource, ctx.request.body)
const isBudibaseSource = const isBudibaseSource =
baseDatasource.type === dbCore.BUDIBASE_DATASOURCE_TYPE baseDatasource.type === dbCore.BUDIBASE_DATASOURCE_TYPE
const dataSourceBody = isBudibaseSource const dataSourceBody: Datasource = isBudibaseSource
? { name: ctx.request.body?.name } ? {
name: ctx.request.body?.name,
type: dbCore.BUDIBASE_DATASOURCE_TYPE,
source: SourceName.BUDIBASE,
}
: ctx.request.body : ctx.request.body
let datasource: Datasource = { let datasource: Datasource = {
...baseDatasource, ...baseDatasource,
...sdk.datasources.mergeConfigs(dataSourceBody, baseDatasource), ...sdk.datasources.mergeConfigs(dataSourceBody, baseDatasource),
} }
// this block is specific to GSheets, if no auth set, set it back
const auth = baseDatasource.config?.auth
if (auth && !ctx.request.body.auth) { if (auth && !ctx.request.body.auth) {
// don't strip auth config from DB // don't strip auth config from DB
datasource.config!.auth = auth datasource.config!.auth = auth
@ -204,7 +218,7 @@ async function destroyInternalTablesBySourceId(datasourceId: string) {
const db = context.getAppDB() const db = context.getAppDB()
// Get all internal tables // Get all internal tables
const internalTables = await db.allDocs( const internalTables = await db.allDocs<Table>(
getTableParams(null, { getTableParams(null, {
include_docs: true, include_docs: true,
}) })
@ -212,8 +226,8 @@ async function destroyInternalTablesBySourceId(datasourceId: string) {
// Filter by datasource and return the docs. // Filter by datasource and return the docs.
const datasourceTableDocs = internalTables.rows.reduce( const datasourceTableDocs = internalTables.rows.reduce(
(acc: any, table: any) => { (acc: Table[], table) => {
if (table.doc.sourceId == datasourceId) { if (table.doc?.sourceId == datasourceId) {
acc.push(table.doc) acc.push(table.doc)
} }
return acc return acc
@ -254,9 +268,9 @@ export async function destroy(ctx: UserCtx) {
if (datasource.type === dbCore.BUDIBASE_DATASOURCE_TYPE) { if (datasource.type === dbCore.BUDIBASE_DATASOURCE_TYPE) {
await destroyInternalTablesBySourceId(datasourceId) await destroyInternalTablesBySourceId(datasourceId)
} else { } else {
const queries = await db.allDocs(getQueryParams(datasourceId)) const queries = await db.allDocs<RowValue>(getQueryParams(datasourceId))
await db.bulkDocs( await db.bulkDocs(
queries.rows.map((row: any) => ({ queries.rows.map(row => ({
_id: row.id, _id: row.id,
_rev: row.value.rev, _rev: row.value.rev,
_deleted: true, _deleted: true,

View File

@ -1,7 +1,10 @@
import { getDefinition, getDefinitions } from "../../integrations" import { getDefinition, getDefinitions } from "../../integrations"
import { SourceName, UserCtx } from "@budibase/types" import { SourceName, UserCtx } from "@budibase/types"
const DISABLED_EXTERNAL_INTEGRATIONS = [SourceName.AIRTABLE] const DISABLED_EXTERNAL_INTEGRATIONS = [
SourceName.AIRTABLE,
SourceName.BUDIBASE,
]
export async function fetch(ctx: UserCtx) { export async function fetch(ctx: UserCtx) {
const definitions = await getDefinitions() const definitions = await getDefinitions()

View File

@ -1,9 +1,17 @@
import { EMPTY_LAYOUT } from "../../constants/layouts" import { EMPTY_LAYOUT } from "../../constants/layouts"
import { generateLayoutID, getScreenParams } from "../../db/utils" import { generateLayoutID, getScreenParams } from "../../db/utils"
import { events, context } from "@budibase/backend-core" import { events, context } from "@budibase/backend-core"
import { BBContext, Layout } from "@budibase/types" import {
BBContext,
Layout,
SaveLayoutRequest,
SaveLayoutResponse,
UserCtx,
} from "@budibase/types"
export async function save(ctx: BBContext) { export async function save(
ctx: UserCtx<SaveLayoutRequest, SaveLayoutResponse>
) {
const db = context.getAppDB() const db = context.getAppDB()
let layout = ctx.request.body let layout = ctx.request.body

View File

@ -7,6 +7,10 @@ import {
GetResourcePermsResponse, GetResourcePermsResponse,
ResourcePermissionInfo, ResourcePermissionInfo,
GetDependantResourcesResponse, GetDependantResourcesResponse,
AddPermissionResponse,
AddPermissionRequest,
RemovePermissionRequest,
RemovePermissionResponse,
} from "@budibase/types" } from "@budibase/types"
import { getRoleParams } from "../../db/utils" import { getRoleParams } from "../../db/utils"
import { import {
@ -16,9 +20,9 @@ import {
import { removeFromArray } from "../../utilities" import { removeFromArray } from "../../utilities"
import sdk from "../../sdk" import sdk from "../../sdk"
const PermissionUpdateType = { const enum PermissionUpdateType {
REMOVE: "remove", REMOVE = "remove",
ADD: "add", ADD = "add",
} }
const SUPPORTED_LEVELS = CURRENTLY_SUPPORTED_LEVELS const SUPPORTED_LEVELS = CURRENTLY_SUPPORTED_LEVELS
@ -39,7 +43,7 @@ async function updatePermissionOnRole(
resourceId, resourceId,
level, level,
}: { roleId: string; resourceId: string; level: PermissionLevel }, }: { roleId: string; resourceId: string; level: PermissionLevel },
updateType: string updateType: PermissionUpdateType
) { ) {
const allowedAction = await sdk.permissions.resourceActionAllowed({ const allowedAction = await sdk.permissions.resourceActionAllowed({
resourceId, resourceId,
@ -107,11 +111,15 @@ async function updatePermissionOnRole(
} }
const response = await db.bulkDocs(docUpdates) const response = await db.bulkDocs(docUpdates)
return response.map((resp: any) => { return response.map(resp => {
const version = docUpdates.find(role => role._id === resp.id)?.version const version = docUpdates.find(role => role._id === resp.id)?.version
resp._id = roles.getExternalRoleID(resp.id, version) const _id = roles.getExternalRoleID(resp.id, version)
delete resp.id return {
return resp _id,
rev: resp.rev,
error: resp.error,
reason: resp.reason,
}
}) })
} }
@ -189,13 +197,14 @@ export async function getDependantResources(
} }
} }
export async function addPermission(ctx: UserCtx) { export async function addPermission(ctx: UserCtx<void, AddPermissionResponse>) {
ctx.body = await updatePermissionOnRole(ctx.params, PermissionUpdateType.ADD) const params: AddPermissionRequest = ctx.params
ctx.body = await updatePermissionOnRole(params, PermissionUpdateType.ADD)
} }
export async function removePermission(ctx: UserCtx) { export async function removePermission(
ctx.body = await updatePermissionOnRole( ctx: UserCtx<void, RemovePermissionResponse>
ctx.params, ) {
PermissionUpdateType.REMOVE const params: RemovePermissionRequest = ctx.params
) ctx.body = await updatePermissionOnRole(params, PermissionUpdateType.REMOVE)
} }

View File

@ -17,10 +17,12 @@ import {
QueryPreview, QueryPreview,
QuerySchema, QuerySchema,
FieldType, FieldType,
type ExecuteQueryRequest, ExecuteQueryRequest,
type ExecuteQueryResponse, ExecuteQueryResponse,
type Row, Row,
QueryParameter, QueryParameter,
PreviewQueryRequest,
PreviewQueryResponse,
} from "@budibase/types" } from "@budibase/types"
import { ValidQueryNameRegex, utils as JsonUtils } from "@budibase/shared-core" import { ValidQueryNameRegex, utils as JsonUtils } from "@budibase/shared-core"
@ -73,7 +75,7 @@ const _import = async (ctx: UserCtx) => {
} }
export { _import as import } export { _import as import }
export async function save(ctx: UserCtx) { export async function save(ctx: UserCtx<Query, Query>) {
const db = context.getAppDB() const db = context.getAppDB()
const query: Query = ctx.request.body const query: Query = ctx.request.body
@ -134,14 +136,16 @@ function enrichParameters(
return requestParameters return requestParameters
} }
export async function preview(ctx: UserCtx) { export async function preview(
ctx: UserCtx<PreviewQueryRequest, PreviewQueryResponse>
) {
const { datasource, envVars } = await sdk.datasources.getWithEnvVars( const { datasource, envVars } = await sdk.datasources.getWithEnvVars(
ctx.request.body.datasourceId ctx.request.body.datasourceId
) )
const query: QueryPreview = ctx.request.body
// preview may not have a queryId as it hasn't been saved, but if it does // preview may not have a queryId as it hasn't been saved, but if it does
// this stops dynamic variables from calling the same query // this stops dynamic variables from calling the same query
const { fields, parameters, queryVerb, transformer, queryId, schema } = query const { fields, parameters, queryVerb, transformer, queryId, schema } =
ctx.request.body
let existingSchema = schema let existingSchema = schema
if (queryId && !existingSchema) { if (queryId && !existingSchema) {
@ -266,9 +270,7 @@ export async function preview(ctx: UserCtx) {
}, },
} }
const { rows, keys, info, extra } = (await Runner.run( const { rows, keys, info, extra } = await Runner.run<QueryResponse>(inputs)
inputs
)) as QueryResponse
const { previewSchema, nestedSchemaFields } = getSchemaFields(rows, keys) const { previewSchema, nestedSchemaFields } = getSchemaFields(rows, keys)
// if existing schema, update to include any previous schema keys // if existing schema, update to include any previous schema keys
@ -281,7 +283,7 @@ export async function preview(ctx: UserCtx) {
} }
// remove configuration before sending event // remove configuration before sending event
delete datasource.config delete datasource.config
await events.query.previewed(datasource, query) await events.query.previewed(datasource, ctx.request.body)
ctx.body = { ctx.body = {
rows, rows,
nestedSchemaFields, nestedSchemaFields,
@ -295,7 +297,10 @@ export async function preview(ctx: UserCtx) {
} }
async function execute( async function execute(
ctx: UserCtx<ExecuteQueryRequest, ExecuteQueryResponse | Row[]>, ctx: UserCtx<
ExecuteQueryRequest,
ExecuteQueryResponse | Record<string, any>[]
>,
opts: any = { rowsOnly: false, isAutomation: false } opts: any = { rowsOnly: false, isAutomation: false }
) { ) {
const db = context.getAppDB() const db = context.getAppDB()
@ -350,18 +355,23 @@ async function execute(
} }
} }
export async function executeV1(ctx: UserCtx) { export async function executeV1(
ctx: UserCtx<ExecuteQueryRequest, Record<string, any>[]>
) {
return execute(ctx, { rowsOnly: true, isAutomation: false }) return execute(ctx, { rowsOnly: true, isAutomation: false })
} }
export async function executeV2( export async function executeV2(
ctx: UserCtx, ctx: UserCtx<
ExecuteQueryRequest,
ExecuteQueryResponse | Record<string, any>[]
>,
{ isAutomation }: { isAutomation?: boolean } = {} { isAutomation }: { isAutomation?: boolean } = {}
) { ) {
return execute(ctx, { rowsOnly: false, isAutomation }) return execute(ctx, { rowsOnly: false, isAutomation })
} }
const removeDynamicVariables = async (queryId: any) => { const removeDynamicVariables = async (queryId: string) => {
const db = context.getAppDB() const db = context.getAppDB()
const query = await db.get<Query>(queryId) const query = await db.get<Query>(queryId)
const datasource = await sdk.datasources.get(query.datasourceId) const datasource = await sdk.datasources.get(query.datasourceId)
@ -384,7 +394,7 @@ const removeDynamicVariables = async (queryId: any) => {
export async function destroy(ctx: UserCtx) { export async function destroy(ctx: UserCtx) {
const db = context.getAppDB() const db = context.getAppDB()
const queryId = ctx.params.queryId const queryId = ctx.params.queryId as string
await removeDynamicVariables(queryId) await removeDynamicVariables(queryId)
const query = await db.get<Query>(queryId) const query = await db.get<Query>(queryId)
const datasource = await sdk.datasources.get(query.datasourceId) const datasource = await sdk.datasources.get(query.datasourceId)

View File

@ -7,8 +7,14 @@ import {
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { getUserMetadataParams, InternalTables } from "../../db/utils" import { getUserMetadataParams, InternalTables } from "../../db/utils"
import { import {
AccessibleRolesResponse,
Database, Database,
DestroyRoleResponse,
FetchRolesResponse,
FindRoleResponse,
Role, Role,
SaveRoleRequest,
SaveRoleResponse,
UserCtx, UserCtx,
UserMetadata, UserMetadata,
UserRoles, UserRoles,
@ -25,43 +31,36 @@ async function updateRolesOnUserTable(
db: Database, db: Database,
roleId: string, roleId: string,
updateOption: string, updateOption: string,
roleVersion: string | undefined roleVersion?: string
) { ) {
const table = await sdk.tables.getTable(InternalTables.USER_METADATA) const table = await sdk.tables.getTable(InternalTables.USER_METADATA)
const schema = table.schema const constraints = table.schema.roleId?.constraints
if (!constraints) {
return
}
const updatedRoleId =
roleVersion === roles.RoleIDVersion.NAME
? roles.getExternalRoleID(roleId, roleVersion)
: roleId
const indexOfRoleId = constraints.inclusion!.indexOf(updatedRoleId)
const remove = updateOption === UpdateRolesOptions.REMOVED const remove = updateOption === UpdateRolesOptions.REMOVED
let updated = false if (remove && indexOfRoleId !== -1) {
for (let prop of Object.keys(schema)) { constraints.inclusion!.splice(indexOfRoleId, 1)
if (prop === "roleId") { } else if (!remove && indexOfRoleId === -1) {
updated = true constraints.inclusion!.push(updatedRoleId)
const constraints = schema[prop].constraints!
const updatedRoleId =
roleVersion === roles.RoleIDVersion.NAME
? roles.getExternalRoleID(roleId, roleVersion)
: roleId
const indexOfRoleId = constraints.inclusion!.indexOf(updatedRoleId)
if (remove && indexOfRoleId !== -1) {
constraints.inclusion!.splice(indexOfRoleId, 1)
} else if (!remove && indexOfRoleId === -1) {
constraints.inclusion!.push(updatedRoleId)
}
break
}
}
if (updated) {
await db.put(table)
} }
await db.put(table)
} }
export async function fetch(ctx: UserCtx) { export async function fetch(ctx: UserCtx<void, FetchRolesResponse>) {
ctx.body = await roles.getAllRoles() ctx.body = await roles.getAllRoles()
} }
export async function find(ctx: UserCtx) { export async function find(ctx: UserCtx<void, FindRoleResponse>) {
ctx.body = await roles.getRole(ctx.params.roleId) ctx.body = await roles.getRole(ctx.params.roleId)
} }
export async function save(ctx: UserCtx) { export async function save(ctx: UserCtx<SaveRoleRequest, SaveRoleResponse>) {
const db = context.getAppDB() const db = context.getAppDB()
let { _id, name, inherits, permissionId, version } = ctx.request.body let { _id, name, inherits, permissionId, version } = ctx.request.body
let isCreate = false let isCreate = false
@ -107,11 +106,26 @@ export async function save(ctx: UserCtx) {
) )
role._rev = result.rev role._rev = result.rev
ctx.body = role ctx.body = role
const devDb = context.getDevAppDB()
const prodDb = context.getProdAppDB()
if (await prodDb.exists()) {
const replication = new dbCore.Replication({
source: devDb.name,
target: prodDb.name,
})
await replication.replicate({
filter: (doc: any, params: any) => {
return doc._id && doc._id.startsWith("role_")
},
})
}
} }
export async function destroy(ctx: UserCtx) { export async function destroy(ctx: UserCtx<void, DestroyRoleResponse>) {
const db = context.getAppDB() const db = context.getAppDB()
let roleId = ctx.params.roleId let roleId = ctx.params.roleId as string
if (roles.isBuiltin(roleId)) { if (roles.isBuiltin(roleId)) {
ctx.throw(400, "Cannot delete builtin role.") ctx.throw(400, "Cannot delete builtin role.")
} else { } else {
@ -144,14 +158,18 @@ export async function destroy(ctx: UserCtx) {
ctx.status = 200 ctx.status = 200
} }
export async function accessible(ctx: UserCtx) { export async function accessible(ctx: UserCtx<void, AccessibleRolesResponse>) {
let roleId = ctx.user?.roleId let roleId = ctx.user?.roleId
if (!roleId) { if (!roleId) {
roleId = roles.BUILTIN_ROLE_IDS.PUBLIC roleId = roles.BUILTIN_ROLE_IDS.PUBLIC
} }
if (ctx.user && sharedSdk.users.isAdminOrBuilder(ctx.user)) { if (ctx.user && sharedSdk.users.isAdminOrBuilder(ctx.user)) {
const appId = context.getAppId() const appId = context.getAppId()
ctx.body = await roles.getAllRoleIds(appId) if (!appId) {
ctx.body = []
} else {
ctx.body = await roles.getAllRoleIds(appId)
}
} else { } else {
ctx.body = await roles.getUserRoleIdHierarchy(roleId!) ctx.body = await roles.getUserRoleIdHierarchy(roleId!)
} }

View File

@ -63,7 +63,7 @@ export async function fetch(ctx: UserCtx) {
export async function clientFetch(ctx: UserCtx) { export async function clientFetch(ctx: UserCtx) {
const routing = await getRoutingStructure() const routing = await getRoutingStructure()
let roleId = ctx.user?.role?._id let roleId = ctx.user?.role?._id
const roleIds = await roles.getUserRoleIdHierarchy(roleId) const roleIds = roleId ? await roles.getUserRoleIdHierarchy(roleId) : []
for (let topLevel of Object.values(routing.routes) as any) { for (let topLevel of Object.values(routing.routes) as any) {
for (let subpathKey of Object.keys(topLevel.subpaths)) { for (let subpathKey of Object.keys(topLevel.subpaths)) {
let found = false let found = false

View File

@ -7,6 +7,7 @@ import {
FilterType, FilterType,
IncludeRelationship, IncludeRelationship,
ManyToManyRelationshipFieldMetadata, ManyToManyRelationshipFieldMetadata,
ManyToOneRelationshipFieldMetadata,
OneToManyRelationshipFieldMetadata, OneToManyRelationshipFieldMetadata,
Operation, Operation,
PaginationJson, PaginationJson,
@ -18,6 +19,7 @@ import {
SortJson, SortJson,
SortType, SortType,
Table, Table,
isManyToOne,
} from "@budibase/types" } from "@budibase/types"
import { import {
breakExternalTableId, breakExternalTableId,
@ -32,7 +34,9 @@ import { processObjectSync } from "@budibase/string-templates"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { processDates, processFormulas } from "../../../utilities/rowProcessor" import { processDates, processFormulas } from "../../../utilities/rowProcessor"
import { db as dbCore } from "@budibase/backend-core" import { db as dbCore } from "@budibase/backend-core"
import AliasTables from "./alias"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import env from "../../../environment"
export interface ManyRelationship { export interface ManyRelationship {
tableId?: string tableId?: string
@ -101,6 +105,39 @@ function buildFilters(
} }
} }
async function removeManyToManyRelationships(
rowId: string,
table: Table,
colName: string
) {
const tableId = table._id!
const filters = buildFilters(rowId, {}, table)
// safety check, if there are no filters on deletion bad things happen
if (Object.keys(filters).length !== 0) {
return getDatasourceAndQuery({
endpoint: getEndpoint(tableId, Operation.DELETE),
body: { [colName]: null },
filters,
})
} else {
return []
}
}
async function removeOneToManyRelationships(rowId: string, table: Table) {
const tableId = table._id!
const filters = buildFilters(rowId, {}, table)
// safety check, if there are no filters on deletion bad things happen
if (Object.keys(filters).length !== 0) {
return getDatasourceAndQuery({
endpoint: getEndpoint(tableId, Operation.UPDATE),
filters,
})
} else {
return []
}
}
/** /**
* This function checks the incoming parameters to make sure all the inputs are * This function checks the incoming parameters to make sure all the inputs are
* valid based on on the table schema. The main thing this is looking for is when a * valid based on on the table schema. The main thing this is looking for is when a
@ -178,13 +215,13 @@ function generateIdForRow(
function getEndpoint(tableId: string | undefined, operation: string) { function getEndpoint(tableId: string | undefined, operation: string) {
if (!tableId) { if (!tableId) {
return {} throw new Error("Cannot get endpoint information - no table ID specified")
} }
const { datasourceId, tableName } = breakExternalTableId(tableId) const { datasourceId, tableName } = breakExternalTableId(tableId)
return { return {
datasourceId, datasourceId: datasourceId!,
entityId: tableName, entityId: tableName!,
operation, operation: operation as Operation,
} }
} }
@ -304,6 +341,18 @@ export class ExternalRequest<T extends Operation> {
} }
} }
async getRow(table: Table, rowId: string): Promise<Row> {
const response = await getDatasourceAndQuery({
endpoint: getEndpoint(table._id!, Operation.READ),
filters: buildFilters(rowId, {}, table),
})
if (Array.isArray(response) && response.length > 0) {
return response[0]
} else {
throw new Error(`Cannot fetch row by ID "${rowId}"`)
}
}
inputProcessing(row: Row | undefined, table: Table) { inputProcessing(row: Row | undefined, table: Table) {
if (!row) { if (!row) {
return { row, manyRelationships: [] } return { row, manyRelationships: [] }
@ -571,7 +620,9 @@ export class ExternalRequest<T extends Operation> {
* information. * information.
*/ */
async lookupRelations(tableId: string, row: Row) { async lookupRelations(tableId: string, row: Row) {
const related: { [key: string]: any } = {} const related: {
[key: string]: { rows: Row[]; isMany: boolean; tableId: string }
} = {}
const { tableName } = breakExternalTableId(tableId) const { tableName } = breakExternalTableId(tableId)
if (!tableName) { if (!tableName) {
return related return related
@ -589,14 +640,26 @@ export class ExternalRequest<T extends Operation> {
) { ) {
continue continue
} }
const isMany = field.relationshipType === RelationshipType.MANY_TO_MANY let tableId: string | undefined,
const tableId = isMany ? field.through : field.tableId lookupField: string | undefined,
fieldName: string | undefined
if (isManyToMany(field)) {
tableId = field.through
lookupField = primaryKey
fieldName = field.throughTo || primaryKey
} else if (isManyToOne(field)) {
tableId = field.tableId
lookupField = field.foreignKey
fieldName = field.fieldName
}
if (!tableId || !lookupField || !fieldName) {
throw new Error(
"Unable to lookup relationships - undefined column properties."
)
}
const { tableName: relatedTableName } = breakExternalTableId(tableId) const { tableName: relatedTableName } = breakExternalTableId(tableId)
// @ts-ignore // @ts-ignore
const linkPrimaryKey = this.tables[relatedTableName].primary[0] const linkPrimaryKey = this.tables[relatedTableName].primary[0]
const lookupField = isMany ? primaryKey : field.foreignKey
const fieldName = isMany ? field.throughTo || primaryKey : field.fieldName
if (!lookupField || !row[lookupField]) { if (!lookupField || !row[lookupField]) {
continue continue
} }
@ -609,9 +672,12 @@ export class ExternalRequest<T extends Operation> {
}, },
}) })
// this is the response from knex if no rows found // this is the response from knex if no rows found
const rows = !response[0].read ? response : [] const rows: Row[] =
const storeTo = isMany ? field.throughFrom || linkPrimaryKey : fieldName !Array.isArray(response) || response?.[0].read ? [] : response
related[storeTo] = { rows, isMany, tableId } const storeTo = isManyToMany(field)
? field.throughFrom || linkPrimaryKey
: fieldName
related[storeTo] = { rows, isMany: isManyToMany(field), tableId }
} }
return related return related
} }
@ -697,24 +763,43 @@ export class ExternalRequest<T extends Operation> {
continue continue
} }
for (let row of rows) { for (let row of rows) {
const filters = buildFilters(generateIdForRow(row, table), {}, table) const rowId = generateIdForRow(row, table)
// safety check, if there are no filters on deletion bad things happen const promise: Promise<any> = isMany
if (Object.keys(filters).length !== 0) { ? removeManyToManyRelationships(rowId, table, colName)
const op = isMany ? Operation.DELETE : Operation.UPDATE : removeOneToManyRelationships(rowId, table)
const body = isMany ? null : { [colName]: null } if (promise) {
promises.push( promises.push(promise)
getDatasourceAndQuery({
endpoint: getEndpoint(tableId, op),
body,
filters,
})
)
} }
} }
} }
await Promise.all(promises) await Promise.all(promises)
} }
async removeRelationshipsToRow(table: Table, rowId: string) {
const row = await this.getRow(table, rowId)
const related = await this.lookupRelations(table._id!, row)
for (let column of Object.values(table.schema)) {
const relationshipColumn = column as RelationshipFieldMetadata
if (!isManyToOne(relationshipColumn)) {
continue
}
const { rows, isMany, tableId } = related[relationshipColumn.fieldName]
const table = this.getTable(tableId)!
await Promise.all(
rows.map(row => {
const rowId = generateIdForRow(row, table)
return isMany
? removeManyToManyRelationships(
rowId,
table,
relationshipColumn.fieldName
)
: removeOneToManyRelationships(rowId, table)
})
)
}
}
/** /**
* This function is a bit crazy, but the exact purpose of it is to protect against the scenario in which * This function is a bit crazy, but the exact purpose of it is to protect against the scenario in which
* you have column overlap in relationships, e.g. we join a few different tables and they all have the * you have column overlap in relationships, e.g. we join a few different tables and they all have the
@ -804,7 +889,7 @@ export class ExternalRequest<T extends Operation> {
} }
let json = { let json = {
endpoint: { endpoint: {
datasourceId, datasourceId: datasourceId!,
entityId: tableName, entityId: tableName,
operation, operation,
}, },
@ -826,17 +911,30 @@ export class ExternalRequest<T extends Operation> {
}, },
} }
// can't really use response right now // remove any relationships that could block deletion
const response = await getDatasourceAndQuery(json) if (operation === Operation.DELETE && id) {
// handle many to many relationships now if we know the ID (could be auto increment) await this.removeRelationshipsToRow(table, generateRowIdField(id))
}
// aliasing can be disabled fully if desired
let response
if (env.SQL_ALIASING_DISABLE) {
response = await getDatasourceAndQuery(json)
} else {
const aliasing = new AliasTables(Object.keys(this.tables))
response = await aliasing.queryWithAliasing(json)
}
const responseRows = Array.isArray(response) ? response : []
// handle many-to-many relationships now if we know the ID (could be auto increment)
if (operation !== Operation.READ) { if (operation !== Operation.READ) {
await this.handleManyRelationships( await this.handleManyRelationships(
table._id || "", table._id || "",
response[0], responseRows[0],
processed.manyRelationships processed.manyRelationships
) )
} }
const output = this.outputProcessing(response, table, relationships) const output = this.outputProcessing(responseRows, table, relationships)
// if reading it'll just be an array of rows, return whole thing // if reading it'll just be an array of rows, return whole thing
if (operation === Operation.READ) { if (operation === Operation.READ) {
return ( return (

View File

@ -0,0 +1,204 @@
import {
Datasource,
DatasourcePlusQueryResponse,
Operation,
QueryJson,
Row,
SearchFilters,
} from "@budibase/types"
import { getSQLClient } from "../../../sdk/app/rows/utils"
import { cloneDeep } from "lodash"
import sdk from "../../../sdk"
import { makeExternalQuery } from "../../../integrations/base/query"
import { SqlClient } from "../../../integrations/utils"
const WRITE_OPERATIONS: Operation[] = [
Operation.CREATE,
Operation.UPDATE,
Operation.DELETE,
]
const DISABLED_WRITE_CLIENTS: SqlClient[] = [
SqlClient.MY_SQL,
SqlClient.MS_SQL,
SqlClient.ORACLE,
]
class CharSequence {
static alphabet = "abcdefghijklmnopqrstuvwxyz"
counters: number[]
constructor() {
this.counters = [0]
}
getCharacter(): string {
const char = this.counters.map(i => CharSequence.alphabet[i]).join("")
for (let i = this.counters.length - 1; i >= 0; i--) {
if (this.counters[i] < CharSequence.alphabet.length - 1) {
this.counters[i]++
return char
}
this.counters[i] = 0
}
this.counters.unshift(0)
return char
}
}
export default class AliasTables {
aliases: Record<string, string>
tableAliases: Record<string, string>
tableNames: string[]
charSeq: CharSequence
constructor(tableNames: string[]) {
this.tableNames = tableNames
this.aliases = {}
this.tableAliases = {}
this.charSeq = new CharSequence()
}
isAliasingEnabled(json: QueryJson, datasource: Datasource) {
const fieldLength = json.resource?.fields?.length
if (!fieldLength || fieldLength <= 0) {
return false
}
try {
const sqlClient = getSQLClient(datasource)
const isWrite = WRITE_OPERATIONS.includes(json.endpoint.operation)
const isDisabledClient = DISABLED_WRITE_CLIENTS.includes(sqlClient)
if (isWrite && isDisabledClient) {
return false
}
} catch (err) {
// if we can't get an SQL client, we can't alias
return false
}
return true
}
getAlias(tableName: string) {
if (this.aliases[tableName]) {
return this.aliases[tableName]
}
const char = this.charSeq.getCharacter()
this.aliases[tableName] = char
this.tableAliases[char] = tableName
return char
}
aliasField(field: string) {
const tableNames = this.tableNames
if (field.includes(".")) {
const [tableName, column] = field.split(".")
const foundTableName = tableNames.find(name => {
const idx = tableName.indexOf(name)
if (idx === -1 || idx > 1) {
return
}
// this might look a bit mad, but the idea is if the field is wrapped, say in "", `` or []
// then the idx of the table name will be 1, and we should allow for it ending in a closing
// character - otherwise it should be the full length if the index is zero
const allowedCharacterDiff = idx * 2
return Math.abs(tableName.length - name.length) <= allowedCharacterDiff
})
if (foundTableName) {
const aliasedTableName = tableName.replace(
foundTableName,
this.getAlias(foundTableName)
)
field = `${aliasedTableName}.${column}`
}
}
return field
}
reverse<T extends Row | Row[]>(rows: T): T {
const process = (row: Row) => {
const final: Row = {}
for (let [key, value] of Object.entries(row)) {
if (!key.includes(".")) {
final[key] = value
} else {
const [alias, column] = key.split(".")
const tableName = this.tableAliases[alias] || alias
final[`${tableName}.${column}`] = value
}
}
return final
}
if (Array.isArray(rows)) {
return rows.map(row => process(row)) as T
} else {
return process(rows) as T
}
}
aliasMap(tableNames: (string | undefined)[]) {
const map: Record<string, string> = {}
for (let tableName of tableNames) {
if (tableName) {
map[tableName] = this.getAlias(tableName)
}
}
return map
}
async queryWithAliasing(json: QueryJson): DatasourcePlusQueryResponse {
const datasourceId = json.endpoint.datasourceId
const datasource = await sdk.datasources.get(datasourceId)
const aliasingEnabled = this.isAliasingEnabled(json, datasource)
if (aliasingEnabled) {
json = cloneDeep(json)
// run through the query json to update anywhere a table may be used
if (json.resource?.fields) {
json.resource.fields = json.resource.fields.map(field =>
this.aliasField(field)
)
}
if (json.filters) {
for (let [filterKey, filter] of Object.entries(json.filters)) {
if (typeof filter !== "object") {
continue
}
const aliasedFilters: typeof filter = {}
for (let key of Object.keys(filter)) {
aliasedFilters[this.aliasField(key)] = filter[key]
}
json.filters[filterKey as keyof SearchFilters] = aliasedFilters
}
}
if (json.meta?.table) {
this.getAlias(json.meta.table.name)
}
if (json.meta?.tables) {
Object.keys(json.meta.tables).forEach(tableName =>
this.getAlias(tableName)
)
}
if (json.relationships) {
json.relationships = json.relationships.map(relationship => ({
...relationship,
aliases: this.aliasMap([
relationship.through,
relationship.tableName,
json.endpoint.entityId,
]),
}))
}
// invert and return
const invertedTableAliases: Record<string, string> = {}
for (let [key, value] of Object.entries(this.tableAliases)) {
invertedTableAliases[value] = key
}
json.tableAliases = invertedTableAliases
}
const response = await makeExternalQuery(datasource, json)
if (Array.isArray(response) && aliasingEnabled) {
return this.reverse(response)
} else {
return response
}
}
}

View File

@ -211,7 +211,7 @@ export async function validate(ctx: Ctx<Row, ValidateResponse>) {
} }
} }
export async function fetchEnrichedRow(ctx: any) { export async function fetchEnrichedRow(ctx: UserCtx<void, Row>) {
const tableId = utils.getTableId(ctx) const tableId = utils.getTableId(ctx)
ctx.body = await pickApi(tableId).fetchEnrichedRow(ctx) ctx.body = await pickApi(tableId).fetchEnrichedRow(ctx)
} }

View File

@ -189,11 +189,12 @@ export async function fetchEnrichedRow(ctx: UserCtx) {
const tableId = utils.getTableId(ctx) const tableId = utils.getTableId(ctx)
const rowId = ctx.params.rowId as string const rowId = ctx.params.rowId as string
// need table to work out where links go in row, as well as the link docs // need table to work out where links go in row, as well as the link docs
const [table, row, links] = await Promise.all([ const [table, links] = await Promise.all([
sdk.tables.getTable(tableId), sdk.tables.getTable(tableId),
utils.findRow(ctx, tableId, rowId),
linkRows.getLinkDocuments({ tableId, rowId, fieldName }), linkRows.getLinkDocuments({ tableId, rowId, fieldName }),
]) ])
let row = await utils.findRow(ctx, tableId, rowId)
row = await outputProcessing(table, row)
const linkVals = links as LinkDocumentValue[] const linkVals = links as LinkDocumentValue[]
// look up the actual rows based on the ids // look up the actual rows based on the ids

View File

@ -7,7 +7,13 @@ import {
roles, roles,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import { updateAppPackage } from "./application" import { updateAppPackage } from "./application"
import { Plugin, ScreenProps, BBContext, Screen } from "@budibase/types" import {
Plugin,
ScreenProps,
BBContext,
Screen,
UserCtx,
} from "@budibase/types"
import { builderSocket } from "../../websockets" import { builderSocket } from "../../websockets"
export async function fetch(ctx: BBContext) { export async function fetch(ctx: BBContext) {
@ -31,7 +37,7 @@ export async function fetch(ctx: BBContext) {
) )
} }
export async function save(ctx: BBContext) { export async function save(ctx: UserCtx<Screen, Screen>) {
const db = context.getAppDB() const db = context.getAppDB()
let screen = ctx.request.body let screen = ctx.request.body

View File

@ -170,6 +170,7 @@ export const serveApp = async function (ctx: Ctx) {
if (!env.isJest()) { if (!env.isJest()) {
const plugins = objectStore.enrichPluginURLs(appInfo.usedPlugins) const plugins = objectStore.enrichPluginURLs(appInfo.usedPlugins)
const { head, html, css } = AppComponent.render({ const { head, html, css } = AppComponent.render({
title: branding?.platformTitle || `${appInfo.name}`,
metaImage: metaImage:
branding?.metaImageUrl || branding?.metaImageUrl ||
"https://res.cloudinary.com/daog6scxm/image/upload/v1698759482/meta-images/plain-branded-meta-image-coral_ocxmgu.png", "https://res.cloudinary.com/daog6scxm/image/upload/v1698759482/meta-images/plain-branded-meta-image-coral_ocxmgu.png",

View File

@ -6,6 +6,7 @@ import {
BulkImportRequest, BulkImportRequest,
BulkImportResponse, BulkImportResponse,
Operation, Operation,
RenameColumn,
SaveTableRequest, SaveTableRequest,
SaveTableResponse, SaveTableResponse,
Table, Table,
@ -25,9 +26,12 @@ function getDatasourceId(table: Table) {
return breakExternalTableId(table._id).datasourceId return breakExternalTableId(table._id).datasourceId
} }
export async function save(ctx: UserCtx<SaveTableRequest, SaveTableResponse>) { export async function save(
ctx: UserCtx<SaveTableRequest, SaveTableResponse>,
renaming?: RenameColumn
) {
const inputs = ctx.request.body const inputs = ctx.request.body
const renaming = inputs?._rename const adding = inputs?._add
// can't do this right now // can't do this right now
delete inputs.rows delete inputs.rows
const tableId = ctx.request.body._id const tableId = ctx.request.body._id
@ -40,7 +44,7 @@ export async function save(ctx: UserCtx<SaveTableRequest, SaveTableResponse>) {
const { datasource, table } = await sdk.tables.external.save( const { datasource, table } = await sdk.tables.external.save(
datasourceId!, datasourceId!,
inputs, inputs,
{ tableId, renaming } { tableId, renaming, adding }
) )
builderSocket?.emitDatasourceUpdate(ctx, datasource) builderSocket?.emitDatasourceUpdate(ctx, datasource)
return table return table

View File

@ -74,8 +74,15 @@ export async function save(ctx: UserCtx<SaveTableRequest, SaveTableResponse>) {
const appId = ctx.appId const appId = ctx.appId
const table = ctx.request.body const table = ctx.request.body
const isImport = table.rows const isImport = table.rows
const renaming = ctx.request.body._rename
let savedTable = await pickApi({ table }).save(ctx) const api = pickApi({ table })
// do not pass _rename or _add if saving to CouchDB
if (api === internal) {
delete ctx.request.body._add
delete ctx.request.body._rename
}
let savedTable = await api.save(ctx, renaming)
if (!table._id) { if (!table._id) {
await events.table.created(savedTable) await events.table.created(savedTable)
savedTable = sdk.tables.enrichViewSchemas(savedTable) savedTable = sdk.tables.enrichViewSchemas(savedTable)

View File

@ -12,11 +12,12 @@ import {
} from "@budibase/types" } from "@budibase/types"
import sdk from "../../../sdk" import sdk from "../../../sdk"
export async function save(ctx: UserCtx<SaveTableRequest, SaveTableResponse>) { export async function save(
ctx: UserCtx<SaveTableRequest, SaveTableResponse>,
renaming?: RenameColumn
) {
const { rows, ...rest } = ctx.request.body const { rows, ...rest } = ctx.request.body
let tableToSave: Table & { let tableToSave: Table = {
_rename?: RenameColumn
} = {
_id: generateTableID(), _id: generateTableID(),
...rest, ...rest,
// Ensure these fields are populated, even if not sent in the request // Ensure these fields are populated, even if not sent in the request
@ -28,15 +29,12 @@ export async function save(ctx: UserCtx<SaveTableRequest, SaveTableResponse>) {
tableToSave.views = {} tableToSave.views = {}
} }
const renaming = tableToSave._rename
delete tableToSave._rename
try { try {
const { table } = await sdk.tables.internal.save(tableToSave, { const { table } = await sdk.tables.internal.save(tableToSave, {
user: ctx.user, user: ctx.user,
rowsToImport: rows, rowsToImport: rows,
tableId: ctx.request.body._id, tableId: ctx.request.body._id,
renaming: renaming, renaming,
}) })
return table return table

View File

@ -1,6 +1,6 @@
import { generateUserFlagID, InternalTables } from "../../db/utils" import { generateUserFlagID, InternalTables } from "../../db/utils"
import { getFullUser } from "../../utilities/users" import { getFullUser } from "../../utilities/users"
import { context } from "@budibase/backend-core" import { cache, context } from "@budibase/backend-core"
import { import {
ContextUserMetadata, ContextUserMetadata,
Ctx, Ctx,

View File

@ -13,7 +13,7 @@ describe("/api/keys", () => {
describe("fetch", () => { describe("fetch", () => {
it("should allow fetching", async () => { it("should allow fetching", async () => {
await setup.switchToSelfHosted(async () => { await config.withEnv({ SELF_HOSTED: "true" }, async () => {
const res = await request const res = await request
.get(`/api/keys`) .get(`/api/keys`)
.set(config.defaultHeaders()) .set(config.defaultHeaders())
@ -34,7 +34,7 @@ describe("/api/keys", () => {
describe("update", () => { describe("update", () => {
it("should allow updating a value", async () => { it("should allow updating a value", async () => {
await setup.switchToSelfHosted(async () => { await config.withEnv({ SELF_HOSTED: "true" }, async () => {
const res = await request const res = await request
.put(`/api/keys/TEST`) .put(`/api/keys/TEST`)
.send({ .send({

View File

@ -16,8 +16,9 @@ import * as setup from "./utilities"
import { AppStatus } from "../../../db/utils" import { AppStatus } from "../../../db/utils"
import { events, utils, context } from "@budibase/backend-core" import { events, utils, context } from "@budibase/backend-core"
import env from "../../../environment" import env from "../../../environment"
import type { App } from "@budibase/types" import { type App } from "@budibase/types"
import tk from "timekeeper" import tk from "timekeeper"
import * as uuid from "uuid"
describe("/applications", () => { describe("/applications", () => {
let config = setup.getConfig() let config = setup.getConfig()
@ -184,7 +185,7 @@ describe("/applications", () => {
it("app should not sync if production", async () => { it("app should not sync if production", async () => {
const { message } = await config.api.application.sync( const { message } = await config.api.application.sync(
app.appId.replace("_dev", ""), app.appId.replace("_dev", ""),
{ statusCode: 400 } { status: 400 }
) )
expect(message).toEqual( expect(message).toEqual(
@ -248,4 +249,93 @@ describe("/applications", () => {
expect(devLogs.data.length).toBe(0) expect(devLogs.data.length).toBe(0)
}) })
}) })
describe("permissions", () => {
it("should only return apps a user has access to", async () => {
let user = await config.createUser({
builder: { global: false },
admin: { global: false },
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(0)
})
user = await config.globalUser({
...user,
builder: {
apps: [config.getProdAppId()],
},
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(1)
})
})
it("should only return apps a user has access to through a custom role", async () => {
let user = await config.createUser({
builder: { global: false },
admin: { global: false },
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(0)
})
const role = await config.api.roles.save({
name: "Test",
inherits: "PUBLIC",
permissionId: "read_only",
version: "name",
})
user = await config.globalUser({
...user,
roles: {
[config.getProdAppId()]: role.name,
},
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(1)
})
})
it.only("should only return apps a user has access to through a custom role on a group", async () => {
let user = await config.createUser({
builder: { global: false },
admin: { global: false },
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(0)
})
const roleName = uuid.v4().replace(/-/g, "")
const role = await config.api.roles.save({
name: roleName,
inherits: "PUBLIC",
permissionId: "read_only",
version: "name",
})
const group = await config.createGroup(role._id!)
user = await config.globalUser({
...user,
userGroups: [group._id!],
})
await config.withUser(user, async () => {
const apps = await config.api.application.fetch()
expect(apps).toHaveLength(1)
})
})
})
}) })

View File

@ -29,7 +29,7 @@ describe("/api/applications/:appId/sync", () => {
let resp = (await config.api.attachment.process( let resp = (await config.api.attachment.process(
"ohno.exe", "ohno.exe",
Buffer.from([0]), Buffer.from([0]),
{ expectStatus: 400 } { status: 400 }
)) as unknown as APIError )) as unknown as APIError
expect(resp.message).toContain("invalid extension") expect(resp.message).toContain("invalid extension")
}) })
@ -40,7 +40,7 @@ describe("/api/applications/:appId/sync", () => {
let resp = (await config.api.attachment.process( let resp = (await config.api.attachment.process(
"OHNO.EXE", "OHNO.EXE",
Buffer.from([0]), Buffer.from([0]),
{ expectStatus: 400 } { status: 400 }
)) as unknown as APIError )) as unknown as APIError
expect(resp.message).toContain("invalid extension") expect(resp.message).toContain("invalid extension")
}) })
@ -51,7 +51,7 @@ describe("/api/applications/:appId/sync", () => {
undefined as any, undefined as any,
undefined as any, undefined as any,
{ {
expectStatus: 400, status: 400,
} }
)) as unknown as APIError )) as unknown as APIError
expect(resp.message).toContain("No file provided") expect(resp.message).toContain("No file provided")

Some files were not shown because too many files have changed in this diff Show More