Merge remote-tracking branch 'origin/feature/app-list-actions' into feature/app-favourites
|
@ -44,7 +44,8 @@
|
|||
"no-undef": "off",
|
||||
"no-prototype-builtins": "off",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
@ -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" })'
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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).
|
||||
모든 종류의 기여를 환영합니다!
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "2.20.10",
|
||||
"version": "2.21.4",
|
||||
"npmClient": "yarn",
|
||||
"packages": [
|
||||
"packages/*",
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit de6d44c372a7f48ca0ce8c6c0c19311d4bc21646
|
||||
Subproject commit 0c050591c21d3b67dc0c9225d60cc9e2324c8dac
|
|
@ -67,7 +67,7 @@
|
|||
"@types/lodash": "4.14.200",
|
||||
"@types/node-fetch": "2.6.4",
|
||||
"@types/pouchdb": "6.4.0",
|
||||
"@types/redlock": "4.0.3",
|
||||
"@types/redlock": "4.0.7",
|
||||
"@types/semver": "7.3.7",
|
||||
"@types/tar-fs": "2.0.1",
|
||||
"@types/uuid": "8.3.4",
|
||||
|
@ -78,6 +78,7 @@
|
|||
"jest-serial-runner": "1.2.1",
|
||||
"pino-pretty": "10.0.0",
|
||||
"pouchdb-adapter-memory": "7.2.2",
|
||||
"testcontainers": "^10.7.2",
|
||||
"timekeeper": "2.2.0",
|
||||
"typescript": "5.2.2"
|
||||
},
|
||||
|
|
|
@ -23,6 +23,18 @@ export default class BaseCache {
|
|||
return client.keys(pattern)
|
||||
}
|
||||
|
||||
async exists(key: string, opts = { useTenancy: true }) {
|
||||
key = opts.useTenancy ? generateTenantKey(key) : key
|
||||
const client = await this.getClient()
|
||||
return client.exists(key)
|
||||
}
|
||||
|
||||
async scan(key: string, opts = { useTenancy: true }) {
|
||||
key = opts.useTenancy ? generateTenantKey(key) : key
|
||||
const client = await this.getClient()
|
||||
return client.scan(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read only from the cache.
|
||||
*/
|
||||
|
@ -32,6 +44,15 @@ export default class BaseCache {
|
|||
return client.get(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read only from the cache.
|
||||
*/
|
||||
async bulkGet<T>(keys: string[], opts = { useTenancy: true }) {
|
||||
keys = opts.useTenancy ? keys.map(key => generateTenantKey(key)) : keys
|
||||
const client = await this.getClient()
|
||||
return client.bulkGet<T>(keys)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write to the cache.
|
||||
*/
|
||||
|
@ -46,6 +67,25 @@ export default class BaseCache {
|
|||
await client.store(key, value, ttl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk write to the cache.
|
||||
*/
|
||||
async bulkStore(
|
||||
data: Record<string, any>,
|
||||
ttl: number | null = null,
|
||||
opts = { useTenancy: true }
|
||||
) {
|
||||
if (opts.useTenancy) {
|
||||
data = Object.entries(data).reduce((acc, [key, value]) => {
|
||||
acc[generateTenantKey(key)] = value
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
}
|
||||
|
||||
const client = await this.getClient()
|
||||
await client.bulkStore(data, ttl)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove from cache.
|
||||
*/
|
||||
|
@ -55,15 +95,24 @@ export default class BaseCache {
|
|||
return client.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove from cache.
|
||||
*/
|
||||
async bulkDelete(keys: string[], opts = { useTenancy: true }) {
|
||||
keys = opts.useTenancy ? keys.map(key => generateTenantKey(key)) : keys
|
||||
const client = await this.getClient()
|
||||
return client.bulkDelete(keys)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read from the cache. Write to the cache if not exists.
|
||||
*/
|
||||
async withCache(
|
||||
async withCache<T>(
|
||||
key: string,
|
||||
ttl: number,
|
||||
fetchFn: any,
|
||||
ttl: number | null = null,
|
||||
fetchFn: () => Promise<T> | T,
|
||||
opts = { useTenancy: true }
|
||||
) {
|
||||
): Promise<T> {
|
||||
const cachedValue = await this.get(key, opts)
|
||||
if (cachedValue) {
|
||||
return cachedValue
|
||||
|
@ -89,4 +138,13 @@ export default class BaseCache {
|
|||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the entry if the provided value matches the stored one.
|
||||
*/
|
||||
async deleteIfValue(key: string, value: any, opts = { useTenancy: true }) {
|
||||
key = opts.useTenancy ? generateTenantKey(key) : key
|
||||
const client = await this.getClient()
|
||||
await client.deleteIfValue(key, value)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
import { AnyDocument, Database } from "@budibase/types"
|
||||
|
||||
import { JobQueue, createQueue } from "../queue"
|
||||
import * as dbUtils from "../db"
|
||||
|
||||
interface ProcessDocMessage {
|
||||
dbName: string
|
||||
docId: string
|
||||
data: Record<string, any>
|
||||
}
|
||||
|
||||
const PERSIST_MAX_ATTEMPTS = 100
|
||||
|
||||
export const docWritethroughProcessorQueue = createQueue<ProcessDocMessage>(
|
||||
JobQueue.DOC_WRITETHROUGH_QUEUE,
|
||||
{
|
||||
jobOptions: {
|
||||
attempts: PERSIST_MAX_ATTEMPTS,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
class DocWritethroughProcessor {
|
||||
init() {
|
||||
docWritethroughProcessorQueue.process(async message => {
|
||||
try {
|
||||
await this.persistToDb(message.data)
|
||||
} catch (err: any) {
|
||||
if (err.status === 409) {
|
||||
// If we get a 409, it means that another job updated it meanwhile. We want to retry it to persist it again.
|
||||
throw new Error(
|
||||
`Conflict persisting message ${message.id}. Attempt ${message.attemptsMade}`
|
||||
)
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
private async persistToDb({
|
||||
dbName,
|
||||
docId,
|
||||
data,
|
||||
}: {
|
||||
dbName: string
|
||||
docId: string
|
||||
data: Record<string, any>
|
||||
}) {
|
||||
const db = dbUtils.getDB(dbName)
|
||||
let doc: AnyDocument | undefined
|
||||
try {
|
||||
doc = await db.get(docId)
|
||||
} catch {
|
||||
doc = { _id: docId }
|
||||
}
|
||||
|
||||
doc = { ...doc, ...data }
|
||||
await db.put(doc)
|
||||
}
|
||||
}
|
||||
|
||||
export const processor = new DocWritethroughProcessor().init()
|
||||
|
||||
export class DocWritethrough {
|
||||
private db: Database
|
||||
private _docId: string
|
||||
|
||||
constructor(db: Database, docId: string) {
|
||||
this.db = db
|
||||
this._docId = docId
|
||||
}
|
||||
|
||||
get docId() {
|
||||
return this._docId
|
||||
}
|
||||
|
||||
async patch(data: Record<string, any>) {
|
||||
await docWritethroughProcessorQueue.add({
|
||||
dbName: this.db.name,
|
||||
docId: this.docId,
|
||||
data,
|
||||
})
|
||||
}
|
||||
}
|
|
@ -26,7 +26,8 @@ export const store = (...args: Parameters<typeof GENERIC.store>) =>
|
|||
GENERIC.store(...args)
|
||||
export const destroy = (...args: Parameters<typeof GENERIC.delete>) =>
|
||||
GENERIC.delete(...args)
|
||||
export const withCache = (...args: Parameters<typeof GENERIC.withCache>) =>
|
||||
GENERIC.withCache(...args)
|
||||
export const withCache = <T>(
|
||||
...args: Parameters<typeof GENERIC.withCache<T>>
|
||||
) => GENERIC.withCache(...args)
|
||||
export const bustCache = (...args: Parameters<typeof GENERIC.bustCache>) =>
|
||||
GENERIC.bustCache(...args)
|
||||
|
|
|
@ -5,3 +5,4 @@ export * as writethrough from "./writethrough"
|
|||
export * as invite from "./invite"
|
||||
export * as passwordReset from "./passwordReset"
|
||||
export * from "./generic"
|
||||
export * as docWritethrough from "./docWritethrough"
|
||||
|
|
|
@ -0,0 +1,288 @@
|
|||
import tk from "timekeeper"
|
||||
|
||||
import _ from "lodash"
|
||||
import { DBTestConfiguration, generator, structures } from "../../../tests"
|
||||
import { getDB } from "../../db"
|
||||
|
||||
import {
|
||||
DocWritethrough,
|
||||
docWritethroughProcessorQueue,
|
||||
} from "../docWritethrough"
|
||||
|
||||
import InMemoryQueue from "../../queue/inMemoryQueue"
|
||||
|
||||
const initialTime = Date.now()
|
||||
|
||||
async function waitForQueueCompletion() {
|
||||
const queue: InMemoryQueue = docWritethroughProcessorQueue as never
|
||||
await queue.waitForCompletion()
|
||||
}
|
||||
|
||||
describe("docWritethrough", () => {
|
||||
const config = new DBTestConfiguration()
|
||||
|
||||
const db = getDB(structures.db.id())
|
||||
let documentId: string
|
||||
let docWritethrough: DocWritethrough
|
||||
|
||||
describe("patch", () => {
|
||||
function generatePatchObject(fieldCount: number) {
|
||||
const keys = generator.unique(() => generator.word(), fieldCount)
|
||||
return keys.reduce((acc, c) => {
|
||||
acc[c] = generator.word()
|
||||
return acc
|
||||
}, {} as Record<string, any>)
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks()
|
||||
documentId = structures.uuid()
|
||||
docWritethrough = new DocWritethrough(db, documentId)
|
||||
})
|
||||
|
||||
it("patching will not persist until the messages are persisted", async () => {
|
||||
await config.doInTenant(async () => {
|
||||
await docWritethrough.patch(generatePatchObject(2))
|
||||
await docWritethrough.patch(generatePatchObject(2))
|
||||
|
||||
expect(await db.exists(documentId)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it("patching will persist when the messages are persisted", async () => {
|
||||
await config.doInTenant(async () => {
|
||||
const patch1 = generatePatchObject(2)
|
||||
const patch2 = generatePatchObject(2)
|
||||
await docWritethrough.patch(patch1)
|
||||
await docWritethrough.patch(patch2)
|
||||
|
||||
await waitForQueueCompletion()
|
||||
|
||||
// This will not be persisted
|
||||
const patch3 = generatePatchObject(3)
|
||||
await docWritethrough.patch(patch3)
|
||||
|
||||
expect(await db.get(documentId)).toEqual({
|
||||
_id: documentId,
|
||||
...patch1,
|
||||
...patch2,
|
||||
_rev: expect.stringMatching(/2-.+/),
|
||||
createdAt: new Date(initialTime).toISOString(),
|
||||
updatedAt: new Date(initialTime).toISOString(),
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it("patching will persist keeping the previous data", async () => {
|
||||
await config.doInTenant(async () => {
|
||||
const patch1 = generatePatchObject(2)
|
||||
const patch2 = generatePatchObject(2)
|
||||
await docWritethrough.patch(patch1)
|
||||
await docWritethrough.patch(patch2)
|
||||
|
||||
await waitForQueueCompletion()
|
||||
|
||||
const patch3 = generatePatchObject(3)
|
||||
await docWritethrough.patch(patch3)
|
||||
|
||||
await waitForQueueCompletion()
|
||||
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect.objectContaining({
|
||||
_id: documentId,
|
||||
...patch1,
|
||||
...patch2,
|
||||
...patch3,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("date audit fields are set correctly when persisting", async () => {
|
||||
await config.doInTenant(async () => {
|
||||
const patch1 = generatePatchObject(2)
|
||||
const patch2 = generatePatchObject(2)
|
||||
await docWritethrough.patch(patch1)
|
||||
const date1 = new Date()
|
||||
await waitForQueueCompletion()
|
||||
await docWritethrough.patch(patch2)
|
||||
|
||||
tk.travel(Date.now() + 100)
|
||||
const date2 = new Date()
|
||||
await waitForQueueCompletion()
|
||||
|
||||
expect(date1).not.toEqual(date2)
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect.objectContaining({
|
||||
createdAt: date1.toISOString(),
|
||||
updatedAt: date2.toISOString(),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("concurrent patches will override keys", async () => {
|
||||
await config.doInTenant(async () => {
|
||||
const patch1 = generatePatchObject(2)
|
||||
await docWritethrough.patch(patch1)
|
||||
await waitForQueueCompletion()
|
||||
const patch2 = generatePatchObject(1)
|
||||
await docWritethrough.patch(patch2)
|
||||
|
||||
const keyToOverride = _.sample(Object.keys(patch1))!
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect.objectContaining({
|
||||
[keyToOverride]: patch1[keyToOverride],
|
||||
})
|
||||
)
|
||||
|
||||
await waitForQueueCompletion()
|
||||
|
||||
const patch3 = {
|
||||
...generatePatchObject(3),
|
||||
[keyToOverride]: generator.word(),
|
||||
}
|
||||
await docWritethrough.patch(patch3)
|
||||
await waitForQueueCompletion()
|
||||
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect.objectContaining({
|
||||
...patch1,
|
||||
...patch2,
|
||||
...patch3,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("concurrent patches to different docWritethrough will not pollute each other", async () => {
|
||||
await config.doInTenant(async () => {
|
||||
const secondDocWritethrough = new DocWritethrough(
|
||||
db,
|
||||
structures.db.id()
|
||||
)
|
||||
|
||||
const doc1Patch = generatePatchObject(2)
|
||||
await docWritethrough.patch(doc1Patch)
|
||||
const doc2Patch = generatePatchObject(1)
|
||||
await secondDocWritethrough.patch(doc2Patch)
|
||||
|
||||
await waitForQueueCompletion()
|
||||
|
||||
const doc1Patch2 = generatePatchObject(3)
|
||||
await docWritethrough.patch(doc1Patch2)
|
||||
const doc2Patch2 = generatePatchObject(3)
|
||||
await secondDocWritethrough.patch(doc2Patch2)
|
||||
await waitForQueueCompletion()
|
||||
|
||||
expect(await db.get(docWritethrough.docId)).toEqual(
|
||||
expect.objectContaining({
|
||||
...doc1Patch,
|
||||
...doc1Patch2,
|
||||
})
|
||||
)
|
||||
|
||||
expect(await db.get(secondDocWritethrough.docId)).toEqual(
|
||||
expect.objectContaining({
|
||||
...doc2Patch,
|
||||
...doc2Patch2,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("cached values are persisted only once", async () => {
|
||||
await config.doInTenant(async () => {
|
||||
const initialPatch = generatePatchObject(5)
|
||||
|
||||
await docWritethrough.patch(initialPatch)
|
||||
await waitForQueueCompletion()
|
||||
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect.objectContaining(initialPatch)
|
||||
)
|
||||
|
||||
await db.remove(await db.get(documentId))
|
||||
|
||||
await waitForQueueCompletion()
|
||||
const extraPatch = generatePatchObject(5)
|
||||
await docWritethrough.patch(extraPatch)
|
||||
await waitForQueueCompletion()
|
||||
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect.objectContaining(extraPatch)
|
||||
)
|
||||
expect(await db.get(documentId)).not.toEqual(
|
||||
expect.objectContaining(initialPatch)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("concurrent calls will not cause conflicts", async () => {
|
||||
async function parallelPatch(count: number) {
|
||||
const patches = Array.from({ length: count }).map(() =>
|
||||
generatePatchObject(1)
|
||||
)
|
||||
await Promise.all(patches.map(p => docWritethrough.patch(p)))
|
||||
|
||||
return patches.reduce((acc, c) => {
|
||||
acc = { ...acc, ...c }
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
const queueMessageSpy = jest.spyOn(docWritethroughProcessorQueue, "add")
|
||||
|
||||
await config.doInTenant(async () => {
|
||||
let patches = await parallelPatch(5)
|
||||
expect(queueMessageSpy).toBeCalledTimes(5)
|
||||
|
||||
await waitForQueueCompletion()
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect.objectContaining(patches)
|
||||
)
|
||||
|
||||
patches = { ...patches, ...(await parallelPatch(40)) }
|
||||
expect(queueMessageSpy).toBeCalledTimes(45)
|
||||
|
||||
await waitForQueueCompletion()
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect.objectContaining(patches)
|
||||
)
|
||||
|
||||
patches = { ...patches, ...(await parallelPatch(10)) }
|
||||
expect(queueMessageSpy).toBeCalledTimes(55)
|
||||
|
||||
await waitForQueueCompletion()
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect.objectContaining(patches)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// This is not yet supported
|
||||
it.skip("patches will execute in order", async () => {
|
||||
let incrementalValue = 0
|
||||
const keyToOverride = generator.word()
|
||||
async function incrementalPatches(count: number) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await docWritethrough.patch({ [keyToOverride]: incrementalValue++ })
|
||||
}
|
||||
}
|
||||
|
||||
await config.doInTenant(async () => {
|
||||
await incrementalPatches(5)
|
||||
|
||||
await waitForQueueCompletion()
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect.objectContaining({ [keyToOverride]: 5 })
|
||||
)
|
||||
|
||||
await incrementalPatches(40)
|
||||
await waitForQueueCompletion()
|
||||
expect(await db.get(documentId)).toEqual(
|
||||
expect.objectContaining({ [keyToOverride]: 45 })
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
|
@ -6,7 +6,7 @@ import env from "../environment"
|
|||
import * as accounts from "../accounts"
|
||||
import { UserDB } from "../users"
|
||||
import { sdk } from "@budibase/shared-core"
|
||||
import { User } from "@budibase/types"
|
||||
import { User, UserMetadata } from "@budibase/types"
|
||||
|
||||
const EXPIRY_SECONDS = 3600
|
||||
|
||||
|
@ -15,7 +15,7 @@ const EXPIRY_SECONDS = 3600
|
|||
*/
|
||||
async function populateFromDB(userId: string, tenantId: string) {
|
||||
const db = tenancy.getTenantDB(tenantId)
|
||||
const user = await db.get<any>(userId)
|
||||
const user = await db.get<UserMetadata>(userId)
|
||||
user.budibaseAccess = true
|
||||
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
|
||||
const account = await accounts.getAccount(user.email)
|
||||
|
|
|
@ -57,6 +57,9 @@ export const StaticDatabases = {
|
|||
AUDIT_LOGS: {
|
||||
name: "audit-logs",
|
||||
},
|
||||
SCIM_LOGS: {
|
||||
name: "scim-logs",
|
||||
},
|
||||
}
|
||||
|
||||
export const APP_PREFIX = prefixed(DocumentType.APP)
|
||||
|
|
|
@ -35,6 +35,17 @@ export function getAuditLogDBName(tenantId?: string) {
|
|||
}
|
||||
}
|
||||
|
||||
export function getScimDBName(tenantId?: string) {
|
||||
if (!tenantId) {
|
||||
tenantId = getTenantId()
|
||||
}
|
||||
if (tenantId === DEFAULT_TENANT_ID) {
|
||||
return StaticDatabases.SCIM_LOGS.name
|
||||
} else {
|
||||
return `${tenantId}${SEPARATOR}${StaticDatabases.SCIM_LOGS.name}`
|
||||
}
|
||||
}
|
||||
|
||||
export function baseGlobalDBName(tenantId: string | undefined | null) {
|
||||
if (!tenantId || tenantId === DEFAULT_TENANT_ID) {
|
||||
return StaticDatabases.GLOBAL.name
|
||||
|
|
|
@ -1,66 +1,57 @@
|
|||
import PouchDB from "pouchdb"
|
||||
import { getPouchDB, closePouchDB } from "./couch"
|
||||
import { DocumentType } from "../constants"
|
||||
|
||||
class Replication {
|
||||
source: any
|
||||
target: any
|
||||
replication: any
|
||||
source: PouchDB.Database
|
||||
target: PouchDB.Database
|
||||
|
||||
/**
|
||||
*
|
||||
* @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) {
|
||||
constructor({ source, target }: { source: string; target: string }) {
|
||||
this.source = getPouchDB(source)
|
||||
this.target = getPouchDB(target)
|
||||
}
|
||||
|
||||
close() {
|
||||
return Promise.all([closePouchDB(this.source), closePouchDB(this.target)])
|
||||
async close() {
|
||||
await Promise.all([closePouchDB(this.source), closePouchDB(this.target)])
|
||||
}
|
||||
|
||||
promisify(operation: any, opts = {}) {
|
||||
return new Promise(resolve => {
|
||||
operation(this.target, opts)
|
||||
.on("denied", function (err: any) {
|
||||
replicate(opts: PouchDB.Replication.ReplicateOptions = {}) {
|
||||
return new Promise<PouchDB.Replication.ReplicationResult<{}>>(resolve => {
|
||||
this.source.replicate
|
||||
.to(this.target, opts)
|
||||
.on("denied", function (err) {
|
||||
// a document failed to replicate (e.g. due to permissions)
|
||||
throw new Error(`Denied: Document failed to replicate ${err}`)
|
||||
})
|
||||
.on("complete", function (info: any) {
|
||||
.on("complete", function (info) {
|
||||
return resolve(info)
|
||||
})
|
||||
.on("error", function (err: any) {
|
||||
.on("error", function (err) {
|
||||
throw new Error(`Replication Error: ${err}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Two way replication operation, intended to be promise based.
|
||||
* @param opts - PouchDB replication options
|
||||
*/
|
||||
sync(opts = {}) {
|
||||
this.replication = this.promisify(this.source.sync, opts)
|
||||
return this.replication
|
||||
}
|
||||
appReplicateOpts(
|
||||
opts: PouchDB.Replication.ReplicateOptions = {}
|
||||
): PouchDB.Replication.ReplicateOptions {
|
||||
if (typeof opts.filter === "string") {
|
||||
return opts
|
||||
}
|
||||
|
||||
/**
|
||||
* One way replication operation, intended to be promise based.
|
||||
* @param opts - PouchDB replication options
|
||||
*/
|
||||
replicate(opts = {}) {
|
||||
this.replication = this.promisify(this.source.replicate.to, opts)
|
||||
return this.replication
|
||||
}
|
||||
const filter = opts.filter
|
||||
delete opts.filter
|
||||
|
||||
appReplicateOpts() {
|
||||
return {
|
||||
filter: (doc: any) => {
|
||||
...opts,
|
||||
filter: (doc: any, params: any) => {
|
||||
if (doc._id && doc._id.startsWith(DocumentType.AUTOMATION_LOG)) {
|
||||
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
|
||||
await this.replicate()
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.replication.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
export default Replication
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
Document,
|
||||
isDocument,
|
||||
RowResponse,
|
||||
RowValue,
|
||||
} from "@budibase/types"
|
||||
import { getCouchInfo } from "./connections"
|
||||
import { directCouchUrlCall } from "./utils"
|
||||
|
@ -69,7 +70,15 @@ export class DatabaseImpl implements Database {
|
|||
DatabaseImpl.nano = buildNano(couchInfo)
|
||||
}
|
||||
|
||||
async exists() {
|
||||
exists(docId?: string) {
|
||||
if (docId === undefined) {
|
||||
return this.dbExists()
|
||||
}
|
||||
|
||||
return this.docExists(docId)
|
||||
}
|
||||
|
||||
private async dbExists() {
|
||||
const response = await directCouchUrlCall({
|
||||
url: `${this.couchInfo.url}/${this.name}`,
|
||||
method: "HEAD",
|
||||
|
@ -78,6 +87,15 @@ export class DatabaseImpl implements Database {
|
|||
return response.status === 200
|
||||
}
|
||||
|
||||
private async docExists(id: string): Promise<boolean> {
|
||||
try {
|
||||
await this.performCall(db => () => db.head(id))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private nano() {
|
||||
return this.instanceNano || DatabaseImpl.nano
|
||||
}
|
||||
|
@ -221,7 +239,7 @@ export class DatabaseImpl implements Database {
|
|||
})
|
||||
}
|
||||
|
||||
async allDocs<T extends Document>(
|
||||
async allDocs<T extends Document | RowValue>(
|
||||
params: DatabaseQueryOpts
|
||||
): Promise<AllDocsResponse<T>> {
|
||||
return this.performCall(db => {
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
import {
|
||||
DocumentScope,
|
||||
DocumentDestroyResponse,
|
||||
DocumentInsertResponse,
|
||||
DocumentBulkResponse,
|
||||
|
@ -13,6 +12,7 @@ import {
|
|||
DatabasePutOpts,
|
||||
DatabaseQueryOpts,
|
||||
Document,
|
||||
RowValue,
|
||||
} from "@budibase/types"
|
||||
import tracer from "dd-trace"
|
||||
import { Writable } from "stream"
|
||||
|
@ -24,9 +24,12 @@ export class DDInstrumentedDatabase implements Database {
|
|||
return this.db.name
|
||||
}
|
||||
|
||||
exists(): Promise<boolean> {
|
||||
exists(docId?: string): Promise<boolean> {
|
||||
return tracer.trace("db.exists", span => {
|
||||
span?.addTags({ db_name: this.name })
|
||||
span?.addTags({ db_name: this.name, doc_id: docId })
|
||||
if (docId) {
|
||||
return this.db.exists(docId)
|
||||
}
|
||||
return this.db.exists()
|
||||
})
|
||||
}
|
||||
|
@ -79,7 +82,7 @@ export class DDInstrumentedDatabase implements Database {
|
|||
})
|
||||
}
|
||||
|
||||
allDocs<T extends Document>(
|
||||
allDocs<T extends Document | RowValue>(
|
||||
params: DatabaseQueryOpts
|
||||
): Promise<AllDocsResponse<T>> {
|
||||
return tracer.trace("db.allDocs", span => {
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
import _ from "lodash"
|
||||
import { AnyDocument } from "@budibase/types"
|
||||
import { generator } from "../../../tests"
|
||||
import { DatabaseImpl } from "../couch"
|
||||
import { newid } from "../../utils"
|
||||
|
||||
describe("DatabaseImpl", () => {
|
||||
const database = new DatabaseImpl(generator.word())
|
||||
const documents: AnyDocument[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
const docsToCreate = Array.from({ length: 10 }).map(() => ({
|
||||
_id: newid(),
|
||||
}))
|
||||
const createdDocs = await database.bulkDocs(docsToCreate)
|
||||
|
||||
documents.push(...createdDocs.map((x: any) => ({ _id: x.id, _rev: x.rev })))
|
||||
})
|
||||
|
||||
describe("document exists", () => {
|
||||
it("can check existing docs by id", async () => {
|
||||
const existingDoc = _.sample(documents)
|
||||
const result = await database.exists(existingDoc!._id!)
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("can check non existing docs by id", async () => {
|
||||
const result = await database.exists(newid())
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("can check an existing doc by id multiple times", async () => {
|
||||
const existingDoc = _.sample(documents)
|
||||
const id = existingDoc!._id!
|
||||
|
||||
const results = []
|
||||
results.push(await database.exists(id))
|
||||
results.push(await database.exists(id))
|
||||
results.push(await database.exists(id))
|
||||
|
||||
expect(results).toEqual([true, true, true])
|
||||
})
|
||||
|
||||
it("returns false after the doc is deleted", async () => {
|
||||
const existingDoc = _.sample(documents)
|
||||
const id = existingDoc!._id!
|
||||
expect(await database.exists(id)).toBe(true)
|
||||
|
||||
await database.remove(existingDoc!)
|
||||
expect(await database.exists(id)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -74,7 +74,7 @@ export function getGlobalIDFromUserMetadataID(id: string) {
|
|||
* Generates a template ID.
|
||||
* @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()}`
|
||||
}
|
||||
|
||||
|
@ -105,7 +105,7 @@ export function prefixRoleID(name: string) {
|
|||
* 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.
|
||||
*/
|
||||
export const generateDevInfoID = (userId: any) => {
|
||||
export const generateDevInfoID = (userId: string) => {
|
||||
return `${DocumentType.DEV_INFO}${SEPARATOR}${userId}`
|
||||
}
|
||||
|
||||
|
|
|
@ -186,6 +186,7 @@ const environment = {
|
|||
environment[key] = value
|
||||
},
|
||||
ROLLING_LOG_MAX_SIZE: process.env.ROLLING_LOG_MAX_SIZE || "10M",
|
||||
DISABLE_SCIM_CALLS: process.env.DISABLE_SCIM_CALLS,
|
||||
}
|
||||
|
||||
// clean up any environment variable edge cases
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { APIError } from "@budibase/types"
|
||||
import * as errors from "../errors"
|
||||
import environment from "../environment"
|
||||
|
||||
export async function errorHandling(ctx: any, next: any) {
|
||||
try {
|
||||
|
@ -14,15 +15,19 @@ export async function errorHandling(ctx: any, next: any) {
|
|||
console.error(err)
|
||||
}
|
||||
|
||||
const error = errors.getPublicError(err)
|
||||
const body: APIError = {
|
||||
let error: APIError = {
|
||||
message: err.message,
|
||||
status: status,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ import { Plugin } from "@budibase/types"
|
|||
|
||||
// URLS
|
||||
|
||||
export function enrichPluginURLs(plugins: Plugin[]) {
|
||||
export function enrichPluginURLs(plugins?: Plugin[]): Plugin[] {
|
||||
if (!plugins || !plugins.length) {
|
||||
return []
|
||||
}
|
||||
|
|
|
@ -4,4 +4,5 @@ export enum JobQueue {
|
|||
AUDIT_LOG = "auditLogQueue",
|
||||
SYSTEM_EVENT_QUEUE = "systemEventQueue",
|
||||
APP_MIGRATION = "appMigration",
|
||||
DOC_WRITETHROUGH_QUEUE = "docWritethroughQueue",
|
||||
}
|
||||
|
|
|
@ -1,5 +1,14 @@
|
|||
import events from "events"
|
||||
import { timeout } from "../utils"
|
||||
import { newid, timeout } from "../utils"
|
||||
import { Queue, QueueOptions, JobOptions } from "./queue"
|
||||
|
||||
interface JobMessage {
|
||||
id: string
|
||||
timestamp: number
|
||||
queue: string
|
||||
data: any
|
||||
opts?: JobOptions
|
||||
}
|
||||
|
||||
/**
|
||||
* Bull works with a Job wrapper around all messages that contains a lot more information about
|
||||
|
@ -10,12 +19,13 @@ import { timeout } from "../utils"
|
|||
* @returns A new job which can now be put onto the queue, this is mostly an
|
||||
* internal structure so that an in memory queue can be easily swapped for a Bull queue.
|
||||
*/
|
||||
function newJob(queue: string, message: any) {
|
||||
function newJob(queue: string, message: any, opts?: JobOptions): JobMessage {
|
||||
return {
|
||||
id: newid(),
|
||||
timestamp: Date.now(),
|
||||
queue: queue,
|
||||
data: message,
|
||||
opts: {},
|
||||
opts,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -24,26 +34,29 @@ function newJob(queue: string, message: any) {
|
|||
* It is relatively simple, using an event emitter internally to register when messages are available
|
||||
* to the consumers - in can support many inputs and many consumers.
|
||||
*/
|
||||
class InMemoryQueue {
|
||||
class InMemoryQueue implements Partial<Queue> {
|
||||
_name: string
|
||||
_opts?: any
|
||||
_messages: any[]
|
||||
_opts?: QueueOptions
|
||||
_messages: JobMessage[]
|
||||
_queuedJobIds: Set<string>
|
||||
_emitter: EventEmitter
|
||||
_runCount: number
|
||||
_addCount: number
|
||||
|
||||
/**
|
||||
* The constructor the queue, exactly the same as that of Bulls.
|
||||
* @param name The name of the queue which is being configured.
|
||||
* @param opts This is not used by the in memory queue as there is no real use
|
||||
* case when in memory, but is the same API as Bull
|
||||
*/
|
||||
constructor(name: string, opts?: any) {
|
||||
constructor(name: string, opts?: QueueOptions) {
|
||||
this._name = name
|
||||
this._opts = opts
|
||||
this._messages = []
|
||||
this._emitter = new events.EventEmitter()
|
||||
this._runCount = 0
|
||||
this._addCount = 0
|
||||
this._queuedJobIds = new Set<string>()
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -55,22 +68,42 @@ class InMemoryQueue {
|
|||
* note this is incredibly limited compared to Bull as in reality the Job would contain
|
||||
* a lot more information about the queue and current status of Bull cluster.
|
||||
*/
|
||||
process(func: any) {
|
||||
async process(func: any) {
|
||||
this._emitter.on("message", async () => {
|
||||
if (this._messages.length <= 0) {
|
||||
return
|
||||
}
|
||||
let msg = this._messages.shift()
|
||||
|
||||
let resp = func(msg)
|
||||
|
||||
async function retryFunc(fnc: any) {
|
||||
try {
|
||||
await fnc
|
||||
} catch (e: any) {
|
||||
await new Promise<void>(r => setTimeout(() => r(), 50))
|
||||
|
||||
await retryFunc(func(msg))
|
||||
}
|
||||
}
|
||||
|
||||
if (resp.then != null) {
|
||||
await resp
|
||||
try {
|
||||
await retryFunc(resp)
|
||||
} catch (e: any) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
this._runCount++
|
||||
const jobId = msg?.opts?.jobId?.toString()
|
||||
if (jobId && msg?.opts?.removeOnComplete) {
|
||||
this._queuedJobIds.delete(jobId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async isReady() {
|
||||
return true
|
||||
return this as any
|
||||
}
|
||||
|
||||
// simply puts a message to the queue and emits to the queue for processing
|
||||
|
@ -83,27 +116,45 @@ class InMemoryQueue {
|
|||
* @param repeat serves no purpose for the import queue.
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
add(msg: any, repeat: boolean) {
|
||||
if (typeof msg !== "object") {
|
||||
async add(data: any, opts?: JobOptions) {
|
||||
const jobId = opts?.jobId?.toString()
|
||||
if (jobId && this._queuedJobIds.has(jobId)) {
|
||||
console.log(`Ignoring already queued job ${jobId}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof data !== "object") {
|
||||
throw "Queue only supports carrying JSON."
|
||||
}
|
||||
this._messages.push(newJob(this._name, msg))
|
||||
this._addCount++
|
||||
this._emitter.emit("message")
|
||||
if (jobId) {
|
||||
this._queuedJobIds.add(jobId)
|
||||
}
|
||||
|
||||
const pushMessage = () => {
|
||||
this._messages.push(newJob(this._name, data, opts))
|
||||
this._addCount++
|
||||
this._emitter.emit("message")
|
||||
}
|
||||
|
||||
const delay = opts?.delay
|
||||
if (delay) {
|
||||
setTimeout(pushMessage, delay)
|
||||
} else {
|
||||
pushMessage()
|
||||
}
|
||||
return {} as any
|
||||
}
|
||||
|
||||
/**
|
||||
* replicating the close function from bull, which waits for jobs to finish.
|
||||
*/
|
||||
async close() {
|
||||
return []
|
||||
}
|
||||
async close() {}
|
||||
|
||||
/**
|
||||
* This removes a cron which has been implemented, this is part of Bull API.
|
||||
* @param cronJobId The cron which is to be removed.
|
||||
*/
|
||||
removeRepeatableByKey(cronJobId: string) {
|
||||
async removeRepeatableByKey(cronJobId: string) {
|
||||
// TODO: implement for testing
|
||||
console.log(cronJobId)
|
||||
}
|
||||
|
@ -111,12 +162,12 @@ class InMemoryQueue {
|
|||
/**
|
||||
* Implemented for tests
|
||||
*/
|
||||
getRepeatableJobs() {
|
||||
async getRepeatableJobs() {
|
||||
return []
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
removeJobs(pattern: string) {
|
||||
async removeJobs(pattern: string) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
|
@ -128,18 +179,22 @@ class InMemoryQueue {
|
|||
}
|
||||
|
||||
async getJob() {
|
||||
return {}
|
||||
return null
|
||||
}
|
||||
|
||||
on() {
|
||||
// do nothing
|
||||
return this
|
||||
return this as any
|
||||
}
|
||||
|
||||
async waitForCompletion() {
|
||||
do {
|
||||
await timeout(50)
|
||||
} while (this._addCount < this._runCount)
|
||||
} while (this.hasRunningJobs())
|
||||
}
|
||||
|
||||
hasRunningJobs() {
|
||||
return this._addCount > this._runCount
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -88,6 +88,7 @@ enum QueueEventType {
|
|||
AUDIT_LOG_EVENT = "audit-log-event",
|
||||
SYSTEM_EVENT = "system-event",
|
||||
APP_MIGRATION = "app-migration",
|
||||
DOC_WRITETHROUGH = "doc-writethrough",
|
||||
}
|
||||
|
||||
const EventTypeMap: { [key in JobQueue]: QueueEventType } = {
|
||||
|
@ -96,6 +97,7 @@ const EventTypeMap: { [key in JobQueue]: QueueEventType } = {
|
|||
[JobQueue.AUDIT_LOG]: QueueEventType.AUDIT_LOG_EVENT,
|
||||
[JobQueue.SYSTEM_EVENT_QUEUE]: QueueEventType.SYSTEM_EVENT,
|
||||
[JobQueue.APP_MIGRATION]: QueueEventType.APP_MIGRATION,
|
||||
[JobQueue.DOC_WRITETHROUGH_QUEUE]: QueueEventType.DOC_WRITETHROUGH,
|
||||
}
|
||||
|
||||
function logging(queue: Queue, jobQueue: JobQueue) {
|
||||
|
|
|
@ -7,6 +7,8 @@ import { addListeners, StalledFn } from "./listeners"
|
|||
import { Duration } from "../utils"
|
||||
import * as timers from "../timers"
|
||||
|
||||
export { QueueOptions, Queue, JobOptions } from "bull"
|
||||
|
||||
// the queue lock is held for 5 minutes
|
||||
const QUEUE_LOCK_MS = Duration.fromMinutes(5).toMs()
|
||||
// queue lock is refreshed every 30 seconds
|
||||
|
|
|
@ -9,7 +9,8 @@ let userClient: Client,
|
|||
lockClient: Client,
|
||||
socketClient: Client,
|
||||
inviteClient: Client,
|
||||
passwordResetClient: Client
|
||||
passwordResetClient: Client,
|
||||
docWritethroughClient: Client
|
||||
|
||||
export async function init() {
|
||||
userClient = await new Client(utils.Databases.USER_CACHE).init()
|
||||
|
@ -24,6 +25,9 @@ export async function init() {
|
|||
utils.Databases.SOCKET_IO,
|
||||
utils.SelectableDatabase.SOCKET_IO
|
||||
).init()
|
||||
docWritethroughClient = await new Client(
|
||||
utils.Databases.DOC_WRITE_THROUGH
|
||||
).init()
|
||||
}
|
||||
|
||||
export async function shutdown() {
|
||||
|
@ -104,3 +108,10 @@ export async function getPasswordResetClient() {
|
|||
}
|
||||
return passwordResetClient
|
||||
}
|
||||
|
||||
export async function getDocWritethroughClient() {
|
||||
if (!writethroughClient) {
|
||||
await init()
|
||||
}
|
||||
return writethroughClient
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import env from "../environment"
|
||||
import Redis from "ioredis"
|
||||
import Redis, { Cluster } from "ioredis"
|
||||
// mock-redis doesn't have any typing
|
||||
let MockRedis: any | undefined
|
||||
if (env.MOCK_REDIS) {
|
||||
|
@ -28,7 +28,7 @@ const DEFAULT_SELECT_DB = SelectableDatabase.DEFAULT
|
|||
|
||||
// for testing just generate the client once
|
||||
let CLOSED = false
|
||||
let CLIENTS: { [key: number]: any } = {}
|
||||
const CLIENTS: Record<number, Redis> = {}
|
||||
let CONNECTED = false
|
||||
|
||||
// mock redis always connected
|
||||
|
@ -36,7 +36,7 @@ if (env.MOCK_REDIS) {
|
|||
CONNECTED = true
|
||||
}
|
||||
|
||||
function pickClient(selectDb: number): any {
|
||||
function pickClient(selectDb: number) {
|
||||
return CLIENTS[selectDb]
|
||||
}
|
||||
|
||||
|
@ -201,12 +201,15 @@ class RedisWrapper {
|
|||
key = `${db}${SEPARATOR}${key}`
|
||||
let stream
|
||||
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 })
|
||||
} 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) {
|
||||
|
@ -221,14 +224,16 @@ class RedisWrapper {
|
|||
|
||||
async get(key: string) {
|
||||
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
|
||||
// @ts-ignore
|
||||
if (response != null && response.key) {
|
||||
// @ts-ignore
|
||||
response.key = key
|
||||
}
|
||||
// if its not an object just return the response
|
||||
try {
|
||||
return JSON.parse(response)
|
||||
return JSON.parse(response!)
|
||||
} catch (err) {
|
||||
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) {
|
||||
const db = this._db
|
||||
const prefixedKey = addDbPrefix(db, key)
|
||||
return this.getClient().ttl(prefixedKey)
|
||||
}
|
||||
|
||||
async setExpiry(key: string, expirySeconds: number | null) {
|
||||
async setExpiry(key: string, expirySeconds: number) {
|
||||
const db = this._db
|
||||
const prefixedKey = addDbPrefix(db, key)
|
||||
await this.getClient().expire(prefixedKey, expirySeconds)
|
||||
|
@ -291,10 +320,35 @@ class RedisWrapper {
|
|||
await this.getClient().del(addDbPrefix(db, key))
|
||||
}
|
||||
|
||||
async bulkDelete(keys: string[]) {
|
||||
const db = this._db
|
||||
await this.getClient().del(keys.map(key => addDbPrefix(db, key)))
|
||||
}
|
||||
|
||||
async clear() {
|
||||
let items = await this.scan()
|
||||
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
|
||||
|
|
|
@ -72,7 +72,7 @@ const OPTIONS: Record<keyof typeof LockType, Redlock.Options> = {
|
|||
export async function newRedlock(opts: Redlock.Options = {}) {
|
||||
const options = { ...OPTIONS.DEFAULT, ...opts }
|
||||
const redisWrapper = await getLockClient()
|
||||
const client = redisWrapper.getClient()
|
||||
const client = redisWrapper.getClient() as any
|
||||
return new Redlock([client], options)
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
|
@ -30,6 +30,7 @@ export enum Databases {
|
|||
LOCKS = "locks",
|
||||
SOCKET_IO = "socket_io",
|
||||
BPM_EVENTS = "bpmEvents",
|
||||
DOC_WRITE_THROUGH = "docWriteThrough",
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -84,25 +84,24 @@ export function getBuiltinRoles(): { [key: string]: RoleDoc } {
|
|||
return cloneDeep(BUILTIN_ROLES)
|
||||
}
|
||||
|
||||
export const BUILTIN_ROLE_ID_ARRAY = Object.values(BUILTIN_ROLES).map(
|
||||
role => role._id
|
||||
)
|
||||
export function isBuiltin(role: string) {
|
||||
return getBuiltinRole(role) !== undefined
|
||||
}
|
||||
|
||||
export const BUILTIN_ROLE_NAME_ARRAY = Object.values(BUILTIN_ROLES).map(
|
||||
role => role.name
|
||||
)
|
||||
|
||||
export function isBuiltin(role?: string) {
|
||||
return BUILTIN_ROLE_ID_ARRAY.some(builtin => role?.includes(builtin))
|
||||
export function getBuiltinRole(roleId: string): Role | undefined {
|
||||
const role = Object.values(BUILTIN_ROLES).find(role =>
|
||||
roleId.includes(role._id)
|
||||
)
|
||||
if (!role) {
|
||||
return undefined
|
||||
}
|
||||
return cloneDeep(role)
|
||||
}
|
||||
|
||||
/**
|
||||
* Works through the inheritance ranks to see how far up the builtin stack this ID is.
|
||||
*/
|
||||
export function builtinRoleToNumber(id?: string) {
|
||||
if (!id) {
|
||||
return 0
|
||||
}
|
||||
export function builtinRoleToNumber(id: string) {
|
||||
const builtins = getBuiltinRoles()
|
||||
const MAX = Object.values(builtins).length + 1
|
||||
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.
|
||||
*/
|
||||
export async function roleToNumber(id?: string) {
|
||||
export async function roleToNumber(id: string) {
|
||||
if (isBuiltin(id)) {
|
||||
return builtinRoleToNumber(id)
|
||||
}
|
||||
|
@ -131,7 +130,7 @@ export async function roleToNumber(id?: string) {
|
|||
defaultPublic: true,
|
||||
})) as RoleDoc[]
|
||||
for (let role of hierarchy) {
|
||||
if (isBuiltin(role?.inherits)) {
|
||||
if (role?.inherits && isBuiltin(role.inherits)) {
|
||||
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.
|
||||
*/
|
||||
export async function getRole(
|
||||
roleId?: string,
|
||||
roleId: string,
|
||||
opts?: { defaultPublic?: boolean }
|
||||
): Promise<RoleDoc | undefined> {
|
||||
if (!roleId) {
|
||||
return undefined
|
||||
}
|
||||
let role: any = {}
|
||||
): Promise<RoleDoc> {
|
||||
// built in roles mostly come from the in-code implementation,
|
||||
// but can be extended by a doc stored about them (e.g. permissions)
|
||||
if (isBuiltin(roleId)) {
|
||||
role = cloneDeep(
|
||||
Object.values(BUILTIN_ROLES).find(role => role._id === roleId)
|
||||
)
|
||||
} else {
|
||||
let role: RoleDoc | undefined = getBuiltinRole(roleId)
|
||||
if (!role) {
|
||||
// make sure has the prefix (if it has it then it won't be added)
|
||||
roleId = prefixRoleID(roleId)
|
||||
}
|
||||
try {
|
||||
const db = getAppDB()
|
||||
const dbRole = await db.get(getDBRoleID(roleId))
|
||||
role = Object.assign(role, dbRole)
|
||||
const dbRole = await db.get<RoleDoc>(getDBRoleID(roleId))
|
||||
role = Object.assign(role || {}, dbRole)
|
||||
// finalise the ID
|
||||
role._id = getExternalRoleID(role._id, role.version)
|
||||
role._id = getExternalRoleID(role._id!, role.version)
|
||||
} catch (err) {
|
||||
if (!isBuiltin(roleId) && opts?.defaultPublic) {
|
||||
return cloneDeep(BUILTIN_ROLES.PUBLIC)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
@ -200,7 +192,7 @@ export async function getRole(
|
|||
* Simple function to get all the roles based on the top level user role ID.
|
||||
*/
|
||||
async function getAllUserRoles(
|
||||
userRoleId?: string,
|
||||
userRoleId: string,
|
||||
opts?: { defaultPublic?: boolean }
|
||||
): Promise<RoleDoc[]> {
|
||||
// admins have access to all roles
|
||||
|
@ -226,7 +218,7 @@ async function getAllUserRoles(
|
|||
}
|
||||
|
||||
export async function getUserRoleIdHierarchy(
|
||||
userRoleId?: string
|
||||
userRoleId: string
|
||||
): Promise<string[]> {
|
||||
const roles = await getUserRoleHierarchy(userRoleId)
|
||||
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.
|
||||
*/
|
||||
export async function getUserRoleHierarchy(
|
||||
userRoleId?: string,
|
||||
userRoleId: string,
|
||||
opts?: { defaultPublic?: boolean }
|
||||
) {
|
||||
// special case, if they don't have a role then they are a public user
|
||||
|
@ -265,9 +257,9 @@ export function checkForRoleResourceArray(
|
|||
return rolePerms
|
||||
}
|
||||
|
||||
export async function getAllRoleIds(appId?: string) {
|
||||
export async function getAllRoleIds(appId: string): Promise<string[]> {
|
||||
const roles = await getAllRoles(appId)
|
||||
return roles.map(role => role._id)
|
||||
return roles.map(role => role._id!)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -18,7 +18,7 @@ export const account = (partial: Partial<Account> = {}): Account => {
|
|||
return {
|
||||
accountId: uuid(),
|
||||
tenantId: generator.word(),
|
||||
email: generator.email(),
|
||||
email: generator.email({ domain: "example.com" }),
|
||||
tenantName: generator.word(),
|
||||
hosting: Hosting.SELF,
|
||||
createdAt: Date.now(),
|
||||
|
|
|
@ -13,7 +13,7 @@ interface CreateUserRequestFields {
|
|||
export function createUserRequest(userData?: Partial<CreateUserRequestFields>) {
|
||||
const defaultValues = {
|
||||
externalId: uuid(),
|
||||
email: generator.email(),
|
||||
email: `${uuid()}@example.com`,
|
||||
firstName: generator.first(),
|
||||
lastName: generator.last(),
|
||||
username: generator.name(),
|
||||
|
|
|
@ -35,7 +35,10 @@ export default function positionDropdown(element, opts) {
|
|||
}
|
||||
|
||||
if (typeof customUpdate === "function") {
|
||||
styles = customUpdate(anchorBounds, elementBounds, styles)
|
||||
styles = customUpdate(anchorBounds, elementBounds, {
|
||||
...styles,
|
||||
offset: opts.offset,
|
||||
})
|
||||
} else {
|
||||
// Determine vertical styles
|
||||
if (align === "right-outside") {
|
||||
|
|
|
@ -9,13 +9,17 @@ const intercom = new IntercomClient(process.env.INTERCOM_TOKEN)
|
|||
class AnalyticsHub {
|
||||
constructor() {
|
||||
this.clients = [posthog, intercom]
|
||||
this.initialised = false
|
||||
}
|
||||
|
||||
async activate() {
|
||||
// Check analytics are enabled
|
||||
const analyticsStatus = await API.getAnalyticsStatus()
|
||||
if (analyticsStatus.enabled) {
|
||||
this.clients.forEach(client => client.init())
|
||||
if (analyticsStatus.enabled && !this.initialised) {
|
||||
this.clients.forEach(client => {
|
||||
client.init()
|
||||
})
|
||||
this.initialised = true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -40,8 +40,15 @@
|
|||
part2: PrettyRelationshipDefinitions.MANY,
|
||||
},
|
||||
}
|
||||
let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions)
|
||||
let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions)
|
||||
$: relationshipOpts1 =
|
||||
relationshipPart2 === PrettyRelationshipDefinitions.ONE
|
||||
? [PrettyRelationshipDefinitions.MANY]
|
||||
: Object.values(PrettyRelationshipDefinitions)
|
||||
|
||||
$: relationshipOpts2 =
|
||||
relationshipPart1 === PrettyRelationshipDefinitions.ONE
|
||||
? [PrettyRelationshipDefinitions.MANY]
|
||||
: Object.values(PrettyRelationshipDefinitions)
|
||||
|
||||
let relationshipPart1 = PrettyRelationshipDefinitions.ONE
|
||||
let relationshipPart2 = PrettyRelationshipDefinitions.MANY
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
<script>
|
||||
import FontAwesomeIcon from "./FontAwesomeIcon.svelte"
|
||||
import { Popover, Heading, Body } from "@budibase/bbui"
|
||||
import { licensing } from "stores/portal"
|
||||
import { isEnabled, TENANT_FEATURE_FLAGS } from "helpers/featureFlags"
|
||||
import { licensing } from "stores/portal"
|
||||
import { isPremiumOrAbove } from "helpers/planTitle"
|
||||
|
||||
$: isBusinessAndAbove =
|
||||
$licensing.isBusinessPlan || $licensing.isEnterprisePlan
|
||||
$: premiumOrAboveLicense = isPremiumOrAbove($licensing?.license.plan.type)
|
||||
|
||||
let show
|
||||
let hide
|
||||
|
@ -56,22 +56,25 @@
|
|||
<div class="divider" />
|
||||
{#if isEnabled(TENANT_FEATURE_FLAGS.LICENSING)}
|
||||
<a
|
||||
href={isBusinessAndAbove
|
||||
href={premiumOrAboveLicense
|
||||
? "mailto:support@budibase.com"
|
||||
: "/builder/portal/account/usage"}
|
||||
>
|
||||
<div class="premiumLinkContent" class:disabled={!isBusinessAndAbove}>
|
||||
<div
|
||||
class="premiumLinkContent"
|
||||
class:disabled={!premiumOrAboveLicense}
|
||||
>
|
||||
<div class="icon">
|
||||
<FontAwesomeIcon name="fa-solid fa-envelope" />
|
||||
</div>
|
||||
<Body size="S">Email support</Body>
|
||||
</div>
|
||||
{#if !isBusinessAndAbove}
|
||||
{#if !premiumOrAboveLicense}
|
||||
<div class="premiumBadge">
|
||||
<div class="icon">
|
||||
<FontAwesomeIcon name="fa-solid fa-lock" />
|
||||
</div>
|
||||
<Body size="XS">Business</Body>
|
||||
<Body size="XS">Premium</Body>
|
||||
</div>
|
||||
{/if}
|
||||
</a>
|
||||
|
|
|
@ -164,9 +164,10 @@
|
|||
</div>
|
||||
{/if}
|
||||
<TourWrap
|
||||
tourStepKey={$builderStore.onboarding
|
||||
? TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT
|
||||
: TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT}
|
||||
stepKeys={[
|
||||
TOUR_STEP_KEYS.BUILDER_USER_MANAGEMENT,
|
||||
TOUR_STEP_KEYS.FEATURE_USER_MANAGEMENT,
|
||||
]}
|
||||
>
|
||||
<div class="app-action-button users">
|
||||
<div class="app-action" id="builder-app-users-button">
|
||||
|
@ -209,7 +210,7 @@
|
|||
<div bind:this={appActionPopoverAnchor}>
|
||||
<div class="app-action">
|
||||
<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">
|
||||
Publish
|
||||
<Icon
|
||||
|
|
|
@ -56,9 +56,10 @@
|
|||
disabled={deletionConfirmationAppName !== appName || deleting}
|
||||
>
|
||||
Are you sure you want to delete
|
||||
<b class="app-name" role="button" tabindex={-1} on:click={copyName}>
|
||||
<span class="app-name" role="button" tabindex={-1} on:click={copyName}>
|
||||
{appName}
|
||||
</b>?
|
||||
</span>?
|
||||
|
||||
<br />
|
||||
Please enter the app name below to confirm.
|
||||
<br /><br />
|
||||
|
@ -68,5 +69,7 @@
|
|||
<style>
|
||||
.app-name {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -45,7 +45,10 @@
|
|||
<Checkbox text="Require confirmation" bind:value={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
|
||||
placeholder="Are you sure you want to delete?"
|
||||
bind:value={parameters.confirmText}
|
||||
|
|
|
@ -72,7 +72,13 @@
|
|||
<Checkbox text="Require confirmation" bind:value={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
|
||||
placeholder="Are you sure you want to duplicate this row?"
|
||||
bind:value={parameters.confirmText}
|
||||
|
|
|
@ -64,7 +64,13 @@
|
|||
|
||||
{#if parameters.confirm}
|
||||
<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?"
|
||||
bind:value={parameters.confirmText}
|
||||
/>
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
<script>
|
||||
import { Label, Select, Body, Multiselect } from "@budibase/bbui"
|
||||
import { findAllMatchingComponents, findComponent } from "helpers/components"
|
||||
import { selectedScreen } from "stores/builder"
|
||||
import { Label, Select, Body } from "@budibase/bbui"
|
||||
import { onMount } from "svelte"
|
||||
import { getDatasourceForProvider, getSchemaForDatasource } from "dataBinding"
|
||||
import ColumnEditor from "../../ColumnEditor/ColumnEditor.svelte"
|
||||
import { findAllMatchingComponents } from "helpers/components"
|
||||
import { selectedScreen } from "stores/builder"
|
||||
|
||||
export let parameters
|
||||
|
||||
|
@ -18,37 +18,65 @@
|
|||
},
|
||||
]
|
||||
|
||||
const DELIMITERS = [
|
||||
{
|
||||
label: ",",
|
||||
value: ",",
|
||||
},
|
||||
{
|
||||
label: ";",
|
||||
value: ";",
|
||||
},
|
||||
{
|
||||
label: ":",
|
||||
value: ":",
|
||||
},
|
||||
{
|
||||
label: "|",
|
||||
value: "|",
|
||||
},
|
||||
{
|
||||
label: "~",
|
||||
value: "~",
|
||||
},
|
||||
{
|
||||
label: "[tab]",
|
||||
value: "\t",
|
||||
},
|
||||
{
|
||||
label: "[space]",
|
||||
value: " ",
|
||||
},
|
||||
]
|
||||
|
||||
$: tables = findAllMatchingComponents($selectedScreen?.props, component =>
|
||||
component._component.endsWith("table")
|
||||
).map(table => ({
|
||||
label: table._instanceName,
|
||||
value: table._id,
|
||||
}))
|
||||
)
|
||||
$: tableBlocks = findAllMatchingComponents(
|
||||
$selectedScreen?.props,
|
||||
component => component._component.endsWith("tableblock")
|
||||
).map(block => ({
|
||||
label: block._instanceName,
|
||||
value: `${block._id}-table`,
|
||||
)
|
||||
$: components = tables.concat(tableBlocks)
|
||||
$: componentOptions = components.map(table => ({
|
||||
label: table._instanceName,
|
||||
value: table._component.includes("tableblock")
|
||||
? `${table._id}-table`
|
||||
: table._id,
|
||||
}))
|
||||
$: componentOptions = tables.concat(tableBlocks)
|
||||
$: columnOptions = getColumnOptions(parameters.tableComponentId)
|
||||
|
||||
const getColumnOptions = tableId => {
|
||||
// Strip block suffix if block component
|
||||
if (tableId?.includes("-")) {
|
||||
tableId = tableId.split("-")[0]
|
||||
}
|
||||
const selectedTable = findComponent($selectedScreen?.props, tableId)
|
||||
const datasource = getDatasourceForProvider($selectedScreen, selectedTable)
|
||||
const { schema } = getSchemaForDatasource($selectedScreen, datasource)
|
||||
return Object.keys(schema || {})
|
||||
}
|
||||
$: selectedTableId = parameters.tableComponentId?.includes("-")
|
||||
? parameters.tableComponentId.split("-")[0]
|
||||
: parameters.tableComponentId
|
||||
$: selectedTable = components.find(
|
||||
component => component._id === selectedTableId
|
||||
)
|
||||
|
||||
onMount(() => {
|
||||
if (!parameters.type) {
|
||||
parameters.type = "csv"
|
||||
}
|
||||
if (!parameters.delimiter) {
|
||||
parameters.delimiter = ","
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
@ -67,13 +95,30 @@
|
|||
options={componentOptions}
|
||||
on:change={() => (parameters.columns = [])}
|
||||
/>
|
||||
<span />
|
||||
<Label small>Export as</Label>
|
||||
<Select bind:value={parameters.type} options={FORMATS} />
|
||||
<Select
|
||||
bind:value={parameters.delimiter}
|
||||
placeholder={null}
|
||||
options={DELIMITERS}
|
||||
disabled={parameters.type !== "csv"}
|
||||
/>
|
||||
<Label small>Export columns</Label>
|
||||
<Multiselect
|
||||
placeholder="All columns"
|
||||
bind:value={parameters.columns}
|
||||
options={columnOptions}
|
||||
<ColumnEditor
|
||||
value={parameters.columns}
|
||||
allowCellEditing={false}
|
||||
componentInstance={selectedTable}
|
||||
on:change={e => {
|
||||
const columns = e.detail
|
||||
parameters.columns = columns
|
||||
parameters.customHeaders = columns.reduce((headerMap, column) => {
|
||||
return {
|
||||
[column.name]: column.displayName,
|
||||
...headerMap,
|
||||
}
|
||||
}, {})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -97,8 +142,8 @@
|
|||
.params {
|
||||
display: grid;
|
||||
column-gap: var(--spacing-xs);
|
||||
row-gap: var(--spacing-s);
|
||||
grid-template-columns: 90px 1fr;
|
||||
row-gap: var(--spacing-m);
|
||||
grid-template-columns: 90px 1fr 90px;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -72,7 +72,10 @@
|
|||
<Checkbox text="Require confirmation" bind:value={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
|
||||
placeholder="Are you sure you want to save this row?"
|
||||
bind:value={parameters.confirmText}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import EditComponentPopover from "../EditComponentPopover.svelte"
|
||||
import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte"
|
||||
import { Icon } from "@budibase/bbui"
|
||||
import { runtimeToReadableBinding } from "dataBinding"
|
||||
import { isJSBinding } from "@budibase/string-templates"
|
||||
|
|
|
@ -29,6 +29,12 @@
|
|||
allowLinks: true,
|
||||
})
|
||||
|
||||
$: {
|
||||
value = (value || []).filter(
|
||||
column => (schema || {})[column.name || column] !== undefined
|
||||
)
|
||||
}
|
||||
|
||||
const getText = value => {
|
||||
if (!value?.length) {
|
||||
return "All columns"
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import { componentStore } from "stores/builder"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
import { createEventDispatcher, getContext } from "svelte"
|
||||
import { customPositionHandler } from "."
|
||||
import ComponentSettingsSection from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
|
||||
|
||||
export let anchor
|
||||
|
@ -54,25 +55,6 @@
|
|||
|
||||
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>
|
||||
|
||||
<Icon
|
||||
|
@ -104,6 +86,7 @@
|
|||
showPopover={drawers.length === 0}
|
||||
clickOutsideOverride={drawers.length > 0}
|
||||
maxHeight={600}
|
||||
offset={18}
|
||||
handlePostionUpdate={customPositionHandler}
|
||||
>
|
||||
<span class="popover-wrap">
|
|
@ -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 }
|
||||
}
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import EditComponentPopover from "../EditComponentPopover.svelte"
|
||||
import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte"
|
||||
import { Toggle, Icon } from "@budibase/bbui"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
|
|
|
@ -52,8 +52,8 @@
|
|||
_id: Helpers.uuid(),
|
||||
_component: componentType,
|
||||
_instanceName: `Step ${currentStep + 1}`,
|
||||
title: stepSettings.title ?? defaults.title,
|
||||
buttons: stepSettings.buttons || defaults.buttons,
|
||||
title: stepSettings.title ?? defaults?.title,
|
||||
buttons: stepSettings.buttons || defaults?.buttons,
|
||||
fields: stepSettings.fields,
|
||||
desc: stepSettings.desc,
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import EditComponentPopover from "../EditComponentPopover.svelte"
|
||||
import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte"
|
||||
import { Toggle, Icon } from "@budibase/bbui"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { cloneDeep } from "lodash/fp"
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import EditComponentPopover from "../EditComponentPopover.svelte"
|
||||
import EditComponentPopover from "../EditComponentPopover/EditComponentPopover.svelte"
|
||||
import { Icon } from "@budibase/bbui"
|
||||
import { setContext } from "svelte"
|
||||
import { writable } from "svelte/store"
|
||||
|
|
|
@ -116,7 +116,6 @@
|
|||
$: pagerText = `Page ${currentPage} of ${totalPages}`
|
||||
</script>
|
||||
|
||||
a11y-click-events-have-key-events
|
||||
<div bind:this={buttonAnchor}>
|
||||
<ActionButton on:click={dropdown.show}>
|
||||
{displayValue}
|
||||
|
@ -140,10 +139,22 @@ a11y-click-events-have-key-events
|
|||
{/each}
|
||||
</div>
|
||||
<div class="search-input">
|
||||
<div class="input-wrapper">
|
||||
<Input bind:value={searchTerm} thin placeholder="Search Icon" />
|
||||
<div class="input-wrapper" style={`width: ${value ? "425" : "510"}px`}>
|
||||
<Input
|
||||
bind:value={searchTerm}
|
||||
on:keyup={event => {
|
||||
if (event.key === "Enter") {
|
||||
searchForIcon()
|
||||
}
|
||||
}}
|
||||
thin
|
||||
placeholder="Search Icon"
|
||||
/>
|
||||
</div>
|
||||
<Button secondary on:click={searchForIcon}>Search</Button>
|
||||
{#if value}
|
||||
<Button primary on:click={() => (value = null)}>Clear</Button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="page-area">
|
||||
<div class="pager">
|
||||
|
@ -240,6 +251,7 @@ a11y-click-events-have-key-events
|
|||
flex-flow: row nowrap;
|
||||
width: 100%;
|
||||
padding-right: 15px;
|
||||
gap: 10px;
|
||||
}
|
||||
.input-wrapper {
|
||||
width: 510px;
|
||||
|
|
|
@ -20,17 +20,23 @@
|
|||
export let bindings = []
|
||||
export let componentBindings = []
|
||||
export let nested = false
|
||||
export let highlighted = false
|
||||
export let propertyFocus = false
|
||||
export let info = null
|
||||
export let disableBindings = false
|
||||
export let wide
|
||||
|
||||
$: nullishValue = value == null || value === ""
|
||||
let highlightType
|
||||
|
||||
$: highlightedProp = $builderStore.highlightedSetting
|
||||
$: allBindings = getAllBindings(bindings, componentBindings, nested)
|
||||
$: safeValue = getSafeValue(value, defaultValue, allBindings)
|
||||
$: replaceBindings = val => readableToRuntimeBinding(allBindings, val)
|
||||
|
||||
$: if (!Array.isArray(value)) {
|
||||
highlightType =
|
||||
highlightedProp?.key === key ? `highlighted-${highlightedProp?.type}` : ""
|
||||
}
|
||||
|
||||
const getAllBindings = (bindings, componentBindings, nested) => {
|
||||
if (!nested) {
|
||||
return bindings
|
||||
|
@ -71,16 +77,17 @@
|
|||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (highlighted) {
|
||||
if (highlightedProp) {
|
||||
builderStore.highlightSetting(null)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="property-control"
|
||||
id={`${key}-prop-control-wrap`}
|
||||
class={`property-control ${highlightType}`}
|
||||
class:wide={!label || labelHidden || wide === true}
|
||||
class:highlighted={highlighted && nullishValue}
|
||||
class:highlighted={highlightType}
|
||||
class:property-focus={propertyFocus}
|
||||
>
|
||||
{#if label && !labelHidden}
|
||||
|
@ -115,6 +122,16 @@
|
|||
</div>
|
||||
|
||||
<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 {
|
||||
position: relative;
|
||||
display: grid;
|
||||
|
@ -132,6 +149,10 @@
|
|||
.property-control.highlighted {
|
||||
background: var(--spectrum-global-color-gray-300);
|
||||
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) {
|
||||
|
|
|
@ -31,17 +31,11 @@
|
|||
: null}
|
||||
>
|
||||
<Body>
|
||||
You are currently on our <span class="free-plan">Free plan</span>. Upgrade
|
||||
to our Pro plan to get unlimited apps and additional features.
|
||||
You have exceeded the app limit for your current plan. Upgrade to get
|
||||
unlimited apps and additional features!
|
||||
</Body>
|
||||
{#if !$auth.user.accountPortalAccess}
|
||||
<Body>Please contact the account holder to upgrade.</Body>
|
||||
{/if}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
.free-plan {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
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 { builderStore } from "stores/builder"
|
||||
|
||||
|
@ -20,6 +20,13 @@
|
|||
|
||||
const updateTourStep = (targetStepKey, tourKey) => {
|
||||
if (!tourKey) {
|
||||
tourSteps = null
|
||||
tourStepIdx = null
|
||||
lastStep = null
|
||||
tourStep = null
|
||||
popoverAnchor = null
|
||||
popover = null
|
||||
skipping = false
|
||||
return
|
||||
}
|
||||
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>
|
||||
|
||||
{#if tourKey}
|
||||
|
@ -98,7 +95,9 @@
|
|||
anchor={popoverAnchor}
|
||||
maxWidth={300}
|
||||
dismissible={false}
|
||||
offset={15}
|
||||
offset={12}
|
||||
handlePostionUpdate={tourStep?.positionHandler}
|
||||
customZindex={3}
|
||||
>
|
||||
<div class="tour-content">
|
||||
<Layout noPadding gap="M">
|
||||
|
@ -119,7 +118,7 @@
|
|||
</Body>
|
||||
<div class="tour-footer">
|
||||
<div class="tour-navigation">
|
||||
{#if typeof tourOnSkip === "function"}
|
||||
{#if typeof tourOnSkip === "function" && !lastStep}
|
||||
<Link
|
||||
secondary
|
||||
quiet
|
||||
|
|
|
@ -1,39 +1,63 @@
|
|||
<script>
|
||||
import { tourHandler } from "./tourHandler"
|
||||
import { TOURS } from "./tours"
|
||||
import { TOURSBYSTEP, TOURS, getCurrentStepIdx } from "./tours"
|
||||
import { onMount, onDestroy } from "svelte"
|
||||
import { builderStore } from "stores/builder"
|
||||
|
||||
export let tourStepKey
|
||||
export let stepKeys = []
|
||||
|
||||
let currentTourStep
|
||||
let ready = false
|
||||
let registered = false
|
||||
let handler
|
||||
let registered = {}
|
||||
|
||||
const registerTourNode = (tourKey, stepKey) => {
|
||||
if (ready && !registered && tourKey) {
|
||||
currentTourStep = TOURS[tourKey].steps.find(step => step.id === stepKey)
|
||||
if (!currentTourStep) {
|
||||
return
|
||||
const step = TOURSBYSTEP[stepKey]
|
||||
if (ready && step && !registered[stepKey] && step?.tour === tourKey) {
|
||||
const elem = document.querySelector(step.query)
|
||||
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
|
||||
$: registerTourNode(tourKeyWatch, tourStepKey, ready)
|
||||
$: tourStepKeyWatch = $builderStore.tourStepKey
|
||||
$: if (tourKeyWatch || stepKeys || ready) {
|
||||
stepKeys.forEach(tourStepKey => {
|
||||
registerTourNode(tourKeyWatch, tourStepKey)
|
||||
})
|
||||
}
|
||||
$: scrollToStep(tourKeyWatch, tourStepKeyWatch)
|
||||
|
||||
onMount(() => {
|
||||
ready = true
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
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>
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -1,3 +1,5 @@
|
|||
export { default as OnboardingData } from "./OnboardingData.svelte"
|
||||
export { default as OnboardingDesign } from "./OnboardingDesign.svelte"
|
||||
export { default as OnboardingPublish } from "./OnboardingPublish.svelte"
|
||||
export { default as NewViewUpdateFormRowId } from "./NewViewUpdateFormRowId.svelte"
|
||||
export { default as NewFormSteps } from "./NewFormSteps.svelte"
|
||||
|
|
|
@ -2,8 +2,15 @@ import { get } from "svelte/store"
|
|||
import { builderStore } from "stores/builder"
|
||||
import { auth } from "stores/portal"
|
||||
import analytics from "analytics"
|
||||
import { OnboardingData, OnboardingDesign, OnboardingPublish } from "./steps"
|
||||
import {
|
||||
OnboardingData,
|
||||
OnboardingDesign,
|
||||
OnboardingPublish,
|
||||
NewViewUpdateFormRowId,
|
||||
NewFormSteps,
|
||||
} from "./steps"
|
||||
import { API } from "api"
|
||||
import { customPositionHandler } from "components/design/settings/controls/EditComponentPopover"
|
||||
|
||||
const ONBOARDING_EVENT_PREFIX = "onboarding"
|
||||
|
||||
|
@ -14,11 +21,26 @@ export const TOUR_STEP_KEYS = {
|
|||
BUILDER_USER_MANAGEMENT: "builder-user-management",
|
||||
BUILDER_AUTOMATION_SECTION: "builder-automation-section",
|
||||
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 = {
|
||||
TOUR_BUILDER_ONBOARDING: "builder-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 } = {}) => {
|
||||
|
@ -37,13 +59,8 @@ const endUserOnboarding = async ({ skipped = false } = {}) => {
|
|||
// Update the cached user
|
||||
await auth.getSelf()
|
||||
|
||||
builderStore.update(state => ({
|
||||
...state,
|
||||
tourNodes: null,
|
||||
tourKey: null,
|
||||
tourStepKey: null,
|
||||
onboarding: false,
|
||||
}))
|
||||
builderStore.endBuilderOnboarding()
|
||||
builderStore.setTour()
|
||||
} catch (e) {
|
||||
console.error("Onboarding failed", e)
|
||||
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}`, {
|
||||
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 TOURSBYSTEP = Object.keys(TOURS).reduce((acc, tour) => {
|
||||
TOURS[tour].steps.forEach(element => {
|
||||
acc[element.id] = element
|
||||
acc[element.id]["tour"] = tour
|
||||
})
|
||||
return acc
|
||||
}, {})
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
<script>
|
||||
import { ActionMenu, MenuItem, Icon, Modal } from "@budibase/bbui"
|
||||
import DeleteModal from "components/deploy/DeleteModal.svelte"
|
||||
import AppLimitModal from "components/portal/licensing/AppLimitModal.svelte"
|
||||
import ExportAppModal from "./ExportAppModal.svelte"
|
||||
import DuplicateAppModal from "./DuplicateAppModal.svelte"
|
||||
import { onMount } from "svelte"
|
||||
import { licensing } from "stores/portal"
|
||||
|
||||
export let app
|
||||
export let align = "right"
|
||||
|
@ -65,21 +67,32 @@
|
|||
onMount(() => {
|
||||
loaded = true
|
||||
})
|
||||
let appLimitModal
|
||||
</script>
|
||||
|
||||
<DeleteModal
|
||||
bind:this={deleteModal}
|
||||
appId={app.devId}
|
||||
appName={app.name}
|
||||
onDeleteSuccess={() => {}}
|
||||
onDeleteSuccess={async () => {
|
||||
await licensing.init()
|
||||
}}
|
||||
/>
|
||||
|
||||
<AppLimitModal bind:this={appLimitModal} />
|
||||
|
||||
<Modal bind:this={exportModal} padding={false}>
|
||||
<ExportAppModal {app} published={exportPublishedVersion} />
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={duplicateModal} padding={false}>
|
||||
<DuplicateAppModal appId={app.devId} appName={app.name} />
|
||||
<DuplicateAppModal
|
||||
appId={app.devId}
|
||||
appName={app.name}
|
||||
onDuplicateSuccess={async () => {
|
||||
await licensing.init()
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<ActionMenu {align} on:open on:close>
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
export let appId
|
||||
export let appName
|
||||
export let onDuplicateSuccess = () => {}
|
||||
|
||||
const validation = createValidationStore()
|
||||
const values = writable({ name: appName + " copy", url: null })
|
||||
|
@ -68,6 +69,7 @@
|
|||
try {
|
||||
await API.duplicateApp(data, appId)
|
||||
appsStore.load()
|
||||
onDuplicateSuccess()
|
||||
notifications.success("App duplicated successfully")
|
||||
} catch (err) {
|
||||
notifications.error("Error duplicating app")
|
||||
|
|
|
@ -1146,7 +1146,7 @@ export const getAllStateVariables = () => {
|
|||
"@budibase/standard-components/multistepformblockstep"
|
||||
)
|
||||
|
||||
steps.forEach(step => {
|
||||
steps?.forEach(step => {
|
||||
parseComponentSettings(stepDefinition, step)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -22,6 +22,7 @@ body {
|
|||
--grey-7: var(--spectrum-global-color-gray-700);
|
||||
--grey-8: var(--spectrum-global-color-gray-800);
|
||||
--grey-9: var(--spectrum-global-color-gray-900);
|
||||
--spectrum-global-color-yellow-1000: #d8b500;
|
||||
|
||||
color: var(--ink);
|
||||
background-color: var(--background-alt);
|
||||
|
|
|
@ -17,6 +17,10 @@ export function breakQueryString(qs) {
|
|||
return paramObj
|
||||
}
|
||||
|
||||
function isEncoded(str) {
|
||||
return typeof str == "string" && decodeURIComponent(str) !== str
|
||||
}
|
||||
|
||||
export function buildQueryString(obj) {
|
||||
let str = ""
|
||||
if (obj) {
|
||||
|
@ -35,7 +39,7 @@ export function buildQueryString(obj) {
|
|||
value = value.replace(binding, marker)
|
||||
bindingMarkers[marker] = binding
|
||||
})
|
||||
let encoded = encodeURIComponent(value || "")
|
||||
let encoded = isEncoded(value) ? value : encodeURIComponent(value || "")
|
||||
Object.entries(bindingMarkers).forEach(([marker, binding]) => {
|
||||
encoded = encoded.replace(marker, binding)
|
||||
})
|
||||
|
|
|
@ -25,3 +25,7 @@ export function getFormattedPlanName(userPlanType) {
|
|||
}
|
||||
return `${planName} Plan`
|
||||
}
|
||||
|
||||
export function isPremiumOrAbove(userPlanType) {
|
||||
return ![PlanType.PRO, PlanType.TEAM, PlanType.FREE].includes(userPlanType)
|
||||
}
|
||||
|
|
|
@ -39,4 +39,11 @@ describe("check query string utils", () => {
|
|||
expect(broken.key1).toBe(obj2.key1)
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
|
|
@ -69,11 +69,12 @@
|
|||
// brought back to the same screen.
|
||||
const topItemNavigate = path => () => {
|
||||
const activeTopNav = $layout.children.find(c => $isActive(c.path))
|
||||
if (!activeTopNav) return
|
||||
builderStore.setPreviousTopNavPath(
|
||||
activeTopNav.path,
|
||||
window.location.pathname
|
||||
)
|
||||
if (activeTopNav) {
|
||||
builderStore.setPreviousTopNavPath(
|
||||
activeTopNav.path,
|
||||
window.location.pathname
|
||||
)
|
||||
}
|
||||
$goto($builderStore.previousTopNavPath[path] || path)
|
||||
}
|
||||
|
||||
|
@ -95,7 +96,7 @@
|
|||
const release_date = new Date("2023-03-01T00:00:00.000Z")
|
||||
const onboarded = new Date($auth.user?.onboardedAt)
|
||||
if (onboarded < release_date) {
|
||||
builderStore.startTour(TOUR_KEYS.FEATURE_ONBOARDING)
|
||||
builderStore.setTour(TOUR_KEYS.FEATURE_ONBOARDING)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -143,7 +144,7 @@
|
|||
</span>
|
||||
<Tabs {selected} size="M">
|
||||
{#each $layout.children as { path, title }}
|
||||
<TourWrap tourStepKey={`builder-${title}-section`}>
|
||||
<TourWrap stepKeys={[`builder-${title}-section`]}>
|
||||
<Tab
|
||||
quiet
|
||||
selected={$isActive(path)}
|
||||
|
|
|
@ -16,6 +16,14 @@
|
|||
} from "dataBinding"
|
||||
import { ActionButton, notifications } from "@budibase/bbui"
|
||||
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 => {
|
||||
try {
|
||||
|
@ -46,7 +54,6 @@
|
|||
|
||||
$: id = $selectedComponent?._id
|
||||
$: id, (section = tabs[0])
|
||||
|
||||
$: componentName = getComponentName(componentInstance)
|
||||
</script>
|
||||
|
||||
|
@ -92,13 +99,21 @@
|
|||
</div>
|
||||
</span>
|
||||
{#if section == "settings"}
|
||||
<ComponentSettingsSection
|
||||
{componentInstance}
|
||||
{componentDefinition}
|
||||
{bindings}
|
||||
{componentBindings}
|
||||
{isScreen}
|
||||
/>
|
||||
<TourWrap
|
||||
stepKeys={[
|
||||
BUILDER_FORM_CREATE_STEPS,
|
||||
BUILDER_FORM_VIEW_UPDATE_STEPS,
|
||||
BUILDER_FORM_ROW_ID,
|
||||
]}
|
||||
>
|
||||
<ComponentSettingsSection
|
||||
{componentInstance}
|
||||
{componentDefinition}
|
||||
{bindings}
|
||||
{componentBindings}
|
||||
{isScreen}
|
||||
/>
|
||||
</TourWrap>
|
||||
{/if}
|
||||
{#if section == "styles"}
|
||||
<DesignSection
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { helpers } from "@budibase/shared-core"
|
||||
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 ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte"
|
||||
import EjectBlockButton from "components/design/settings/controls/EjectBlockButton.svelte"
|
||||
|
@ -177,9 +177,7 @@
|
|||
defaultValue={setting.defaultValue}
|
||||
nested={setting.nested}
|
||||
onChange={val => updateSetting(setting, val)}
|
||||
highlighted={$componentStore.highlightedSettingKey ===
|
||||
setting.key}
|
||||
propertyFocus={$componentStore.propertyFocus === setting.key}
|
||||
propertyFocus={$builderStore.propertyFocus === setting.key}
|
||||
info={setting.info}
|
||||
disableBindings={setting.disableBindings}
|
||||
props={{
|
||||
|
|
|
@ -174,7 +174,7 @@
|
|||
} else if (type === "request-add-component") {
|
||||
toggleAddComponent()
|
||||
} else if (type === "highlight-setting") {
|
||||
builderStore.highlightSetting(data.setting)
|
||||
builderStore.highlightSetting(data.setting, "error")
|
||||
|
||||
// Also scroll setting into view
|
||||
const selector = `#${data.setting}-prop-control`
|
||||
|
|
|
@ -3,13 +3,23 @@
|
|||
import DatasourceModal from "./DatasourceModal.svelte"
|
||||
import ScreenRoleModal from "./ScreenRoleModal.svelte"
|
||||
import sanitizeUrl from "helpers/sanitizeUrl"
|
||||
import FormTypeModal from "./FormTypeModal.svelte"
|
||||
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 getTemplates from "templates"
|
||||
import { Roles } from "constants/backend"
|
||||
import { capitalise } from "helpers"
|
||||
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 pendingScreen
|
||||
|
@ -18,12 +28,18 @@
|
|||
let screenDetailsModal
|
||||
let datasourceModal
|
||||
let screenAccessRoleModal
|
||||
let formTypeModal
|
||||
|
||||
// Cache variables for workflow
|
||||
let screenAccessRole = Roles.BASIC
|
||||
let selectedTemplates = null
|
||||
|
||||
let templates = null
|
||||
let screens = null
|
||||
|
||||
let selectedDatasources = null
|
||||
let blankScreenUrl = null
|
||||
let screenMode = null
|
||||
let formType = null
|
||||
|
||||
// Creates an array of screens, checking and sanitising their URLs
|
||||
const createScreens = async ({ screens, screenAccessRole }) => {
|
||||
|
@ -32,7 +48,7 @@
|
|||
}
|
||||
|
||||
try {
|
||||
let screenId
|
||||
let createdScreens = []
|
||||
|
||||
for (let screen of screens) {
|
||||
// Check we aren't clashing with an existing URL
|
||||
|
@ -56,7 +72,7 @@
|
|||
|
||||
// Create the 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
|
||||
// for autoscreens, so it's always safe to do this.
|
||||
|
@ -66,9 +82,7 @@
|
|||
)
|
||||
}
|
||||
|
||||
// Go to new screen
|
||||
$goto(`./${screenId}`)
|
||||
screenStore.select(screenId)
|
||||
return createdScreens
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
notifications.error("Error creating screens")
|
||||
|
@ -104,13 +118,16 @@
|
|||
// Handler for NewScreenModal
|
||||
export const show = newMode => {
|
||||
mode = newMode
|
||||
selectedTemplates = null
|
||||
templates = null
|
||||
screens = null
|
||||
selectedDatasources = null
|
||||
blankScreenUrl = null
|
||||
screenMode = mode
|
||||
pendingScreen = null
|
||||
screenAccessRole = Roles.BASIC
|
||||
formType = null
|
||||
|
||||
if (mode === "table" || mode === "grid") {
|
||||
if (mode === "table" || mode === "grid" || mode === "form") {
|
||||
datasourceModal.show()
|
||||
} else if (mode === "blank") {
|
||||
let templates = getTemplates($tables.list)
|
||||
|
@ -125,19 +142,26 @@
|
|||
}
|
||||
|
||||
// Handler for DatasourceModal confirmation, move to screen access select
|
||||
const confirmScreenDatasources = async ({ templates }) => {
|
||||
selectedTemplates = templates
|
||||
screenAccessRoleModal.show()
|
||||
const confirmScreenDatasources = async ({ datasources }) => {
|
||||
selectedDatasources = datasources
|
||||
if (screenMode === "form") {
|
||||
formTypeModal.show()
|
||||
} else {
|
||||
screenAccessRoleModal.show()
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for Datasource Screen Creation
|
||||
const completeDatasourceScreenCreation = async () => {
|
||||
const screens = selectedTemplates.map(template => {
|
||||
templates = rowListScreen(selectedDatasources, mode)
|
||||
|
||||
const screens = templates.map(template => {
|
||||
let screenTemplate = template.create()
|
||||
screenTemplate.autoTableId = template.resourceId
|
||||
return screenTemplate
|
||||
})
|
||||
await createScreens({ screens, screenAccessRole })
|
||||
const createdScreens = await createScreens({ screens, screenAccessRole })
|
||||
loadNewScreen(createdScreens)
|
||||
}
|
||||
|
||||
const confirmScreenBlank = async ({ screenUrl }) => {
|
||||
|
@ -154,7 +178,54 @@
|
|||
return
|
||||
}
|
||||
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.
|
||||
|
@ -164,6 +235,8 @@
|
|||
screenUrl: blankScreenUrl,
|
||||
screenAccessRole,
|
||||
})
|
||||
} else if (screenMode === "form") {
|
||||
confirmFormScreenCreation()
|
||||
} else {
|
||||
completeDatasourceScreenCreation()
|
||||
}
|
||||
|
@ -179,19 +252,18 @@
|
|||
</script>
|
||||
|
||||
<Modal bind:this={datasourceModal} autoFocus={false}>
|
||||
<DatasourceModal
|
||||
{mode}
|
||||
onConfirm={confirmScreenDatasources}
|
||||
initialScreens={!selectedTemplates ? [] : [...selectedTemplates]}
|
||||
/>
|
||||
<DatasourceModal {mode} onConfirm={confirmScreenDatasources} />
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={screenAccessRoleModal}>
|
||||
<ScreenRoleModal
|
||||
onConfirm={confirmScreenCreation}
|
||||
onCancel={roleSelectBack}
|
||||
onConfirm={() => {
|
||||
confirmScreenCreation()
|
||||
}}
|
||||
bind:screenAccessRole
|
||||
onCancel={roleSelectBack}
|
||||
screenUrl={blankScreenUrl}
|
||||
confirmText={screenMode === "form" ? "Confirm" : "Done"}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
|
@ -201,3 +273,17 @@
|
|||
initialUrl={blankScreenUrl}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal bind:this={formTypeModal}>
|
||||
<FormTypeModal
|
||||
onConfirm={onConfirmFormType}
|
||||
onCancel={() => {
|
||||
formTypeModal.hide()
|
||||
datasourceModal.show()
|
||||
}}
|
||||
on:select={e => {
|
||||
formType = e.detail
|
||||
}}
|
||||
type={formType}
|
||||
/>
|
||||
</Modal>
|
||||
|
|
|
@ -4,37 +4,33 @@
|
|||
import ICONS from "components/backend/DatasourceNavigator/icons"
|
||||
import { IntegrationNames } from "constants"
|
||||
import { onMount } from "svelte"
|
||||
import rowListScreen from "templates/rowListScreen"
|
||||
import DatasourceTemplateRow from "./DatasourceTemplateRow.svelte"
|
||||
|
||||
export let mode
|
||||
export let onCancel
|
||||
export let onConfirm
|
||||
export let initialScreens = []
|
||||
|
||||
let selectedScreens = [...initialScreens]
|
||||
let selectedSources = []
|
||||
|
||||
$: filteredSources = $datasources.list?.filter(datasource => {
|
||||
return datasource.source !== IntegrationNames.REST && datasource["entities"]
|
||||
})
|
||||
|
||||
const toggleSelection = datasource => {
|
||||
const { resourceId } = datasource
|
||||
if (selectedScreens.find(s => s.resourceId === resourceId)) {
|
||||
selectedScreens = selectedScreens.filter(
|
||||
screen => screen.resourceId !== resourceId
|
||||
const exists = selectedSources.find(
|
||||
d => d.resourceId === datasource.resourceId
|
||||
)
|
||||
if (exists) {
|
||||
selectedSources = selectedSources.filter(
|
||||
d => d.resourceId === datasource.resourceId
|
||||
)
|
||||
} else {
|
||||
selectedScreens = [
|
||||
...selectedScreens,
|
||||
rowListScreen([datasource], mode)[0],
|
||||
]
|
||||
selectedSources = [...selectedSources, datasource]
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDatasourceSelection = async () => {
|
||||
await onConfirm({
|
||||
templates: selectedScreens,
|
||||
datasources: selectedSources,
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -54,7 +50,7 @@
|
|||
cancelText="Back"
|
||||
onConfirm={confirmDatasourceSelection}
|
||||
{onCancel}
|
||||
disabled={!selectedScreens.length}
|
||||
disabled={!selectedSources.length}
|
||||
size="L"
|
||||
>
|
||||
<Body size="S">
|
||||
|
@ -85,8 +81,8 @@
|
|||
resourceId: table._id,
|
||||
type: "table",
|
||||
}}
|
||||
{@const selected = selectedScreens.find(
|
||||
screen => screen.resourceId === tableDS.resourceId
|
||||
{@const selected = selectedSources.find(
|
||||
datasource => datasource.resourceId === tableDS.resourceId
|
||||
)}
|
||||
<DatasourceTemplateRow
|
||||
on:click={() => toggleSelection(tableDS)}
|
||||
|
@ -103,7 +99,7 @@
|
|||
tableId: view.tableId,
|
||||
type: "viewV2",
|
||||
}}
|
||||
{@const selected = selectedScreens.find(
|
||||
{@const selected = selectedSources.find(
|
||||
x => x.resourceId === viewDS.resourceId
|
||||
)}
|
||||
<DatasourceTemplateRow
|
||||
|
|
|
@ -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>
|
|
@ -9,6 +9,7 @@
|
|||
export let onCancel
|
||||
export let screenUrl
|
||||
export let screenAccessRole
|
||||
export let confirmText = "Done"
|
||||
|
||||
let error
|
||||
|
||||
|
@ -40,7 +41,7 @@
|
|||
|
||||
<ModalContent
|
||||
title="Access"
|
||||
confirmText="Done"
|
||||
{confirmText}
|
||||
cancelText="Back"
|
||||
{onConfirm}
|
||||
{onCancel}
|
||||
|
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
@ -1,9 +1,10 @@
|
|||
<script>
|
||||
import { Body } from "@budibase/bbui"
|
||||
import CreationPage from "components/common/CreationPage.svelte"
|
||||
import blankImage from "./blank.png"
|
||||
import tableImage from "./table.png"
|
||||
import gridImage from "./grid.png"
|
||||
import blankImage from "./images/blank.png"
|
||||
import tableImage from "./images/table.png"
|
||||
import gridImage from "./images/grid.png"
|
||||
import formImage from "./images/form.png"
|
||||
import CreateScreenModal from "./CreateScreenModal.svelte"
|
||||
import { screenStore } from "stores/builder"
|
||||
|
||||
|
@ -56,6 +57,16 @@
|
|||
<Body size="XS">View and manipulate rows on a grid</Body>
|
||||
</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>
|
||||
</CreationPage>
|
||||
</div>
|
||||
|
|
|
@ -1,10 +1,15 @@
|
|||
<script>
|
||||
import { sideBarCollapsed, enriched as enrichedApps } from "stores/portal"
|
||||
import {
|
||||
sideBarCollapsed,
|
||||
enriched as enrichedApps,
|
||||
auth,
|
||||
} from "stores/portal"
|
||||
import { params, goto } from "@roxi/routify"
|
||||
import NavItem from "components/common/NavItem.svelte"
|
||||
import NavHeader from "components/common/NavHeader.svelte"
|
||||
import AppRowContext from "components/start/AppRowContext.svelte"
|
||||
import FavouriteAppButton from "../FavouriteAppButton.svelte"
|
||||
import { sdk } from "@budibase/shared-core"
|
||||
|
||||
let searchString
|
||||
let opened
|
||||
|
@ -50,16 +55,18 @@
|
|||
showActions
|
||||
>
|
||||
<div class="app-entry-actions">
|
||||
<AppRowContext
|
||||
{app}
|
||||
align="left"
|
||||
on:open={() => {
|
||||
opened = app.appId
|
||||
}}
|
||||
on:close={() => {
|
||||
opened = null
|
||||
}}
|
||||
/>
|
||||
{#if sdk.users.isBuilder($auth.user, app?.devId)}
|
||||
<AppRowContext
|
||||
{app}
|
||||
align="left"
|
||||
on:open={() => {
|
||||
opened = app.appId
|
||||
}}
|
||||
on:close={() => {
|
||||
opened = null
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="favourite-icon">
|
||||
<FavouriteAppButton {app} size="XS" />
|
||||
|
|
|
@ -29,6 +29,7 @@ export const INITIAL_APP_META_STATE = {
|
|||
initialised: false,
|
||||
hasAppPackage: false,
|
||||
usedPlugins: null,
|
||||
automations: {},
|
||||
routes: {},
|
||||
}
|
||||
|
||||
|
@ -63,6 +64,7 @@ export class AppMetaStore extends BudiStore {
|
|||
...app.features,
|
||||
},
|
||||
initialised: true,
|
||||
automations: app.automations || {},
|
||||
hasAppPackage: true,
|
||||
}))
|
||||
}
|
||||
|
|
|
@ -2,12 +2,11 @@ import { get } from "svelte/store"
|
|||
import { createBuilderWebsocket } from "./websocket.js"
|
||||
import { BuilderSocketEvent } from "@budibase/shared-core"
|
||||
import BudiStore from "../BudiStore.js"
|
||||
import { previewStore } from "./preview.js"
|
||||
import { TOUR_KEYS } from "components/portal/onboarding/tours.js"
|
||||
|
||||
export const INITIAL_BUILDER_STATE = {
|
||||
previousTopNavPath: {},
|
||||
highlightedSettingKey: null,
|
||||
highlightedSetting: null,
|
||||
propertyFocus: null,
|
||||
builderSidePanel: false,
|
||||
onboarding: false,
|
||||
|
@ -26,7 +25,6 @@ export class BuilderStore extends BudiStore {
|
|||
this.reset = this.reset.bind(this)
|
||||
this.highlightSetting = this.highlightSetting.bind(this)
|
||||
this.propertyFocus = this.propertyFocus.bind(this)
|
||||
this.hover = this.hover.bind(this)
|
||||
this.hideBuilderSidePanel = this.hideBuilderSidePanel.bind(this)
|
||||
this.showBuilderSidePanel = this.showBuilderSidePanel.bind(this)
|
||||
this.setPreviousTopNavPath = this.setPreviousTopNavPath.bind(this)
|
||||
|
@ -58,10 +56,10 @@ export class BuilderStore extends BudiStore {
|
|||
this.websocket = null
|
||||
}
|
||||
|
||||
highlightSetting(key) {
|
||||
highlightSetting(key, type) {
|
||||
this.update(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 => ({
|
||||
...state,
|
||||
tourKey: tourKey,
|
||||
onboarding: false,
|
||||
}))
|
||||
}
|
||||
|
||||
hover(componentId, notifyClient = true) {
|
||||
const store = get(this.store)
|
||||
if (componentId === store.hoveredComponentId) {
|
||||
return
|
||||
}
|
||||
this.update(state => {
|
||||
state.hoveredComponentId = componentId
|
||||
return state
|
||||
})
|
||||
if (notifyClient) {
|
||||
previewStore.sendEvent("hover-component", componentId)
|
||||
}
|
||||
setTour(tourKey) {
|
||||
this.update(state => ({
|
||||
...state,
|
||||
tourStepKey: null,
|
||||
tourNodes: null,
|
||||
tourKey: tourKey,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -147,6 +147,12 @@ export function createTablesStore() {
|
|||
if (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,
|
||||
[field.name]: cloneDeep(field),
|
||||
|
|
|
@ -88,14 +88,42 @@ describe("Builder store", () => {
|
|||
)
|
||||
})
|
||||
|
||||
it("Sync a highlighted setting key to state", ctx => {
|
||||
expect(ctx.test.store.highlightedSettingKey).toBeNull()
|
||||
it("Sync a highlighted setting key to state. Default to info type", ctx => {
|
||||
expect(ctx.test.store.highlightedSetting).toBeNull()
|
||||
|
||||
ctx.test.builderStore.highlightSetting("testing")
|
||||
|
||||
expect(ctx.test.store).toStrictEqual({
|
||||
...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,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
@ -146,8 +146,8 @@ export const enriched = derived([appsStore, auth], ([$store, $auth]) => {
|
|||
? $store.apps.map(app => ({
|
||||
...app,
|
||||
deployed: app.status === AppStatus.DEPLOYED,
|
||||
lockedYou: app.lockedBy && app.lockedBy.email === user?.email,
|
||||
lockedOther: app.lockedBy && app.lockedBy.email !== user?.email,
|
||||
lockedYou: app.lockedBy && app.lockedBy.email === $auth.user?.email,
|
||||
lockedOther: app.lockedBy && app.lockedBy.email !== $auth.user?.email,
|
||||
favourite: $auth?.user.appFavourites?.includes(app.appId),
|
||||
}))
|
||||
: []
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -1,7 +1,11 @@
|
|||
import rowListScreen from "./rowListScreen"
|
||||
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
|
||||
const createTemplateOverride = template => () => {
|
||||
|
@ -19,6 +23,7 @@ export default datasources => {
|
|||
})
|
||||
const fromScratch = enrichTemplate(createFromScratchScreen)
|
||||
const tableTemplates = allTemplates(datasources).map(enrichTemplate)
|
||||
|
||||
return [
|
||||
fromScratch,
|
||||
...tableTemplates.sort((templateA, templateB) => {
|
||||
|
|
|
@ -525,6 +525,38 @@
|
|||
"barTitle": "Disable button",
|
||||
"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",
|
||||
"label": "On click",
|
||||
|
@ -1105,6 +1137,12 @@
|
|||
"key": "color",
|
||||
"showInBar": true
|
||||
},
|
||||
{
|
||||
"type": "color",
|
||||
"label": "Text Color",
|
||||
"key": "textColor",
|
||||
"showInBar": true
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"label": "Allow delete",
|
||||
|
|
|
@ -13,9 +13,10 @@
|
|||
export let size = "M"
|
||||
export let type = "cta"
|
||||
export let quiet = false
|
||||
export let icon = null
|
||||
export let gap = "M"
|
||||
|
||||
// For internal use only for now - not defined in the manifest
|
||||
export let icon = null
|
||||
export let active = false
|
||||
|
||||
const handleOnClick = async () => {
|
||||
|
@ -47,7 +48,7 @@
|
|||
|
||||
{#key $component.editing}
|
||||
<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}
|
||||
disabled={disabled || handlingOnClick}
|
||||
use:styleable={$component.styles}
|
||||
|
@ -58,15 +59,7 @@
|
|||
class:active
|
||||
>
|
||||
{#if icon}
|
||||
<svg
|
||||
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>
|
||||
<i class="{icon} {size}" />
|
||||
{/if}
|
||||
{componentText}
|
||||
</button>
|
||||
|
@ -92,4 +85,13 @@
|
|||
.active {
|
||||
color: var(--spectrum-global-color-blue-600);
|
||||
}
|
||||
.gap-S {
|
||||
gap: 8px;
|
||||
}
|
||||
.gap-M {
|
||||
gap: 16px;
|
||||
}
|
||||
.gap-L {
|
||||
gap: 32px;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
wrap: true,
|
||||
}}
|
||||
>
|
||||
{#each buttons as { text, type, quiet, disabled, onClick, size }}
|
||||
{#each buttons as { text, type, quiet, disabled, onClick, size, icon, gap }}
|
||||
<BlockComponent
|
||||
type="button"
|
||||
props={{
|
||||
|
@ -29,6 +29,8 @@
|
|||
type,
|
||||
quiet,
|
||||
disabled,
|
||||
icon,
|
||||
gap,
|
||||
size: size || "M",
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
export let onClick
|
||||
export let text = ""
|
||||
export let color
|
||||
export let textColor
|
||||
export let closable = false
|
||||
export let size = "M"
|
||||
|
||||
|
@ -14,7 +15,7 @@
|
|||
|
||||
// Add color styles to main styles object, otherwise the styleable helper
|
||||
// overrides the color when it's passed as inline style.
|
||||
$: styles = enrichStyles($component.styles, color)
|
||||
$: styles = enrichStyles($component.styles, color, textColor)
|
||||
$: componentText = getComponentText(text, $builderStore, $component)
|
||||
|
||||
const getComponentText = (text, builderState, componentState) => {
|
||||
|
@ -24,7 +25,7 @@
|
|||
return text || componentState.name || "Placeholder text"
|
||||
}
|
||||
|
||||
const enrichStyles = (styles, color) => {
|
||||
const enrichStyles = (styles, color, textColor) => {
|
||||
if (!color) {
|
||||
return styles
|
||||
}
|
||||
|
@ -34,7 +35,7 @@
|
|||
...styles?.normal,
|
||||
"background-color": color,
|
||||
"border-color": color,
|
||||
color: "white",
|
||||
color: textColor || "white",
|
||||
"--spectrum-clearbutton-medium-icon-color": "white",
|
||||
},
|
||||
}
|
||||
|
|
|
@ -92,9 +92,9 @@
|
|||
{#if schemaLoaded}
|
||||
<Button
|
||||
onClick={openEditor}
|
||||
icon="Properties"
|
||||
icon="ri-filter-3-line"
|
||||
text="Filter"
|
||||
{size}
|
||||
size="XL"
|
||||
type="secondary"
|
||||
quiet
|
||||
active={filters?.length > 0}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<script>
|
||||
import { CoreSelect, CoreMultiselect } from "@budibase/bbui"
|
||||
import { fetchData, Utils } from "@budibase/frontend-core"
|
||||
import { getContext } from "svelte"
|
||||
import { getContext, onMount } from "svelte"
|
||||
import Field from "./Field.svelte"
|
||||
import { FieldTypes } from "../../../constants"
|
||||
|
||||
|
@ -28,6 +28,7 @@
|
|||
let tableDefinition
|
||||
let searchTerm
|
||||
let open
|
||||
let initialValue
|
||||
|
||||
$: type =
|
||||
datasourceType === "table" ? FieldTypes.LINK : FieldTypes.BB_REFERENCE
|
||||
|
@ -109,7 +110,11 @@
|
|||
}
|
||||
|
||||
$: forceFetchRows(filter)
|
||||
$: debouncedFetchRows(searchTerm, primaryDisplay, defaultValue)
|
||||
$: debouncedFetchRows(
|
||||
searchTerm,
|
||||
primaryDisplay,
|
||||
initialValue || defaultValue
|
||||
)
|
||||
|
||||
const forceFetchRows = async () => {
|
||||
// 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) {
|
||||
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({
|
||||
query: { equal: { _id: defaultVal } },
|
||||
query: { oneOf: { _id: defaultVal } },
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -202,6 +211,16 @@
|
|||
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>
|
||||
|
||||
<Field
|
||||
|
|
|
@ -341,7 +341,11 @@ const exportDataHandler = async action => {
|
|||
tableId: selection.tableId,
|
||||
rows: selection.selectedRows,
|
||||
format: action.parameters.type,
|
||||
columns: action.parameters.columns,
|
||||
columns: action.parameters.columns?.map(
|
||||
column => column.name || column
|
||||
),
|
||||
delimiter: action.parameters.delimiter,
|
||||
customHeaders: action.parameters.customHeaders,
|
||||
})
|
||||
download(
|
||||
new Blob([data], { type: "text/plain" }),
|
||||
|
|
|
@ -89,13 +89,24 @@ export const buildRowEndpoints = API => ({
|
|||
* @param rows the array of rows to export
|
||||
* @param format the format to export (csv or json)
|
||||
* @param columns which columns to export (all if undefined)
|
||||
* @param delimiter how values should be separated in a CSV (default is comma)
|
||||
*/
|
||||
exportRows: async ({ tableId, rows, format, columns, search }) => {
|
||||
exportRows: async ({
|
||||
tableId,
|
||||
rows,
|
||||
format,
|
||||
columns,
|
||||
search,
|
||||
delimiter,
|
||||
customHeaders,
|
||||
}) => {
|
||||
return await API.post({
|
||||
url: `/api/${tableId}/rows/exportRows?format=${format}`,
|
||||
body: {
|
||||
rows,
|
||||
columns,
|
||||
delimiter,
|
||||
customHeaders,
|
||||
...search,
|
||||
},
|
||||
parseResponse: async response => {
|
||||
|
|
|
@ -59,13 +59,13 @@
|
|||
isReadonly: () => readonly,
|
||||
getType: () => column.schema.type,
|
||||
getValue: () => row[column.name],
|
||||
setValue: (value, options = { save: true }) => {
|
||||
setValue: (value, options = { apply: true }) => {
|
||||
validation.actions.setError(cellId, null)
|
||||
updateValue({
|
||||
rowId: row._id,
|
||||
column: column.name,
|
||||
value,
|
||||
save: options?.save,
|
||||
apply: options?.apply,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
|
|
@ -217,14 +217,14 @@
|
|||
const type = $focusedCellAPI.getType()
|
||||
if (type === "number" && keyCodeIsNumber(keyCode)) {
|
||||
// Update the value locally but don't save it yet
|
||||
$focusedCellAPI.setValue(parseInt(key), { save: false })
|
||||
$focusedCellAPI.setValue(parseInt(key), { apply: false })
|
||||
$focusedCellAPI.focus()
|
||||
} else if (
|
||||
["string", "barcodeqr", "longform"].includes(type) &&
|
||||
(keyCodeIsLetter(keyCode) || keyCodeIsNumber(keyCode))
|
||||
) {
|
||||
// Update the value locally but don't save it yet
|
||||
$focusedCellAPI.setValue(key, { save: false })
|
||||
$focusedCellAPI.setValue(key, { apply: false })
|
||||
$focusedCellAPI.focus()
|
||||
}
|
||||
}
|
||||
|
|