Merge branch 'develop' into qa-core-datasource-api-tests

This commit is contained in:
Pedro Silva 2023-04-26 15:51:47 +01:00
commit 796c50d3e0
382 changed files with 11627 additions and 2270 deletions

View File

@ -56,6 +56,7 @@ jobs:
run: yarn install:pro $BRANCH $BASE_BRANCH
- run: yarn
- run: yarn bootstrap
- run: yarn build
- run: yarn test
- uses: codecov/codecov-action@v3
with:
@ -94,4 +95,4 @@ jobs:
yarn test:ci
env:
BB_ADMIN_USER_EMAIL: admin
BB_ADMIN_USER_PASSWORD: admin
BB_ADMIN_USER_PASSWORD: admin

View File

@ -75,7 +75,6 @@ jobs:
- name: Build/release Docker images
run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
yarn build
yarn build:docker
env:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}

View File

@ -216,35 +216,9 @@ If you are having issues between updates of the builder, please use the guide [h
Thanks goes to these wonderful people ([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 -->
<a href="https://github.com/Budibase/budibase/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Budibase/budibase" />
</a>
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
Made with [contrib.rocks](https://contrib.rocks).

View File

@ -14,6 +14,9 @@ metadata:
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS":443}]'
alb.ingress.kubernetes.io/certificate-arn: {{ .Values.ingress.certificateArn }}
{{- end }}
{{- if .Values.ingress.sslPolicy }}
alb.ingress.kubernetes.io/actions.ssl-policy: {{ .Values.ingress.sslPolicy }}
{{- end }}
{{- if .Values.ingress.securityGroups }}
alb.ingress.kubernetes.io/security-groups: {{ .Values.ingress.securityGroups }}
{{- end }}

View File

@ -61,5 +61,18 @@ http://127.0.0.1:10000/builder/admin
| **NOTE**: If you are working on a M1 Apple Silicon, you will need to uncomment `# platform: linux/amd64` line in
[hosting/docker-compose-dev.yaml](../hosting/docker-compose.dev.yaml)
### Troubleshooting
If there are errors with the `yarn setup` command, you can try installing nvm and node 14. This is the same as the instructions for Debian 11.
### Troubleshootings
#### Yarn setup errors
If there are errors with the `yarn setup` command, you can try installing nvm and node 14. This is the same as the instructions for Debian 11.
#### Node 14.20.1 not supported for arm64
If you are working with M1 or M2 Mac and trying the Node installation via `nvm`, probably you will find the error `curl: (22) The requested URL returned error: 404`.
Version `v14.20.1` is not supported for arm64; in order to use it, you can switch the CPU architecture for this by the following command:
```shell
arch -x86_64 zsh #Run this before nvm install
```

View File

@ -1,5 +1,5 @@
{
"version": "2.5.5-alpha.0",
"version": "2.5.6-alpha.32",
"npmClient": "yarn",
"useWorkspaces": true,
"packages": ["packages/*"],

View File

@ -44,7 +44,7 @@
"dev": "yarn run kill-all && lerna link && lerna run --stream --parallel dev:builder --concurrency 1 --stream",
"dev:noserver": "yarn run kill-builder && lerna link && lerna run --stream dev:stack:up && lerna run --stream --parallel dev:builder --concurrency 1 --ignore @budibase/backend-core --ignore @budibase/server --ignore @budibase/worker",
"dev:server": "yarn run kill-server && lerna run --stream --parallel dev:builder --concurrency 1 --scope @budibase/backend-core --scope @budibase/worker --scope @budibase/server",
"dev:built": "cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream --parallel dev:built",
"dev:built": "yarn run kill-all && cd packages/server && yarn dev:stack:up && cd ../../ && lerna run --stream --parallel dev:built",
"test": "lerna run --stream test --stream",
"test:pro": "bash scripts/pro/test.sh",
"lint:eslint": "eslint packages && eslint qa-core",

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/backend-core",
"version": "2.5.5-alpha.0",
"version": "2.5.6-alpha.32",
"description": "Budibase backend core libraries used in server and worker",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
@ -24,7 +24,7 @@
"dependencies": {
"@budibase/nano": "10.1.2",
"@budibase/pouchdb-replication-stream": "1.2.10",
"@budibase/types": "2.5.5-alpha.0",
"@budibase/types": "2.5.6-alpha.32",
"@shopify/jest-koa-mocks": "5.0.1",
"@techpass/passport-openidconnect": "0.3.2",
"aws-cloudfront-sign": "2.2.0",
@ -47,6 +47,8 @@
"passport-jwt": "4.0.0",
"passport-local": "1.0.0",
"passport-oauth2-refresh": "^2.1.0",
"pino": "8.11.0",
"pino-http": "8.3.3",
"posthog-node": "1.3.0",
"pouchdb": "7.3.0",
"pouchdb-find": "7.2.2",
@ -54,8 +56,7 @@
"sanitize-s3-objectkey": "0.0.1",
"semver": "7.3.7",
"tar-fs": "2.1.1",
"uuid": "8.3.2",
"zlib": "1.0.5"
"uuid": "8.3.2"
},
"devDependencies": {
"@jest/test-sequencer": "29.5.0",
@ -81,7 +82,6 @@
"jest-serial-runner": "^1.2.1",
"koa": "2.13.4",
"nodemon": "2.0.16",
"pino": "7.11.0",
"pino-pretty": "10.0.0",
"pouchdb-adapter-memory": "7.2.2",
"timekeeper": "2.2.0",

View File

@ -14,6 +14,7 @@ export enum ViewName {
USER_BY_APP = "by_app",
USER_BY_EMAIL = "by_email2",
BY_API_KEY = "by_api_key",
/** @deprecated - could be deleted */
USER_BY_BUILDERS = "by_builders",
LINK = "by_link",
ROUTING = "screen_routes",

View File

@ -115,10 +115,10 @@ export async function doInContext(appId: string, task: any): Promise<any> {
)
}
export async function doInTenant(
export async function doInTenant<T>(
tenantId: string | null,
task: any
): Promise<any> {
task: () => T
): Promise<T> {
// make sure default always selected in single tenancy
if (!env.MULTI_TENANCY) {
tenantId = tenantId || DEFAULT_TENANT_ID

View File

@ -243,7 +243,7 @@ export class QueryBuilder<T> {
}
// Escape characters
if (!this.#noEscaping && escape && originalType === "string") {
value = `${value}`.replace(/[ #+\-&|!(){}\]^"~*?:\\]/g, "\\$&")
value = `${value}`.replace(/[ \/#+\-&|!(){}\]^"~*?:\\]/g, "\\$&")
}
// Wrap in quotes
@ -320,6 +320,18 @@ export class QueryBuilder<T> {
return `${key}:(${statement})`
}
const fuzzy = (key: string, value: any) => {
if (!value) {
return null
}
value = builder.preprocess(value, {
escape: true,
lowercase: true,
type: "fuzzy",
})
return `${key}:/.*${value}.*/`
}
const notContains = (key: string, value: any) => {
const allPrefix = allOr ? "*:* AND " : ""
const mode = allOr ? "AND" : undefined
@ -408,17 +420,7 @@ export class QueryBuilder<T> {
})
}
if (this.#query.fuzzy) {
build(this.#query.fuzzy, (key: string, value: any) => {
if (!value) {
return null
}
value = builder.preprocess(value, {
escape: true,
lowercase: true,
type: "fuzzy",
})
return `${key}:${value}~`
})
build(this.#query.fuzzy, fuzzy)
}
if (this.#query.equal) {
build(this.#query.equal, equal)

View File

@ -7,7 +7,7 @@ import {
} from "../constants"
import { getGlobalDB } from "../context"
import { doWithDB } from "./"
import { Database, DatabaseQueryOpts } from "@budibase/types"
import { AllDocsResponse, Database, DatabaseQueryOpts } from "@budibase/types"
import env from "../environment"
const DESIGN_DB = "_design/database"
@ -42,7 +42,11 @@ async function removeDeprecated(db: Database, viewName: ViewName) {
}
}
export async function createView(db: any, viewJs: string, viewName: string) {
export async function createView(
db: any,
viewJs: string,
viewName: string
): Promise<void> {
let designDoc
try {
designDoc = (await db.get(DESIGN_DB)) as DesignDocument
@ -57,7 +61,15 @@ export async function createView(db: any, viewJs: string, viewName: string) {
...designDoc.views,
[viewName]: view,
}
await db.put(designDoc)
try {
await db.put(designDoc)
} catch (err: any) {
if (err.status === 409) {
return await createView(db, viewJs, viewName)
} else {
throw err
}
}
}
export const createNewUserEmailView = async () => {
@ -107,6 +119,34 @@ export interface QueryViewOptions {
arrayResponse?: boolean
}
export async function queryViewRaw<T>(
viewName: ViewName,
params: DatabaseQueryOpts,
db: Database,
createFunc: any,
opts?: QueryViewOptions
): Promise<AllDocsResponse<T>> {
try {
const response = await db.query<T>(`database/${viewName}`, params)
// await to catch error
return response
} catch (err: any) {
const pouchNotFound = err && err.name === "not_found"
const couchNotFound = err && err.status === 404
if (pouchNotFound || couchNotFound) {
await removeDeprecated(db, viewName)
await createFunc()
return queryViewRaw(viewName, params, db, createFunc, opts)
} else if (err.status === 409) {
// can happen when multiple queries occur at once, view couldn't be created
// other design docs being updated, re-run
return queryViewRaw(viewName, params, db, createFunc, opts)
} else {
throw err
}
}
}
export const queryView = async <T>(
viewName: ViewName,
params: DatabaseQueryOpts,
@ -114,30 +154,18 @@ export const queryView = async <T>(
createFunc: any,
opts?: QueryViewOptions
): Promise<T[] | T | undefined> => {
try {
let response = await db.query<T>(`database/${viewName}`, params)
const rows = response.rows
const docs = rows.map((row: any) =>
params.include_docs ? row.doc : row.value
)
const response = await queryViewRaw<T>(viewName, params, db, createFunc, opts)
const rows = response.rows
const docs = rows.map((row: any) =>
params.include_docs ? row.doc : row.value
)
// if arrayResponse has been requested, always return array regardless of length
if (opts?.arrayResponse) {
return docs as T[]
} else {
// return the single document if there is only one
return docs.length <= 1 ? (docs[0] as T) : (docs as T[])
}
} catch (err: any) {
const pouchNotFound = err && err.name === "not_found"
const couchNotFound = err && err.status === 404
if (pouchNotFound || couchNotFound) {
await removeDeprecated(db, viewName)
await createFunc()
return queryView(viewName, params, db, createFunc, opts)
} else {
throw err
}
// if arrayResponse has been requested, always return array regardless of length
if (opts?.arrayResponse) {
return docs as T[]
} else {
// return the single document if there is only one
return docs.length <= 1 ? (docs[0] as T) : (docs as T[])
}
}
@ -192,18 +220,19 @@ export const queryPlatformView = async <T>(
})
}
const CreateFuncByName: any = {
[ViewName.USER_BY_EMAIL]: createNewUserEmailView,
[ViewName.BY_API_KEY]: createApiKeyView,
[ViewName.USER_BY_BUILDERS]: createUserBuildersView,
[ViewName.USER_BY_APP]: createUserAppView,
}
export const queryGlobalView = async <T>(
viewName: ViewName,
params: DatabaseQueryOpts,
db?: Database,
opts?: QueryViewOptions
): Promise<T[] | T | undefined> => {
const CreateFuncByName: any = {
[ViewName.USER_BY_EMAIL]: createNewUserEmailView,
[ViewName.BY_API_KEY]: createApiKeyView,
[ViewName.USER_BY_BUILDERS]: createUserBuildersView,
[ViewName.USER_BY_APP]: createUserAppView,
}
// can pass DB in if working with something specific
if (!db) {
db = getGlobalDB()
@ -211,3 +240,13 @@ export const queryGlobalView = async <T>(
const createFn = CreateFuncByName[viewName]
return queryView(viewName, params, db!, createFn, opts)
}
export async function queryGlobalViewRaw<T>(
viewName: ViewName,
params: DatabaseQueryOpts,
opts?: QueryViewOptions
) {
const db = getGlobalDB()
const createFn = CreateFuncByName[viewName]
return queryViewRaw<T>(viewName, params, db, createFn, opts)
}

View File

@ -0,0 +1,29 @@
import { asyncEventQueue, init as initQueue } from "../events/asyncEvents"
import {
ProcessorMap,
default as DocumentUpdateProcessor,
} from "../events/processors/async/DocumentUpdateProcessor"
let processingPromise: Promise<void>
let documentProcessor: DocumentUpdateProcessor
export function init(processors: ProcessorMap) {
if (!asyncEventQueue) {
initQueue()
}
if (!documentProcessor) {
documentProcessor = new DocumentUpdateProcessor(processors)
}
// if not processing in this instance, kick it off
if (!processingPromise) {
processingPromise = asyncEventQueue.process(async job => {
const { event, identity, properties, timestamp } = job.data
await documentProcessor.processEvent(
event,
identity,
properties,
timestamp
)
})
}
}

View File

@ -1,3 +1,5 @@
import { existsSync, readFileSync } from "fs"
function isTest() {
return isCypress() || isJest()
}
@ -45,6 +47,35 @@ function httpLogging() {
return process.env.HTTP_LOGGING
}
function findVersion() {
function findFileInAncestors(
fileName: string,
currentDir: string
): string | null {
const filePath = `${currentDir}/${fileName}`
if (existsSync(filePath)) {
return filePath
}
const parentDir = `${currentDir}/..`
if (parentDir === currentDir) {
// reached root directory
return null
}
return findFileInAncestors(fileName, parentDir)
}
try {
const packageJsonFile = findFileInAncestors("package.json", process.cwd())
const content = readFileSync(packageJsonFile!, "utf-8")
const version = JSON.parse(content).version
return version
} catch {
throw new Error("Cannot find a valid version in its package.json")
}
}
const environment = {
isTest,
isJest,
@ -122,6 +153,7 @@ const environment = {
ENABLE_SSO_MAINTENANCE_MODE: selfHosted
? process.env.ENABLE_SSO_MAINTENANCE_MODE
: false,
VERSION: findVersion(),
_set(key: any, value: any) {
process.env[key] = value
// @ts-ignore

View File

@ -0,0 +1,2 @@
export * from "./queue"
export * from "./publisher"

View File

@ -0,0 +1,12 @@
import { AsyncEvents } from "@budibase/types"
import { EventPayload, asyncEventQueue, init } from "./queue"
export async function publishAsyncEvent(payload: EventPayload) {
if (!asyncEventQueue) {
init()
}
const { event, identity } = payload
if (AsyncEvents.indexOf(event) !== -1 && identity.tenantId) {
await asyncEventQueue.add(payload)
}
}

View File

@ -0,0 +1,22 @@
import BullQueue from "bull"
import { createQueue, JobQueue } from "../../queue"
import { Event, Identity } from "@budibase/types"
export interface EventPayload {
event: Event
identity: Identity
properties: any
timestamp?: string | number
}
export let asyncEventQueue: BullQueue.Queue
export function init() {
asyncEventQueue = createQueue<EventPayload>(JobQueue.SYSTEM_EVENT_QUEUE)
}
export async function shutdown() {
if (asyncEventQueue) {
await asyncEventQueue.close()
}
}

View File

@ -0,0 +1,56 @@
import {
Event,
UserCreatedEvent,
UserUpdatedEvent,
UserDeletedEvent,
UserPermissionAssignedEvent,
UserPermissionRemovedEvent,
GroupCreatedEvent,
GroupUpdatedEvent,
GroupDeletedEvent,
GroupUsersAddedEvent,
GroupUsersDeletedEvent,
GroupPermissionsEditedEvent,
} from "@budibase/types"
const getEventProperties: Record<
string,
(properties: any) => string | undefined
> = {
[Event.USER_CREATED]: (properties: UserCreatedEvent) => properties.userId,
[Event.USER_UPDATED]: (properties: UserUpdatedEvent) => properties.userId,
[Event.USER_DELETED]: (properties: UserDeletedEvent) => properties.userId,
[Event.USER_PERMISSION_ADMIN_ASSIGNED]: (
properties: UserPermissionAssignedEvent
) => properties.userId,
[Event.USER_PERMISSION_ADMIN_REMOVED]: (
properties: UserPermissionRemovedEvent
) => properties.userId,
[Event.USER_PERMISSION_BUILDER_ASSIGNED]: (
properties: UserPermissionAssignedEvent
) => properties.userId,
[Event.USER_PERMISSION_BUILDER_REMOVED]: (
properties: UserPermissionRemovedEvent
) => properties.userId,
[Event.USER_GROUP_CREATED]: (properties: GroupCreatedEvent) =>
properties.groupId,
[Event.USER_GROUP_UPDATED]: (properties: GroupUpdatedEvent) =>
properties.groupId,
[Event.USER_GROUP_DELETED]: (properties: GroupDeletedEvent) =>
properties.groupId,
[Event.USER_GROUP_USERS_ADDED]: (properties: GroupUsersAddedEvent) =>
properties.groupId,
[Event.USER_GROUP_USERS_REMOVED]: (properties: GroupUsersDeletedEvent) =>
properties.groupId,
[Event.USER_GROUP_PERMISSIONS_EDITED]: (
properties: GroupPermissionsEditedEvent
) => properties.groupId,
}
export function getDocumentId(event: Event, properties: any) {
const extractor = getEventProperties[event]
if (!extractor) {
throw new Error("Event does not have a method of document ID extraction")
}
return extractor(properties)
}

View File

@ -1,7 +1,8 @@
import { Event, AuditedEventFriendlyName } from "@budibase/types"
import { Event } from "@budibase/types"
import { processors } from "./processors"
import identification from "./identification"
import * as backfill from "./backfill"
import { publishAsyncEvent } from "./asyncEvents"
export const publishEvent = async (
event: Event,
@ -14,6 +15,14 @@ export const publishEvent = async (
const backfilling = await backfill.isBackfillingEvent(event)
// no backfill - send the event and exit
if (!backfilling) {
// send off async events if required
await publishAsyncEvent({
event,
identity,
properties,
timestamp,
})
// now handle the main sync event processing pipeline
await processors.processEvent(event, identity, properties, timestamp)
return
}

View File

@ -23,8 +23,6 @@ import * as installation from "../installation"
import * as configs from "../configs"
import { withCache, TTL, CacheKey } from "../cache/generic"
const pkg = require("../../package.json")
/**
* An identity can be:
* - account user (Self host)
@ -65,6 +63,7 @@ const getCurrentIdentity = async (): Promise<Identity> => {
hosting,
installationId,
tenantId,
realTenantId: context.getTenantId(),
environment,
}
} else if (identityType === IdentityType.USER) {
@ -101,7 +100,7 @@ const identifyInstallationGroup = async (
const id = installId
const type = IdentityType.INSTALLATION
const hosting = getHostingFromEnv()
const version = pkg.version
const version = env.VERSION
const environment = getDeploymentEnvironment()
const group: InstallationGroup = {
@ -305,4 +304,5 @@ export default {
identify,
identifyGroup,
getInstallationId,
getUniqueTenantId,
}

View File

@ -6,6 +6,8 @@ export * as backfillCache from "./backfill"
import { processors } from "./processors"
export function initAsyncEvents() {}
export const shutdown = () => {
processors.shutdown()
console.log("Events shutdown")

View File

@ -25,7 +25,9 @@ export default class Processor implements EventProcessor {
timestamp?: string | number
): Promise<void> {
for (const eventProcessor of this.processors) {
await eventProcessor.identify(identity, timestamp)
if (eventProcessor.identify) {
await eventProcessor.identify(identity, timestamp)
}
}
}
@ -34,13 +36,17 @@ export default class Processor implements EventProcessor {
timestamp?: string | number
): Promise<void> {
for (const eventProcessor of this.processors) {
await eventProcessor.identifyGroup(identity, timestamp)
if (eventProcessor.identifyGroup) {
await eventProcessor.identifyGroup(identity, timestamp)
}
}
}
shutdown() {
for (const eventProcessor of this.processors) {
eventProcessor.shutdown()
if (eventProcessor.shutdown) {
eventProcessor.shutdown()
}
}
}
}

View File

@ -0,0 +1,43 @@
import { EventProcessor } from "../types"
import { Event, Identity, DocUpdateEvent } from "@budibase/types"
import { doInTenant } from "../../../context"
import { getDocumentId } from "../../documentId"
import { shutdown } from "../../asyncEvents"
export type Processor = (update: DocUpdateEvent) => Promise<void>
export type ProcessorMap = { events: Event[]; processor: Processor }[]
export default class DocumentUpdateProcessor implements EventProcessor {
processors: ProcessorMap = []
constructor(processors: ProcessorMap) {
this.processors = processors
}
async processEvent(
event: Event,
identity: Identity,
properties: any,
timestamp?: string | number
) {
const tenantId = identity.realTenantId
const docId = getDocumentId(event, properties)
if (!tenantId || !docId) {
return
}
for (let { events, processor } of this.processors) {
if (events.includes(event)) {
await doInTenant(tenantId, async () => {
await processor({
id: docId,
tenantId,
})
})
}
}
}
shutdown() {
return shutdown()
}
}

View File

@ -4,7 +4,6 @@ import { EventProcessor } from "../types"
import env from "../../../environment"
import * as context from "../../../context"
import * as rateLimiting from "./rateLimiting"
const pkg = require("../../../../package.json")
const EXCLUDED_EVENTS: Event[] = [
Event.USER_UPDATED,
@ -49,7 +48,7 @@ export default class PosthogProcessor implements EventProcessor {
properties = this.clearPIIProperties(properties)
properties.version = pkg.version
properties.version = env.VERSION
properties.service = env.SERVICE
properties.environment = identity.environment
properties.hosting = identity.hosting

View File

@ -1,18 +1 @@
import { Event, Identity, Group } from "@budibase/types"
export enum EventProcessorType {
POSTHOG = "posthog",
LOGGING = "logging",
}
export interface EventProcessor {
processEvent(
event: Event,
identity: Identity,
properties: any,
timestamp?: string | number
): Promise<void>
identify(identity: Identity, timestamp?: string | number): Promise<void>
identifyGroup(group: Group, timestamp?: string | number): Promise<void>
shutdown(): void
}
export { EventProcessor } from "@budibase/types"

View File

@ -27,6 +27,7 @@ export * as errors from "./errors"
export * as timers from "./timers"
export { default as env } from "./environment"
export * as blacklist from "./blacklist"
export * as docUpdates from "./docUpdates"
export { SearchParams } from "./db"
// Add context to tenancy for backwards compatibility
// only do this for external usages to prevent internal

View File

@ -6,8 +6,7 @@ import { Installation, IdentityType, Database } from "@budibase/types"
import * as context from "./context"
import semver from "semver"
import { bustCache, withCache, TTL, CacheKey } from "./cache/generic"
const pkg = require("../package.json")
import environment from "./environment"
export const getInstall = async (): Promise<Installation> => {
return withCache(CacheKey.INSTALLATION, TTL.ONE_DAY, getInstallFromDB, {
@ -18,7 +17,7 @@ async function createInstallDoc(platformDb: Database) {
const install: Installation = {
_id: StaticDatabases.PLATFORM_INFO.docs.install,
installId: newid(),
version: pkg.version,
version: environment.VERSION,
}
try {
const resp = await platformDb.put(install)
@ -33,7 +32,7 @@ async function createInstallDoc(platformDb: Database) {
}
}
const getInstallFromDB = async (): Promise<Installation> => {
export const getInstallFromDB = async (): Promise<Installation> => {
return doWithDB(
StaticDatabases.PLATFORM_INFO.name,
async (platformDb: any) => {
@ -80,7 +79,7 @@ export const checkInstallVersion = async (): Promise<void> => {
const install = await getInstall()
const currentVersion = install.version
const newVersion = pkg.version
const newVersion = environment.VERSION
if (currentVersion !== newVersion) {
const isUpgrade = semver.gt(newVersion, currentVersion)

View File

@ -1,5 +1,5 @@
export * as correlation from "./correlation/correlation"
export { default as logger } from "./pino/logger"
export { logger, disableLogger } from "./pino/logger"
export * from "./alerts"
// turn off or on context logging i.e. tenantId, appId etc

View File

@ -5,6 +5,17 @@ import * as correlation from "../correlation"
import { IdentityType } from "@budibase/types"
import { LOG_CONTEXT } from "../index"
// CORE LOGGERS - for disabling
const BUILT_INS = {
log: console.log,
error: console.error,
info: console.info,
warn: console.warn,
trace: console.trace,
debug: console.debug,
}
// LOGGER
const pinoOptions: LoggerOptions = {
@ -31,6 +42,15 @@ if (env.isDev()) {
export const logger = pino(pinoOptions)
export function disableLogger() {
console.log = BUILT_INS.log
console.error = BUILT_INS.error
console.info = BUILT_INS.info
console.warn = BUILT_INS.warn
console.trace = BUILT_INS.trace
console.debug = BUILT_INS.debug
}
// CONSOLE OVERRIDES
interface MergingObject {
@ -166,5 +186,3 @@ const getIdentity = () => {
}
return identity
}
export default logger

View File

@ -1,5 +1,5 @@
import env from "../../environment"
import logger from "./logger"
import { logger } from "./logger"
import { IncomingMessage } from "http"
const pino = require("koa-pino-logger")
import { Options } from "pino-http"

View File

@ -44,7 +44,7 @@ async function checkApiKey(apiKey: string, populateUser?: Function) {
// check both the primary and the fallback internal api keys
// this allows for rotation
if (isValidInternalAPIKey(apiKey)) {
return { valid: true }
return { valid: true, user: undefined }
}
const decrypted = decrypt(apiKey)
const tenantId = decrypted.split(SEPARATOR)[0]

View File

@ -3,7 +3,7 @@ import AWS from "aws-sdk"
import stream from "stream"
import fetch from "node-fetch"
import tar from "tar-fs"
const zlib = require("zlib")
import zlib from "zlib"
import { promisify } from "util"
import { join } from "path"
import fs from "fs"
@ -415,7 +415,7 @@ export const downloadTarballDirect = async (
throw new Error(`unexpected response ${response.statusText}`)
}
await streamPipeline(response.body, zlib.Unzip(), tar.extract(path))
await streamPipeline(response.body, zlib.createUnzip(), tar.extract(path))
}
export const downloadTarball = async (
@ -431,7 +431,7 @@ export const downloadTarball = async (
}
const tmpPath = join(budibaseTempDir(), path)
await streamPipeline(response.body, zlib.Unzip(), tar.extract(tmpPath))
await streamPipeline(response.body, zlib.createUnzip(), tar.extract(tmpPath))
if (!env.isTest() && env.SELF_HOSTED) {
await uploadDirectory(bucketName, tmpPath, path)
}

View File

@ -0,0 +1,83 @@
import { validate } from "../utils"
import fetch from "node-fetch"
import { PluginType } from "@budibase/types"
const repoUrl =
"https://raw.githubusercontent.com/Budibase/budibase-skeleton/master"
const automationLink = `${repoUrl}/automation/schema.json.hbs`
const componentLink = `${repoUrl}/component/schema.json.hbs`
const datasourceLink = `${repoUrl}/datasource/schema.json.hbs`
async function getSchema(link: string) {
const response = await fetch(link)
if (response.status > 300) {
return
}
const text = await response.text()
return JSON.parse(text)
}
async function runTest(opts: { link?: string; schema?: any }) {
let error
try {
let schema = opts.schema
if (opts.link) {
schema = await getSchema(opts.link)
}
validate(schema)
} catch (err) {
error = err
}
return error
}
describe("it should be able to validate an automation schema", () => {
it("should return automation skeleton schema is valid", async () => {
const error = await runTest({ link: automationLink })
expect(error).toBeUndefined()
})
it("should fail given invalid automation schema", async () => {
const error = await runTest({
schema: {
type: PluginType.AUTOMATION,
schema: {},
},
})
expect(error).toBeDefined()
})
})
describe("it should be able to validate a component schema", () => {
it("should return component skeleton schema is valid", async () => {
const error = await runTest({ link: componentLink })
expect(error).toBeUndefined()
})
it("should fail given invalid component schema", async () => {
const error = await runTest({
schema: {
type: PluginType.COMPONENT,
schema: {},
},
})
expect(error).toBeDefined()
})
})
describe("it should be able to validate a datasource schema", () => {
it("should return datasource skeleton schema is valid", async () => {
const error = await runTest({ link: datasourceLink })
expect(error).toBeUndefined()
})
it("should fail given invalid datasource schema", async () => {
const error = await runTest({
schema: {
type: PluginType.DATASOURCE,
schema: {},
},
})
expect(error).toBeDefined()
})
})

View File

@ -1,4 +1,12 @@
import { DatasourceFieldType, QueryType, PluginType } from "@budibase/types"
import {
DatasourceFieldType,
QueryType,
PluginType,
AutomationStepType,
AutomationStepIdArray,
AutomationIOType,
AutomationCustomIOType,
} from "@budibase/types"
import joi from "joi"
const DATASOURCE_TYPES = [
@ -19,7 +27,7 @@ function runJoi(validator: joi.Schema, schema: any) {
function validateComponent(schema: any) {
const validator = joi.object({
type: joi.string().allow("component").required(),
type: joi.string().allow(PluginType.COMPONENT).required(),
metadata: joi.object().unknown(true).required(),
hash: joi.string().optional(),
version: joi.string().optional(),
@ -53,7 +61,7 @@ function validateDatasource(schema: any) {
.required()
const validator = joi.object({
type: joi.string().allow("datasource").required(),
type: joi.string().allow(PluginType.DATASOURCE).required(),
metadata: joi.object().unknown(true).required(),
hash: joi.string().optional(),
version: joi.string().optional(),
@ -82,6 +90,55 @@ function validateDatasource(schema: any) {
runJoi(validator, schema)
}
function validateAutomation(schema: any) {
const basePropsValidator = joi.object().pattern(joi.string(), {
type: joi
.string()
.allow(...Object.values(AutomationIOType))
.required(),
customType: joi.string().allow(...Object.values(AutomationCustomIOType)),
title: joi.string(),
description: joi.string(),
enum: joi.array().items(joi.string()),
pretty: joi.array().items(joi.string()),
})
const stepSchemaValidator = joi
.object({
properties: basePropsValidator,
required: joi.array().items(joi.string()),
})
.concat(basePropsValidator)
.required()
const validator = joi.object({
type: joi.string().allow(PluginType.AUTOMATION).required(),
metadata: joi.object().unknown(true).required(),
hash: joi.string().optional(),
version: joi.string().optional(),
schema: joi.object({
name: joi.string().required(),
tagline: joi.string().required(),
icon: joi.string().required(),
description: joi.string().required(),
type: joi
.string()
.allow(AutomationStepType.ACTION, AutomationStepType.LOGIC)
.required(),
stepId: joi
.string()
.disallow(...AutomationStepIdArray)
.required(),
inputs: joi.object().optional(),
schema: joi
.object({
inputs: stepSchemaValidator,
outputs: stepSchemaValidator,
})
.required(),
}),
})
runJoi(validator, schema)
}
export function validate(schema: any) {
switch (schema?.type) {
case PluginType.COMPONENT:
@ -90,6 +147,9 @@ export function validate(schema: any) {
case PluginType.DATASOURCE:
validateDatasource(schema)
break
case PluginType.AUTOMATION:
validateAutomation(schema)
break
default:
throw new Error(`Unknown plugin type - check schema.json: ${schema.type}`)
}

View File

@ -2,4 +2,5 @@ export enum JobQueue {
AUTOMATION = "automationQueue",
APP_BACKUP = "appBackupQueue",
AUDIT_LOG = "auditLogQueue",
SYSTEM_EVENT_QUEUE = "systemEventQueue",
}

View File

@ -1,10 +1,16 @@
import Redlock, { Options } from "redlock"
import Redlock from "redlock"
import { getLockClient } from "./init"
import { LockOptions, LockType } from "@budibase/types"
import * as context from "../context"
import env from "../environment"
const getClient = async (type: LockType): Promise<Redlock> => {
const getClient = async (
type: LockType,
opts?: Redlock.Options
): Promise<Redlock> => {
if (type === LockType.CUSTOM) {
return newRedlock(opts)
}
if (env.isTest() && type !== LockType.TRY_ONCE) {
return newRedlock(OPTIONS.TEST)
}
@ -56,7 +62,7 @@ const OPTIONS = {
},
}
const newRedlock = async (opts: Options = {}) => {
const newRedlock = async (opts: Redlock.Options = {}) => {
let options = { ...OPTIONS.DEFAULT, ...opts }
const redisWrapper = await getLockClient()
const client = redisWrapper.getClient()

View File

@ -24,7 +24,7 @@ export enum PermissionType {
QUERY = "query",
}
class Permission {
export class Permission {
type: PermissionType
level: PermissionLevel
@ -34,7 +34,7 @@ class Permission {
}
}
function levelToNumber(perm: PermissionLevel) {
export function levelToNumber(perm: PermissionLevel) {
switch (perm) {
// not everything has execute privileges
case PermissionLevel.EXECUTE:
@ -55,7 +55,7 @@ function levelToNumber(perm: PermissionLevel) {
* @param {string} userPermLevel The permission level of the user.
* @return {string[]} All the permission levels this user is allowed to carry out.
*/
function getAllowedLevels(userPermLevel: PermissionLevel) {
export function getAllowedLevels(userPermLevel: PermissionLevel): string[] {
switch (userPermLevel) {
case PermissionLevel.EXECUTE:
return [PermissionLevel.EXECUTE]
@ -64,9 +64,9 @@ function getAllowedLevels(userPermLevel: PermissionLevel) {
case PermissionLevel.WRITE:
case PermissionLevel.ADMIN:
return [
PermissionLevel.EXECUTE,
PermissionLevel.READ,
PermissionLevel.WRITE,
PermissionLevel.EXECUTE,
]
default:
return []
@ -81,7 +81,7 @@ export enum BuiltinPermissionID {
POWER = "power",
}
const BUILTIN_PERMISSIONS = {
export const BUILTIN_PERMISSIONS = {
PUBLIC: {
_id: BuiltinPermissionID.PUBLIC,
name: "Public",

View File

@ -0,0 +1,145 @@
import { cloneDeep } from "lodash"
import * as permissions from "../permissions"
import { BUILTIN_ROLE_IDS } from "../roles"
describe("levelToNumber", () => {
it("should return 0 for EXECUTE", () => {
expect(permissions.levelToNumber(permissions.PermissionLevel.EXECUTE)).toBe(
0
)
})
it("should return 1 for READ", () => {
expect(permissions.levelToNumber(permissions.PermissionLevel.READ)).toBe(1)
})
it("should return 2 for WRITE", () => {
expect(permissions.levelToNumber(permissions.PermissionLevel.WRITE)).toBe(2)
})
it("should return 3 for ADMIN", () => {
expect(permissions.levelToNumber(permissions.PermissionLevel.ADMIN)).toBe(3)
})
it("should return -1 for an unknown permission level", () => {
expect(
permissions.levelToNumber("unknown" as permissions.PermissionLevel)
).toBe(-1)
})
})
describe("getAllowedLevels", () => {
it('should return ["execute"] for EXECUTE', () => {
expect(
permissions.getAllowedLevels(permissions.PermissionLevel.EXECUTE)
).toEqual([permissions.PermissionLevel.EXECUTE])
})
it('should return ["execute", "read"] for READ', () => {
expect(
permissions.getAllowedLevels(permissions.PermissionLevel.READ)
).toEqual([
permissions.PermissionLevel.EXECUTE,
permissions.PermissionLevel.READ,
])
})
it('should return ["execute", "read", "write"] for WRITE', () => {
expect(
permissions.getAllowedLevels(permissions.PermissionLevel.WRITE)
).toEqual([
permissions.PermissionLevel.EXECUTE,
permissions.PermissionLevel.READ,
permissions.PermissionLevel.WRITE,
])
})
it('should return ["execute", "read", "write"] for ADMIN', () => {
expect(
permissions.getAllowedLevels(permissions.PermissionLevel.ADMIN)
).toEqual([
permissions.PermissionLevel.EXECUTE,
permissions.PermissionLevel.READ,
permissions.PermissionLevel.WRITE,
])
})
it("should return [] for an unknown permission level", () => {
expect(
permissions.getAllowedLevels("unknown" as permissions.PermissionLevel)
).toEqual([])
})
})
describe("doesHaveBasePermission", () => {
it("should return true if base permission has the required level", () => {
const permType = permissions.PermissionType.USER
const permLevel = permissions.PermissionLevel.READ
const rolesHierarchy = [
{
roleId: BUILTIN_ROLE_IDS.ADMIN,
permissionId: permissions.BuiltinPermissionID.ADMIN,
},
]
expect(
permissions.doesHaveBasePermission(permType, permLevel, rolesHierarchy)
).toBe(true)
})
it("should return false if base permission does not have the required level", () => {
const permType = permissions.PermissionType.APP
const permLevel = permissions.PermissionLevel.READ
const rolesHierarchy = [
{
roleId: BUILTIN_ROLE_IDS.PUBLIC,
permissionId: permissions.BuiltinPermissionID.PUBLIC,
},
]
expect(
permissions.doesHaveBasePermission(permType, permLevel, rolesHierarchy)
).toBe(false)
})
})
describe("isPermissionLevelHigherThanRead", () => {
it("should return true if level is higher than read", () => {
expect(
permissions.isPermissionLevelHigherThanRead(
permissions.PermissionLevel.WRITE
)
).toBe(true)
})
it("should return false if level is read or lower", () => {
expect(
permissions.isPermissionLevelHigherThanRead(
permissions.PermissionLevel.READ
)
).toBe(false)
})
})
describe("getBuiltinPermissions", () => {
it("returns a clone of the builtin permissions", () => {
const builtins = permissions.getBuiltinPermissions()
expect(builtins).toEqual(cloneDeep(permissions.BUILTIN_PERMISSIONS))
expect(builtins).not.toBe(permissions.BUILTIN_PERMISSIONS)
})
})
describe("getBuiltinPermissionByID", () => {
it("returns correct permission object for valid ID", () => {
const expectedPermission = {
_id: permissions.BuiltinPermissionID.PUBLIC,
name: "Public",
permissions: [
new permissions.Permission(
permissions.PermissionType.WEBHOOK,
permissions.PermissionLevel.EXECUTE
),
],
}
expect(permissions.getBuiltinPermissionByID("public")).toEqual(
expectedPermission
)
})
})

View File

@ -1,15 +1,16 @@
import {
ViewName,
getUsersByAppParams,
getProdAppID,
generateAppUserID,
queryGlobalView,
UNICODE_MAX,
DocumentType,
SEPARATOR,
directCouchFind,
DocumentType,
generateAppUserID,
getGlobalUserParams,
getProdAppID,
getUsersByAppParams,
pagination,
queryGlobalView,
queryGlobalViewRaw,
SEPARATOR,
UNICODE_MAX,
ViewName,
} from "./db"
import { BulkDocsResponse, SearchUsersRequest, User } from "@budibase/types"
import { getGlobalDB } from "./context"
@ -239,3 +240,11 @@ export const paginatedUsers = async ({
getKey,
})
}
export async function getUserCount() {
const response = await queryGlobalViewRaw(ViewName.USER_BY_EMAIL, {
limit: 0, // to be as fast as possible - we just want the total rows count
include_docs: false,
})
return response.total_rows
}

View File

@ -46,8 +46,9 @@ export async function resolveAppUrl(ctx: Ctx) {
}
// search prod apps for a url that matches
const apps: App[] = await context.doInTenant(tenantId, () =>
getAllApps({ dev: false })
const apps: App[] = await context.doInTenant(
tenantId,
() => getAllApps({ dev: false }) as Promise<App[]>
)
const app = apps.filter(
a => a.url && a.url.toLowerCase() === possibleAppUrl
@ -221,27 +222,6 @@ export function isClient(ctx: Ctx) {
return ctx.headers[Header.TYPE] === "client"
}
async function getBuilders() {
const builders = await queryGlobalView(ViewName.USER_BY_BUILDERS, {
include_docs: false,
})
if (!builders) {
return []
}
if (Array.isArray(builders)) {
return builders
} else {
return [builders]
}
}
export async function getBuildersCount() {
const builders = await getBuilders()
return builders.length
}
export function timeout(timeMs: number) {
return new Promise(resolve => setTimeout(resolve, timeMs))
}

View File

@ -2,5 +2,5 @@ export * as mocks from "./mocks"
export * as structures from "./structures"
export { generator } from "./structures"
export * as testContainerUtils from "./testContainerUtils"
export * as utils from "./utils"
export * from "./jestUtils"

View File

@ -1,3 +1,5 @@
import * as events from "../../../../src/events"
beforeAll(async () => {
const processors = await import("../../../../src/events/processors")
const events = await import("../../../../src/events")
@ -120,4 +122,13 @@ beforeAll(async () => {
jest.spyOn(events.plugin, "init")
jest.spyOn(events.plugin, "imported")
jest.spyOn(events.plugin, "deleted")
jest.spyOn(events.license, "tierChanged")
jest.spyOn(events.license, "planChanged")
jest.spyOn(events.license, "activated")
jest.spyOn(events.license, "checkoutOpened")
jest.spyOn(events.license, "checkoutSuccess")
jest.spyOn(events.license, "portalOpened")
jest.spyOn(events.license, "paymentFailed")
jest.spyOn(events.license, "paymentRecovered")
})

View File

@ -1,7 +1,7 @@
const mockFetch = jest.fn((url: any, opts: any) => {
const fetch = jest.requireActual("node-fetch")
const env = jest.requireActual("../../../../src/environment").default
if (url.includes(env.COUCH_DB_URL)) {
if (url.includes(env.COUCH_DB_URL) || url.includes("raw.github")) {
return fetch(url, opts)
}
return undefined

View File

@ -0,0 +1,20 @@
import Chance from "chance"
export default class CustomChance extends Chance {
arrayOf<T>(
generateFn: () => T,
opts: { min?: number; max?: number } = {}
): T[] {
const itemCount = this.integer({
min: opts.min != null ? opts.min : 1,
max: opts.max != null ? opts.max : 50,
})
const items = []
for (let i = 0; i < itemCount; i++) {
items.push(generateFn())
}
return items
}
}

View File

@ -1,4 +1,4 @@
import { generator, uuid } from "."
import { generator, uuid, quotas } from "."
import { generateGlobalUserID } from "../../../../src/docIds"
import {
Account,
@ -28,6 +28,7 @@ export const account = (): Account => {
name: generator.name(),
size: "10+",
profession: "Software Engineer",
quotaUsage: quotas.usage(),
}
}

View File

@ -1,2 +1,2 @@
import Chance from "chance"
import Chance from "./Chance"
export const generator = new Chance()

View File

@ -11,3 +11,4 @@ export * as users from "./users"
export * as userGroups from "./userGroups"
export { generator } from "./generator"
export * as scim from "./scim"
export * as quotas from "./quotas"

View File

@ -1,18 +1,132 @@
import { AccountPlan, License, PlanType, Quotas } from "@budibase/types"
import {
Billing,
Customer,
Feature,
License,
PlanModel,
PlanType,
PriceDuration,
PurchasedPlan,
Quotas,
Subscription,
} from "@budibase/types"
const newPlan = (type: PlanType = PlanType.FREE): AccountPlan => {
export const plan = (type: PlanType = PlanType.FREE): PurchasedPlan => {
return {
type,
usesInvoicing: false,
minUsers: 1,
model: PlanModel.PER_USER,
}
}
export const newLicense = (opts: {
quotas: Quotas
planType?: PlanType
}): License => {
export function quotas(): Quotas {
return {
features: [],
quotas: opts.quotas,
plan: newPlan(opts.planType),
usage: {
monthly: {
queries: {
name: "Queries",
value: 1,
triggers: [],
},
automations: {
name: "Queries",
value: 1,
triggers: [],
},
dayPasses: {
name: "Queries",
value: 1,
triggers: [],
},
},
static: {
rows: {
name: "Rows",
value: 1,
triggers: [],
},
apps: {
name: "Apps",
value: 1,
triggers: [],
},
users: {
name: "Users",
value: 1,
triggers: [],
},
userGroups: {
name: "User Groups",
value: 1,
triggers: [],
},
plugins: {
name: "Plugins",
value: 1,
triggers: [],
},
},
},
constant: {
automationLogRetentionDays: {
name: "Automation Logs",
value: 1,
triggers: [],
},
appBackupRetentionDays: {
name: "Backups",
value: 1,
triggers: [],
},
},
}
}
export function billing(
opts: { customer?: Customer; subscription?: Subscription } = {}
): Billing {
return {
customer: opts.customer || customer(),
subscription: opts.subscription || subscription(),
}
}
export function customer(): Customer {
return {
balance: 0,
currency: "usd",
}
}
export function subscription(): Subscription {
return {
amount: 10000,
cancelAt: undefined,
currency: "usd",
currentPeriodEnd: 0,
currentPeriodStart: 0,
downgradeAt: 0,
duration: PriceDuration.MONTHLY,
pastDueAt: undefined,
quantity: 0,
status: "active",
}
}
export const license = (
opts: {
quotas?: Quotas
plan?: PurchasedPlan
planType?: PlanType
features?: Feature[]
billing?: Billing
} = {}
): License => {
return {
features: opts.features || [],
quotas: opts.quotas || quotas(),
plan: opts.plan || plan(opts.planType),
billing: opts.billing || billing(),
}
}

View File

@ -0,0 +1,67 @@
import { MonthlyQuotaName, QuotaUsage } from "@budibase/types"
export const usage = (): QuotaUsage => {
return {
_id: "usage_quota",
quotaReset: new Date().toISOString(),
apps: {
app_1: {
// @ts-ignore - the apps definition doesn't match up to actual usage
usageQuota: {
rows: 0,
},
},
},
monthly: {
"01-2023": {
automations: 0,
dayPasses: 0,
queries: 0,
triggers: {},
breakdown: {
rowQueries: {
parent: MonthlyQuotaName.QUERIES,
values: {
row_1: 0,
row_2: 0,
},
},
datasourceQueries: {
parent: MonthlyQuotaName.QUERIES,
values: {
ds_1: 0,
ds_2: 0,
},
},
automations: {
parent: MonthlyQuotaName.AUTOMATIONS,
values: {
auto_1: 0,
auto_2: 0,
},
},
},
},
"02-2023": {
automations: 0,
dayPasses: 0,
queries: 0,
triggers: {},
},
current: {
automations: 0,
dayPasses: 0,
queries: 0,
triggers: {},
},
},
usageQuota: {
apps: 0,
plugins: 0,
users: 0,
userGroups: 0,
rows: 0,
triggers: {},
},
}
}

View File

@ -0,0 +1 @@
export * as time from "./time"

View File

@ -0,0 +1,3 @@
export function addDaysToDate(date: Date, days: number) {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000)
}

View File

@ -10,15 +10,11 @@
"incremental": true,
"sourceMap": true,
"declaration": true,
"types": [ "node", "jest" ],
"types": ["node", "jest"],
"outDir": "dist",
"skipLibCheck": true
},
"include": [
"**/*.js",
"**/*.ts",
"package.json"
],
"include": ["**/*.js", "**/*.ts"],
"exclude": [
"node_modules",
"dist",
@ -26,4 +22,4 @@
"**/*.spec.js",
"__mocks__"
]
}
}

View File

@ -1,7 +1,7 @@
{
"name": "@budibase/bbui",
"description": "A UI solution used in the different Budibase projects.",
"version": "2.5.5-alpha.0",
"version": "2.5.6-alpha.32",
"license": "MPL-2.0",
"svelte": "src/index.js",
"module": "dist/bbui.es.js",
@ -38,8 +38,8 @@
],
"dependencies": {
"@adobe/spectrum-css-workflow-icons": "1.2.1",
"@budibase/shared-core": "2.5.5-alpha.0",
"@budibase/string-templates": "2.5.5-alpha.0",
"@budibase/shared-core": "2.5.6-alpha.32",
"@budibase/string-templates": "2.5.6-alpha.32",
"@spectrum-css/accordion": "3.0.24",
"@spectrum-css/actionbutton": "1.0.1",
"@spectrum-css/actiongroup": "1.0.1",
@ -84,7 +84,7 @@
"@spectrum-css/vars": "3.0.1",
"dayjs": "^1.10.4",
"easymde": "^2.16.1",
"svelte-flatpickr": "^3.2.3",
"svelte-flatpickr": "^3.3.2",
"svelte-portal": "^1.0.0"
},
"resolutions": {

View File

@ -71,7 +71,7 @@
{/if}
{#if icon}
<svg
class="spectrum-Icon spectrum-Icon--size{size}"
class="spectrum-Icon spectrum-Icon--sizeS"
focusable="false"
aria-hidden="true"
aria-label={icon}

View File

@ -6,6 +6,9 @@ let clickHandlers = []
*/
const handleClick = event => {
// Ignore click if this is an ignored class
if (event.target.closest('[data-ignore-click-outside="true"]')) {
return
}
for (let className of ignoredClasses) {
if (event.target.closest(className)) {
return
@ -29,6 +32,7 @@ const handleClick = event => {
})
}
document.documentElement.addEventListener("click", handleClick, true)
document.documentElement.addEventListener("contextmenu", handleClick, true)
/**
* Adds or updates a click handler

View File

@ -10,7 +10,14 @@ export default function positionDropdown(element, opts) {
// Updates the position of the dropdown
const updatePosition = opts => {
const { anchor, align, maxWidth, useAnchorWidth, offset = 5 } = opts
const {
anchor,
align,
maxHeight,
maxWidth,
useAnchorWidth,
offset = 5,
} = opts
if (!anchor) {
return
}
@ -31,10 +38,11 @@ export default function positionDropdown(element, opts) {
styles.top = anchorBounds.top
} else if (window.innerHeight - anchorBounds.bottom < 100) {
styles.top = anchorBounds.top - elementBounds.height - offset
styles.maxHeight = 240
styles.maxHeight = maxHeight || 240
} else {
styles.top = anchorBounds.bottom + offset
styles.maxHeight = window.innerHeight - anchorBounds.bottom - 20
styles.maxHeight =
maxHeight || window.innerHeight - anchorBounds.bottom - 20
}
// Determine horizontal styles

View File

@ -138,7 +138,7 @@
}
</script>
<div class="container">
<div class="container" class:compact>
{#if selectedImage}
{#if gallery}
<div class="gallery">
@ -355,6 +355,9 @@
input[type="file"] {
display: none;
}
.compact .spectrum-Dropzone {
padding: 6px 0 !important;
}
.gallery {
display: flex;
@ -379,6 +382,17 @@
object-fit: contain;
margin: 20px 30px;
}
.compact .placeholder,
.compact img {
margin: 10px 16px;
}
.compact img {
height: 90px;
}
.compact .gallery {
padding: 6px 10px;
margin-bottom: 8px;
}
.title {
display: flex;
flex-direction: row;
@ -447,6 +461,13 @@
.disabled .spectrum-Heading--sizeL {
color: var(--spectrum-alias-text-color-disabled);
}
.compact .spectrum-Dropzone {
padding-top: 8px;
padding-bottom: 8px;
}
.compact .spectrum-IllustratedMessage-description {
margin: 0;
}
.tags {
margin-top: 20px;

View File

@ -20,12 +20,13 @@
const dispatch = createEventDispatcher()
$: selectedLookupMap = getSelectedLookupMap(value)
$: arrayValue = Array.isArray(value) ? value : [value].filter(x => !!x)
$: selectedLookupMap = getSelectedLookupMap(arrayValue)
$: optionLookupMap = getOptionLookupMap(options)
$: fieldText = getFieldText(value, optionLookupMap, placeholder)
$: fieldText = getFieldText(arrayValue, optionLookupMap, placeholder)
$: isOptionSelected = optionValue => selectedLookupMap[optionValue] === true
$: toggleOption = makeToggleOption(selectedLookupMap, value)
$: toggleOption = makeToggleOption(selectedLookupMap, arrayValue)
const getFieldText = (value, map, placeholder) => {
if (Array.isArray(value) && value.length > 0) {
@ -84,7 +85,7 @@
{readonly}
{fieldText}
{options}
isPlaceholder={!value?.length}
isPlaceholder={!arrayValue.length}
{autocomplete}
bind:fetchTerm
{useFetch}

View File

@ -16,6 +16,7 @@
export let gallery = true
export let fileTags = []
export let maximum = undefined
export let compact = false
const dispatch = createEventDispatcher()
const onChange = e => {
@ -37,6 +38,7 @@
{gallery}
{fileTags}
{maximum}
{compact}
on:change={onChange}
/>
</Field>

View File

@ -7,7 +7,7 @@
export let message = ""
export let onConfirm = undefined
export let buttonText = ""
export let cta = false
$: icon = selectIcon(type)
// if newlines used, convert them to different elements
$: split = message.split("\n")
@ -41,7 +41,9 @@
{/each}
{#if onConfirm}
<div class="spectrum-InLineAlert-footer button">
<Button secondary on:click={onConfirm}>{buttonText || "OK"}</Button>
<Button {cta} secondary={cta ? false : true} on:click={onConfirm}
>{buttonText || "OK"}</Button
>
</div>
{/if}
</div>
@ -57,7 +59,6 @@
--spectrum-semantic-negative-icon-color: #e34850;
min-width: 100px;
margin: 0;
border-color: var(--spectrum-global-color-gray-400);
border-width: 1px;
}
</style>

View File

@ -9,6 +9,7 @@
<TooltipWrapper {tooltip} {size}>
<label
data-testid="label"
class:muted
for=""
class={`spectrum-FieldLabel spectrum-FieldLabel--size${size}`}

View File

@ -4,6 +4,7 @@
export let wide = false
export let narrow = false
export let narrower = false
export let noPadding = false
let sidePanelVisble = false
@ -16,7 +17,7 @@
<div class="page">
<div class="main">
<div class="content" class:wide class:noPadding class:narrow>
<div class="content" class:wide class:noPadding class:narrow class:narrower>
<slot />
<div class="fix-scroll-padding" />
</div>
@ -70,6 +71,9 @@
.content.narrow {
max-width: 840px;
}
.content.narrower {
max-width: 700px;
}
#side-panel {
position: absolute;
right: 0;

View File

@ -14,11 +14,13 @@
export let align = "right"
export let portalTarget
export let maxWidth
export let maxHeight
export let open = false
export let useAnchorWidth = false
export let dismissible = true
export let offset = 5
export let customHeight
export let animate = true
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
@ -64,6 +66,7 @@
use:positionDropdown={{
anchor,
align,
maxHeight,
maxWidth,
useAnchorWidth,
offset,
@ -76,7 +79,7 @@
class="spectrum-Popover is-open"
role="presentation"
style="height: {customHeight}"
transition:fly|local={{ y: -20, duration: 200 }}
transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }}
>
<slot />
</div>

View File

@ -3,6 +3,7 @@ import { writable } from "svelte/store"
export const BANNER_TYPES = {
INFO: "info",
NEGATIVE: "negative",
WARNING: "warning",
}
export function createBannerStore() {
@ -38,7 +39,8 @@ export function createBannerStore() {
const queue = async entries => {
const priority = {
[BANNER_TYPES.NEGATIVE]: 0,
[BANNER_TYPES.INFO]: 1,
[BANNER_TYPES.WARNING]: 1,
[BANNER_TYPES.INFO]: 2,
}
banner.update(store => {
const sorted = [...store.messages, ...entries].sort((a, b) => {

View File

@ -5,8 +5,9 @@
const displayLimit = 5
$: badges = Array.isArray(value) ? value.slice(0, displayLimit) : []
$: leftover = (value?.length ?? 0) - badges.length
$: arrayValue = Array.isArray(value) ? value : [value].filter(x => !!x)
$: badges = arrayValue.slice(0, displayLimit)
$: leftover = arrayValue.length - badges.length
</script>
{#each badges as badge}

View File

@ -143,7 +143,7 @@
}
fields?.forEach(field => {
const fieldSchema = schema[field]
if (fieldSchema.width) {
if (fieldSchema.width && typeof fieldSchema.width === "string") {
style += ` ${fieldSchema.width}`
} else {
style += " minmax(auto, 1fr)"

View File

@ -9,6 +9,7 @@
</script>
<p
data-testid="typography-body"
style={`
${weight ? `font-weight:${weight};` : ""}
${textAlign ? `text-align:${textAlign};` : ""}

View File

@ -3,9 +3,13 @@
export let size = "M"
export let serif = false
export let weight = 600
</script>
<p
style={`
${weight ? `font-weight:${weight};` : ""}
`}
class="spectrum-Detail spectrum-Detail--size{size}"
class:spectrum-Detail--serif={serif}
>
@ -13,7 +17,4 @@
</p>
<style>
p {
font-weight: 600;
}
</style>

View File

@ -9,6 +9,7 @@
</script>
<h1
data-testid="typography-heading"
style={textAlign ? `text-align:${textAlign}` : ``}
class:noPadding
class="spectrum-Heading spectrum-Heading--size{size} spectrum-Heading--{weight}"

View File

@ -97,4 +97,22 @@
a {
text-decoration: none;
}
}
/* Custom theme additions */
.spectrum--darkest {
--drop-shadow: rgba(0, 0, 0, 0.6);
--spectrum-global-color-blue-100: rgb(28, 33, 43);
}
.spectrum--dark {
--drop-shadow: rgba(0, 0, 0, 0.3);
--spectrum-global-color-blue-100: rgb(42, 47, 57);
}
.spectrum--light {
--drop-shadow: rgba(0, 0, 0, 0.075);
--spectrum-global-color-blue-100: rgb(240, 245, 255);
}
.spectrum--lightest {
--drop-shadow: rgba(0, 0, 0, 0.05);
--spectrum-global-color-blue-100: rgb(240, 244, 255);
}

View File

@ -1,6 +1,6 @@
{
"name": "@budibase/builder",
"version": "2.5.5-alpha.0",
"version": "2.5.6-alpha.32",
"license": "GPL-3.0",
"private": true,
"scripts": {
@ -9,7 +9,7 @@
"dev:builder": "routify -c dev:vite",
"dev:vite": "vite --host 0.0.0.0",
"rollup": "rollup -c -w",
"test": "jest"
"test": "vitest"
},
"jest": {
"globals": {
@ -58,11 +58,10 @@
}
},
"dependencies": {
"@budibase/bbui": "2.5.5-alpha.0",
"@budibase/client": "2.5.5-alpha.0",
"@budibase/frontend-core": "2.5.5-alpha.0",
"@budibase/shared-core": "2.5.5-alpha.0",
"@budibase/string-templates": "2.5.5-alpha.0",
"@budibase/bbui": "2.5.6-alpha.32",
"@budibase/frontend-core": "2.5.6-alpha.32",
"@budibase/shared-core": "2.5.6-alpha.32",
"@budibase/string-templates": "2.5.6-alpha.32",
"@fortawesome/fontawesome-svg-core": "^6.2.1",
"@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",
@ -93,13 +92,14 @@
"@roxi/routify": "2.18.5",
"@sveltejs/vite-plugin-svelte": "1.0.1",
"@testing-library/jest-dom": "^5.11.10",
"@testing-library/svelte": "^3.0.0",
"@testing-library/svelte": "^3.2.2",
"babel-jest": "^26.6.3",
"cypress": "^9.3.1",
"cypress-multi-reporters": "^1.6.0",
"cypress-terminal-report": "^1.4.1",
"identity-obj-proxy": "^3.0.0",
"jest": "^26.6.3",
"jsdom": "^21.1.1",
"mochawesome": "^7.1.3",
"mochawesome-merge": "^4.2.1",
"mochawesome-report-generator": "^6.2.0",
@ -113,7 +113,8 @@
"ts-node": "10.8.1",
"tsconfig-paths": "4.0.0",
"typescript": "4.7.3",
"vite": "^3.0.8"
"vite": "^3.0.8",
"vitest": "^0.29.2"
},
"gitHead": "115189f72a850bfb52b65ec61d932531bf327072"
}

View File

@ -10,7 +10,10 @@ import { auth } from "./stores/portal"
export const API = createAPIClient({
attachHeaders: headers => {
// Attach app ID header from store
headers["x-budibase-app-id"] = get(store).appId
let appId = get(store).appId
if (appId) {
headers["x-budibase-app-id"] = appId
}
// Add csrf token if authenticated
const user = get(auth).user

View File

@ -134,6 +134,7 @@ export const getFrontendStore = () => {
previousTopNavPath: {},
version: application.version,
revertableVersion: application.revertableVersion,
upgradableVersion: application.upgradableVersion,
navigation: application.navigation || {},
usedPlugins: application.usedPlugins || [],
}))

View File

@ -1,13 +1,13 @@
import * as jsonpatch from "fast-json-patch/index.mjs"
import { writable, derived, get } from "svelte/store"
const Operations = {
export const Operations = {
Add: "Add",
Delete: "Delete",
Change: "Change",
}
const initialState = {
export const initialState = {
history: [],
position: 0,
loading: false,

View File

@ -0,0 +1,345 @@
import { it, expect, describe, beforeEach, vi } from "vitest"
import { Operations, initialState, createHistoryStore } from "./history"
import { writable, derived, get } from "svelte/store"
import * as jsonpatch from "fast-json-patch/index.mjs"
vi.mock("svelte/store", () => {
return {
writable: vi.fn(),
derived: vi.fn(),
get: vi.fn(),
}
})
vi.mock("fast-json-patch/index.mjs", () => {
return {
compare: vi.fn(),
deepClone: vi.fn(),
applyPatch: vi.fn(),
}
})
describe("admin store", () => {
beforeEach(ctx => {
vi.clearAllMocks()
ctx.writableReturn = { update: vi.fn(), subscribe: vi.fn(), set: vi.fn() }
writable.mockReturnValue(ctx.writableReturn)
ctx.derivedReturn = { subscribe: vi.fn() }
derived.mockReturnValue(ctx.derivedReturn)
ctx.getDoc = vi.fn().mockReturnValue({})
ctx.selectDoc = vi.fn().mockReturnValue({})
ctx.beforeAction = vi.fn()
ctx.afterAction = vi.fn()
ctx.returnedStore = createHistoryStore({
getDoc: ctx.getDoc,
selectDoc: ctx.selectDoc,
beforeAction: ctx.beforeAction,
afterAction: ctx.afterAction,
})
})
describe("init", () => {
it("inits the writable store with the default config", () => {
expect(writable).toHaveBeenCalledTimes(1)
expect(writable).toHaveBeenCalledWith(initialState)
})
it("inits the derived store with the initial writable store and an update function", () => {
expect(derived).toHaveBeenCalledTimes(1)
expect(derived.calls[0][1]({ position: 0, history: [] })).toEqual({
position: 0,
history: [],
canUndo: false,
canRedo: false,
})
})
it("returns the created store and methods to manipulate it", ctx => {
expect(ctx.returnedStore).toEqual({
subscribe: expect.toBe(ctx.derivedReturn.subscribe),
wrapSaveDoc: expect.toBeFunc(),
wrapDeleteDoc: expect.toBeFunc(),
reset: expect.toBeFunc(),
undo: expect.toBeFunc(),
redo: expect.toBeFunc(),
})
})
})
describe("wrapSaveDoc", () => {
beforeEach(async ctx => {
ctx.saveFn = vi.fn().mockResolvedValue("fn")
ctx.doc = { _id: "id" }
ctx.oldDoc = { _id: "oldDoc" }
ctx.newDoc = { _id: "newDoc" }
ctx.getDoc.mockReturnValue(ctx.oldDoc)
jsonpatch.deepClone.mockReturnValue(ctx.newDoc)
vi.stubGlobal("Math", { random: vi.fn() })
ctx.forwardPatch = { foo: 1 }
ctx.backwardsPatch = { bar: 2 }
jsonpatch.compare.mockReturnValueOnce(ctx.forwardPatch)
jsonpatch.compare.mockReturnValueOnce(ctx.backwardsPatch)
Math.random.mockReturnValue(1234)
const wrappedSaveFn = ctx.returnedStore.wrapSaveDoc(ctx.saveFn)
await wrappedSaveFn(ctx.doc, null)
})
it("sets the state to loading", ctx => {
expect(ctx.writableReturn.update.calls[0][0]({})).toEqual({
loading: true,
})
})
it("retrieves the old doc", ctx => {
expect(ctx.getDoc).toHaveBeenCalledTimes(1)
expect(ctx.getDoc).toHaveBeenCalledWith("id")
})
it("clones the new doc", ctx => {
expect(ctx.saveFn).toHaveBeenCalledTimes(1)
expect(ctx.saveFn).toHaveBeenCalledWith(ctx.doc)
expect(jsonpatch.deepClone).toHaveBeenCalledTimes(1)
expect(jsonpatch.deepClone).toHaveBeenCalledWith("fn")
})
it("creates the undo/redo patches", ctx => {
expect(jsonpatch.compare).toHaveBeenCalledTimes(2)
expect(jsonpatch.compare.calls[0]).toEqual([ctx.oldDoc, ctx.doc])
expect(jsonpatch.compare.calls[1]).toEqual([ctx.doc, ctx.oldDoc])
})
it("saves the operation", ctx => {
expect(
ctx.writableReturn.update.calls[1][0]({
history: [],
position: 0,
})
).toEqual({
history: [
{
type: Operations.Change,
backwardsPatch: ctx.backwardsPatch,
forwardPatch: ctx.forwardPatch,
doc: ctx.newDoc,
id: 1234,
},
],
position: 1,
})
})
it("sets the state to not loading", ctx => {
expect(ctx.writableReturn.update).toHaveBeenCalledTimes(3)
expect(ctx.writableReturn.update.calls[2][0]({})).toEqual({
loading: false,
})
})
})
describe("wrapDeleteDoc", () => {
beforeEach(async ctx => {
ctx.deleteFn = vi.fn().mockResolvedValue("fn")
ctx.doc = { _id: "id" }
ctx.oldDoc = { _id: "oldDoc" }
jsonpatch.deepClone.mockReturnValue(ctx.oldDoc)
vi.stubGlobal("Math", { random: vi.fn() })
Math.random.mockReturnValue(1235)
const wrappedDeleteDoc = ctx.returnedStore.wrapDeleteDoc(ctx.deleteFn)
await wrappedDeleteDoc(ctx.doc, null)
})
it("sets the state to loading", ctx => {
expect(ctx.writableReturn.update.calls[0][0]({})).toEqual({
loading: true,
})
})
it("clones the doc", ctx => {
expect(jsonpatch.deepClone).toHaveBeenCalledTimes(1)
expect(jsonpatch.deepClone).toHaveBeenCalledWith(ctx.doc)
})
it("calls the delete fn with the doc", ctx => {
expect(ctx.deleteFn).toHaveBeenCalledTimes(1)
expect(ctx.deleteFn).toHaveBeenCalledWith(ctx.doc)
})
it("saves the operation", ctx => {
expect(
ctx.writableReturn.update.calls[1][0]({
history: [],
position: 0,
})
).toEqual({
history: [
{
type: Operations.Delete,
doc: ctx.oldDoc,
id: 1235,
},
],
position: 1,
})
})
it("sets the state to not loading", ctx => {
expect(ctx.writableReturn.update).toHaveBeenCalledTimes(3)
expect(ctx.writableReturn.update.calls[2][0]({})).toEqual({
loading: false,
})
})
})
describe("reset", () => {
beforeEach(ctx => {
ctx.returnedStore.reset()
})
it("sets the store to the initial state", ctx => {
expect(ctx.writableReturn.set).toHaveBeenCalledTimes(1)
expect(ctx.writableReturn.set).toHaveBeenCalledWith(initialState)
})
})
describe("undo", () => {
beforeEach(async ctx => {
ctx.history = [
{ type: Operations.Delete, doc: { _id: 1236, _rev: "rev" } },
]
ctx.derivedReturn = {
subscribe: vi.fn(),
canUndo: true,
history: ctx.history,
position: 1,
loading: false,
}
get.mockReturnValue(ctx.derivedReturn)
jsonpatch.deepClone.mockReturnValueOnce(ctx.history[0].doc)
ctx.newDoc = { _id: 1337 }
ctx.saveFn = vi.fn().mockResolvedValue(ctx.newDoc)
jsonpatch.deepClone.mockReturnValueOnce(ctx.newDoc)
// We need to create a wrapped saveFn before undo can be invoked
ctx.returnedStore.wrapSaveDoc(ctx.saveFn)
await ctx.returnedStore.undo()
})
it("sets the state to loading", ctx => {
expect(ctx.writableReturn.update.calls[0][0]({})).toEqual({
loading: true,
})
})
it("calls the beforeAction", ctx => {
expect(ctx.beforeAction).toHaveBeenCalledTimes(1)
expect(ctx.beforeAction).toHaveBeenCalledWith(ctx.history[0])
})
it("sets the state to the previous position", ctx => {
expect(
ctx.writableReturn.update.calls[1][0]({ history: [], position: 1 })
).toEqual({ history: [], position: 0 })
})
it("clones the doc", ctx => {
expect(jsonpatch.deepClone).toHaveBeenCalledWith(ctx.history[0].doc)
})
it("deletes the doc's rev", ctx => {
expect(ctx.history[0].doc._rev).toBe(undefined)
})
it("calls the wrappedSaveFn", ctx => {
expect(jsonpatch.deepClone).toHaveBeenCalledWith(ctx.newDoc)
expect(ctx.saveFn).toHaveBeenCalledWith(ctx.history[0].doc)
})
it("calls selectDoc", ctx => {
expect(ctx.selectDoc).toHaveBeenCalledWith(ctx.newDoc._id)
})
it("sets the state to not loading", ctx => {
expect(ctx.writableReturn.update.calls[5][0]({})).toEqual({
loading: false,
})
})
it("calls the afterAction", ctx => {
expect(ctx.afterAction).toHaveBeenCalledTimes(1)
expect(ctx.afterAction).toHaveBeenCalledWith(ctx.history[0])
})
})
describe("redo", () => {
beforeEach(async ctx => {
ctx.history = [
{ type: Operations.Delete, doc: { _id: 1236, _rev: "rev" } },
]
ctx.derivedReturn = {
subscribe: vi.fn(),
canRedo: true,
history: ctx.history,
position: 0,
loading: false,
}
get.mockReturnValue(ctx.derivedReturn)
ctx.latestDoc = { _id: 1337 }
ctx.getDoc.mockReturnValue(ctx.latestDoc)
// We need to create a wrapped deleteFn before redo can be invoked
ctx.deleteFn = vi.fn().mockResolvedValue(ctx.newDoc)
ctx.returnedStore.wrapDeleteDoc(ctx.deleteFn)
await ctx.returnedStore.redo()
})
it("sets the state to loading", ctx => {
expect(ctx.writableReturn.update.calls[0][0]({})).toEqual({
loading: true,
})
})
it("calls the beforeAction", ctx => {
expect(ctx.beforeAction).toHaveBeenCalledTimes(1)
expect(ctx.beforeAction).toHaveBeenCalledWith(ctx.history[0])
})
it("sets the state to the next position", ctx => {
expect(
ctx.writableReturn.update.calls[1][0]({ history: [], position: 0 })
).toEqual({ history: [], position: 1 })
})
it("calls the wrappedDeleteFn", ctx => {
expect(ctx.deleteFn).toHaveBeenCalledWith(ctx.latestDoc)
})
it("sets the state to not loading", ctx => {
expect(ctx.writableReturn.update.calls[5][0]({})).toEqual({
loading: false,
})
})
it("calls the afterAction", ctx => {
expect(ctx.afterAction).toHaveBeenCalledTimes(1)
expect(ctx.afterAction).toHaveBeenCalledWith(ctx.history[0])
})
})
})

View File

@ -26,7 +26,7 @@
const external = actions.reduce((acc, elm) => {
const [k, v] = elm
if (!v.internal) {
if (!v.internal && !v.custom) {
acc[k] = v
}
return acc
@ -41,6 +41,15 @@
return acc
}, {})
const plugins = actions.reduce((acc, elm) => {
const [k, v] = elm
if (v.custom) {
acc[k] = v
}
return acc
}, {})
console.log(plugins)
const selectAction = action => {
actionVal = action
selectedAction = action.name
@ -116,6 +125,26 @@
{/each}
</div>
</Layout>
{#if Object.keys(plugins).length}
<Layout noPadding gap="XS">
<Detail size="S">Plugins</Detail>
<div class="item-list">
{#each Object.entries(plugins) as [idx, action]}
<div
class="item"
class:selected={selectedAction === action.name}
on:click={() => selectAction(action)}
>
<div class="item-body">
<Icon name={action.icon} />
<Body size="XS">{action.name}</Body>
</div>
</div>
{/each}
</div>
</Layout>
{/if}
</ModalContent>
<style>
@ -126,7 +155,7 @@
}
.item-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
grid-template-columns: repeat(2, minmax(150px, 1fr));
grid-gap: var(--spectrum-alias-grid-baseline);
}

View File

@ -67,6 +67,15 @@
newInputData = cloneDeep(blockInputs)
}
inputData = newInputData
setDefaultEnumValues()
}
const setDefaultEnumValues = () => {
for (const [key, value] of schemaProperties) {
if (value.type === "string" && value.enum && inputData[key] == null) {
inputData[key] = value.enum[0]
}
}
}
const onChange = Utils.sequential(async (e, key) => {
@ -243,6 +252,7 @@
<Select
on:change={e => onChange(e, key)}
value={inputData[key]}
placeholder={false}
options={value.enum}
getOptionLabel={(x, idx) => (value.pretty ? value.pretty[idx] : x)}
/>

View File

@ -1,286 +1,75 @@
<script>
import { fade } from "svelte/transition"
import { tables } from "stores/backend"
import CreateRowButton from "./buttons/CreateRowButton.svelte"
import CreateColumnButton from "./buttons/CreateColumnButton.svelte"
import CreateViewButton from "./buttons/CreateViewButton.svelte"
import ExistingRelationshipButton from "./buttons/ExistingRelationshipButton.svelte"
import ExportButton from "./buttons/ExportButton.svelte"
import ImportButton from "./buttons/ImportButton.svelte"
import EditRolesButton from "./buttons/EditRolesButton.svelte"
import ManageAccessButton from "./buttons/ManageAccessButton.svelte"
import HideAutocolumnButton from "./buttons/HideAutocolumnButton.svelte"
import TableFilterButton from "./buttons/TableFilterButton.svelte"
import Table from "./Table.svelte"
import { TableNames } from "constants"
import CreateEditRow from "./modals/CreateEditRow.svelte"
import {
Pagination,
Heading,
Body,
Layout,
notifications,
} from "@budibase/bbui"
import { fetchData } from "@budibase/frontend-core"
import { Grid } from "@budibase/frontend-core"
import { API } from "api"
import GridAddColumnModal from "components/backend/DataTable/modals/grid/GridCreateColumnModal.svelte"
import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte"
import GridEditUserModal from "components/backend/DataTable/modals/grid/GridEditUserModal.svelte"
import GridCreateViewButton from "components/backend/DataTable/buttons/grid/GridCreateViewButton.svelte"
import GridImportButton from "components/backend/DataTable/buttons/grid/GridImportButton.svelte"
import GridExportButton from "components/backend/DataTable/buttons/grid/GridExportButton.svelte"
import GridFilterButton from "components/backend/DataTable/buttons/grid/GridFilterButton.svelte"
import GridManageAccessButton from "components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte"
import GridRelationshipButton from "components/backend/DataTable/buttons/grid/GridRelationshipButton.svelte"
import GridEditColumnModal from "components/backend/DataTable/modals/grid/GridEditColumnModal.svelte"
let hideAutocolumns = true
let filters
const userSchemaOverrides = {
firstName: { name: "First name", disabled: true },
lastName: { name: "Last name", disabled: true },
email: { name: "Email", disabled: true },
roleId: { name: "Role", disabled: true },
status: { name: "Status", disabled: true },
}
$: isUsersTable = $tables.selected?._id === TableNames.USERS
$: type = $tables.selected?.type
$: isInternal = type !== "external"
$: schema = $tables.selected?.schema
$: enrichedSchema = enrichSchema($tables.selected?.schema)
$: id = $tables.selected?._id
$: fetch = createFetch(id)
$: hasCols = checkHasCols(schema)
$: hasRows = !!$fetch.rows?.length
$: showError($fetch.error)
$: id, (filters = null)
let appliedFilter
let rawFilter
let appliedSort
let selectedRows = []
$: enrichedSchema,
() => {
appliedFilter = null
rawFilter = null
appliedSort = null
selectedRows = []
}
$: if (Number.isInteger($fetch.pageNumber)) {
selectedRows = []
}
const showError = error => {
if (error) {
notifications.error(error?.message || "Unable to fetch data.")
}
}
const enrichSchema = schema => {
let tempSchema = { ...schema }
tempSchema._id = {
type: "internal",
editable: false,
displayName: "ID",
autocolumn: true,
}
if (isInternal) {
tempSchema._rev = {
type: "internal",
editable: false,
displayName: "Revision",
autocolumn: true,
}
}
return tempSchema
}
const checkHasCols = schema => {
if (!schema || Object.keys(schema).length === 0) {
return false
}
let fields = Object.values(schema)
for (let field of fields) {
if (!field.autocolumn) {
return true
}
}
return false
}
// Fetches new data whenever the table changes
const createFetch = tableId => {
return fetchData({
API,
datasource: {
tableId,
type: "table",
},
options: {
schema,
limit: 10,
paginate: true,
},
})
}
// Fetch data whenever sorting option changes
const onSort = async e => {
const sort = {
sortColumn: e.detail.column,
sortOrder: e.detail.order,
}
await fetch.update(sort)
appliedSort = { ...sort }
appliedSort.sortOrder = appliedSort.sortOrder.toLowerCase()
selectedRows = []
}
// Fetch data whenever filters change
const onFilter = e => {
filters = e.detail
fetch.update({
filter: filters,
})
appliedFilter = e.detail
}
// Fetch data whenever schema changes
const onUpdateColumns = () => {
selectedRows = []
fetch.refresh()
tables.fetchTable(id)
}
// Fetch data whenever rows are modified. Unfortunately we have to lose
// our pagination place, as our bookmarks will have shifted.
const onUpdateRows = () => {
selectedRows = []
fetch.refresh()
}
// When importing new rows it is better to reinitialise request/paging data.
// Not doing so causes inconsistency in paging behaviour and content.
const onImportData = () => {
fetch.getInitialData()
}
$: isUsersTable = id === TableNames.USERS
$: isInternal = $tables.selected?.type !== "external"
</script>
<div>
<Table
title={$tables.selected?.name}
schema={enrichedSchema}
{type}
<div class="wrapper">
<Grid
{API}
tableId={id}
data={$fetch.rows}
bind:hideAutocolumns
loading={!$fetch.loaded}
on:sort={onSort}
allowEditing
disableSorting
on:updatecolumns={onUpdateColumns}
on:updaterows={onUpdateRows}
on:selectionUpdated={e => {
selectedRows = e.detail
}}
customPlaceholder
allowAddRows={!isUsersTable}
allowDeleteRows={!isUsersTable}
schemaOverrides={isUsersTable ? userSchemaOverrides : null}
on:updatetable={e => tables.updateTable(e.detail)}
>
<div class="buttons">
<div class="left-buttons">
<CreateColumnButton
highlighted={$fetch.loaded && (!hasCols || !hasRows)}
on:updatecolumns={onUpdateColumns}
/>
{#if !isUsersTable}
<CreateRowButton
on:updaterows={onUpdateRows}
title={"Create row"}
modalContentComponent={CreateEditRow}
disabled={!hasCols}
highlighted={$fetch.loaded && hasCols && !hasRows}
/>
{/if}
{#if isInternal}
<CreateViewButton disabled={!hasCols || !hasRows} />
{/if}
</div>
<div class="right-buttons">
<ManageAccessButton resourceId={$tables.selected?._id} />
{#if isUsersTable}
<EditRolesButton />
{/if}
{#if !isInternal}
<ExistingRelationshipButton
table={$tables.selected}
on:updatecolumns={onUpdateColumns}
/>
{/if}
<HideAutocolumnButton bind:hideAutocolumns />
<ImportButton
disabled={$tables.selected?._id === "ta_users"}
tableId={$tables.selected?._id}
on:importrows={onImportData}
/>
<ExportButton
disabled={!hasRows || !hasCols}
view={$tables.selected?._id}
filters={appliedFilter}
sorting={appliedSort}
{selectedRows}
/>
{#key id}
<TableFilterButton
{schema}
{filters}
on:change={onFilter}
disabled={!hasCols}
tableId={id}
/>
{/key}
</div>
</div>
<div slot="placeholder">
<Layout gap="S">
{#if !hasCols}
<Heading>Let's create some columns</Heading>
<Body>
Start building out your table structure<br />
by adding some columns
</Body>
{:else}
<Heading>Now let's add a row</Heading>
<Body>
Add some data to your table<br />
by adding some rows
</Body>
{/if}
</Layout>
</div>
</Table>
{#key id}
<div in:fade={{ delay: 200, duration: 100 }}>
<div class="pagination">
<Pagination
page={$fetch.pageNumber + 1}
hasPrevPage={$fetch.hasPrevPage}
hasNextPage={$fetch.hasNextPage}
goToPrevPage={$fetch.loading ? null : fetch.prevPage}
goToNextPage={$fetch.loading ? null : fetch.nextPage}
/>
</div>
</div>
{/key}
<svelte:fragment slot="controls">
{#if isInternal}
<GridCreateViewButton />
{/if}
<GridManageAccessButton />
{#if !isInternal}
<GridRelationshipButton />
{/if}
{#if isUsersTable}
<EditRolesButton />
{:else}
<GridImportButton />
{/if}
<GridExportButton />
<GridFilterButton />
<GridAddColumnModal />
<GridEditColumnModal />
{#if isUsersTable}
<GridEditUserModal />
{:else}
<GridCreateEditRowModal />
{/if}
</svelte:fragment>
</Grid>
</div>
<style>
.pagination {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
margin-top: var(--spacing-xl);
}
.buttons {
.wrapper {
flex: 1 1 auto;
margin: -28px -40px -40px -40px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
}
.left-buttons,
.right-buttons {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-m);
flex-direction: column;
background: var(--background);
overflow: hidden;
}
</style>

View File

@ -1,15 +1,10 @@
<script>
import { fade } from "svelte/transition"
import { goto, params } from "@roxi/routify"
import { Table, Modal, Heading, notifications, Layout } from "@budibase/bbui"
import { API } from "api"
import { Table, Heading, Layout } from "@budibase/bbui"
import Spinner from "components/common/Spinner.svelte"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import DeleteRowsButton from "./buttons/DeleteRowsButton.svelte"
import CreateEditRow from "./modals/CreateEditRow.svelte"
import CreateEditUser from "./modals/CreateEditUser.svelte"
import CreateEditColumn from "./modals/CreateEditColumn.svelte"
import { cloneDeep } from "lodash/fp"
import {
TableNames,
UNEDITABLE_USER_FIELDS,
@ -22,7 +17,6 @@
export let data = []
export let tableId
export let title
export let allowEditing = false
export let loading = false
export let hideAutocolumns
export let rowCount
@ -32,12 +26,7 @@
const dispatch = createEventDispatcher()
let selectedRows = []
let editableColumn
let editableRow
let editRowModal
let editColumnModal
let customRenderers = []
let confirmDelete
$: selectedRows, dispatch("selectionUpdated", selectedRows)
$: isUsersTable = tableId === TableNames.USERS
@ -92,36 +81,6 @@
`/builder/app/${$params.application}/data/table/${tableId}/relationship/${rowId}/${fieldName}`
)
}
const deleteRows = async targetRows => {
try {
await API.deleteRows({
tableId,
rows: targetRows,
})
const deletedRowIds = targetRows.map(row => row._id)
data = data.filter(row => deletedRowIds.indexOf(row._id))
notifications.success(`Successfully deleted ${targetRows.length} rows`)
} catch (error) {
notifications.error("Error deleting rows")
}
}
const editRow = row => {
editableRow = row
if (row) {
editRowModal.show()
}
}
const editColumn = field => {
editableColumn = cloneDeep(schema?.[field])
if (editableColumn) {
editColumnModal.show()
}
}
</script>
<Layout noPadding gap="S">
@ -138,16 +97,6 @@
{/if}
<div class="popovers">
<slot />
{#if !isUsersTable && selectedRows.length > 0}
<DeleteRowsButton
on:updaterows
{selectedRows}
deleteRows={async rows => {
await deleteRows(rows)
resetSelectedRows()
}}
/>
{/if}
</div>
</Layout>
{#key tableId}
@ -160,13 +109,7 @@
{rowCount}
{disableSorting}
{customPlaceholder}
bind:selectedRows
allowSelectRows={allowEditing && !isUsersTable}
allowEditRows={allowEditing}
allowEditColumns={allowEditing}
showAutoColumns={!hideAutocolumns}
on:editcolumn={e => editColumn(e.detail)}
on:editrow={e => editRow(e.detail)}
on:clickrelationship={e => selectRelationship(e.detail)}
on:sort
>
@ -176,42 +119,6 @@
{/key}
</Layout>
<Modal bind:this={editRowModal}>
<svelte:component
this={editRowComponent}
on:updaterows
on:deleteRows={() => {
confirmDelete.show()
}}
row={editableRow}
/>
</Modal>
<ConfirmDialog
bind:this={confirmDelete}
okText="Delete"
onOk={async () => {
if (editableRow) {
await deleteRows([editableRow])
}
editableRow = undefined
}}
onCancel={async () => {
editRow(editableRow)
}}
title="Confirm Deletion"
>
Are you sure you want to delete this row?
</ConfirmDialog>
<Modal bind:this={editColumnModal}>
<CreateEditColumn
field={editableColumn}
on:updatecolumns
onClosed={editColumnModal.hide}
/>
</Modal>
<style>
.table-title {
height: 24px;

View File

@ -57,7 +57,6 @@
{data}
{loading}
{type}
allowEditing={false}
rowCount={10}
bind:hideAutocolumns
>

View File

@ -9,7 +9,6 @@
<ActionButton
icon="Calculator"
size="S"
quiet
on:click={modal.show}
active={view.field && view.calculation}

View File

@ -1,24 +0,0 @@
<script>
import { ActionButton, Modal } from "@budibase/bbui"
import CreateEditColumn from "../modals/CreateEditColumn.svelte"
export let highlighted = false
export let disabled = false
let modal
</script>
<ActionButton
{disabled}
selected={highlighted}
emphasized={highlighted}
icon="TableColumnAddRight"
quiet
size="S"
on:click={modal.show}
>
Create column
</ActionButton>
<Modal bind:this={modal}>
<CreateEditColumn on:updatecolumns />
</Modal>

View File

@ -1,26 +0,0 @@
<script>
import { ActionButton, Modal } from "@budibase/bbui"
import CreateEditRow from "../modals/CreateEditRow.svelte"
export let modalContentComponent = CreateEditRow
export let title = "Create row"
export let disabled = false
export let highlighted = false
let modal
</script>
<ActionButton
{disabled}
emphasized={highlighted}
selected={highlighted}
icon="TableRowAddBottom"
size="S"
quiet
on:click={modal.show}
>
{title}
</ActionButton>
<Modal bind:this={modal}>
<svelte:component this={modalContentComponent} on:updaterows />
</Modal>

View File

@ -1,21 +0,0 @@
<script>
import { Modal, ActionButton } from "@budibase/bbui"
import CreateViewModal from "../modals/CreateViewModal.svelte"
export let disabled = false
let modal
</script>
<ActionButton
{disabled}
icon="CollectionAdd"
size="S"
quiet
on:click={modal.show}
>
Create view
</ActionButton>
<Modal bind:this={modal}>
<CreateViewModal />
</Modal>

View File

@ -19,7 +19,7 @@
$: text = `${item}${selectedRows?.length === 1 ? "" : "s"}`
</script>
<Button icon="Delete" size="s" warning quiet on:click={modal.show}>
<Button icon="Delete" warning quiet on:click={modal.show}>
Delete
{selectedRows.length}
{text}

View File

@ -1,15 +1,13 @@
<script>
import { Button, Modal } from "@budibase/bbui"
import { ActionButton, Modal } from "@budibase/bbui"
import EditRolesModal from "../modals/EditRoles.svelte"
let modal
</script>
<div>
<Button icon="UsersLock" primary size="S" quiet on:click={modal.show}>
Edit roles
</Button>
</div>
<ActionButton icon="UsersLock" quiet on:click={modal.show}>
Edit roles
</ActionButton>
<Modal bind:this={modal}>
<EditRolesModal />
</Modal>

View File

@ -7,15 +7,23 @@
export let table
const dispatch = createEventDispatcher()
$: datasource = findDatasource(table?._id)
$: plusTables = datasource?.plus
? Object.values(datasource?.entities || {})
: []
$: datasource = $datasources.list.find(
source => source._id === table?.sourceId
)
let modal
const findDatasource = tableId => {
return $datasources.list.find(datasource => {
return (
Object.values(datasource.entities || {}).find(entity => {
return entity._id === tableId
}) != null
)
})
}
async function saveRelationship() {
try {
// Create datasource
@ -28,15 +36,9 @@
}
</script>
{#if table.sourceId}
{#if datasource}
<div>
<ActionButton
icon="DataCorrelated"
primary
size="S"
quiet
on:click={modal.show}
>
<ActionButton icon="DataCorrelated" primary quiet on:click={modal.show}>
Define existing relationship
</ActionButton>
</div>

View File

@ -11,13 +11,7 @@
let modal
</script>
<ActionButton
{disabled}
icon="DataDownload"
size="S"
quiet
on:click={modal.show}
>
<ActionButton {disabled} icon="DataDownload" quiet on:click={modal.show}>
Export
</ActionButton>
<Modal bind:this={modal}>

View File

@ -10,7 +10,6 @@
<Button
icon="Group"
primary
size="S"
quiet
active={!!view.groupBy}
on:click={modal.show}

View File

@ -11,7 +11,6 @@
<ActionButton
icon={hideAutocolumns ? "VisibilityOff" : "Visibility"}
primary
size="S"
quiet
on:click={hideOrUnhide}
>

View File

@ -8,7 +8,7 @@
let modal
</script>
<ActionButton icon="DataUpload" size="S" quiet on:click={modal.show} {disabled}>
<ActionButton icon="DataUpload" quiet on:click={modal.show} {disabled}>
Import
</ActionButton>
<Modal bind:this={modal}>

View File

@ -4,6 +4,7 @@
import ManageAccessModal from "../modals/ManageAccessModal.svelte"
export let resourceId
export let disabled = false
let modal
let resourcePermissions
@ -14,8 +15,8 @@
}
</script>
<ActionButton icon="LockClosed" size="S" quiet on:click={openDropdown}>
Manage access
<ActionButton icon="LockClosed" quiet on:click={openDropdown} {disabled}>
Access
</ActionButton>
<Modal bind:this={modal}>
<ManageAccessModal

View File

@ -18,11 +18,10 @@
<ActionButton
icon="Filter"
size="S"
quiet
{disabled}
on:click={modal.show}
active={tempValue?.length > 0}
selected={tempValue?.length > 0}
>
Filter
</ActionButton>

View File

@ -9,7 +9,6 @@
<ActionButton
icon="Filter"
size="S"
quiet
on:click={modal.show}
active={view.filters?.length}

View File

@ -0,0 +1,18 @@
<script>
import { getContext } from "svelte"
import { Modal, ActionButton } from "@budibase/bbui"
import CreateViewModal from "../../modals/CreateViewModal.svelte"
const { rows, columns } = getContext("grid")
let modal
$: disabled = !$columns.length || !$rows.length
</script>
<ActionButton {disabled} icon="CollectionAdd" quiet on:click={modal.show}>
Add view
</ActionButton>
<Modal bind:this={modal}>
<CreateViewModal />
</Modal>

View File

@ -0,0 +1,21 @@
<script>
import ExportButton from "../ExportButton.svelte"
import { getContext } from "svelte"
const { rows, columns, tableId, sort, selectedRows, filter } =
getContext("grid")
$: disabled = !$rows.length || !$columns.length
$: selectedRowArray = Object.keys($selectedRows).map(id => ({ _id: id }))
</script>
<ExportButton
{disabled}
view={$tableId}
filters={$filter}
sorting={{
sortColumn: $sort.column,
sortOrder: $sort.order,
}}
selectedRows={selectedRowArray}
/>

View File

@ -0,0 +1,20 @@
<script>
import TableFilterButton from "../TableFilterButton.svelte"
import { getContext } from "svelte"
const { columns, config, filter, table } = getContext("grid")
const onFilter = e => {
filter.set(e.detail || [])
}
</script>
{#key $config.tableId}
<TableFilterButton
schema={$table?.schema}
filters={$filter}
on:change={onFilter}
disabled={!$columns.length}
tableId={$config.tableId}
/>
{/key}

View File

@ -0,0 +1,14 @@
<script>
import ImportButton from "../ImportButton.svelte"
import { getContext } from "svelte"
export let disabled = false
const { rows, tableId } = getContext("grid")
</script>
<ImportButton
{disabled}
tableId={$tableId}
on:importrows={rows.actions.refreshData}
/>

View File

@ -0,0 +1,8 @@
<script>
import ManageAccessButton from "../ManageAccessButton.svelte"
import { getContext } from "svelte"
const { config } = getContext("grid")
</script>
<ManageAccessButton resourceId={$config.tableId} />

View File

@ -0,0 +1,13 @@
<script>
import ExistingRelationshipButton from "../ExistingRelationshipButton.svelte"
import { getContext } from "svelte"
const { table, rows } = getContext("grid")
</script>
{#if $table}
<ExistingRelationshipButton
table={$table}
on:updatecolumns={() => rows.actions.refreshData()}
/>
{/if}

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