diff --git a/.github/workflows/release-master.yml b/.github/workflows/release-master.yml index 9ab8530341..df25182cd6 100644 --- a/.github/workflows/release-master.yml +++ b/.github/workflows/release-master.yml @@ -36,6 +36,7 @@ jobs: - uses: actions/setup-node@v1 with: node-version: 18.x + cache: yarn - run: yarn install --frozen-lockfile - name: Update versions @@ -63,14 +64,64 @@ jobs: echo "Using tag $version" echo "version=$version" >> "$GITHUB_OUTPUT" - - name: Build/release Docker images + - name: Setup Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Docker login run: | docker login -u $DOCKER_USER -p $DOCKER_PASSWORD - yarn build:docker env: DOCKER_USER: ${{ secrets.DOCKER_USERNAME }} DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }} - BUDIBASE_RELEASE_VERSION: ${{ steps.currenttag.outputs.version }} + + - name: Build worker docker + uses: docker/build-push-action@v5 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + build-args: | + BUDIBASE_VERSION=${{ env.BUDIBASE_VERSION }} + tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} + file: ./packages/worker/Dockerfile.v2 + cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:latest + cache-to: type=inline + env: + IMAGE_NAME: budibase/worker + IMAGE_TAG: ${{ steps.currenttag.outputs.version }} + BUDIBASE_VERSION: ${{ steps.currenttag.outputs.version }} + + - name: Build server docker + uses: docker/build-push-action@v5 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + build-args: | + BUDIBASE_VERSION=${{ env.BUDIBASE_VERSION }} + tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} + file: ./packages/server/Dockerfile.v2 + cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:latest + cache-to: type=inline + env: + IMAGE_NAME: budibase/apps + IMAGE_TAG: ${{ steps.currenttag.outputs.version }} + BUDIBASE_VERSION: ${{ steps.currenttag.outputs.version }} + + - name: Build proxy docker + uses: docker/build-push-action@v5 + with: + context: ./hosting/proxy + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} + file: ./hosting/proxy/Dockerfile + cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:latest + cache-to: type=inline + env: + IMAGE_NAME: budibase/proxy + IMAGE_TAG: ${{ steps.currenttag.outputs.version }} release-helm-chart: needs: [release-images] diff --git a/.github/workflows/release-singleimage-test.yml b/.github/workflows/release-singleimage-test.yml deleted file mode 100644 index c3a14226ce..0000000000 --- a/.github/workflows/release-singleimage-test.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: Test - -on: - workflow_dispatch: - -env: - CI: true - PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - REGISTRY_URL: registry.hub.docker.com - NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }} -jobs: - build: - name: "build" - runs-on: ubuntu-latest - strategy: - matrix: - node-version: [18.x] - steps: - - name: "Checkout" - uses: actions/checkout@v4 - with: - submodules: true - token: ${{ secrets.PERSONAL_ACCESS_TOKEN }} - - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - cache: "yarn" - - name: Setup QEMU - uses: docker/setup-qemu-action@v3 - - name: Setup Docker Buildx - id: buildx - uses: docker/setup-buildx-action@v3 - - name: Run Yarn - run: yarn - - name: Run Yarn Build - run: yarn build --scope @budibase/server --scope @budibase/worker - - name: Login to Docker Hub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_API_KEY }} - - name: Get the latest release version - id: version - run: | - release_version=$(cat lerna.json | jq -r '.version') - echo $release_version - echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV - - name: Tag and release Budibase service docker image - uses: docker/build-push-action@v5 - with: - context: . - push: true - pull: true - platforms: linux/amd64,linux/arm64 - build-args: BUDIBASE_VERSION=0.0.0+test - tags: budibase/budibase-test:test - file: ./hosting/single/Dockerfile.v2 - cache-from: type=registry,ref=budibase/budibase-test:test - cache-to: type=inline - - name: Tag and release Budibase Azure App Service docker image - uses: docker/build-push-action@v2 - with: - context: . - push: true - platforms: linux/amd64 - build-args: | - TARGETBUILD=aas - BUDIBASE_VERSION=0.0.0+test - tags: budibase/budibase-test:aas - file: ./hosting/single/Dockerfile.v2 diff --git a/.github/workflows/release-singleimage.yml b/.github/workflows/release-singleimage.yml index f7f87f6e4c..4d35916f4d 100644 --- a/.github/workflows/release-singleimage.yml +++ b/.github/workflows/release-singleimage.yml @@ -67,7 +67,7 @@ jobs: push: true platforms: linux/amd64,linux/arm64 tags: budibase/budibase,budibase/budibase:${{ env.RELEASE_VERSION }} - file: ./hosting/single/Dockerfile + file: ./hosting/single/Dockerfile.v2 - name: Tag and release Budibase Azure App Service docker image uses: docker/build-push-action@v2 with: @@ -76,4 +76,4 @@ jobs: platforms: linux/amd64 build-args: TARGETBUILD=aas tags: budibase/budibase-aas,budibase/budibase-aas:${{ env.RELEASE_VERSION }} - file: ./hosting/single/Dockerfile + file: ./hosting/single/Dockerfile.v2 diff --git a/README.md b/README.md index 9deb16cd4f..7827d4e48a 100644 --- a/README.md +++ b/README.md @@ -126,13 +126,6 @@ You can learn more about the Budibase API at the following places: - [Build an app with Budibase and Next.js](https://budibase.com/blog/building-a-crud-app-with-budibase-and-next.js/) -<p align="center"> - <img alt="Budibase data" src="https://res.cloudinary.com/daog6scxm/image/upload/v1647858558/Feb%20release/Start_building_with_Budibase_s_API_3_rhlzhv.png"> -</p> -<br /><br /> - -<br /><br /><br /> - ## 🏁 Get started Deploy Budibase self-hosted in your existing infrastructure, using Docker, Kubernetes, and Digital Ocean. diff --git a/hosting/proxy/nginx.prod.conf b/hosting/proxy/nginx.prod.conf index 365765ccbb..6da2e4a1c3 100644 --- a/hosting/proxy/nginx.prod.conf +++ b/hosting/proxy/nginx.prod.conf @@ -51,7 +51,7 @@ http { proxy_buffering off; set $csp_default "default-src 'self'"; - set $csp_script "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.budibase.net https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io"; + set $csp_script "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.budibase.net https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io https://d2l5prqdbvm3op.cloudfront.net"; set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com"; set $csp_object "object-src 'none'"; set $csp_base_uri "base-uri 'self'"; diff --git a/hosting/scripts/linux/release-to-docker-hub.sh b/hosting/scripts/linux/release-to-docker-hub.sh deleted file mode 100755 index 599a10f914..0000000000 --- a/hosting/scripts/linux/release-to-docker-hub.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/bin/bash - -tag=$1 - -if [[ ! "$tag" ]]; then - echo "No tag present. You must pass a tag to this script" - exit 1 -fi - -echo "Tagging images with tag: $tag" - -docker tag proxy-service budibase/proxy:$tag -docker tag app-service budibase/apps:$tag -docker tag worker-service budibase/worker:$tag - -docker push --all-tags budibase/apps -docker push --all-tags budibase/worker -docker push --all-tags budibase/proxy diff --git a/lerna.json b/lerna.json index 7d14875c97..cb92b3ba0d 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.11.44", + "version": "2.12.2", "npmClient": "yarn", "packages": [ "packages/*" diff --git a/package.json b/package.json index 100a306a35..417fb31e0e 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,6 @@ "build:sdk": "lerna run --stream build:sdk", "deps:circular": "madge packages/server/dist/index.js packages/worker/src/index.ts packages/backend-core/dist/src/index.js packages/cli/src/index.js --circular", "release": "lerna publish from-package --yes --force-publish --no-git-tag-version --no-push --no-git-reset", - "release:develop": "yarn release --dist-tag develop", "restore": "yarn run clean && yarn && yarn run build", "nuke": "yarn run nuke:packages && yarn run nuke:docker", "nuke:packages": "yarn run restore", @@ -55,10 +54,6 @@ "lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --write \"qa-core/**/*.{js,ts,svelte}\"", "lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint", "build:specs": "lerna run --stream specs", - "build:docker": "lerna run --stream build:docker && yarn build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh $BUDIBASE_RELEASE_VERSION && cd -", - "build:docker:proxy": "docker build hosting/proxy -t proxy-service", - "build:docker:selfhost": "lerna run --stream build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh latest && cd -", - "build:docker:develop": "node scripts/pinVersions && lerna run --stream build:docker && yarn build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -", "build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild", "build:docker:airgap:single": "SINGLE_IMAGE=1 node hosting/scripts/airgapped/airgappedDockerBuild", "build:digitalocean": "cd hosting/digitalocean && ./build.sh && cd -", diff --git a/packages/backend-core/__mocks__/aws-sdk.ts b/packages/backend-core/__mocks__/aws-sdk.ts index b8d91dbaa9..e3be511d08 100644 --- a/packages/backend-core/__mocks__/aws-sdk.ts +++ b/packages/backend-core/__mocks__/aws-sdk.ts @@ -3,6 +3,7 @@ const mockS3 = { deleteObject: jest.fn().mockReturnThis(), deleteObjects: jest.fn().mockReturnThis(), createBucket: jest.fn().mockReturnThis(), + getObject: jest.fn().mockReturnThis(), listObject: jest.fn().mockReturnThis(), getSignedUrl: jest.fn((operation: string, params: any) => { return `http://s3.example.com/${params.Bucket}/${params.Key}` diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index b23cd8e5b1..dc8d71b52c 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -21,7 +21,7 @@ "test:watch": "jest --watchAll" }, "dependencies": { - "@budibase/nano": "10.1.2", + "@budibase/nano": "10.1.3", "@budibase/pouchdb-replication-stream": "1.2.10", "@budibase/shared-core": "0.0.0", "@budibase/types": "0.0.0", diff --git a/packages/backend-core/src/cache/writethrough.ts b/packages/backend-core/src/cache/writethrough.ts index e64c116663..c331d791a6 100644 --- a/packages/backend-core/src/cache/writethrough.ts +++ b/packages/backend-core/src/cache/writethrough.ts @@ -119,8 +119,8 @@ export class Writethrough { this.writeRateMs = writeRateMs } - async put(doc: any) { - return put(this.db, doc, this.writeRateMs) + async put(doc: any, writeRateMs: number = this.writeRateMs) { + return put(this.db, doc, writeRateMs) } async get(id: string) { diff --git a/packages/backend-core/src/db/constants.ts b/packages/backend-core/src/db/constants.ts index aea485e3e3..bfa7595d62 100644 --- a/packages/backend-core/src/db/constants.ts +++ b/packages/backend-core/src/db/constants.ts @@ -8,3 +8,7 @@ export const CONSTANT_INTERNAL_ROW_COLS = [ ] as const export const CONSTANT_EXTERNAL_ROW_COLS = ["_id", "_rev", "tableId"] as const + +export function isInternalColumnName(name: string): boolean { + return (CONSTANT_INTERNAL_ROW_COLS as readonly string[]).includes(name) +} diff --git a/packages/backend-core/src/docIds/params.ts b/packages/backend-core/src/docIds/params.ts index 36fd75622b..d9baee3dc6 100644 --- a/packages/backend-core/src/docIds/params.ts +++ b/packages/backend-core/src/docIds/params.ts @@ -6,6 +6,7 @@ import { ViewName, } from "../constants" import { getProdAppID } from "./conversions" +import { DatabaseQueryOpts } from "@budibase/types" /** * If creating DB allDocs/query params with only a single top level ID this can be used, this @@ -22,8 +23,8 @@ import { getProdAppID } from "./conversions" export function getDocParams( docType: string, docId?: string | null, - otherProps: any = {} -) { + otherProps: Partial<DatabaseQueryOpts> = {} +): DatabaseQueryOpts { if (docId == null) { docId = "" } @@ -45,8 +46,8 @@ export function getDocParams( export function getRowParams( tableId?: string | null, rowId?: string | null, - otherProps = {} -) { + otherProps: Partial<DatabaseQueryOpts> = {} +): DatabaseQueryOpts { if (tableId == null) { return getDocParams(DocumentType.ROW, null, otherProps) } @@ -88,7 +89,10 @@ export const isDatasourceId = (id: string) => { /** * Gets parameters for retrieving workspaces. */ -export function getWorkspaceParams(id = "", otherProps = {}) { +export function getWorkspaceParams( + id = "", + otherProps: Partial<DatabaseQueryOpts> = {} +): DatabaseQueryOpts { return { ...otherProps, startkey: `${DocumentType.WORKSPACE}${SEPARATOR}${id}`, @@ -99,7 +103,10 @@ export function getWorkspaceParams(id = "", otherProps = {}) { /** * Gets parameters for retrieving users. */ -export function getGlobalUserParams(globalId: any, otherProps: any = {}) { +export function getGlobalUserParams( + globalId: any, + otherProps: Partial<DatabaseQueryOpts> = {} +): DatabaseQueryOpts { if (!globalId) { globalId = "" } @@ -117,11 +124,17 @@ export function getGlobalUserParams(globalId: any, otherProps: any = {}) { /** * Gets parameters for retrieving users, this is a utility function for the getDocParams function. */ -export function getUserMetadataParams(userId?: string | null, otherProps = {}) { +export function getUserMetadataParams( + userId?: string | null, + otherProps: Partial<DatabaseQueryOpts> = {} +): DatabaseQueryOpts { return getRowParams(InternalTable.USER_METADATA, userId, otherProps) } -export function getUsersByAppParams(appId: any, otherProps: any = {}) { +export function getUsersByAppParams( + appId: any, + otherProps: Partial<DatabaseQueryOpts> = {} +): DatabaseQueryOpts { const prodAppId = getProdAppID(appId) return { ...otherProps, diff --git a/packages/backend-core/src/security/roles.ts b/packages/backend-core/src/security/roles.ts index b05cf79c8c..0d33031de5 100644 --- a/packages/backend-core/src/security/roles.ts +++ b/packages/backend-core/src/security/roles.ts @@ -122,7 +122,9 @@ export async function roleToNumber(id?: string) { if (isBuiltin(id)) { return builtinRoleToNumber(id) } - const hierarchy = (await getUserRoleHierarchy(id)) as RoleDoc[] + const hierarchy = (await getUserRoleHierarchy(id, { + defaultPublic: true, + })) as RoleDoc[] for (let role of hierarchy) { if (isBuiltin(role?.inherits)) { return builtinRoleToNumber(role.inherits) + 1 @@ -192,12 +194,15 @@ export async function getRole( /** * Simple function to get all the roles based on the top level user role ID. */ -async function getAllUserRoles(userRoleId?: string): Promise<RoleDoc[]> { +async function getAllUserRoles( + userRoleId?: string, + opts?: { defaultPublic?: boolean } +): Promise<RoleDoc[]> { // admins have access to all roles if (userRoleId === BUILTIN_IDS.ADMIN) { return getAllRoles() } - let currentRole = await getRole(userRoleId) + let currentRole = await getRole(userRoleId, opts) let roles = currentRole ? [currentRole] : [] let roleIds = [userRoleId] // get all the inherited roles @@ -226,12 +231,16 @@ export async function getUserRoleIdHierarchy( * Returns an ordered array of the user's inherited role IDs, this can be used * to determine if a user can access something that requires a specific role. * @param userRoleId The user's role ID, this can be found in their access token. + * @param opts optional - if want to default to public use this. * @returns returns an ordered array of the roles, with the first being their * highest level of access and the last being the lowest level. */ -export async function getUserRoleHierarchy(userRoleId?: string) { +export async function getUserRoleHierarchy( + userRoleId?: string, + opts?: { defaultPublic?: boolean } +) { // special case, if they don't have a role then they are a public user - return getAllUserRoles(userRoleId) + return getAllUserRoles(userRoleId, opts) } // this function checks that the provided permissions are in an array format diff --git a/packages/backend-core/src/users/db.ts b/packages/backend-core/src/users/db.ts index a2539e836e..c071064713 100644 --- a/packages/backend-core/src/users/db.ts +++ b/packages/backend-core/src/users/db.ts @@ -25,12 +25,17 @@ import { import { getAccountHolderFromUserIds, isAdmin, + isCreator, validateUniqueUser, } from "./utils" import { searchExistingEmails } from "./lookup" import { hash } from "../utils" -type QuotaUpdateFn = (change: number, cb?: () => Promise<any>) => Promise<any> +type QuotaUpdateFn = ( + change: number, + creatorsChange: number, + cb?: () => Promise<any> +) => Promise<any> type GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any> type FeatureFn = () => Promise<Boolean> type GroupGetFn = (ids: string[]) => Promise<UserGroup[]> @@ -160,13 +165,9 @@ export class UserDB { } static async getUsersByAppAccess(opts: { appId?: string; limit?: number }) { - const params: any = { - include_docs: true, - limit: opts.limit || 50, - } let response: User[] = await usersCore.searchGlobalUsersByAppAccess( opts.appId, - params + { limit: opts.limit || 50 } ) return response } @@ -245,7 +246,8 @@ export class UserDB { } const change = dbUser ? 0 : 1 // no change if there is existing user - return UserDB.quotas.addUsers(change, async () => { + const creatorsChange = isCreator(dbUser) !== isCreator(user) ? 1 : 0 + return UserDB.quotas.addUsers(change, creatorsChange, async () => { await validateUniqueUser(email, tenantId) let builtUser = await UserDB.buildUser(user, opts, tenantId, dbUser) @@ -307,6 +309,7 @@ export class UserDB { let usersToSave: any[] = [] let newUsers: any[] = [] + let newCreators: any[] = [] const emails = newUsersRequested.map((user: User) => user.email) const existingEmails = await searchExistingEmails(emails) @@ -327,59 +330,66 @@ export class UserDB { } newUser.userGroups = groups newUsers.push(newUser) + if (isCreator(newUser)) { + newCreators.push(newUser) + } } const account = await accountSdk.getAccountByTenantId(tenantId) - return UserDB.quotas.addUsers(newUsers.length, async () => { - // create the promises array that will be called by bulkDocs - newUsers.forEach((user: any) => { - usersToSave.push( - UserDB.buildUser( - user, - { - hashPassword: true, - requirePassword: user.requirePassword, - }, - tenantId, - undefined, // no dbUser - account + return UserDB.quotas.addUsers( + newUsers.length, + newCreators.length, + async () => { + // create the promises array that will be called by bulkDocs + newUsers.forEach((user: any) => { + usersToSave.push( + UserDB.buildUser( + user, + { + hashPassword: true, + requirePassword: user.requirePassword, + }, + tenantId, + undefined, // no dbUser + account + ) ) - ) - }) + }) - const usersToBulkSave = await Promise.all(usersToSave) - await usersCore.bulkUpdateGlobalUsers(usersToBulkSave) + const usersToBulkSave = await Promise.all(usersToSave) + await usersCore.bulkUpdateGlobalUsers(usersToBulkSave) - // Post-processing of bulk added users, e.g. events and cache operations - for (const user of usersToBulkSave) { - // TODO: Refactor to bulk insert users into the info db - // instead of relying on looping tenant creation - await platform.users.addUser(tenantId, user._id, user.email) - await eventHelpers.handleSaveEvents(user, undefined) - } + // Post-processing of bulk added users, e.g. events and cache operations + for (const user of usersToBulkSave) { + // TODO: Refactor to bulk insert users into the info db + // instead of relying on looping tenant creation + await platform.users.addUser(tenantId, user._id, user.email) + await eventHelpers.handleSaveEvents(user, undefined) + } + + const saved = usersToBulkSave.map(user => { + return { + _id: user._id, + email: user.email, + } + }) + + // now update the groups + if (Array.isArray(saved) && groups) { + const groupPromises = [] + const createdUserIds = saved.map(user => user._id) + for (let groupId of groups) { + groupPromises.push(UserDB.groups.addUsers(groupId, createdUserIds)) + } + await Promise.all(groupPromises) + } - const saved = usersToBulkSave.map(user => { return { - _id: user._id, - email: user.email, + successful: saved, + unsuccessful, } - }) - - // now update the groups - if (Array.isArray(saved) && groups) { - const groupPromises = [] - const createdUserIds = saved.map(user => user._id) - for (let groupId of groups) { - groupPromises.push(UserDB.groups.addUsers(groupId, createdUserIds)) - } - await Promise.all(groupPromises) } - - return { - successful: saved, - unsuccessful, - } - }) + ) } static async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> { @@ -419,11 +429,12 @@ export class UserDB { _deleted: true, })) const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete) + const creatorsToDelete = usersToDelete.filter(isCreator) - await UserDB.quotas.removeUsers(toDelete.length) for (let user of usersToDelete) { await bulkDeleteProcessing(user) } + await UserDB.quotas.removeUsers(toDelete.length, creatorsToDelete.length) // Build Response // index users by id @@ -472,7 +483,8 @@ export class UserDB { await db.remove(userId, dbUser._rev) - await UserDB.quotas.removeUsers(1) + const creatorsToDelete = isCreator(dbUser) ? 1 : 0 + await UserDB.quotas.removeUsers(1, creatorsToDelete) await eventHelpers.handleDeleteEvents(dbUser) await cache.user.invalidateUser(userId) await sessions.invalidateSessions(userId, { reason: "deletion" }) diff --git a/packages/backend-core/src/users/users.ts b/packages/backend-core/src/users/users.ts index 6237c23972..6dc8750b62 100644 --- a/packages/backend-core/src/users/users.ts +++ b/packages/backend-core/src/users/users.ts @@ -14,12 +14,13 @@ import { } from "../db" import { BulkDocsResponse, - ContextUser, SearchQuery, SearchQueryOperators, SearchUsersRequest, User, + ContextUser, DatabaseQueryOpts, + CouchFindOptions, } from "@budibase/types" import { getGlobalDB } from "../context" import * as context from "../context" @@ -140,7 +141,7 @@ export const getGlobalUserByEmail = async ( export const searchGlobalUsersByApp = async ( appId: any, - opts: any, + opts: DatabaseQueryOpts, getOpts?: GetOpts ) => { if (typeof appId !== "string") { @@ -166,7 +167,10 @@ export const searchGlobalUsersByApp = async ( Return any user who potentially has access to the application Admins, developers and app users with the explicitly role. */ -export const searchGlobalUsersByAppAccess = async (appId: any, opts: any) => { +export const searchGlobalUsersByAppAccess = async ( + appId: any, + opts?: { limit?: number } +) => { const roleSelector = `roles.${appId}` let orQuery: any[] = [ @@ -187,7 +191,7 @@ export const searchGlobalUsersByAppAccess = async (appId: any, opts: any) => { orQuery.push(roleCheck) } - let searchOptions = { + let searchOptions: CouchFindOptions = { selector: { $or: orQuery, _id: { @@ -198,7 +202,7 @@ export const searchGlobalUsersByAppAccess = async (appId: any, opts: any) => { } const resp = await directCouchFind(context.getGlobalDBName(), searchOptions) - return resp?.rows + return resp.rows } export const getGlobalUserByAppPage = (appId: string, user: User) => { @@ -245,7 +249,8 @@ export const paginatedUsers = async ({ limit, }: SearchUsersRequest = {}) => { const db = getGlobalDB() - const pageLimit = limit ? limit + 1 : PAGE_LIMIT + 1 + const pageSize = limit ?? PAGE_LIMIT + const pageLimit = pageSize + 1 // get one extra document, to have the next page const opts: DatabaseQueryOpts = { include_docs: true, @@ -272,7 +277,7 @@ export const paginatedUsers = async ({ const response = await db.allDocs(getGlobalUserParams(null, opts)) userList = response.rows.map((row: any) => row.doc) } - return pagination(userList, pageLimit, { + return pagination(userList, pageSize, { paginate: true, property, getKey, diff --git a/packages/backend-core/tests/core/users/users.spec.js b/packages/backend-core/tests/core/users/users.spec.js new file mode 100644 index 0000000000..ae7109344a --- /dev/null +++ b/packages/backend-core/tests/core/users/users.spec.js @@ -0,0 +1,54 @@ +const _ = require('lodash/fp') +const {structures} = require("../../../tests") + +jest.mock("../../../src/context") +jest.mock("../../../src/db") + +const context = require("../../../src/context") +const db = require("../../../src/db") + +const {getCreatorCount} = require('../../../src/users/users') + +describe("Users", () => { + + let getGlobalDBMock + let getGlobalUserParamsMock + let paginationMock + + beforeEach(() => { + jest.resetAllMocks() + + getGlobalDBMock = jest.spyOn(context, "getGlobalDB") + getGlobalUserParamsMock = jest.spyOn(db, "getGlobalUserParams") + paginationMock = jest.spyOn(db, "pagination") + }) + + it("Retrieves the number of creators", async () => { + const getUsers = (offset, limit, creators = false) => { + const range = _.range(offset, limit) + const opts = creators ? {builder: {global: true}} : undefined + return range.map(() => structures.users.user(opts)) + } + const page1Data = getUsers(0, 8) + const page2Data = getUsers(8, 12, true) + getGlobalDBMock.mockImplementation(() => ({ + name : "fake-db", + allDocs: () => ({ + rows: [...page1Data, ...page2Data] + }) + })) + paginationMock.mockImplementationOnce(() => ({ + data: page1Data, + hasNextPage: true, + nextPage: "1" + })) + paginationMock.mockImplementation(() => ({ + data: page2Data, + hasNextPage: false, + nextPage: undefined + })) + const creatorsCount = await getCreatorCount() + expect(creatorsCount).toBe(4) + expect(paginationMock).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/backend-core/tests/core/utilities/mocks/date.ts b/packages/backend-core/tests/core/utilities/mocks/date.ts index f580b68349..1e6d105d93 100644 --- a/packages/backend-core/tests/core/utilities/mocks/date.ts +++ b/packages/backend-core/tests/core/utilities/mocks/date.ts @@ -1,2 +1,3 @@ export const MOCK_DATE = new Date("2020-01-01T00:00:00.000Z") + export const MOCK_DATE_TIMESTAMP = 1577836800000 diff --git a/packages/backend-core/tests/core/utilities/structures/licenses.ts b/packages/backend-core/tests/core/utilities/structures/licenses.ts index 0e34f2e9bb..bb452f9ad5 100644 --- a/packages/backend-core/tests/core/utilities/structures/licenses.ts +++ b/packages/backend-core/tests/core/utilities/structures/licenses.ts @@ -123,6 +123,10 @@ export function customer(): Customer { export function subscription(): Subscription { return { amount: 10000, + amounts: { + user: 10000, + creator: 0, + }, cancelAt: undefined, currency: "usd", currentPeriodEnd: 0, @@ -131,6 +135,10 @@ export function subscription(): Subscription { duration: PriceDuration.MONTHLY, pastDueAt: undefined, quantity: 0, + quantities: { + user: 0, + creator: 0, + }, status: "active", } } diff --git a/packages/bbui/src/Form/Core/Dropzone.svelte b/packages/bbui/src/Form/Core/Dropzone.svelte index e9ee75bd8b..0b6a9bb94f 100644 --- a/packages/bbui/src/Form/Core/Dropzone.svelte +++ b/packages/bbui/src/Form/Core/Dropzone.svelte @@ -159,8 +159,10 @@ {#if selectedImage.size} <div class="filesize"> {#if selectedImage.size <= BYTES_IN_MB} - {`${selectedImage.size / BYTES_IN_KB} KB`} - {:else}{`${selectedImage.size / BYTES_IN_MB} MB`}{/if} + {`${(selectedImage.size / BYTES_IN_KB).toFixed(1)} KB`} + {:else}{`${(selectedImage.size / BYTES_IN_MB).toFixed( + 1 + )} MB`}{/if} </div> {/if} {#if !disabled} @@ -203,8 +205,8 @@ {#if file.size} <div class="filesize"> {#if file.size <= BYTES_IN_MB} - {`${file.size / BYTES_IN_KB} KB`} - {:else}{`${file.size / BYTES_IN_MB} MB`}{/if} + {`${(file.size / BYTES_IN_KB).toFixed(1)} KB`} + {:else}{`${(file.size / BYTES_IN_MB).toFixed(1)} MB`}{/if} </div> {/if} {#if !disabled} diff --git a/packages/builder/.gitignore b/packages/builder/.gitignore index e5c961d509..acd1a70579 100644 --- a/packages/builder/.gitignore +++ b/packages/builder/.gitignore @@ -5,4 +5,4 @@ package-lock.json release/ dist/ routify -.routify/ \ No newline at end of file +.routify/ diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index a567caf87f..a4729b4a8a 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -580,7 +580,7 @@ export const getFrontendStore = () => { let table = validTables.find(table => { return ( table.sourceId !== BUDIBASE_INTERNAL_DB_ID && - table.type === DB_TYPE_INTERNAL + table.sourceType === DB_TYPE_INTERNAL ) }) if (table) { @@ -591,7 +591,7 @@ export const getFrontendStore = () => { table = validTables.find(table => { return ( table.sourceId === BUDIBASE_INTERNAL_DB_ID && - table.type === DB_TYPE_INTERNAL + table.sourceType === DB_TYPE_INTERNAL ) }) if (table) { @@ -599,7 +599,7 @@ export const getFrontendStore = () => { } // Finally try an external table - return validTables.find(table => table.type === DB_TYPE_EXTERNAL) + return validTables.find(table => table.sourceType === DB_TYPE_EXTERNAL) }, enrichEmptySettings: (component, opts) => { if (!component?._component) { diff --git a/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js b/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js index b17bd99e10..59bcd0d5e8 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js +++ b/packages/builder/src/builderStore/store/screenTemplates/rowListScreen.js @@ -2,14 +2,14 @@ import sanitizeUrl from "./utils/sanitizeUrl" import { Screen } from "./utils/Screen" import { Component } from "./utils/Component" -export default function (datasources) { +export default function (datasources, mode = "table") { if (!Array.isArray(datasources)) { return [] } return datasources.map(datasource => { return { name: `${datasource.label} - List`, - create: () => createScreen(datasource), + create: () => createScreen(datasource, mode), id: ROW_LIST_TEMPLATE, resourceId: datasource.resourceId, } @@ -40,10 +40,24 @@ const generateTableBlock = datasource => { return tableBlock } -const createScreen = datasource => { +const generateGridBlock = datasource => { + const gridBlock = new Component("@budibase/standard-components/gridblock") + gridBlock + .customProps({ + table: datasource, + }) + .instanceName(`${datasource.label} - Grid block`) + return gridBlock +} + +const createScreen = (datasource, mode) => { return new Screen() .route(rowListUrl(datasource)) .instanceName(`${datasource.label} - List`) - .addChild(generateTableBlock(datasource)) + .addChild( + mode === "table" + ? generateTableBlock(datasource) + : generateGridBlock(datasource) + ) .json() } diff --git a/packages/builder/src/components/backend/DataTable/RelationshipDataTable.svelte b/packages/builder/src/components/backend/DataTable/RelationshipDataTable.svelte index 8ef870caca..4e67a92443 100644 --- a/packages/builder/src/components/backend/DataTable/RelationshipDataTable.svelte +++ b/packages/builder/src/components/backend/DataTable/RelationshipDataTable.svelte @@ -16,7 +16,6 @@ $: linkedTable = $tables.list.find(table => table._id === linkedTableId) $: schema = linkedTable?.schema $: table = $tables.list.find(table => table._id === tableId) - $: type = table?.type $: fetchData(tableId, rowId) $: { let rowLabel = row?.[table?.primaryDisplay] @@ -41,5 +40,5 @@ </script> {#if row && row._id === rowId} - <Table {title} {schema} {data} {type} /> + <Table {title} {schema} {data} /> {/if} diff --git a/packages/builder/src/components/backend/DataTable/Table.svelte b/packages/builder/src/components/backend/DataTable/Table.svelte index f8087d8a39..f7eccd5242 100644 --- a/packages/builder/src/components/backend/DataTable/Table.svelte +++ b/packages/builder/src/components/backend/DataTable/Table.svelte @@ -24,17 +24,23 @@ let selectedRows = [] let customRenderers = [] + let parsedSchema = {} + + $: if (schema) { + parsedSchema = Object.keys(schema).reduce((acc, key) => { + acc[key] = + typeof schema[key] === "string" ? { type: schema[key] } : schema[key] + + if (!canBeSortColumn(acc[key].type)) { + acc[key].sortable = false + } + return acc + }, {}) + } $: selectedRows, dispatch("selectionUpdated", selectedRows) $: isUsersTable = tableId === TableNames.USERS $: data && resetSelectedRows() - $: { - Object.values(schema || {}).forEach(col => { - if (!canBeSortColumn(col.type)) { - col.sortable = false - } - }) - } $: { if (isUsersTable) { customRenderers = [ @@ -44,24 +50,24 @@ }, ] UNEDITABLE_USER_FIELDS.forEach(field => { - if (schema[field]) { - schema[field].editable = false + if (parsedSchema[field]) { + parsedSchema[field].editable = false } }) - if (schema.email) { - schema.email.displayName = "Email" + if (parsedSchema.email) { + parsedSchema.email.displayName = "Email" } - if (schema.roleId) { - schema.roleId.displayName = "Role" + if (parsedSchema.roleId) { + parsedSchema.roleId.displayName = "Role" } - if (schema.firstName) { - schema.firstName.displayName = "First Name" + if (parsedSchema.firstName) { + parsedSchema.firstName.displayName = "First Name" } - if (schema.lastName) { - schema.lastName.displayName = "Last Name" + if (parsedSchema.lastName) { + parsedSchema.lastName.displayName = "Last Name" } - if (schema.status) { - schema.status.displayName = "Status" + if (parsedSchema.status) { + parsedSchema.status.displayName = "Status" } } } @@ -97,7 +103,7 @@ <div class="table-wrapper"> <Table {data} - {schema} + schema={parsedSchema} {loading} {customRenderers} {rowCount} diff --git a/packages/builder/src/components/backend/DataTable/TableDataTable.svelte b/packages/builder/src/components/backend/DataTable/TableDataTable.svelte index 5fee849afb..8dd685e766 100644 --- a/packages/builder/src/components/backend/DataTable/TableDataTable.svelte +++ b/packages/builder/src/components/backend/DataTable/TableDataTable.svelte @@ -16,6 +16,7 @@ import GridRelationshipButton from "components/backend/DataTable/buttons/grid/GridRelationshipButton.svelte" import GridEditColumnModal from "components/backend/DataTable/modals/grid/GridEditColumnModal.svelte" import GridUsersTableButton from "components/backend/DataTable/modals/grid/GridUsersTableButton.svelte" + import { DB_TYPE_EXTERNAL } from "constants/backend" const userSchemaOverrides = { firstName: { displayName: "First name", disabled: true }, @@ -27,7 +28,7 @@ $: id = $tables.selected?._id $: isUsersTable = id === TableNames.USERS - $: isInternal = $tables.selected?.type !== "external" + $: isInternal = $tables.selected?.sourceType !== DB_TYPE_EXTERNAL $: gridDatasource = { type: "table", tableId: id, @@ -46,10 +47,7 @@ tables.replaceTable(id, e.detail) // We need to refresh datasources when an external table changes. - // Type "external" may exist - sometimes type is "table" and sometimes it - // is "external" - it has different meanings in different endpoints. - // If we check both these then we hopefully catch all external tables. - if (e.detail?.type === "external" || e.detail?.sql) { + if (e.detail?.sourceType === DB_TYPE_EXTERNAL) { await datasources.fetch() } } diff --git a/packages/builder/src/components/backend/DataTable/ViewDataTable.svelte b/packages/builder/src/components/backend/DataTable/ViewDataTable.svelte index f6160e3caa..6fcba8d418 100644 --- a/packages/builder/src/components/backend/DataTable/ViewDataTable.svelte +++ b/packages/builder/src/components/backend/DataTable/ViewDataTable.svelte @@ -17,9 +17,9 @@ let hideAutocolumns = true let data = [] let loading = false - let type = "internal" $: name = view.name + $: schema = view.schema $: calculation = view.calculation $: supportedFormats = Object.values(ROW_EXPORT_FORMATS).filter(key => { @@ -61,11 +61,10 @@ <Table title={decodeURI(name)} - schema={view.schema} + {schema} tableId={view.tableId} {data} {loading} - {type} rowCount={10} allowEditing={false} bind:hideAutocolumns diff --git a/packages/builder/src/components/backend/DataTable/buttons/grid/GridImportButton.svelte b/packages/builder/src/components/backend/DataTable/buttons/grid/GridImportButton.svelte index 71d971891c..74e255cf7e 100644 --- a/packages/builder/src/components/backend/DataTable/buttons/grid/GridImportButton.svelte +++ b/packages/builder/src/components/backend/DataTable/buttons/grid/GridImportButton.svelte @@ -10,6 +10,6 @@ <ImportButton {disabled} tableId={$datasource?.tableId} - tableType={$definition?.type} + tableType={$definition?.sourceType} on:importrows={rows.actions.refreshData} /> diff --git a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte index 7b51e6c839..d5a9aba488 100644 --- a/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte +++ b/packages/builder/src/components/backend/DataTable/modals/CreateEditColumn.svelte @@ -26,6 +26,7 @@ ALLOWABLE_NUMBER_TYPES, SWITCHABLE_TYPES, PrettyRelationshipDefinitions, + DB_TYPE_EXTERNAL, } from "constants/backend" import { getAutoColumnInformation, buildAutoColumn } from "builderStore/utils" import ConfirmDialog from "components/common/ConfirmDialog.svelte" @@ -254,10 +255,11 @@ !uneditable && editableColumn?.type !== AUTO_TYPE && !editableColumn.autocolumn - $: external = table.type === "external" + $: externalTable = table.sourceType === DB_TYPE_EXTERNAL // in the case of internal tables the sourceId will just be undefined $: tableOptions = $tables.list.filter( - opt => opt.type === table.type && table.sourceId === opt.sourceId + opt => + opt.sourceType === table.sourceType && table.sourceId === opt.sourceId ) $: typeEnabled = !originalName || @@ -409,7 +411,7 @@ editableColumn.type === FieldType.BB_REFERENCE && editableColumn.subtype === FieldSubtype.USERS - if (!external) { + if (!externalTable) { return [ FIELDS.STRING, FIELDS.BARCODEQR, @@ -441,7 +443,7 @@ isUsers ? FIELDS.USERS : FIELDS.USER, ] // no-sql or a spreadsheet - if (!external || table.sql) { + if (!externalTable || table.sql) { fields = [...fields, FIELDS.LINK, FIELDS.ARRAY] } return fields @@ -486,7 +488,7 @@ }) } const newError = {} - if (!external && fieldInfo.name?.startsWith("_")) { + if (!externalTable && fieldInfo.name?.startsWith("_")) { newError.name = `Column name cannot start with an underscore.` } else if (fieldInfo.name && !fieldInfo.name.match(ValidColumnNameRegex)) { newError.name = `Illegal character; must be alpha-numeric.` @@ -498,7 +500,7 @@ newError.name = `Column name already in use.` } - if (fieldInfo.type == "auto" && !fieldInfo.subtype) { + if (fieldInfo.type === "auto" && !fieldInfo.subtype) { newError.subtype = `Auto Column requires a type` } @@ -777,7 +779,8 @@ disabled={deleteColName !== originalName} > <p> - Are you sure you wish to delete the column <b>{originalName}?</b> + Are you sure you wish to delete the column + <b on:click={() => (deleteColName = originalName)}>{originalName}?</b> Your data will be deleted and this action cannot be undone - enter the column name to confirm. </p> @@ -810,4 +813,11 @@ gap: 8px; display: flex; } + b { + transition: color 130ms ease-out; + } + b:hover { + cursor: pointer; + color: var(--spectrum-global-color-gray-900); + } </style> diff --git a/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte b/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte index 43751ad944..eb1e7bc7ff 100644 --- a/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte +++ b/packages/builder/src/components/backend/TableNavigator/ExistingTableDataImport.svelte @@ -1,6 +1,6 @@ <script> import { Select, Toggle, Multiselect } from "@budibase/bbui" - import { FIELDS } from "constants/backend" + import { DB_TYPE_INTERNAL, FIELDS } from "constants/backend" import { API } from "api" import { parseFile } from "./utils" @@ -169,7 +169,7 @@ </div> {/each} </div> - {#if tableType === "internal"} + {#if tableType === DB_TYPE_INTERNAL} <br /> <Toggle bind:value={updateExistingRows} diff --git a/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte b/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte index d13a7c30db..e5227af409 100644 --- a/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte +++ b/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte @@ -8,6 +8,7 @@ import { BUDIBASE_INTERNAL_DB_ID, BUDIBASE_DATASOURCE_TYPE, + DB_TYPE_INTERNAL, } from "constants/backend" $: tableNames = $tables.list.map(table => table.name) @@ -55,8 +56,9 @@ name, schema: { ...schema }, rows, - type: "internal", + type: "table", sourceId: targetDatasourceId, + sourceType: DB_TYPE_INTERNAL, } // Only set primary display if defined diff --git a/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte b/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte index 1760938c53..18c0d460a8 100644 --- a/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte +++ b/packages/builder/src/components/backend/TableNavigator/popovers/EditTablePopover.svelte @@ -13,6 +13,7 @@ notifications, } from "@budibase/bbui" import ConfirmDialog from "components/common/ConfirmDialog.svelte" + import { DB_TYPE_EXTERNAL } from "constants/backend" export let table @@ -27,8 +28,8 @@ let willBeDeleted let deleteTableName - $: external = table?.type === "external" - $: allowDeletion = !external || table?.created + $: externalTable = table?.sourceType === DB_TYPE_EXTERNAL + $: allowDeletion = !externalTable || table?.created function showDeleteModal() { templateScreens = $store.screens.filter( @@ -48,7 +49,7 @@ for (let screen of templateScreens) { await store.actions.screens.delete(screen) } - if (table.type === "external") { + if (table.sourceType === DB_TYPE_EXTERNAL) { await datasources.fetch() } notifications.success("Table deleted") @@ -91,7 +92,7 @@ <div slot="control" class="icon"> <Icon s hoverable name="MoreSmallList" /> </div> - {#if !external} + {#if !externalTable} <MenuItem icon="Edit" on:click={editorModal.show}>Edit</MenuItem> {/if} <MenuItem icon="Delete" on:click={showDeleteModal}>Delete</MenuItem> diff --git a/packages/builder/src/components/common/Dropzone.svelte b/packages/builder/src/components/common/Dropzone.svelte index fd2359fd91..daa6ad1807 100644 --- a/packages/builder/src/components/common/Dropzone.svelte +++ b/packages/builder/src/components/common/Dropzone.svelte @@ -23,7 +23,7 @@ try { return await API.uploadBuilderAttachment(data) } catch (error) { - notifications.error("Failed to upload attachment") + notifications.error(error.message || "Failed to upload attachment") return [] } } diff --git a/packages/builder/src/components/common/RoleSelect.svelte b/packages/builder/src/components/common/RoleSelect.svelte index 82752554d5..2df61926e1 100644 --- a/packages/builder/src/components/common/RoleSelect.svelte +++ b/packages/builder/src/components/common/RoleSelect.svelte @@ -39,7 +39,15 @@ allowCreator ) => { if (allowedRoles?.length) { - return roles.filter(role => allowedRoles.includes(role._id)) + const filteredRoles = roles.filter(role => + allowedRoles.includes(role._id) + ) + return [ + ...filteredRoles, + ...(allowedRoles.includes(Constants.Roles.CREATOR) + ? [{ _id: Constants.Roles.CREATOR, name: "Creator", enabled: false }] + : []), + ] } let newRoles = [...roles] @@ -129,8 +137,9 @@ getOptionColour={getColor} getOptionIcon={getIcon} isOptionEnabled={option => - option._id !== Constants.Roles.CREATOR || - $licensing.perAppBuildersEnabled} + (option._id !== Constants.Roles.CREATOR || + $licensing.perAppBuildersEnabled) && + option.enabled !== false} {placeholder} {error} /> diff --git a/packages/builder/src/components/design/settings/componentSettings.js b/packages/builder/src/components/design/settings/componentSettings.js index 4c49587372..232b4bef31 100644 --- a/packages/builder/src/components/design/settings/componentSettings.js +++ b/packages/builder/src/components/design/settings/componentSettings.js @@ -23,6 +23,7 @@ import BasicColumnEditor from "./controls/ColumnEditor/BasicColumnEditor.svelte" import GridColumnEditor from "./controls/ColumnEditor/GridColumnEditor.svelte" import BarButtonList from "./controls/BarButtonList.svelte" import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte" +import ButtonConfiguration from "./controls/ButtonConfiguration/ButtonConfiguration.svelte" import RelationshipFilterEditor from "./controls/RelationshipFilterEditor.svelte" const componentMap = { @@ -48,6 +49,7 @@ const componentMap = { "filter/relationship": RelationshipFilterEditor, url: URLSelect, fieldConfiguration: FieldConfiguration, + buttonConfiguration: ButtonConfiguration, columns: ColumnEditor, "columns/basic": BasicColumnEditor, "columns/grid": GridColumnEditor, diff --git a/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte new file mode 100644 index 0000000000..324418511b --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonConfiguration.svelte @@ -0,0 +1,134 @@ +<script> + import DraggableList from "../DraggableList/DraggableList.svelte" + import ButtonSetting from "./ButtonSetting.svelte" + import { createEventDispatcher } from "svelte" + import { store } from "builderStore" + import { Helpers } from "@budibase/bbui" + + export let componentBindings + export let bindings + export let value + + const dispatch = createEventDispatcher() + + let focusItem + + $: buttonList = sanitizeValue(value) || [] + $: buttonCount = buttonList.length + $: itemProps = { + componentBindings: componentBindings || [], + bindings, + removeButton, + canRemove: buttonCount > 1, + } + + const sanitizeValue = val => { + return val?.map(button => { + return button._component ? button : buildPseudoInstance(button) + }) + } + + const processItemUpdate = e => { + const updatedField = e.detail + const newButtonList = [...buttonList] + const fieldIdx = newButtonList.findIndex(pSetting => { + return pSetting._id === updatedField?._id + }) + if (fieldIdx === -1) { + newButtonList.push(updatedField) + } else { + newButtonList[fieldIdx] = updatedField + } + dispatch("change", newButtonList) + } + + const listUpdated = e => { + dispatch("change", [...e.detail]) + } + + const buildPseudoInstance = cfg => { + return store.actions.components.createInstance( + `@budibase/standard-components/button`, + { + _instanceName: Helpers.uuid(), + text: cfg.text, + type: cfg.type || "primary", + }, + {} + ) + } + + const addButton = () => { + const newButton = buildPseudoInstance({ + text: `Button ${buttonCount + 1}`, + }) + dispatch("change", [...buttonList, newButton]) + focusItem = newButton._id + } + + const removeButton = id => { + dispatch( + "change", + buttonList.filter(button => button._id !== id) + ) + } +</script> + +<div class="button-configuration"> + {#if buttonCount} + <DraggableList + on:change={listUpdated} + on:itemChange={processItemUpdate} + items={buttonList} + listItemKey={"_id"} + listType={ButtonSetting} + listTypeProps={itemProps} + focus={focusItem} + draggable={buttonCount > 1} + /> + + <div class="list-footer" on:click={addButton}> + <div class="add-button">Add button</div> + </div> + {/if} +</div> + +<style> + .button-configuration :global(.spectrum-ActionButton) { + width: 100%; + } + + .button-configuration :global(.list-wrap > li:last-child), + .button-configuration :global(.list-wrap) { + border-bottom-left-radius: unset; + border-bottom-right-radius: unset; + border-bottom: 0px; + } + + .list-footer { + width: 100%; + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + background-color: var( + --spectrum-table-background-color, + var(--spectrum-global-color-gray-50) + ); + transition: background-color ease-in-out 130ms; + display: flex; + justify-content: center; + border: 1px solid + var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid)); + cursor: pointer; + } + + .add-button { + margin: var(--spacing-s); + } + + .list-footer:hover { + background-color: var( + --spectrum-table-row-background-color-hover, + var(--spectrum-alias-highlight-hover) + ); + } +</style> diff --git a/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonSetting.svelte b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonSetting.svelte new file mode 100644 index 0000000000..a05fd9a39b --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/ButtonConfiguration/ButtonSetting.svelte @@ -0,0 +1,64 @@ +<script> + import EditComponentPopover from "../EditComponentPopover.svelte" + import { Icon } from "@budibase/bbui" + import { runtimeToReadableBinding } from "builderStore/dataBinding" + import { isJSBinding } from "@budibase/string-templates" + + export let item + export let componentBindings + export let bindings + export let anchor + export let removeButton + export let canRemove + + $: readableText = isJSBinding(item.text) + ? "(JavaScript function)" + : runtimeToReadableBinding([...bindings, componentBindings], item.text) +</script> + +<div class="list-item-body"> + <div class="list-item-left"> + <EditComponentPopover + {anchor} + componentInstance={item} + {componentBindings} + {bindings} + on:change + /> + <div class="field-label">{readableText || "Button"}</div> + </div> + <div class="list-item-right"> + <Icon + disabled={!canRemove} + size="S" + name="Close" + hoverable + on:click={() => removeButton(item._id)} + /> + </div> +</div> + +<style> + .field-label { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + .list-item-body, + .list-item-left { + display: flex; + align-items: center; + gap: var(--spacing-m); + min-width: 0; + } + .list-item-body { + margin-top: 8px; + margin-bottom: 8px; + } + .list-item-right :global(div.spectrum-Switch) { + margin: 0px; + } + .list-item-body { + justify-content: space-between; + } +</style> diff --git a/packages/builder/src/components/design/settings/controls/DraggableList.svelte b/packages/builder/src/components/design/settings/controls/DraggableList/DraggableList.svelte similarity index 82% rename from packages/builder/src/components/design/settings/controls/DraggableList.svelte rename to packages/builder/src/components/design/settings/controls/DraggableList/DraggableList.svelte index c8395b2a1f..1992299e90 100644 --- a/packages/builder/src/components/design/settings/controls/DraggableList.svelte +++ b/packages/builder/src/components/design/settings/controls/DraggableList/DraggableList.svelte @@ -1,10 +1,10 @@ <script> - import { Icon } from "@budibase/bbui" import { dndzone } from "svelte-dnd-action" import { createEventDispatcher } from "svelte" import { generate } from "shortid" import { setContext } from "svelte" - import { writable } from "svelte/store" + import { writable, get } from "svelte/store" + import DragHandle from "./drag-handle.svelte" export let items = [] export let showHandle = true @@ -12,6 +12,7 @@ export let listTypeProps = {} export let listItemKey export let draggable = true + export let focus let store = writable({ selected: null, @@ -27,6 +28,10 @@ setContext("draggable", store) + $: if (focus && store) { + get(store).actions.select(focus) + } + const dispatch = createEventDispatcher() const flipDurationMs = 150 @@ -82,13 +87,16 @@ > {#each draggableItems as draggable (draggable.id)} <li + on:mousedown={() => { + get(store).actions.select() + }} bind:this={anchors[draggable.id]} class:highlighted={draggable.id === $store.selected} > <div class="left-content"> {#if showHandle} - <div class="handle" aria-label="drag-handle"> - <Icon name="DragHandle" size="XL" /> + <div class="handle"> + <DragHandle /> </div> {/if} </div> @@ -142,8 +150,9 @@ border-top-right-radius: 4px; } .list-wrap > li:last-child { - border-top-left-radius: var(--spectrum-table-regular-border-radius); - border-top-right-radius: var(--spectrum-table-regular-border-radius); + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: 0px; } .right-content { flex: 1; @@ -153,4 +162,15 @@ padding-left: var(--spacing-s); padding-right: var(--spacing-s); } + .handle { + display: flex; + height: var(--spectrum-global-dimension-size-150); + } + .handle :global(svg) { + fill: var(--spectrum-global-color-gray-500); + margin-right: var(--spacing-m); + margin-left: 2px; + width: var(--spectrum-global-dimension-size-65); + height: 100%; + } </style> diff --git a/packages/builder/src/components/design/settings/controls/DraggableList/drag-handle.svelte b/packages/builder/src/components/design/settings/controls/DraggableList/drag-handle.svelte new file mode 100644 index 0000000000..5cfefdbe54 --- /dev/null +++ b/packages/builder/src/components/design/settings/controls/DraggableList/drag-handle.svelte @@ -0,0 +1,31 @@ +<svg + class="drag-handle spectrum-Icon spectrum-Icon--sizeS" + focusable="false" + aria-hidden="true" + xmlns="http://www.w3.org/2000/svg" +> + <path + d="m1,11c0.55228,0 1,-0.4477 1,-1c0,-0.5523 -0.44772,-1 -1,-1c-0.55228,0 -1,0.4477 -1,1c0,0.5523 0.44772,1 1,1z" + /> + <path + d="m1,8c0.55228,0 1,-0.4477 1,-1c0,-0.55228 -0.44772,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.5523 0.44772,1 1,1z" + /> + <path + d="m1,5c0.55228,0 1,-0.44772 1,-1c0,-0.55228 -0.44772,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.55228 0.44772,1 1,1z" + /> + <path + d="m1,2c0.55228,0 1,-0.44772 1,-1c0,-0.55228 -0.44772,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.55228 0.44772,1 1,1z" + /> + <path + d="m4,11c0.5523,0 1,-0.4477 1,-1c0,-0.5523 -0.4477,-1 -1,-1c-0.55228,0 -1,0.4477 -1,1c0,0.5523 0.44772,1 1,1z" + /> + <path + d="m4,8c0.5523,0 1,-0.4477 1,-1c0,-0.55228 -0.4477,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.5523 0.44772,1 1,1z" + /> + <path + d="m4,5c0.5523,0 1,-0.44772 1,-1c0,-0.55228 -0.4477,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.55228 0.44772,1 1,1z" + /> + <path + d="m4,2c0.5523,0 1,-0.44772 1,-1c0,-0.55228 -0.4477,-1 -1,-1c-0.55228,0 -1,0.44772 -1,1c0,0.55228 0.44772,1 1,1z" + /> +</svg> diff --git a/packages/builder/src/components/design/settings/controls/FieldConfiguration/EditFieldPopover.svelte b/packages/builder/src/components/design/settings/controls/EditComponentPopover.svelte similarity index 61% rename from packages/builder/src/components/design/settings/controls/FieldConfiguration/EditFieldPopover.svelte rename to packages/builder/src/components/design/settings/controls/EditComponentPopover.svelte index 7d2eaae478..1533c0d1d5 100644 --- a/packages/builder/src/components/design/settings/controls/FieldConfiguration/EditFieldPopover.svelte +++ b/packages/builder/src/components/design/settings/controls/EditComponentPopover.svelte @@ -3,31 +3,35 @@ import { store } from "builderStore" import { cloneDeep } from "lodash/fp" import { createEventDispatcher } from "svelte" - import ComponentSettingsSection from "../../../../../pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte" + import ComponentSettingsSection from "../../../../pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte" import { getContext } from "svelte" export let anchor - export let field + export let componentInstance export let componentBindings export let bindings + export let parseSettings const draggable = getContext("draggable") const dispatch = createEventDispatcher() let popover let drawers = [] - let pseudoComponentInstance let open = false - $: if (open && $draggable.selected && $draggable.selected != field._id) { + // Auto hide the component when another item is selected + $: if (open && $draggable.selected != componentInstance._id) { popover.hide() } - $: if (field) { - pseudoComponentInstance = field + // Open automatically if the component is marked as selected + $: if (!open && $draggable.selected === componentInstance._id && popover) { + popover.show() + open = true } + $: componentDef = store.actions.components.getDefinition( - pseudoComponentInstance._component + componentInstance._component ) $: parsedComponentDef = processComponentDefinitionSettings(componentDef) @@ -36,17 +40,16 @@ return {} } const clone = cloneDeep(componentDef) - const updatedSettings = clone.settings - .filter(setting => setting.key !== "field") - .map(setting => { - return { ...setting, nested: true } - }) - clone.settings = updatedSettings + + if (typeof parseSettings === "function") { + clone.settings = parseSettings(clone.settings) + } + return clone } const updateSetting = async (setting, value) => { - const nestedComponentInstance = cloneDeep(pseudoComponentInstance) + const nestedComponentInstance = cloneDeep(componentInstance) const patchFn = store.actions.components.updateComponentSetting( setting.key, @@ -54,12 +57,26 @@ ) patchFn(nestedComponentInstance) - const update = { - ...nestedComponentInstance, - active: pseudoComponentInstance.active, + 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 } - dispatch("change", update) + return { ...cfg, left, top } } </script> @@ -79,11 +96,11 @@ bind:this={popover} on:open={() => { drawers = [] - $draggable.actions.select(field._id) + $draggable.actions.select(componentInstance._id) }} on:close={() => { open = false - if ($draggable.selected == field._id) { + if ($draggable.selected == componentInstance._id) { $draggable.actions.select() } }} @@ -92,33 +109,13 @@ showPopover={drawers.length == 0} clickOutsideOverride={drawers.length > 0} maxHeight={600} - handlePostionUpdate={(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 } - }} + handlePostionUpdate={customPositionHandler} > <span class="popover-wrap"> <Layout noPadding noGap> - <div class="type-icon"> - <Icon name={parsedComponentDef.icon} /> - <span>{field.field}</span> - </div> + <slot name="header" /> <ComponentSettingsSection - componentInstance={pseudoComponentInstance} + {componentInstance} componentDefinition={parsedComponentDef} isScreen={false} onUpdateSetting={updateSetting} @@ -141,20 +138,4 @@ .popover-wrap { background-color: var(--spectrum-alias-background-color-primary); } - .type-icon { - display: flex; - gap: var(--spacing-m); - margin: var(--spacing-xl); - margin-bottom: 0px; - height: var(--spectrum-alias-item-height-m); - padding: 0px var(--spectrum-alias-item-padding-m); - border-width: var(--spectrum-actionbutton-border-size); - border-radius: var(--spectrum-alias-border-radius-regular); - border: 1px solid - var( - --spectrum-actionbutton-m-border-color, - var(--spectrum-alias-border-color) - ); - align-items: center; - } </style> diff --git a/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldConfiguration.svelte b/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldConfiguration.svelte index 4169cb7d3d..6c74705ab0 100644 --- a/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldConfiguration.svelte +++ b/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldConfiguration.svelte @@ -7,7 +7,7 @@ getComponentBindableProperties, } from "builderStore/dataBinding" import { currentAsset } from "builderStore" - import DraggableList from "../DraggableList.svelte" + import DraggableList from "../DraggableList/DraggableList.svelte" import { createEventDispatcher } from "svelte" import { store, selectedScreen } from "builderStore" import FieldSetting from "./FieldSetting.svelte" @@ -50,7 +50,7 @@ updateSanitsedFields(sanitisedValue) unconfigured = buildUnconfiguredOptions(schema, sanitisedFields) fieldList = [...sanitisedFields, ...unconfigured] - .map(buildSudoInstance) + .map(buildPseudoInstance) .filter(x => x != null) } @@ -104,7 +104,7 @@ }) } - const buildSudoInstance = instance => { + const buildPseudoInstance = instance => { if (instance._component) { return instance } diff --git a/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldSetting.svelte b/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldSetting.svelte index b5cfcb12d9..1d9ce733b8 100644 --- a/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldSetting.svelte +++ b/packages/builder/src/components/design/settings/controls/FieldConfiguration/FieldSetting.svelte @@ -1,8 +1,11 @@ <script> - import EditFieldPopover from "./EditFieldPopover.svelte" - import { Toggle } from "@budibase/bbui" + import EditComponentPopover from "../EditComponentPopover.svelte" + import { Toggle, Icon } from "@budibase/bbui" import { createEventDispatcher } from "svelte" import { cloneDeep } from "lodash/fp" + import { store } from "builderStore" + import { runtimeToReadableBinding } from "builderStore/dataBinding" + import { isJSBinding } from "@budibase/string-templates" export let item export let componentBindings @@ -16,18 +19,43 @@ dispatch("change", { ...cloneDeep(item), active: e.detail }) } } + const getReadableText = () => { + if (item.label) { + return isJSBinding(item.label) + ? "(JavaScript function)" + : runtimeToReadableBinding([...bindings, componentBindings], item.label) + } + return item.field + } + + const parseSettings = settings => { + return settings + .filter(setting => setting.key !== "field") + .map(setting => { + return { ...setting, nested: true } + }) + } + + $: readableText = getReadableText(item) + $: componentDef = store.actions.components.getDefinition(item._component) </script> <div class="list-item-body"> <div class="list-item-left"> - <EditFieldPopover + <EditComponentPopover {anchor} - field={item} + componentInstance={item} {componentBindings} {bindings} + {parseSettings} on:change - /> - <div class="field-label">{item.label || item.field}</div> + > + <div slot="header" class="type-icon"> + <Icon name={componentDef.icon} /> + <span>{item.field}</span> + </div> + </EditComponentPopover> + <div class="field-label">{readableText}</div> </div> <div class="list-item-right"> <Toggle on:change={onToggle(item)} text="" value={item.active} thin /> @@ -53,4 +81,20 @@ .list-item-body { justify-content: space-between; } + .type-icon { + display: flex; + gap: var(--spacing-m); + margin: var(--spacing-xl); + margin-bottom: 0px; + height: var(--spectrum-alias-item-height-m); + padding: 0px var(--spectrum-alias-item-padding-m); + border-width: var(--spectrum-actionbutton-border-size); + border-radius: var(--spectrum-alias-border-radius-regular); + border: 1px solid + var( + --spectrum-actionbutton-m-border-color, + var(--spectrum-alias-border-color) + ); + align-items: center; + } </style> diff --git a/packages/builder/src/components/integration/QueryViewerSidePanel/PreviewPanel.svelte b/packages/builder/src/components/integration/QueryViewerSidePanel/PreviewPanel.svelte index f379ad18a1..3873669b63 100644 --- a/packages/builder/src/components/integration/QueryViewerSidePanel/PreviewPanel.svelte +++ b/packages/builder/src/components/integration/QueryViewerSidePanel/PreviewPanel.svelte @@ -23,7 +23,7 @@ </script> <div class="table"> - <Table {schema} data={rowsCopy} type="external" allowEditing={false} /> + <Table {schema} data={rowsCopy} allowEditing={false} /> </div> <style> diff --git a/packages/builder/src/components/integration/RestQueryViewer.svelte b/packages/builder/src/components/integration/RestQueryViewer.svelte index 254f65fcaf..e6913b0953 100644 --- a/packages/builder/src/components/integration/RestQueryViewer.svelte +++ b/packages/builder/src/components/integration/RestQueryViewer.svelte @@ -196,8 +196,36 @@ } } + const validateQuery = async () => { + const forbiddenBindings = /{{\s?user(\.(\w|\$)*\s?|\s?)}}/g + const bindingError = new Error( + "'user' is a protected binding and cannot be used" + ) + + if (forbiddenBindings.test(url)) { + throw bindingError + } + + if (forbiddenBindings.test(query.fields.requestBody ?? "")) { + throw bindingError + } + + Object.values(requestBindings).forEach(bindingValue => { + if (forbiddenBindings.test(bindingValue)) { + throw bindingError + } + }) + + Object.values(query.fields.headers).forEach(headerValue => { + if (forbiddenBindings.test(headerValue)) { + throw bindingError + } + }) + } + async function runQuery() { try { + await validateQuery() response = await queries.preview(buildQuery()) if (response.rows.length === 0) { notifications.info("Request did not return any data") diff --git a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte index a7d9584330..f9a40b09a6 100644 --- a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte @@ -516,6 +516,13 @@ } return null } + + const parseRole = user => { + if (user.isAdminOrGlobalBuilder) { + return Constants.Roles.CREATOR + } + return user.role + } </script> <svelte:window on:keydown={handleKeyDown} /> @@ -725,7 +732,7 @@ <RoleSelect footer={getRoleFooter(user)} placeholder={false} - value={user.role} + value={parseRole(user)} allowRemove={user.role && !user.group} allowPublic={false} allowCreator={true} @@ -744,7 +751,7 @@ autoWidth align="right" allowedRoles={user.isAdminOrGlobalBuilder - ? [Constants.Roles.ADMIN] + ? [Constants.Roles.CREATOR] : null} /> </div> diff --git a/packages/builder/src/pages/builder/app/[application]/data/datasource/[datasourceId]/_components/panels/Tables/CreateExternalTableModal.svelte b/packages/builder/src/pages/builder/app/[application]/data/datasource/[datasourceId]/_components/panels/Tables/CreateExternalTableModal.svelte index 664b5629d4..faa3611f5d 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/datasource/[datasourceId]/_components/panels/Tables/CreateExternalTableModal.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/datasource/[datasourceId]/_components/panels/Tables/CreateExternalTableModal.svelte @@ -2,6 +2,7 @@ import { ModalContent, Body, Input, notifications } from "@budibase/bbui" import { tables, datasources } from "stores/backend" import { goto } from "@roxi/routify" + import { DB_TYPE_EXTERNAL } from "constants/backend" export let datasource @@ -16,9 +17,10 @@ function buildDefaultTable(tableName, datasourceId) { return { name: tableName, - type: "external", + type: "table", primary: ["id"], sourceId: datasourceId, + sourceType: DB_TYPE_EXTERNAL, schema: { id: { autocolumn: true, diff --git a/packages/builder/src/pages/builder/app/[application]/data/datasource/bb_internal/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/datasource/bb_internal/index.svelte index 189141dd39..21214d8840 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/datasource/bb_internal/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/datasource/bb_internal/index.svelte @@ -5,7 +5,7 @@ import { tables, datasources } from "stores/backend" import { goto } from "@roxi/routify" import { onMount } from "svelte" - import { BUDIBASE_INTERNAL_DB_ID } from "constants/backend" + import { BUDIBASE_INTERNAL_DB_ID, DB_TYPE_EXTERNAL } from "constants/backend" import { TableNames } from "constants" import { store } from "builderStore" @@ -14,7 +14,7 @@ $: store.actions.websocket.selectResource(BUDIBASE_INTERNAL_DB_ID) $: internalTablesBySourceId = $tables.list.filter( table => - table.type !== "external" && + table.sourceType !== DB_TYPE_EXTERNAL && table.sourceId === BUDIBASE_INTERNAL_DB_ID && table._id !== TableNames.USERS ) diff --git a/packages/builder/src/pages/builder/app/[application]/data/datasource/datasource_internal_bb_default/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/datasource/datasource_internal_bb_default/index.svelte index f30ffea131..44cb8db3b0 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/datasource/datasource_internal_bb_default/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/datasource/datasource_internal_bb_default/index.svelte @@ -4,7 +4,7 @@ import ICONS from "components/backend/DatasourceNavigator/icons" import { tables, datasources } from "stores/backend" import { goto } from "@roxi/routify" - import { DEFAULT_BB_DATASOURCE_ID } from "constants/backend" + import { DEFAULT_BB_DATASOURCE_ID, DB_TYPE_EXTERNAL } from "constants/backend" import { onMount } from "svelte" import { store } from "builderStore" @@ -13,7 +13,8 @@ $: store.actions.websocket.selectResource(DEFAULT_BB_DATASOURCE_ID) $: internalTablesBySourceId = $tables.list.filter( table => - table.type !== "external" && table.sourceId === DEFAULT_BB_DATASOURCE_ID + table.sourceType !== DB_TYPE_EXTERNAL && + table.sourceId === DEFAULT_BB_DATASOURCE_ID ) onMount(() => { diff --git a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte index 414722a177..a68a782bed 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/table/[tableId]/index.svelte @@ -53,7 +53,8 @@ } .alert-wrap { display: flex; - width: 100%; + flex: 0 0 auto; + margin: -28px -40px 14px -40px; } .alert-wrap :global(> *) { flex: 1; diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsPanel.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsPanel.svelte index 17eadb99bd..affa115ca2 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsPanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsPanel.svelte @@ -91,7 +91,12 @@ /> {/if} {#if section == "styles"} - <DesignSection {componentInstance} {componentDefinition} {bindings} /> + <DesignSection + {componentInstance} + {componentBindings} + {componentDefinition} + {bindings} + /> <CustomStylesSection {componentInstance} {componentDefinition} diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte index f833464d8c..6dc9078f2c 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte @@ -16,18 +16,32 @@ export let isScreen = false export let onUpdateSetting export let showSectionTitle = true + export let tag - $: sections = getSections(componentInstance, componentDefinition, isScreen) + $: sections = getSections( + componentInstance, + componentDefinition, + isScreen, + tag + ) - const getSections = (instance, definition, isScreen) => { + const getSections = (instance, definition, isScreen, tag) => { const settings = definition?.settings ?? [] - const generalSettings = settings.filter(setting => !setting.section) - const customSections = settings.filter(setting => setting.section) + const generalSettings = settings.filter( + setting => !setting.section && setting.tag === tag + ) + const customSections = settings.filter( + setting => setting.section && setting.tag === tag + ) let sections = [ - { - name: "General", - settings: generalSettings, - }, + ...(generalSettings?.length + ? [ + { + name: "General", + settings: generalSettings, + }, + ] + : []), ...(customSections || []), ] @@ -132,7 +146,7 @@ <div class="section-info"> <InfoDisplay body={section.info} /> </div> - {:else if idx === 0 && section.name === "General" && componentDefinition.info} + {:else if idx === 0 && section.name === "General" && componentDefinition?.info && !tag} <InfoDisplay title={componentDefinition.name} body={componentDefinition.info} @@ -181,7 +195,7 @@ </DetailSummary> {/if} {/each} -{#if componentDefinition?.block} +{#if componentDefinition?.block && !tag} <DetailSummary name="Eject" collapsible={false}> <EjectBlockButton /> </DetailSummary> diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/DesignSection.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/DesignSection.svelte index 444ded7e1f..def1fcf24b 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/DesignSection.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/DesignSection.svelte @@ -1,10 +1,12 @@ <script> import StyleSection from "./StyleSection.svelte" import * as ComponentStyles from "./componentStyles" + import ComponentSettingsSection from "./ComponentSettingsSection.svelte" export let componentDefinition export let componentInstance export let bindings + export let componentBindings const getStyles = def => { if (!def?.styles?.length) { @@ -22,6 +24,19 @@ $: styles = getStyles(componentDefinition) </script> +<!-- + Load any general settings or sections tagged as "style" +--> +<ComponentSettingsSection + {componentInstance} + {componentDefinition} + isScreen={false} + showInstanceName={false} + {bindings} + {componentBindings} + tag="style" +/> + {#if styles?.length > 0} {#each styles as style} <StyleSection diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json index 11a130490a..dd129be11e 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/new/_components/componentStructure.json @@ -36,6 +36,7 @@ "heading", "text", "button", + "buttongroup", "tag", "spectrumcard", "cardstat", diff --git a/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/CreateScreenModal.svelte b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/CreateScreenModal.svelte index 9a96242b30..92ed3dcfc7 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/CreateScreenModal.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/CreateScreenModal.svelte @@ -12,6 +12,7 @@ import { capitalise } from "helpers" import { goto } from "@roxi/routify" + let mode let pendingScreen // Modal refs @@ -100,14 +101,15 @@ } // Handler for NewScreenModal - export const show = mode => { + export const show = newMode => { + mode = newMode selectedTemplates = null blankScreenUrl = null screenMode = mode pendingScreen = null screenAccessRole = Roles.BASIC - if (mode === "table") { + if (mode === "table" || mode === "grid") { datasourceModal.show() } else if (mode === "blank") { let templates = getTemplates($tables.list) @@ -123,6 +125,7 @@ // Handler for DatasourceModal confirmation, move to screen access select const confirmScreenDatasources = async ({ templates }) => { + console.log(templates) selectedTemplates = templates screenAccessRoleModal.show() } @@ -177,6 +180,7 @@ <Modal bind:this={datasourceModal} autoFocus={false}> <DatasourceModal + {mode} onConfirm={confirmScreenDatasources} initialScreens={!selectedTemplates ? [] : [...selectedTemplates]} /> diff --git a/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/DatasourceModal.svelte b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/DatasourceModal.svelte index a866cd23d4..731c60a406 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/DatasourceModal.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/DatasourceModal.svelte @@ -7,6 +7,7 @@ import rowListScreen from "builderStore/store/screenTemplates/rowListScreen" import DatasourceTemplateRow from "./DatasourceTemplateRow.svelte" + export let mode export let onCancel export let onConfirm export let initialScreens = [] @@ -24,7 +25,10 @@ screen => screen.resourceId !== resourceId ) } else { - selectedScreens = [...selectedScreens, rowListScreen([datasource])[0]] + selectedScreens = [ + ...selectedScreens, + rowListScreen([datasource], mode)[0], + ] } } diff --git a/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/grid.png b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/grid.png new file mode 100644 index 0000000000..c3efa30a67 Binary files /dev/null and b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/grid.png differ diff --git a/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/index.svelte b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/index.svelte index b504940ca7..6b080747b0 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/index.svelte @@ -3,6 +3,7 @@ import CreationPage from "components/common/CreationPage.svelte" import blankImage from "./blank.png" import tableImage from "./table.png" + import gridImage from "./grid.png" import CreateScreenModal from "./CreateScreenModal.svelte" import { store } from "builderStore" @@ -43,6 +44,16 @@ <Body size="XS">View, edit and delete rows on a table</Body> </div> </div> + + <div class="card" on:click={() => createScreenModal.show("grid")}> + <div class="image"> + <img alt="" src={gridImage} /> + </div> + <div class="text"> + <Body size="S">Grid</Body> + <Body size="XS">View and manipulate rows on a grid</Body> + </div> + </div> </div> </CreationPage> </div> diff --git a/packages/builder/src/pages/builder/portal/users/users/index.svelte b/packages/builder/src/pages/builder/portal/users/users/index.svelte index e1fc0ca7eb..ff8b749602 100644 --- a/packages/builder/src/pages/builder/portal/users/users/index.svelte +++ b/packages/builder/src/pages/builder/portal/users/users/index.svelte @@ -3,7 +3,6 @@ Heading, Body, Button, - ButtonGroup, Table, Layout, Modal, @@ -46,6 +45,10 @@ datasource: { type: "user", }, + options: { + paginate: true, + limit: 10, + }, }) let groupsLoaded = !$licensing.groupsEnabled || $groups?.length @@ -65,10 +68,12 @@ { column: "role", component: RoleTableRenderer }, ] let userData = [] + let invitesLoaded = false + let pendingInvites = [] + let parsedInvites = [] $: isOwner = $auth.accountPortalAccess && $admin.cloud $: readonly = !sdk.users.isAdmin($auth.user) || $features.isScimEnabled - $: debouncedUpdateFetch(searchEmail) $: schema = { email: { @@ -88,16 +93,6 @@ width: "1fr", }, } - - const getPendingSchema = tblSchema => { - if (!tblSchema) { - return {} - } - let pendingSchema = JSON.parse(JSON.stringify(tblSchema)) - pendingSchema.email.displayName = "Pending Invites" - return pendingSchema - } - $: pendingSchema = getPendingSchema(schema) $: userData = [] $: inviteUsersResponse = { successful: [], unsuccessful: [] } @@ -121,9 +116,15 @@ } }) } - let invitesLoaded = false - let pendingInvites = [] - let parsedInvites = [] + + const getPendingSchema = tblSchema => { + if (!tblSchema) { + return {} + } + let pendingSchema = JSON.parse(JSON.stringify(tblSchema)) + pendingSchema.email.displayName = "Pending Invites" + return pendingSchema + } const invitesToSchema = invites => { return invites.map(invite => { @@ -143,7 +144,9 @@ const updateFetch = email => { fetch.update({ query: { - email, + string: { + email, + }, }, }) } @@ -296,7 +299,7 @@ {/if} <div class="controls"> {#if !readonly} - <ButtonGroup> + <div class="buttons"> <Button disabled={readonly} on:click={$licensing.userLimitReached @@ -315,7 +318,7 @@ > Import </Button> - </ButtonGroup> + </div> {:else} <ScimBanner /> {/if} @@ -390,12 +393,15 @@ </Modal> <style> + .buttons { + display: flex; + gap: 10px; + } .pagination { display: flex; flex-direction: row; justify-content: flex-end; } - .controls { display: flex; flex-direction: row; @@ -403,7 +409,6 @@ align-items: center; gap: var(--spacing-xl); } - .controls-right { display: flex; flex-direction: row; @@ -411,7 +416,6 @@ align-items: center; gap: var(--spacing-xl); } - .controls-right :global(.spectrum-Search) { width: 200px; } diff --git a/packages/client/manifest.json b/packages/client/manifest.json index 7094ce88e9..eef1e50b7c 100644 --- a/packages/client/manifest.json +++ b/packages/client/manifest.json @@ -258,6 +258,186 @@ "description": "Contains your app screens", "static": true }, + "buttongroup": { + "name": "Button group", + "icon": "Button", + "hasChildren": false, + "settings": [ + { + "section": true, + "name": "Buttons", + "settings": [ + { + "type": "buttonConfiguration", + "key": "buttons", + "nested": true, + "defaultValue": [ + { + "type": "cta", + "text": "Button 1" + }, + { + "type": "primary", + "text": "Button 2" + } + ] + } + ] + }, + { + "section": true, + "name": "Layout", + "settings": [ + { + "type": "select", + "label": "Direction", + "key": "direction", + "showInBar": true, + "barStyle": "buttons", + "options": [ + { + "label": "Column", + "value": "column", + "barIcon": "ViewColumn", + "barTitle": "Column layout" + }, + { + "label": "Row", + "value": "row", + "barIcon": "ViewRow", + "barTitle": "Row layout" + } + ], + "defaultValue": "row" + }, + { + "type": "select", + "label": "Horiz. align", + "key": "hAlign", + "showInBar": true, + "barStyle": "buttons", + "options": [ + { + "label": "Left", + "value": "left", + "barIcon": "AlignLeft", + "barTitle": "Align left" + }, + { + "label": "Center", + "value": "center", + "barIcon": "AlignCenter", + "barTitle": "Align center" + }, + { + "label": "Right", + "value": "right", + "barIcon": "AlignRight", + "barTitle": "Align right" + }, + { + "label": "Stretch", + "value": "stretch", + "barIcon": "MoveLeftRight", + "barTitle": "Align stretched horizontally" + } + ], + "defaultValue": "left" + }, + { + "type": "select", + "label": "Vert. align", + "key": "vAlign", + "showInBar": true, + "barStyle": "buttons", + "options": [ + { + "label": "Top", + "value": "top", + "barIcon": "AlignTop", + "barTitle": "Align top" + }, + { + "label": "Middle", + "value": "middle", + "barIcon": "AlignMiddle", + "barTitle": "Align middle" + }, + { + "label": "Bottom", + "value": "bottom", + "barIcon": "AlignBottom", + "barTitle": "Align bottom" + }, + { + "label": "Stretch", + "value": "stretch", + "barIcon": "MoveUpDown", + "barTitle": "Align stretched vertically" + } + ], + "defaultValue": "top" + }, + { + "type": "select", + "label": "Size", + "key": "size", + "showInBar": true, + "barStyle": "buttons", + "options": [ + { + "label": "Shrink", + "value": "shrink", + "barIcon": "Minimize", + "barTitle": "Shrink container" + }, + { + "label": "Grow", + "value": "grow", + "barIcon": "Maximize", + "barTitle": "Grow container" + } + ], + "defaultValue": "shrink" + }, + { + "type": "select", + "label": "Gap", + "key": "gap", + "showInBar": true, + "barStyle": "picker", + "options": [ + { + "label": "None", + "value": "N" + }, + { + "label": "Small", + "value": "S" + }, + { + "label": "Medium", + "value": "M" + }, + { + "label": "Large", + "value": "L" + } + ], + "defaultValue": "M" + }, + { + "type": "boolean", + "label": "Wrap", + "key": "wrap", + "showInBar": true, + "barIcon": "ModernGridView", + "barTitle": "Wrap" + } + ] + } + ] + }, "button": { "name": "Button", "description": "A basic html button that is ready for styling", @@ -2409,7 +2589,6 @@ "key": "disabled", "defaultValue": false }, - { "type": "text", "label": "Initial form step", @@ -5288,17 +5467,17 @@ }, "settings": [ { - "type": "select", + "type": "table", + "label": "Data", + "key": "dataSource" + }, + { + "type": "radio", "label": "Type", "key": "actionType", "options": ["Create", "Update", "View"], "defaultValue": "Create" }, - { - "type": "table", - "label": "Data", - "key": "dataSource" - }, { "type": "text", "label": "Title", @@ -5329,13 +5508,37 @@ }, { "type": "text", - "label": "Empty text", + "label": "No rows found", "key": "noRowsMessage", "defaultValue": "We couldn't find a row to display", "nested": true } ] }, + { + "section": true, + "name": "Fields", + "settings": [ + { + "type": "fieldConfiguration", + "key": "fields", + "nested": true, + "resetOn": "dataSource", + "selectAllFields": true + }, + { + "type": "boolean", + "label": "Disabled", + "key": "disabled", + "defaultValue": false, + "dependsOn": { + "setting": "actionType", + "value": "View", + "invert": true + } + } + ] + }, { "section": true, "name": "Buttons", @@ -5388,60 +5591,38 @@ ] }, { - "section": true, - "name": "Fields", - "settings": [ + "tag": "style", + "type": "select", + "label": "Align labels", + "key": "labelPosition", + "defaultValue": "left", + "options": [ { - "type": "select", - "label": "Align labels", - "key": "labelPosition", - "defaultValue": "left", - "options": [ - { - "label": "Left", - "value": "left" - }, - { - "label": "Above", - "value": "above" - } - ] + "label": "Left", + "value": "left" }, { - "type": "select", - "label": "Size", - "key": "size", - "options": [ - { - "label": "Medium", - "value": "spectrum--medium" - }, - { - "label": "Large", - "value": "spectrum--large" - } - ], - "defaultValue": "spectrum--medium" - }, - { - "type": "fieldConfiguration", - "key": "fields", - "nested": true, - "resetOn": "dataSource", - "selectAllFields": true - }, - { - "type": "boolean", - "label": "Disabled", - "key": "disabled", - "defaultValue": false, - "dependsOn": { - "setting": "actionType", - "value": "View", - "invert": true - } + "label": "Above", + "value": "above" } ] + }, + { + "tag": "style", + "type": "select", + "label": "Size", + "key": "size", + "options": [ + { + "label": "Medium", + "value": "spectrum--medium" + }, + { + "label": "Large", + "value": "spectrum--large" + } + ], + "defaultValue": "spectrum--medium" } ], "context": [ diff --git a/packages/client/src/components/app/ButtonGroup.svelte b/packages/client/src/components/app/ButtonGroup.svelte new file mode 100644 index 0000000000..87b0990701 --- /dev/null +++ b/packages/client/src/components/app/ButtonGroup.svelte @@ -0,0 +1,37 @@ +<script> + import BlockComponent from "../BlockComponent.svelte" + import Block from "../Block.svelte" + + export let buttons = [] + export let direction + export let hAlign + export let vAlign + export let gap = "S" +</script> + +<Block> + <BlockComponent + type="container" + props={{ + direction, + hAlign, + vAlign, + gap, + wrap: true, + }} + > + {#each buttons as { text, type, quiet, disabled, onClick, size }} + <BlockComponent + type="button" + props={{ + text: text || "Button", + onClick, + type, + quiet, + disabled, + size, + }} + /> + {/each} + </BlockComponent> +</Block> diff --git a/packages/client/src/components/app/blocks/form/InnerFormBlock.svelte b/packages/client/src/components/app/blocks/form/InnerFormBlock.svelte index e65d2cf90b..f7e9a0d2ed 100644 --- a/packages/client/src/components/app/blocks/form/InnerFormBlock.svelte +++ b/packages/client/src/components/app/blocks/form/InnerFormBlock.svelte @@ -220,15 +220,11 @@ </BlockComponent> {/if} </BlockComponent> - {#if description} - <BlockComponent - type="text" - props={{ text: description }} - order={1} - /> - {/if} </BlockComponent> {/if} + {#if description} + <BlockComponent type="text" props={{ text: description }} order={1} /> + {/if} {#key fields} <BlockComponent type="fieldgroup" props={{ labelPosition }} order={1}> {#each fields as field, idx} diff --git a/packages/client/src/components/app/index.js b/packages/client/src/components/app/index.js index 060c15a857..97df3741e1 100644 --- a/packages/client/src/components/app/index.js +++ b/packages/client/src/components/app/index.js @@ -19,6 +19,7 @@ export { default as dataprovider } from "./DataProvider.svelte" export { default as divider } from "./Divider.svelte" export { default as screenslot } from "./ScreenSlot.svelte" export { default as button } from "./Button.svelte" +export { default as buttongroup } from "./ButtonGroup.svelte" export { default as repeater } from "./Repeater.svelte" export { default as text } from "./Text.svelte" export { default as layout } from "./Layout.svelte" diff --git a/packages/client/src/utils/buttonActions.js b/packages/client/src/utils/buttonActions.js index 18d6b3de3c..9b4640dbb4 100644 --- a/packages/client/src/utils/buttonActions.js +++ b/packages/client/src/utils/buttonActions.js @@ -103,7 +103,6 @@ const fetchRowHandler = async action => { const deleteRowHandler = async action => { const { tableId, rowId: rowConfig, notificationOverride } = action.parameters - if (tableId && rowConfig) { try { let requestConfig @@ -129,9 +128,11 @@ const deleteRowHandler = async action => { requestConfig = [parsedRowConfig] } else if (Array.isArray(parsedRowConfig)) { requestConfig = parsedRowConfig + } else if (Number.isInteger(parsedRowConfig)) { + requestConfig = [String(parsedRowConfig)] } - if (!requestConfig.length) { + if (!requestConfig && !parsedRowConfig) { notificationStore.actions.warning("No valid rows were supplied") return false } diff --git a/packages/frontend-core/src/api/tables.js b/packages/frontend-core/src/api/tables.js index a08e35d3d8..34d2371e1a 100644 --- a/packages/frontend-core/src/api/tables.js +++ b/packages/frontend-core/src/api/tables.js @@ -140,4 +140,13 @@ export const buildTableEndpoints = API => ({ }, }) }, + migrateColumn: async ({ tableId, oldColumn, newColumn }) => { + return await API.post({ + url: `/api/tables/${tableId}/migrate`, + body: { + oldColumn, + newColumn, + }, + }) + }, }) diff --git a/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte b/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte index a27c31bbe5..fc0001d55e 100644 --- a/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/AttachmentCell.svelte @@ -55,7 +55,7 @@ try { return await API.uploadBuilderAttachment(data) } catch (error) { - $notifications.error("Failed to upload attachment") + $notifications.error(error.message || "Failed to upload attachment") return [] } } diff --git a/packages/frontend-core/src/components/grid/cells/DataCell.svelte b/packages/frontend-core/src/components/grid/cells/DataCell.svelte index f9cdef3756..cdaf28978a 100644 --- a/packages/frontend-core/src/components/grid/cells/DataCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/DataCell.svelte @@ -21,6 +21,7 @@ export let invertX = false export let invertY = false export let contentLines = 1 + export let hidden = false const emptyError = writable(null) @@ -78,6 +79,7 @@ {focused} {selectedUser} {readonly} + {hidden} error={$error} on:click={() => focusedCellId.set(cellId)} on:contextmenu={e => menu.actions.open(cellId, e)} diff --git a/packages/frontend-core/src/components/grid/cells/GridCell.svelte b/packages/frontend-core/src/components/grid/cells/GridCell.svelte index fe4bd70ba4..dcc76b9c75 100644 --- a/packages/frontend-core/src/components/grid/cells/GridCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/GridCell.svelte @@ -10,6 +10,7 @@ export let defaultHeight = false export let center = false export let readonly = false + export let hidden = false $: style = getStyle(width, selectedUser) @@ -30,6 +31,7 @@ class:error class:center class:readonly + class:hidden class:default-height={defaultHeight} class:selected-other={selectedUser != null} class:alt={rowIdx % 2 === 1} @@ -81,6 +83,9 @@ .cell.center { align-items: center; } + .cell.hidden { + content-visibility: hidden; + } /* Cell border */ .cell.focused:after, diff --git a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte index a7b232c487..a0de8a5ef3 100644 --- a/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte +++ b/packages/frontend-core/src/components/grid/cells/HeaderCell.svelte @@ -1,11 +1,20 @@ <script> import { getContext, onMount, tick } from "svelte" import { canBeDisplayColumn, canBeSortColumn } from "@budibase/shared-core" - import { Icon, Popover, Menu, MenuItem, clickOutside } from "@budibase/bbui" + import { + Icon, + Popover, + Menu, + MenuItem, + clickOutside, + Modal, + } from "@budibase/bbui" import GridCell from "./GridCell.svelte" import { getColumnIcon } from "../lib/utils" + import MigrationModal from "../controls/MigrationModal.svelte" import { debounce } from "../../../utils/utils" import { FieldType, FormulaTypes } from "@budibase/types" + import { TableNames } from "../../../constants" export let column export let idx @@ -17,7 +26,7 @@ isResizing, rand, sort, - renderedColumns, + visibleColumns, dispatch, subscribe, config, @@ -45,12 +54,13 @@ let editIsOpen = false let timeout let popover + let migrationModal let searchValue let input $: sortedBy = column.name === $sort.column $: canMoveLeft = orderable && idx > 0 - $: canMoveRight = orderable && idx < $renderedColumns.length - 1 + $: canMoveRight = orderable && idx < $visibleColumns.length - 1 $: sortingLabels = getSortingLabels(column.schema?.type) $: searchable = isColumnSearchable(column) $: resetSearchValue(column.name) @@ -189,6 +199,11 @@ }) } + const openMigrationModal = () => { + migrationModal.show() + open = false + } + const startSearching = async () => { $focusedCellId = null searchValue = "" @@ -224,6 +239,10 @@ onMount(() => subscribe("close-edit-column", cancelEdit)) </script> +<Modal bind:this={migrationModal}> + <MigrationModal {column} /> +</Modal> + <div class="header-cell" class:open @@ -363,6 +382,11 @@ > Hide column </MenuItem> + {#if $config.canEditColumns && column.schema.type === "link" && column.schema.tableId === TableNames.USERS} + <MenuItem icon="User" on:click={openMigrationModal}> + Migrate to user column + </MenuItem> + {/if} </Menu> {/if} </Popover> diff --git a/packages/frontend-core/src/components/grid/controls/MigrationModal.svelte b/packages/frontend-core/src/components/grid/controls/MigrationModal.svelte new file mode 100644 index 0000000000..ecef009fe0 --- /dev/null +++ b/packages/frontend-core/src/components/grid/controls/MigrationModal.svelte @@ -0,0 +1,73 @@ +<script> + import { + ModalContent, + notifications, + Input, + InlineAlert, + } from "@budibase/bbui" + import { getContext } from "svelte" + import { ValidColumnNameRegex } from "@budibase/shared-core" + import { FieldSubtype, FieldType, RelationshipType } from "@budibase/types" + + const { API, definition, rows } = getContext("grid") + + export let column + + let newColumnName = `${column.schema.name} migrated` + $: error = checkNewColumnName(newColumnName) + + const checkNewColumnName = newColumnName => { + if (newColumnName === "") { + return "Column name can't be empty." + } + if (newColumnName in $definition.schema) { + return "New column name can't be the same as an existing column name." + } + if (newColumnName.match(ValidColumnNameRegex) === null) { + return "Illegal character; must be alpha-numeric." + } + } + + const migrateUserColumn = async () => { + let subtype = FieldSubtype.USERS + if (column.schema.relationshipType === RelationshipType.ONE_TO_MANY) { + subtype = FieldSubtype.USER + } + + try { + await API.migrateColumn({ + tableId: $definition._id, + oldColumn: column.schema, + newColumn: { + name: newColumnName, + type: FieldType.BB_REFERENCE, + subtype, + }, + }) + notifications.success("Column migrated") + } catch (e) { + notifications.error(`Failed to migrate: ${e.message}`) + } + await rows.actions.refreshData() + } +</script> + +<ModalContent + title="Migrate column" + confirmText="Continue" + cancelText="Cancel" + onConfirm={migrateUserColumn} + disabled={error !== undefined} + size="M" +> + This operation will kick off a migration of the column "{column.schema.name}" + to a new column, with the name provided - this operation may take a moment to + complete. + + <InlineAlert + type="error" + header="Are you sure?" + message="This will leave bindings which utilised the user relationship column in a state where they will need to be updated to use the new column instead." + /> + <Input bind:value={newColumnName} label="New column name" {error} /> +</ModalContent> diff --git a/packages/frontend-core/src/components/grid/layout/GridBody.svelte b/packages/frontend-core/src/components/grid/layout/GridBody.svelte index 762985a4db..0bb2a51fb4 100644 --- a/packages/frontend-core/src/components/grid/layout/GridBody.svelte +++ b/packages/frontend-core/src/components/grid/layout/GridBody.svelte @@ -7,7 +7,7 @@ const { bounds, renderedRows, - renderedColumns, + visibleColumns, rowVerticalInversionIndex, hoveredRowId, dispatch, @@ -17,7 +17,7 @@ let body - $: renderColumnsWidth = $renderedColumns.reduce( + $: columnsWidth = $visibleColumns.reduce( (total, col) => (total += col.width), 0 ) @@ -47,7 +47,7 @@ <div class="blank" class:highlighted={$hoveredRowId === BlankRowID} - style="width:{renderColumnsWidth}px" + style="width:{columnsWidth}px" on:mouseenter={$isDragging ? null : () => ($hoveredRowId = BlankRowID)} on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)} on:click={() => dispatch("add-row-inline")} diff --git a/packages/frontend-core/src/components/grid/layout/GridRow.svelte b/packages/frontend-core/src/components/grid/layout/GridRow.svelte index 4754d493bf..4a0db40ee8 100644 --- a/packages/frontend-core/src/components/grid/layout/GridRow.svelte +++ b/packages/frontend-core/src/components/grid/layout/GridRow.svelte @@ -10,7 +10,7 @@ focusedCellId, reorder, selectedRows, - renderedColumns, + visibleColumns, hoveredRowId, selectedCellMap, focusedRow, @@ -19,6 +19,7 @@ isDragging, dispatch, rows, + columnRenderMap, } = getContext("grid") $: rowSelected = !!$selectedRows[row._id] @@ -34,7 +35,7 @@ on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)} on:click={() => dispatch("rowclick", rows.actions.cleanRow(row))} > - {#each $renderedColumns as column, columnIdx (column.name)} + {#each $visibleColumns as column, columnIdx} {@const cellId = `${row._id}-${column.name}`} <DataCell {cellId} @@ -51,6 +52,7 @@ selectedUser={$selectedCellMap[cellId]} width={column.width} contentLines={$contentLines} + hidden={!$columnRenderMap[column.name]} /> {/each} </div> diff --git a/packages/frontend-core/src/components/grid/layout/GridScrollWrapper.svelte b/packages/frontend-core/src/components/grid/layout/GridScrollWrapper.svelte index 05bd261721..2a131809a9 100644 --- a/packages/frontend-core/src/components/grid/layout/GridScrollWrapper.svelte +++ b/packages/frontend-core/src/components/grid/layout/GridScrollWrapper.svelte @@ -11,7 +11,6 @@ maxScrollLeft, bounds, hoveredRowId, - hiddenColumnsWidth, menu, } = getContext("grid") @@ -23,10 +22,10 @@ let initialTouchX let initialTouchY - $: style = generateStyle($scroll, $rowHeight, $hiddenColumnsWidth) + $: style = generateStyle($scroll, $rowHeight) - const generateStyle = (scroll, rowHeight, hiddenWidths) => { - const offsetX = scrollHorizontally ? -1 * scroll.left + hiddenWidths : 0 + const generateStyle = (scroll, rowHeight) => { + const offsetX = scrollHorizontally ? -1 * scroll.left : 0 const offsetY = scrollVertically ? -1 * (scroll.top % rowHeight) : 0 return `transform: translate3d(${offsetX}px, ${offsetY}px, 0);` } diff --git a/packages/frontend-core/src/components/grid/layout/HeaderRow.svelte b/packages/frontend-core/src/components/grid/layout/HeaderRow.svelte index 97b7d054f3..b8655b98b3 100644 --- a/packages/frontend-core/src/components/grid/layout/HeaderRow.svelte +++ b/packages/frontend-core/src/components/grid/layout/HeaderRow.svelte @@ -5,14 +5,14 @@ import HeaderCell from "../cells/HeaderCell.svelte" import { TempTooltip, TooltipType } from "@budibase/bbui" - const { renderedColumns, config, hasNonAutoColumn, datasource, loading } = + const { visibleColumns, config, hasNonAutoColumn, datasource, loading } = getContext("grid") </script> <div class="header"> <GridScrollWrapper scrollHorizontally> <div class="row"> - {#each $renderedColumns as column, idx} + {#each $visibleColumns as column, idx} <HeaderCell {column} {idx}> <slot name="edit-column" /> </HeaderCell> diff --git a/packages/frontend-core/src/components/grid/layout/NewColumnButton.svelte b/packages/frontend-core/src/components/grid/layout/NewColumnButton.svelte index d131df26e5..46e9b40fb6 100644 --- a/packages/frontend-core/src/components/grid/layout/NewColumnButton.svelte +++ b/packages/frontend-core/src/components/grid/layout/NewColumnButton.svelte @@ -2,17 +2,16 @@ import { getContext, onMount } from "svelte" import { Icon, Popover, clickOutside } from "@budibase/bbui" - const { renderedColumns, scroll, hiddenColumnsWidth, width, subscribe } = - getContext("grid") + const { visibleColumns, scroll, width, subscribe } = getContext("grid") let anchor let open = false - $: columnsWidth = $renderedColumns.reduce( + $: columnsWidth = $visibleColumns.reduce( (total, col) => (total += col.width), 0 ) - $: end = $hiddenColumnsWidth + columnsWidth - 1 - $scroll.left + $: end = columnsWidth - 1 - $scroll.left $: left = Math.min($width - 40, end) const close = () => { @@ -34,7 +33,7 @@ <Popover bind:open {anchor} - align={$renderedColumns.length ? "right" : "left"} + align={$visibleColumns.length ? "right" : "left"} offset={0} popoverTarget={document.getElementById(`add-column-button`)} customZindex={100} diff --git a/packages/frontend-core/src/components/grid/layout/NewRow.svelte b/packages/frontend-core/src/components/grid/layout/NewRow.svelte index bbb6a6a6c5..26706b701c 100644 --- a/packages/frontend-core/src/components/grid/layout/NewRow.svelte +++ b/packages/frontend-core/src/components/grid/layout/NewRow.svelte @@ -20,7 +20,7 @@ datasource, subscribe, renderedRows, - renderedColumns, + visibleColumns, rowHeight, hasNextPage, maxScrollTop, @@ -31,6 +31,7 @@ refreshing, config, filter, + columnRenderMap, } = getContext("grid") let visible = false @@ -38,7 +39,7 @@ let newRow let offset = 0 - $: firstColumn = $stickyColumn || $renderedColumns[0] + $: firstColumn = $stickyColumn || $visibleColumns[0] $: width = GutterWidth + ($stickyColumn?.width || 0) $: $datasource, (visible = false) $: invertY = shouldInvertY(offset, $rowVerticalInversionIndex, $renderedRows) @@ -211,29 +212,28 @@ <div class="normal-columns" transition:fade|local={{ duration: 130 }}> <GridScrollWrapper scrollHorizontally attachHandlers> <div class="row"> - {#each $renderedColumns as column, columnIdx} + {#each $visibleColumns as column, columnIdx} {@const cellId = `new-${column.name}`} - {#key cellId} - <DataCell - {cellId} - {column} - {updateValue} - rowFocused - row={newRow} - focused={$focusedCellId === cellId} - width={column.width} - topRow={offset === 0} - invertX={columnIdx >= $columnHorizontalInversionIndex} - {invertY} - > - {#if column?.schema?.autocolumn} - <div class="readonly-overlay">Can't edit auto column</div> - {/if} - {#if isAdding} - <div in:fade={{ duration: 130 }} class="loading-overlay" /> - {/if} - </DataCell> - {/key} + <DataCell + {cellId} + {column} + {updateValue} + rowFocused + row={newRow} + focused={$focusedCellId === cellId} + width={column.width} + topRow={offset === 0} + invertX={columnIdx >= $columnHorizontalInversionIndex} + {invertY} + hidden={!$columnRenderMap[column.name]} + > + {#if column?.schema?.autocolumn} + <div class="readonly-overlay">Can't edit auto column</div> + {/if} + {#if isAdding} + <div in:fade={{ duration: 130 }} class="loading-overlay" /> + {/if} + </DataCell> {/each} </div> </GridScrollWrapper> diff --git a/packages/frontend-core/src/components/grid/overlays/ResizeOverlay.svelte b/packages/frontend-core/src/components/grid/overlays/ResizeOverlay.svelte index 13e158b300..9e584ab610 100644 --- a/packages/frontend-core/src/components/grid/overlays/ResizeOverlay.svelte +++ b/packages/frontend-core/src/components/grid/overlays/ResizeOverlay.svelte @@ -2,7 +2,7 @@ import { getContext } from "svelte" import { GutterWidth } from "../lib/constants" - const { resize, renderedColumns, stickyColumn, isReordering, scrollLeft } = + const { resize, visibleColumns, stickyColumn, isReordering, scrollLeft } = getContext("grid") $: offset = GutterWidth + ($stickyColumn?.width || 0) @@ -26,7 +26,7 @@ <div class="resize-indicator" /> </div> {/if} - {#each $renderedColumns as column} + {#each $visibleColumns as column} <div class="resize-slider" class:visible={activeColumn === column.name} diff --git a/packages/frontend-core/src/components/grid/stores/viewport.js b/packages/frontend-core/src/components/grid/stores/viewport.js index 6c0c4708b9..8df8acd0f4 100644 --- a/packages/frontend-core/src/components/grid/stores/viewport.js +++ b/packages/frontend-core/src/components/grid/stores/viewport.js @@ -1,4 +1,4 @@ -import { derived, get } from "svelte/store" +import { derived } from "svelte/store" import { MaxCellRenderHeight, MaxCellRenderWidthOverflow, @@ -50,12 +50,11 @@ export const deriveStores = context => { const interval = MinColumnWidth return Math.round($scrollLeft / interval) * interval }) - const renderedColumns = derived( + const columnRenderMap = derived( [visibleColumns, scrollLeftRounded, width], - ([$visibleColumns, $scrollLeft, $width], set) => { + ([$visibleColumns, $scrollLeft, $width]) => { if (!$visibleColumns.length) { - set([]) - return + return {} } let startColIdx = 0 let rightEdge = $visibleColumns[0].width @@ -75,34 +74,16 @@ export const deriveStores = context => { leftEdge += $visibleColumns[endColIdx].width endColIdx++ } - // Render an additional column on either side to account for - // debounce column updates based on scroll position - const next = $visibleColumns.slice( - Math.max(0, startColIdx - 1), - endColIdx + 1 - ) - const current = get(renderedColumns) - if (JSON.stringify(next) !== JSON.stringify(current)) { - set(next) - } - } - ) - const hiddenColumnsWidth = derived( - [renderedColumns, visibleColumns], - ([$renderedColumns, $visibleColumns]) => { - const idx = $visibleColumns.findIndex( - col => col.name === $renderedColumns[0]?.name - ) - let width = 0 - if (idx > 0) { - for (let i = 0; i < idx; i++) { - width += $visibleColumns[i].width - } - } - return width - }, - 0 + // Only update the store if different + let next = {} + $visibleColumns + .slice(Math.max(0, startColIdx), endColIdx) + .forEach(col => { + next[col.name] = true + }) + return next + } ) // Determine the row index at which we should start vertically inverting cell @@ -130,12 +111,12 @@ export const deriveStores = context => { // Determine the column index at which we should start horizontally inverting // cell dropdowns const columnHorizontalInversionIndex = derived( - [renderedColumns, scrollLeft, width], - ([$renderedColumns, $scrollLeft, $width]) => { + [visibleColumns, scrollLeft, width], + ([$visibleColumns, $scrollLeft, $width]) => { const cutoff = $width + $scrollLeft - ScrollBarSize * 3 - let inversionIdx = $renderedColumns.length - for (let i = $renderedColumns.length - 1; i >= 0; i--, inversionIdx--) { - const rightEdge = $renderedColumns[i].left + $renderedColumns[i].width + let inversionIdx = $visibleColumns.length + for (let i = $visibleColumns.length - 1; i >= 0; i--, inversionIdx--) { + const rightEdge = $visibleColumns[i].left + $visibleColumns[i].width if (rightEdge + MaxCellRenderWidthOverflow <= cutoff) { break } @@ -148,8 +129,7 @@ export const deriveStores = context => { scrolledRowCount, visualRowCapacity, renderedRows, - renderedColumns, - hiddenColumnsWidth, + columnRenderMap, rowVerticalInversionIndex, columnHorizontalInversionIndex, } diff --git a/packages/pro b/packages/pro index d24c0dc3a3..3820c0c93a 160000 --- a/packages/pro +++ b/packages/pro @@ -1 +1 @@ -Subproject commit d24c0dc3a30014cbe61860252aa48104cad36376 +Subproject commit 3820c0c93a3e448e10a60a9feb5396844b537ca8 diff --git a/packages/server/Dockerfile b/packages/server/Dockerfile index e1b3b208c7..ea4c5b217a 100644 --- a/packages/server/Dockerfile +++ b/packages/server/Dockerfile @@ -38,7 +38,7 @@ RUN apt update && apt upgrade -y \ COPY package.json . COPY dist/yarn.lock . -RUN yarn install --production=true \ +RUN yarn install --production=true --network-timeout 1000000 \ # Remove unneeded data from file system to reduce image size && yarn cache clean && apt-get remove -y --purge --auto-remove g++ make python \ && rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp diff --git a/packages/server/Dockerfile.v2 b/packages/server/Dockerfile.v2 index 881c21299e..f737570fcd 100644 --- a/packages/server/Dockerfile.v2 +++ b/packages/server/Dockerfile.v2 @@ -44,7 +44,7 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh WORKDIR /string-templates COPY packages/string-templates/package.json package.json RUN ../scripts/removeWorkspaceDependencies.sh package.json -RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true +RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000 COPY packages/string-templates . @@ -57,7 +57,7 @@ COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies. RUN chmod +x ./scripts/removeWorkspaceDependencies.sh RUN ./scripts/removeWorkspaceDependencies.sh package.json -RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true \ +RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000 \ # Remove unneeded data from file system to reduce image size && yarn cache clean && apt-get remove -y --purge --auto-remove g++ make python jq \ && rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp diff --git a/packages/server/__mocks__/aws-sdk.ts b/packages/server/__mocks__/aws-sdk.ts index 8a66f0e213..fa6d099f56 100644 --- a/packages/server/__mocks__/aws-sdk.ts +++ b/packages/server/__mocks__/aws-sdk.ts @@ -70,6 +70,13 @@ module AwsMock { Contents: {}, }) ) + + // @ts-ignore + this.getObject = jest.fn( + response({ + Body: "", + }) + ) } aws.DynamoDB = { DocumentClient } diff --git a/packages/server/package.json b/packages/server/package.json index 4a858f3be9..c845f7889d 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -18,7 +18,6 @@ "test": "bash scripts/test.sh", "test:memory": "jest --maxWorkers=2 --logHeapUsage --forceExit", "test:watch": "jest --watch", - "build:docker": "yarn build && docker build . -t app-service --label version=$BUDIBASE_RELEASE_VERSION --build-arg BUDIBASE_VERSION=$BUDIBASE_RELEASE_VERSION", "run:docker": "node dist/index.js", "run:docker:cluster": "pm2-runtime start pm2.config.js", "dev:stack:up": "node scripts/dev/manage.js up", diff --git a/packages/server/scripts/integrations/postgres/docker-compose.yml b/packages/server/scripts/integrations/postgres/docker-compose.yml index 88efd0301d..0e8e30ecdb 100644 --- a/packages/server/scripts/integrations/postgres/docker-compose.yml +++ b/packages/server/scripts/integrations/postgres/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.8" services: db: container_name: postgres - image: postgres:15 + image: postgres:15-bullseye restart: unless-stopped environment: POSTGRES_USER: root diff --git a/packages/server/src/api/controllers/application.ts b/packages/server/src/api/controllers/application.ts index 4afd7b23f9..4e4c66858e 100644 --- a/packages/server/src/api/controllers/application.ts +++ b/packages/server/src/api/controllers/application.ts @@ -32,11 +32,8 @@ import { tenancy, users, } from "@budibase/backend-core" -import { USERS_TABLE_SCHEMA } from "../../constants" -import { - buildDefaultDocs, - DEFAULT_BB_DATASOURCE_ID, -} from "../../db/defaultData/datasource_bb_default" +import { USERS_TABLE_SCHEMA, DEFAULT_BB_DATASOURCE_ID } from "../../constants" +import { buildDefaultDocs } from "../../db/defaultData/datasource_bb_default" import { removeAppFromUserRoles } from "../../utilities/workerRequests" import { stringToReadStream } from "../../utilities" import { doesUserHaveLock } from "../../utilities/redis" diff --git a/packages/server/src/api/controllers/datasource.ts b/packages/server/src/api/controllers/datasource.ts index b50c2464f0..5d024d51b6 100644 --- a/packages/server/src/api/controllers/datasource.ts +++ b/packages/server/src/api/controllers/datasource.ts @@ -12,7 +12,6 @@ import { CreateDatasourceResponse, Datasource, DatasourcePlus, - ExternalTable, FetchDatasourceInfoRequest, FetchDatasourceInfoResponse, IntegrationBase, @@ -59,7 +58,7 @@ async function buildSchemaHelper(datasource: Datasource): Promise<Schema> { const connector = (await getConnector(datasource)) as DatasourcePlus return await connector.buildSchema( datasource._id!, - datasource.entities! as Record<string, ExternalTable> + datasource.entities! as Record<string, Table> ) } diff --git a/packages/server/src/api/controllers/public/utils.ts b/packages/server/src/api/controllers/public/utils.ts index 1272fcb36a..1d67b49e0d 100644 --- a/packages/server/src/api/controllers/public/utils.ts +++ b/packages/server/src/api/controllers/public/utils.ts @@ -1,12 +1,12 @@ import { context } from "@budibase/backend-core" -import { isExternalTable } from "../../../integrations/utils" +import { isExternalTableID } from "../../../integrations/utils" import { APP_PREFIX, DocumentType } from "../../../db/utils" export async function addRev( body: { _id?: string; _rev?: string }, tableId?: string ) { - if (!body._id || (tableId && isExternalTable(tableId))) { + if (!body._id || (tableId && isExternalTableID(tableId))) { return body } let id = body._id diff --git a/packages/server/src/api/controllers/role.ts b/packages/server/src/api/controllers/role.ts index ed23009706..3697bbe925 100644 --- a/packages/server/src/api/controllers/role.ts +++ b/packages/server/src/api/controllers/role.ts @@ -1,4 +1,10 @@ -import { context, db as dbCore, events, roles } from "@budibase/backend-core" +import { + context, + db as dbCore, + events, + roles, + Header, +} from "@budibase/backend-core" import { getUserMetadataParams, InternalTables } from "../../db/utils" import { Database, Role, UserCtx, UserRoles } from "@budibase/types" import { sdk as sharedSdk } from "@budibase/shared-core" @@ -143,4 +149,20 @@ export async function accessible(ctx: UserCtx) { } else { ctx.body = await roles.getUserRoleIdHierarchy(roleId!) } + + // If a custom role is provided in the header, filter out higher level roles + const roleHeader = ctx.header?.[Header.PREVIEW_ROLE] as string + if (roleHeader && !Object.keys(roles.BUILTIN_ROLE_IDS).includes(roleHeader)) { + const inherits = (await roles.getRole(roleHeader))?.inherits + const orderedRoles = ctx.body.reverse() + let filteredRoles = [roleHeader] + for (let role of orderedRoles) { + filteredRoles = [role, ...filteredRoles] + if (role === inherits) { + break + } + } + filteredRoles.pop() + ctx.body = [roleHeader, ...filteredRoles] + } } diff --git a/packages/server/src/api/controllers/row/index.ts b/packages/server/src/api/controllers/row/index.ts index 0ccbf5cacf..1a6747a085 100644 --- a/packages/server/src/api/controllers/row/index.ts +++ b/packages/server/src/api/controllers/row/index.ts @@ -1,7 +1,7 @@ import { quotas } from "@budibase/pro" import * as internal from "./internal" import * as external from "./external" -import { isExternalTable } from "../../../integrations/utils" +import { isExternalTableID } from "../../../integrations/utils" import { Ctx, UserCtx, @@ -30,7 +30,7 @@ import { Format } from "../view/exporters" export * as views from "./views" function pickApi(tableId: any) { - if (isExternalTable(tableId)) { + if (isExternalTableID(tableId)) { return external } return internal @@ -227,7 +227,7 @@ export async function search(ctx: Ctx<SearchRowRequest, SearchRowResponse>) { export async function validate(ctx: Ctx<Row, ValidateResponse>) { const tableId = utils.getTableId(ctx) // external tables are hard to validate currently - if (isExternalTable(tableId)) { + if (isExternalTableID(tableId)) { ctx.body = { valid: true, errors: {} } } else { ctx.body = await sdk.rows.utils.validate({ diff --git a/packages/server/src/api/controllers/static/index.ts b/packages/server/src/api/controllers/static/index.ts index 984cb16c06..8fbc0db910 100644 --- a/packages/server/src/api/controllers/static/index.ts +++ b/packages/server/src/api/controllers/static/index.ts @@ -1,3 +1,5 @@ +import { ValidFileExtensions } from "@budibase/shared-core" + require("svelte/register") import { join } from "../../../utilities/centralPath" @@ -11,34 +13,21 @@ import { } from "../../../utilities/fileSystem" import env from "../../../environment" import { DocumentType } from "../../../db/utils" -import { context, objectStore, utils, configs } from "@budibase/backend-core" +import { + context, + objectStore, + utils, + configs, + BadRequestError, +} from "@budibase/backend-core" import AWS from "aws-sdk" import fs from "fs" import sdk from "../../../sdk" import * as pro from "@budibase/pro" -import { App, Ctx } from "@budibase/types" +import { App, Ctx, ProcessAttachmentResponse, Upload } from "@budibase/types" const send = require("koa-send") -async function prepareUpload({ s3Key, bucket, metadata, file }: any) { - const response = await objectStore.upload({ - bucket, - metadata, - filename: s3Key, - path: file.path, - type: file.type, - }) - - // don't store a URL, work this out on the way out as the URL could change - return { - size: file.size, - name: file.name, - url: objectStore.getAppFileUrl(s3Key), - extension: [...file.name.split(".")].pop(), - key: response.Key, - } -} - export const toggleBetaUiFeature = async function (ctx: Ctx) { const cookieName = `beta:${ctx.params.feature}` @@ -72,23 +61,58 @@ export const serveBuilder = async function (ctx: Ctx) { await send(ctx, ctx.file, { root: builderPath }) } -export const uploadFile = async function (ctx: Ctx) { +export const uploadFile = async function ( + ctx: Ctx<{}, ProcessAttachmentResponse> +) { const file = ctx.request?.files?.file + if (!file) { + throw new BadRequestError("No file provided") + } + let files = file && Array.isArray(file) ? Array.from(file) : [file] - const uploads = files.map(async (file: any) => { - const fileExtension = [...file.name.split(".")].pop() - // filenames converted to UUIDs so they are unique - const processedFileName = `${uuid.v4()}.${fileExtension}` + ctx.body = await Promise.all( + files.map(async file => { + if (!file.name) { + throw new BadRequestError( + "Attempted to upload a file without a filename" + ) + } - return prepareUpload({ - file, - s3Key: `${context.getProdAppId()}/attachments/${processedFileName}`, - bucket: ObjectStoreBuckets.APPS, + const extension = [...file.name.split(".")].pop() + if (!extension) { + throw new BadRequestError( + `File "${file.name}" has no extension, an extension is required to upload a file` + ) + } + + if (!env.SELF_HOSTED && !ValidFileExtensions.includes(extension)) { + throw new BadRequestError( + `File "${file.name}" has an invalid extension: "${extension}"` + ) + } + + // filenames converted to UUIDs so they are unique + const processedFileName = `${uuid.v4()}.${extension}` + + const s3Key = `${context.getProdAppId()}/attachments/${processedFileName}` + + const response = await objectStore.upload({ + bucket: ObjectStoreBuckets.APPS, + filename: s3Key, + path: file.path, + type: file.type, + }) + + return { + size: file.size, + name: file.name, + url: objectStore.getAppFileUrl(s3Key), + extension, + key: response.Key, + } }) - }) - - ctx.body = await Promise.all(uploads) + ) } export const deleteObjects = async function (ctx: Ctx) { diff --git a/packages/server/src/api/controllers/table/index.ts b/packages/server/src/api/controllers/table/index.ts index afb2a9d12d..db2bd672d0 100644 --- a/packages/server/src/api/controllers/table/index.ts +++ b/packages/server/src/api/controllers/table/index.ts @@ -5,18 +5,27 @@ import { isSchema, validate as validateSchema, } from "../../../utilities/schema" -import { isExternalTable, isSQL } from "../../../integrations/utils" +import { + isExternalTable, + isExternalTableID, + isSQL, +} from "../../../integrations/utils" import { events } from "@budibase/backend-core" import { BulkImportRequest, BulkImportResponse, + DocumentType, FetchTablesResponse, + MigrateRequest, + MigrateResponse, + Row, SaveTableRequest, SaveTableResponse, Table, TableResponse, + TableSourceType, UserCtx, - Row, + SEPARATOR, } from "@budibase/types" import sdk from "../../../sdk" import { jsonFromCsvString } from "../../../utilities/csv" @@ -24,12 +33,10 @@ import { builderSocket } from "../../../websockets" import { cloneDeep, isEqual } from "lodash" function pickApi({ tableId, table }: { tableId?: string; table?: Table }) { - if (table && !tableId) { - tableId = table._id - } - if (table && table.type === "external") { + if (table && isExternalTable(table)) { return external - } else if (tableId && isExternalTable(tableId)) { + } + if (tableId && isExternalTableID(tableId)) { return external } return internal @@ -46,8 +53,8 @@ export async function fetch(ctx: UserCtx<void, FetchTablesResponse>) { if (entities) { return Object.values(entities).map<Table>((entity: Table) => ({ ...entity, - type: "external", - sourceId: datasource._id, + sourceType: TableSourceType.EXTERNAL, + sourceId: datasource._id!, sql: isSQL(datasource), })) } else { @@ -158,3 +165,19 @@ export async function validateExistingTableImport(ctx: UserCtx) { ctx.status = 422 } } + +export async function migrate(ctx: UserCtx<MigrateRequest, MigrateResponse>) { + const { oldColumn, newColumn } = ctx.request.body + let tableId = ctx.params.tableId as string + const table = await sdk.tables.getTable(tableId) + let result = await sdk.tables.migrate(table, oldColumn, newColumn) + + for (let table of result.tablesUpdated) { + builderSocket?.emitTableUpdate(ctx, table, { + includeOriginator: true, + }) + } + + ctx.status = 200 + ctx.body = { message: `Column ${oldColumn.name} migrated.` } +} diff --git a/packages/server/src/api/controllers/table/internal.ts b/packages/server/src/api/controllers/table/internal.ts index 822ff8a75d..bb94f2bc01 100644 --- a/packages/server/src/api/controllers/table/internal.ts +++ b/packages/server/src/api/controllers/table/internal.ts @@ -7,6 +7,7 @@ import { SaveTableRequest, SaveTableResponse, Table, + TableSourceType, UserCtx, } from "@budibase/types" import sdk from "../../../sdk" @@ -16,10 +17,11 @@ export async function save(ctx: UserCtx<SaveTableRequest, SaveTableResponse>) { let tableToSave: Table & { _rename?: RenameColumn } = { - type: "table", _id: generateTableID(), - views: {}, ...rest, + type: "table", + sourceType: TableSourceType.INTERNAL, + views: {}, } const renaming = tableToSave._rename delete tableToSave._rename diff --git a/packages/server/src/api/routes/row.ts b/packages/server/src/api/routes/row.ts index c29cb65eac..516bfd20c6 100644 --- a/packages/server/src/api/routes/row.ts +++ b/packages/server/src/api/routes/row.ts @@ -11,128 +11,24 @@ const { PermissionType, PermissionLevel } = permissions const router: Router = new Router() router - /** - * @api {get} /api/:sourceId/:rowId/enrich Get an enriched row - * @apiName Get an enriched row - * @apiGroup rows - * @apiPermission table read access - * @apiDescription This API is only useful when dealing with rows that have relationships. - * Normally when a row is a returned from the API relationships will only have the structure - * `{ primaryDisplay: "name", _id: ... }` but this call will return the full related rows - * for each relationship instead. - * - * @apiParam {string} rowId The ID of the row which is to be retrieved and enriched. - * - * @apiSuccess {object} row The response body will be the enriched row. - */ .get( "/api/:sourceId/:rowId/enrich", paramSubResource("sourceId", "rowId"), authorized(PermissionType.TABLE, PermissionLevel.READ), rowController.fetchEnrichedRow ) - /** - * @api {get} /api/:sourceId/rows Get all rows in a table - * @apiName Get all rows in a table - * @apiGroup rows - * @apiPermission table read access - * @apiDescription This is a deprecated endpoint that should not be used anymore, instead use the search endpoint. - * This endpoint gets all of the rows within the specified table - it is not heavily used - * due to its lack of support for pagination. With SQL tables this will retrieve up to a limit and then - * will simply stop. - * - * @apiParam {string} sourceId The ID of the table to retrieve all rows within. - * - * @apiSuccess {object[]} rows The response body will be an array of all rows found. - */ .get( "/api/:sourceId/rows", paramResource("sourceId"), authorized(PermissionType.TABLE, PermissionLevel.READ), rowController.fetch ) - /** - * @api {get} /api/:sourceId/rows/:rowId Retrieve a single row - * @apiName Retrieve a single row - * @apiGroup rows - * @apiPermission table read access - * @apiDescription This endpoint retrieves only the specified row. If you wish to retrieve - * a row by anything other than its _id field, use the search endpoint. - * - * @apiParam {string} sourceId The ID of the table to retrieve a row from. - * @apiParam {string} rowId The ID of the row to retrieve. - * - * @apiSuccess {object} body The response body will be the row that was found. - */ .get( "/api/:sourceId/rows/:rowId", paramSubResource("sourceId", "rowId"), authorized(PermissionType.TABLE, PermissionLevel.READ), rowController.find ) - /** - * @api {post} /api/:sourceId/search Search for rows in a table - * @apiName Search for rows in a table - * @apiGroup rows - * @apiPermission table read access - * @apiDescription This is the primary method of accessing rows in Budibase, the data provider - * and data UI in the builder are built atop this. All filtering, sorting and pagination is - * handled through this, for internal and external (datasource plus, e.g. SQL) tables. - * - * @apiParam {string} sourceId The ID of the table to retrieve rows from. - * - * @apiParam (Body) {boolean} [paginate] If pagination is required then this should be set to true, - * defaults to false. - * @apiParam (Body) {object} [query] This contains a set of filters which should be applied, if none - * specified then the request will be unfiltered. An example with all of the possible query - * options has been supplied below. - * @apiParam (Body) {number} [limit] This sets a limit for the number of rows that will be returned, - * this will be implemented at the database level if supported for performance reasons. This - * is useful when paginating to set exactly how many rows per page. - * @apiParam (Body) {string} [bookmark] If pagination is enabled then a bookmark will be returned - * with each successful search request, this should be supplied back to get the next page. - * @apiParam (Body) {object} [sort] If sort is desired this should contain the name of the column to - * sort on. - * @apiParam (Body) {string} [sortOrder] If sort is enabled then this can be either "descending" or - * "ascending" as required. - * @apiParam (Body) {string} [sortType] If sort is enabled then you must specify the type of search - * being used, either "string" or "number". This is only used for internal tables. - * - * @apiParamExample {json} Example: - * { - * "tableId": "ta_70260ff0b85c467ca74364aefc46f26d", - * "query": { - * "string": {}, - * "fuzzy": {}, - * "range": { - * "columnName": { - * "high": 20, - * "low": 10, - * } - * }, - * "equal": { - * "columnName": "someValue" - * }, - * "notEqual": {}, - * "empty": {}, - * "notEmpty": {}, - * "oneOf": { - * "columnName": ["value"] - * } - * }, - * "limit": 10, - * "sort": "name", - * "sortOrder": "descending", - * "sortType": "string", - * "paginate": true - * } - * - * @apiSuccess {object[]} rows An array of rows that was found based on the supplied parameters. - * @apiSuccess {boolean} hasNextPage If pagination was enabled then this specifies whether or - * not there is another page after this request. - * @apiSuccess {string} bookmark The bookmark to be sent with the next request to get the next - * page. - */ .post( "/api/:sourceId/search", internalSearchValidator(), @@ -148,30 +44,6 @@ router authorized(PermissionType.TABLE, PermissionLevel.READ), rowController.search ) - /** - * @api {post} /api/:sourceId/rows Creates a new row - * @apiName Creates a new row - * @apiGroup rows - * @apiPermission table write access - * @apiDescription This API will create a new row based on the supplied body. If the - * body includes an "_id" field then it will update an existing row if the field - * links to one. Please note that "_id", "_rev" and "tableId" are fields that are - * already used by Budibase tables and cannot be used for columns. - * - * @apiParam {string} sourceId The ID of the table to save a row to. - * - * @apiParam (Body) {string} [_id] If the row exists already then an ID for the row must be provided. - * @apiParam (Body) {string} [_rev] If working with an existing row for an internal table its revision - * must also be provided. - * @apiParam (Body) {string} tableId The ID of the table should also be specified in the row body itself. - * @apiParam (Body) {any} [any] Any field supplied in the body will be assessed to see if it matches - * a column in the specified table. All other fields will be dropped and not stored. - * - * @apiSuccess {string} _id The ID of the row that was just saved, if it was just created this - * is the rows new ID. - * @apiSuccess {string} [_rev] If saving to an internal table a revision will also be returned. - * @apiSuccess {object} body The contents of the row that was saved will be returned as well. - */ .post( "/api/:sourceId/rows", paramResource("sourceId"), @@ -179,14 +51,6 @@ router trimViewRowInfo, rowController.save ) - /** - * @api {patch} /api/:sourceId/rows Updates a row - * @apiName Update a row - * @apiGroup rows - * @apiPermission table write access - * @apiDescription This endpoint is identical to the row creation endpoint but instead it will - * error if an _id isn't provided, it will only function for existing rows. - */ .patch( "/api/:sourceId/rows", paramResource("sourceId"), @@ -194,52 +58,12 @@ router trimViewRowInfo, rowController.patch ) - /** - * @api {post} /api/:sourceId/rows/validate Validate inputs for a row - * @apiName Validate inputs for a row - * @apiGroup rows - * @apiPermission table write access - * @apiDescription When attempting to save a row you may want to check if the row is valid - * given the table schema, this will iterate through all the constraints on the table and - * check if the request body is valid. - * - * @apiParam {string} sourceId The ID of the table the row is to be validated for. - * - * @apiParam (Body) {any} [any] Any fields provided in the request body will be tested - * against the table schema and constraints. - * - * @apiSuccess {boolean} valid If inputs provided are acceptable within the table schema this - * will be true, if it is not then then errors property will be populated. - * @apiSuccess {object} [errors] A key value map of information about fields on the input - * which do not match the table schema. The key name will be the column names that have breached - * the schema. - */ .post( "/api/:sourceId/rows/validate", paramResource("sourceId"), authorized(PermissionType.TABLE, PermissionLevel.WRITE), rowController.validate ) - /** - * @api {delete} /api/:sourceId/rows Delete rows - * @apiName Delete rows - * @apiGroup rows - * @apiPermission table write access - * @apiDescription This endpoint can delete a single row, or delete them in a bulk - * fashion. - * - * @apiParam {string} sourceId The ID of the table the row is to be deleted from. - * - * @apiParam (Body) {object[]} [rows] If bulk deletion is desired then provide the rows in this - * key of the request body that are to be deleted. - * @apiParam (Body) {string} [_id] If deleting a single row then provide its ID in this field. - * @apiParam (Body) {string} [_rev] If deleting a single row from an internal table then provide its - * revision here. - * - * @apiSuccess {object[]|object} body If deleting bulk then the response body will be an array - * of the deleted rows, if deleting a single row then the body will contain a "row" property which - * is the deleted row. - */ .delete( "/api/:sourceId/rows", paramResource("sourceId"), @@ -247,20 +71,6 @@ router trimViewRowInfo, rowController.destroy ) - - /** - * @api {post} /api/:sourceId/rows/exportRows Export Rows - * @apiName Export rows - * @apiGroup rows - * @apiPermission table write access - * @apiDescription This API can export a number of provided rows - * - * @apiParam {string} sourceId The ID of the table the row is to be deleted from. - * - * @apiParam (Body) {object[]} [rows] The row IDs which are to be exported - * - * @apiSuccess {object[]|object} - */ .post( "/api/:sourceId/rows/exportRows", paramResource("sourceId"), diff --git a/packages/server/src/api/routes/table.ts b/packages/server/src/api/routes/table.ts index 7ffa5acb3e..b947fa5e0b 100644 --- a/packages/server/src/api/routes/table.ts +++ b/packages/server/src/api/routes/table.ts @@ -9,99 +9,13 @@ const { BUILDER, PermissionLevel, PermissionType } = permissions const router: Router = new Router() router - /** - * @api {get} /api/tables Fetch all tables - * @apiName Fetch all tables - * @apiGroup tables - * @apiPermission table read access - * @apiDescription This endpoint retrieves all of the tables which have been created in - * an app. This includes all of the external and internal tables; to tell the difference - * between these look for the "type" property on each table, either being "internal" or "external". - * - * @apiSuccess {object[]} body The response body will be the list of tables that was found - as - * this does not take any parameters the only error scenario is no access. - */ .get("/api/tables", authorized(BUILDER), tableController.fetch) - /** - * @api {get} /api/tables/:id Fetch a single table - * @apiName Fetch a single table - * @apiGroup tables - * @apiPermission table read access - * @apiDescription Retrieves a single table this could be be internal or external based on - * the provided table ID. - * - * @apiParam {string} id The ID of the table which is to be retrieved. - * - * @apiSuccess {object[]} body The response body will be the table that was found. - */ .get( "/api/tables/:tableId", paramResource("tableId"), authorized(PermissionType.TABLE, PermissionLevel.READ, { schema: true }), tableController.find ) - /** - * @api {post} /api/tables Save a table - * @apiName Save a table - * @apiGroup tables - * @apiPermission builder - * @apiDescription Create or update a table with this endpoint, this will function for both internal - * external tables. - * - * @apiParam (Body) {string} [_id] If updating an existing table then the ID of the table must be specified. - * @apiParam (Body) {string} [_rev] If updating an existing internal table then the revision must also be specified. - * @apiParam (Body) {string} type] This should either be "internal" or "external" depending on the table type - - * this will default to internal. - * @apiParam (Body) {string} [sourceId] If creating an external table then this should be set to the datasource ID. If - * building an internal table this does not need to be set, although it will be returned as "bb_internal". - * @apiParam (Body) {string} name The name of the table, this will be used in the UI. To rename the table simply - * supply the table structure to this endpoint with the name changed. - * @apiParam (Body) {object} schema A key value object which has all of the columns in the table as the keys in this - * object. For each column a "type" and "constraints" must be specified, with some types requiring further information. - * More information about the schema structure can be found in the Typescript definitions. - * @apiParam (Body) {string} [primaryDisplay] The name of the column which should be used when displaying rows - * from this table as relationships. - * @apiParam (Body) {object[]} [indexes] Specifies the search indexes - this is deprecated behaviour with the introduction - * of lucene indexes. This functionality is only available for internal tables. - * @apiParam (Body) {object} [_rename] If a column is to be renamed then the "old" column name should be set in this - * structure, and the "updated", new column name should also be supplied. The schema should also be updated, this field - * lets the server know that a field hasn't just been deleted, that the data has moved to a new name, this will fix - * the rows in the table. This functionality is only available for internal tables. - * @apiParam (Body) {object[]} [rows] When creating a table using a compatible data source, an array of objects to be imported into the new table can be provided. - * - * @apiParamExample {json} Example: - * { - * "_id": "ta_05541307fa0f4044abee071ca2a82119", - * "_rev": "10-0fbe4e78f69b255d79f1017e2eeef807", - * "type": "internal", - * "views": {}, - * "name": "tableName", - * "schema": { - * "column": { - * "type": "string", - * "constraints": { - * "type": "string", - * "length": { - * "maximum": null - * }, - * "presence": false - * }, - * "name": "column" - * }, - * }, - * "primaryDisplay": "column", - * "indexes": [], - * "sourceId": "bb_internal", - * "_rename": { - * "old": "columnName", - * "updated": "newColumnName", - * }, - * "rows": [] - * } - * - * @apiSuccess {object} table The response body will contain the table structure after being cleaned up and - * saved to the database. - */ .post( "/api/tables", // allows control over updating a table @@ -125,41 +39,12 @@ router authorized(BUILDER), tableController.validateExistingTableImport ) - /** - * @api {post} /api/tables/:tableId/:revId Delete a table - * @apiName Delete a table - * @apiGroup tables - * @apiPermission builder - * @apiDescription This endpoint will delete a table and all of its associated data, for this reason it is - * quite dangerous - it will work for internal and external tables. - * - * @apiParam {string} tableId The ID of the table which is to be deleted. - * @apiParam {string} [revId] If deleting an internal table then the revision must also be supplied (_rev), for - * external tables this can simply be set to anything, e.g. "external". - * - * @apiSuccess {string} message A message stating that the table was deleted successfully. - */ .delete( "/api/tables/:tableId/:revId", paramResource("tableId"), authorized(BUILDER), tableController.destroy ) - /** - * @api {post} /api/tables/:tableId/:revId Import CSV to existing table - * @apiName Import CSV to existing table - * @apiGroup tables - * @apiPermission builder - * @apiDescription This endpoint will import data to existing tables, internal or external. It is used in combination - * with the CSV validation endpoint. Take the output of the CSV validation endpoint and pass it to this endpoint to - * import the data; please note this will only import fields that already exist on the table/match the type. - * - * @apiParam {string} tableId The ID of the table which the data should be imported to. - * - * @apiParam (Body) {object[]} rows An array of objects representing the rows to be imported, key-value pairs not matching the table schema will be ignored. - * - * @apiSuccess {string} message A message stating that the data was imported successfully. - */ .post( "/api/tables/:tableId/import", paramResource("tableId"), @@ -167,4 +52,11 @@ router tableController.bulkImport ) + .post( + "/api/tables/:tableId/migrate", + paramResource("tableId"), + authorized(BUILDER), + tableController.migrate + ) + export default router diff --git a/packages/server/src/api/routes/tests/__snapshots__/datasource.spec.ts.snap b/packages/server/src/api/routes/tests/__snapshots__/datasource.spec.ts.snap index 2894f597ab..8dc472173c 100644 --- a/packages/server/src/api/routes/tests/__snapshots__/datasource.spec.ts.snap +++ b/packages/server/src/api/routes/tests/__snapshots__/datasource.spec.ts.snap @@ -7,7 +7,7 @@ exports[`/datasources fetch returns all the datasources from the server 1`] = ` "entities": [ { "_id": "ta_users", - "_rev": "1-2375e1bc58aeec664dc1b1f04ad43e44", + "_rev": "1-73b7912e6cbdd3d696febc60f3715844", "createdAt": "2020-01-01T00:00:00.000Z", "name": "Users", "primaryDisplay": "email", @@ -21,7 +21,6 @@ exports[`/datasources fetch returns all the datasources from the server 1`] = ` "presence": true, "type": "string", }, - "fieldName": "email", "name": "email", "type": "string", }, @@ -30,7 +29,6 @@ exports[`/datasources fetch returns all the datasources from the server 1`] = ` "presence": false, "type": "string", }, - "fieldName": "firstName", "name": "firstName", "type": "string", }, @@ -39,7 +37,6 @@ exports[`/datasources fetch returns all the datasources from the server 1`] = ` "presence": false, "type": "string", }, - "fieldName": "lastName", "name": "lastName", "type": "string", }, @@ -54,7 +51,6 @@ exports[`/datasources fetch returns all the datasources from the server 1`] = ` "presence": false, "type": "string", }, - "fieldName": "roleId", "name": "roleId", "type": "options", }, @@ -67,11 +63,12 @@ exports[`/datasources fetch returns all the datasources from the server 1`] = ` "presence": false, "type": "string", }, - "fieldName": "status", "name": "status", "type": "options", }, }, + "sourceId": "bb_internal", + "sourceType": "internal", "type": "table", "updatedAt": "2020-01-01T00:00:00.000Z", "views": {}, diff --git a/packages/server/src/api/routes/tests/attachment.spec.ts b/packages/server/src/api/routes/tests/attachment.spec.ts new file mode 100644 index 0000000000..14d2e845f6 --- /dev/null +++ b/packages/server/src/api/routes/tests/attachment.spec.ts @@ -0,0 +1,49 @@ +import * as setup from "./utilities" +import { APIError } from "@budibase/types" + +describe("/api/applications/:appId/sync", () => { + let config = setup.getConfig() + + afterAll(setup.afterAll) + beforeAll(async () => { + await config.init() + }) + + describe("/api/attachments/process", () => { + it("should accept an image file upload", async () => { + let resp = await config.api.attachment.process( + "1px.jpg", + Buffer.from([0]) + ) + expect(resp.length).toBe(1) + + let upload = resp[0] + expect(upload.url.endsWith(".jpg")).toBe(true) + expect(upload.extension).toBe("jpg") + expect(upload.size).toBe(1) + expect(upload.name).toBe("1px.jpg") + }) + + it("should reject an upload with a malicious file extension", async () => { + await config.withEnv({ SELF_HOSTED: undefined }, async () => { + let resp = (await config.api.attachment.process( + "ohno.exe", + Buffer.from([0]), + { expectStatus: 400 } + )) as unknown as APIError + expect(resp.message).toContain("invalid extension") + }) + }) + + it("should reject an upload with no file", async () => { + let resp = (await config.api.attachment.process( + undefined as any, + undefined as any, + { + expectStatus: 400, + } + )) as unknown as APIError + expect(resp.message).toContain("No file provided") + }) + }) +}) diff --git a/packages/server/src/api/routes/tests/backup.spec.ts b/packages/server/src/api/routes/tests/backup.spec.ts index 92e0176060..d12b5e1507 100644 --- a/packages/server/src/api/routes/tests/backup.spec.ts +++ b/packages/server/src/api/routes/tests/backup.spec.ts @@ -5,6 +5,8 @@ import sdk from "../../../sdk" import { checkBuilderEndpoint } from "./utilities/TestFunctions" import { mocks } from "@budibase/backend-core/tests" +mocks.licenses.useBackups() + describe("/backups", () => { let request = setup.getRequest() let config = setup.getConfig() @@ -12,16 +14,17 @@ describe("/backups", () => { afterAll(setup.afterAll) beforeEach(async () => { + tk.reset() await config.init() }) - describe("exportAppDump", () => { + describe("/api/backups/export", () => { it("should be able to export app", async () => { - const res = await request - .post(`/api/backups/export?appId=${config.getAppId()}`) - .set(config.defaultHeaders()) - .expect(200) - expect(res.headers["content-type"]).toEqual("application/gzip") + const { body, headers } = await config.api.backup.exportBasicBackup( + config.getAppId()! + ) + expect(body instanceof Buffer).toBe(true) + expect(headers["content-type"]).toEqual("application/gzip") expect(events.app.exported).toBeCalledTimes(1) }) @@ -36,11 +39,11 @@ describe("/backups", () => { it("should infer the app name from the app", async () => { tk.freeze(mocks.date.MOCK_DATE) - const res = await request - .post(`/api/backups/export?appId=${config.getAppId()}`) - .set(config.defaultHeaders()) + const { headers } = await config.api.backup.exportBasicBackup( + config.getAppId()! + ) - expect(res.headers["content-disposition"]).toEqual( + expect(headers["content-disposition"]).toEqual( `attachment; filename="${ config.getApp()!.name }-export-${mocks.date.MOCK_DATE.getTime()}.tar.gz"` @@ -48,6 +51,21 @@ describe("/backups", () => { }) }) + describe("/api/backups/import", () => { + it("should be able to import an app", async () => { + const appId = config.getAppId()! + const automation = await config.createAutomation() + await config.createAutomationLog(automation, appId) + await config.createScreen() + const exportRes = await config.api.backup.createBackup(appId) + expect(exportRes.backupId).toBeDefined() + const importRes = await config.api.backup.importBackup( + appId, + exportRes.backupId + ) + }) + }) + describe("calculateBackupStats", () => { it("should be able to calculate the backup statistics", async () => { await config.createAutomation() diff --git a/packages/server/src/api/routes/tests/role.spec.js b/packages/server/src/api/routes/tests/role.spec.js index c8e383d5ed..d133a69d64 100644 --- a/packages/server/src/api/routes/tests/role.spec.js +++ b/packages/server/src/api/routes/tests/role.spec.js @@ -158,5 +158,25 @@ describe("/roles", () => { expect(res.body.length).toBe(1) expect(res.body[0]).toBe("PUBLIC") }) + + it("should not fetch higher level accessible roles when a custom role header is provided", async () => { + await createRole({ + name: `CUSTOM_ROLE`, + inherits: roles.BUILTIN_ROLE_IDS.BASIC, + permissionId: permissions.BuiltinPermissionID.READ_ONLY, + version: "name", + }) + const res = await request + .get("/api/roles/accessible") + .set({ + ...config.defaultHeaders(), + "x-budibase-role": "CUSTOM_ROLE" + }) + .expect(200) + expect(res.body.length).toBe(3) + expect(res.body[0]).toBe("CUSTOM_ROLE") + expect(res.body[1]).toBe("BASIC") + expect(res.body[2]).toBe("PUBLIC") + }) }) }) diff --git a/packages/server/src/api/routes/tests/routing.spec.js b/packages/server/src/api/routes/tests/routing.spec.js index ff6d7aba1d..4076f4879c 100644 --- a/packages/server/src/api/routes/tests/routing.spec.js +++ b/packages/server/src/api/routes/tests/routing.spec.js @@ -1,5 +1,5 @@ const setup = require("./utilities") -const { basicScreen } = setup.structures +const { basicScreen, powerScreen } = setup.structures const { checkBuilderEndpoint, runInProd } = require("./utilities/TestFunctions") const { roles } = require("@budibase/backend-core") const { BUILTIN_ROLE_IDS } = roles @@ -12,19 +12,14 @@ const route = "/test" describe("/routing", () => { let request = setup.getRequest() let config = setup.getConfig() - let screen, screen2 + let basic, power afterAll(setup.afterAll) beforeAll(async () => { await config.init() - screen = basicScreen() - screen.routing.route = route - screen = await config.createScreen(screen) - screen2 = basicScreen() - screen2.routing.roleId = BUILTIN_ROLE_IDS.POWER - screen2.routing.route = route - screen2 = await config.createScreen(screen2) + basic = await config.createScreen(basicScreen(route)) + power = await config.createScreen(powerScreen(route)) await config.publish() }) @@ -61,8 +56,8 @@ describe("/routing", () => { expect(res.body.routes[route]).toEqual({ subpaths: { [route]: { - screenId: screen._id, - roleId: screen.routing.roleId + screenId: basic._id, + roleId: basic.routing.roleId } } }) @@ -80,8 +75,8 @@ describe("/routing", () => { expect(res.body.routes[route]).toEqual({ subpaths: { [route]: { - screenId: screen2._id, - roleId: screen2.routing.roleId + screenId: power._id, + roleId: power.routing.roleId } } }) @@ -101,8 +96,8 @@ describe("/routing", () => { expect(res.body.routes).toBeDefined() expect(res.body.routes[route].subpaths[route]).toBeDefined() const subpath = res.body.routes[route].subpaths[route] - expect(subpath.screens[screen2.routing.roleId]).toEqual(screen2._id) - expect(subpath.screens[screen.routing.roleId]).toEqual(screen._id) + expect(subpath.screens[power.routing.roleId]).toEqual(power._id) + expect(subpath.screens[basic.routing.roleId]).toEqual(basic._id) }) it("make sure it is a builder only endpoint", async () => { diff --git a/packages/server/src/api/routes/tests/row.spec.ts b/packages/server/src/api/routes/tests/row.spec.ts index 4c2e7a7494..3ae4a6c1e2 100644 --- a/packages/server/src/api/routes/tests/row.spec.ts +++ b/packages/server/src/api/routes/tests/row.spec.ts @@ -10,6 +10,7 @@ import { FieldSchema, FieldType, FieldTypeSubtypes, + INTERNAL_TABLE_SOURCE_ID, MonthlyQuotaName, PermissionLevel, QuotaUsageType, @@ -21,6 +22,7 @@ import { SortType, StaticQuotaName, Table, + TableSourceType, } from "@budibase/types" import { expectAnyExternalColsAttributes, @@ -65,6 +67,8 @@ describe.each([ type: "table", primary: ["id"], primaryDisplay: "name", + sourceType: TableSourceType.INTERNAL, + sourceId: INTERNAL_TABLE_SOURCE_ID, schema: { id: { type: FieldType.AUTO, @@ -134,9 +138,22 @@ describe.each([ } : undefined + async function createTable( + cfg: Omit<SaveTableRequest, "sourceId" | "sourceType">, + opts?: { skipReassigning: boolean } + ) { + let table + if (dsProvider) { + table = await config.createExternalTable(cfg, opts) + } else { + table = await config.createTable(cfg, opts) + } + return table + } + beforeAll(async () => { const tableConfig = generateTableConfig() - const table = await config.createTable(tableConfig) + let table = await createTable(tableConfig) tableId = table._id! }) @@ -165,7 +182,7 @@ describe.each([ const queryUsage = await getQueryUsage() const tableConfig = generateTableConfig() - const newTable = await config.createTable( + const newTable = await createTable( { ...tableConfig, name: "TestTableAuto", @@ -242,7 +259,7 @@ describe.each([ }) it("should list all rows for given tableId", async () => { - const table = await config.createTable(generateTableConfig(), { + const table = await createTable(generateTableConfig(), { skipReassigning: true, }) const tableId = table._id! @@ -323,7 +340,7 @@ describe.each([ inclusion: ["Alpha", "Beta", "Gamma"], }, } - const table = await config.createTable({ + const table = await createTable({ name: "TestTable2", type: "table", schema: { @@ -438,7 +455,8 @@ describe.each([ describe("view save", () => { it("views have extra data trimmed", async () => { - const table = await config.createTable({ + const table = await createTable({ + type: "table", name: "orders", primary: ["OrderID"], schema: { @@ -494,7 +512,7 @@ describe.each([ describe("patch", () => { beforeAll(async () => { const tableConfig = generateTableConfig() - table = await config.createTable(tableConfig) + table = await createTable(tableConfig) }) it("should update only the fields that are supplied", async () => { @@ -548,7 +566,7 @@ describe.each([ describe("destroy", () => { beforeAll(async () => { const tableConfig = generateTableConfig() - table = await config.createTable(tableConfig) + table = await createTable(tableConfig) }) it("should be able to delete a row", async () => { @@ -566,7 +584,7 @@ describe.each([ describe("validate", () => { beforeAll(async () => { const tableConfig = generateTableConfig() - table = await config.createTable(tableConfig) + table = await createTable(tableConfig) }) it("should return no errors on valid row", async () => { @@ -603,7 +621,7 @@ describe.each([ describe("bulkDelete", () => { beforeAll(async () => { const tableConfig = generateTableConfig() - table = await config.createTable(tableConfig) + table = await createTable(tableConfig) }) it("should be able to delete a bulk set of rows", async () => { @@ -687,7 +705,7 @@ describe.each([ describe("fetchView", () => { beforeEach(async () => { const tableConfig = generateTableConfig() - table = await config.createTable(tableConfig) + table = await createTable(tableConfig) }) it("should be able to fetch tables contents via 'view'", async () => { @@ -735,7 +753,7 @@ describe.each([ describe("fetchEnrichedRows", () => { beforeAll(async () => { const tableConfig = generateTableConfig() - table = await config.createTable(tableConfig) + table = await createTable(tableConfig) }) it("should allow enriching some linked rows", async () => { @@ -808,7 +826,7 @@ describe.each([ describe("attachments", () => { beforeAll(async () => { const tableConfig = generateTableConfig() - table = await config.createTable(tableConfig) + table = await createTable(tableConfig) }) it("should allow enriching attachment rows", async () => { @@ -839,7 +857,7 @@ describe.each([ describe("exportData", () => { beforeAll(async () => { const tableConfig = generateTableConfig() - table = await config.createTable(tableConfig) + table = await createTable(tableConfig) }) it("should allow exporting all columns", async () => { @@ -880,6 +898,8 @@ describe.each([ async function userTable(): Promise<Table> { return { name: `users_${generator.word()}`, + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, type: "table", primary: ["id"], schema: { @@ -925,7 +945,7 @@ describe.each([ describe("create", () => { it("should persist a new row with only the provided view fields", async () => { - const table = await config.createTable(await userTable()) + const table = await createTable(await userTable()) const view = await config.createView({ schema: { name: { visible: true }, @@ -960,7 +980,7 @@ describe.each([ describe("patch", () => { it("should update only the view fields for a row", async () => { - const table = await config.createTable(await userTable()) + const table = await createTable(await userTable()) const tableId = table._id! const view = await config.createView({ schema: { @@ -1001,7 +1021,7 @@ describe.each([ describe("destroy", () => { it("should be able to delete a row", async () => { - const table = await config.createTable(await userTable()) + const table = await createTable(await userTable()) const tableId = table._id! const view = await config.createView({ schema: { @@ -1025,7 +1045,7 @@ describe.each([ }) it("should be able to delete multiple rows", async () => { - const table = await config.createTable(await userTable()) + const table = await createTable(await userTable()) const tableId = table._id! const view = await config.createView({ schema: { @@ -1062,6 +1082,8 @@ describe.each([ async function userTable(): Promise<Table> { return { name: `users_${generator.word()}`, + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, type: "table", primary: ["id"], schema: { @@ -1088,7 +1110,7 @@ describe.each([ } it("returns empty rows from view when no schema is passed", async () => { - const table = await config.createTable(await userTable()) + const table = await createTable(await userTable()) const rows = await Promise.all( Array.from({ length: 10 }, () => config.api.row.save(table._id!, { tableId: table._id }) @@ -1119,7 +1141,7 @@ describe.each([ }) it("searching respects the view filters", async () => { - const table = await config.createTable(await userTable()) + const table = await createTable(await userTable()) await Promise.all( Array.from({ length: 10 }, () => @@ -1243,7 +1265,7 @@ describe.each([ describe("sorting", () => { beforeAll(async () => { - const table = await config.createTable(await userTable()) + const table = await createTable(await userTable()) const users = [ { name: "Alice", age: 25 }, { name: "Bob", age: 30 }, @@ -1310,7 +1332,7 @@ describe.each([ }) it("when schema is defined, defined columns and row attributes are returned", async () => { - const table = await config.createTable(await userTable()) + const table = await createTable(await userTable()) const rows = await Promise.all( Array.from({ length: 10 }, () => config.api.row.save(table._id!, { @@ -1341,7 +1363,7 @@ describe.each([ }) it("views without data can be returned", async () => { - const table = await config.createTable(await userTable()) + const table = await createTable(await userTable()) const createViewResponse = await config.createView() const response = await config.api.viewV2.search(createViewResponse.id) @@ -1350,7 +1372,7 @@ describe.each([ }) it("respects the limit parameter", async () => { - await config.createTable(await userTable()) + await createTable(await userTable()) await Promise.all(Array.from({ length: 10 }, () => config.createRow())) const limit = generator.integer({ min: 1, max: 8 }) @@ -1365,7 +1387,7 @@ describe.each([ }) it("can handle pagination", async () => { - await config.createTable(await userTable()) + await createTable(await userTable()) await Promise.all(Array.from({ length: 10 }, () => config.createRow())) const createViewResponse = await config.createView() @@ -1443,7 +1465,7 @@ describe.each([ let tableId: string beforeAll(async () => { - await config.createTable(await userTable()) + await createTable(await userTable()) await Promise.all( Array.from({ length: 10 }, () => config.createRow()) ) @@ -1521,13 +1543,13 @@ describe.each([ let o2mTable: Table let m2mTable: Table beforeAll(async () => { - o2mTable = await config.createTable( + o2mTable = await createTable( { ...generateTableConfig(), name: "o2m" }, { skipReassigning: true, } ) - m2mTable = await config.createTable( + m2mTable = await createTable( { ...generateTableConfig(), name: "m2m" }, { skipReassigning: true, @@ -1597,9 +1619,9 @@ describe.each([ const tableConfig = generateTableConfig() if (config.datasource) { - tableConfig.sourceId = config.datasource._id + tableConfig.sourceId = config.datasource._id! if (config.datasource.plus) { - tableConfig.type = "external" + tableConfig.sourceType = TableSourceType.EXTERNAL } } const table = await config.api.table.create({ diff --git a/packages/server/src/api/routes/tests/static.spec.js b/packages/server/src/api/routes/tests/static.spec.js index 13d963d057..a28d9ecd79 100644 --- a/packages/server/src/api/routes/tests/static.spec.js +++ b/packages/server/src/api/routes/tests/static.spec.js @@ -5,11 +5,15 @@ describe("/static", () => { let request = setup.getRequest() let config = setup.getConfig() let app + let cleanupEnv - afterAll(setup.afterAll) + afterAll(() => { + setup.afterAll() + cleanupEnv() + }) beforeAll(async () => { - config.modeSelf() + cleanupEnv = config.setEnv({ SELF_HOSTED: "true" }) app = await config.init() }) diff --git a/packages/server/src/api/routes/tests/table.spec.ts b/packages/server/src/api/routes/tests/table.spec.ts index ded54729b9..c239c596fe 100644 --- a/packages/server/src/api/routes/tests/table.spec.ts +++ b/packages/server/src/api/routes/tests/table.spec.ts @@ -1,16 +1,24 @@ -import { events, context } from "@budibase/backend-core" +import { context, events } from "@budibase/backend-core" import { - FieldType, - SaveTableRequest, - RelationshipType, - Table, - ViewCalculation, AutoFieldSubTypes, + FieldSubtype, + FieldType, + INTERNAL_TABLE_SOURCE_ID, + InternalTable, + RelationshipType, + Row, + SaveTableRequest, + Table, + TableSourceType, + User, + ViewCalculation, } from "@budibase/types" import { checkBuilderEndpoint } from "./utilities/TestFunctions" import * as setup from "./utilities" -const { basicTable } = setup.structures import sdk from "../../../sdk" +import uuid from "uuid" + +const { basicTable } = setup.structures describe("/tables", () => { let request = setup.getRequest() @@ -239,7 +247,8 @@ describe("/tables", () => { .expect(200) const fetchedTable = res.body[0] expect(fetchedTable.name).toEqual(testTable.name) - expect(fetchedTable.type).toEqual("internal") + expect(fetchedTable.type).toEqual("table") + expect(fetchedTable.sourceType).toEqual("internal") }) it("should apply authorization to endpoint", async () => { @@ -417,4 +426,281 @@ describe("/tables", () => { }) }) }) + + describe("migrate", () => { + let users: User[] + beforeAll(async () => { + users = await Promise.all([ + config.createUser({ email: `${uuid.v4()}@example.com` }), + config.createUser({ email: `${uuid.v4()}@example.com` }), + config.createUser({ email: `${uuid.v4()}@example.com` }), + ]) + }) + + it("should successfully migrate a one-to-many user relationship to a user column", async () => { + const table = await config.api.table.create({ + name: "table", + type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, + schema: { + "user relationship": { + type: FieldType.LINK, + fieldName: "test", + name: "user relationship", + constraints: { + type: "array", + presence: false, + }, + relationshipType: RelationshipType.ONE_TO_MANY, + tableId: InternalTable.USER_METADATA, + }, + }, + }) + + const rows = await Promise.all( + users.map(u => + config.api.row.save(table._id!, { "user relationship": [u] }) + ) + ) + + await config.api.table.migrate(table._id!, { + oldColumn: table.schema["user relationship"], + newColumn: { + name: "user column", + type: FieldType.BB_REFERENCE, + subtype: FieldSubtype.USER, + }, + }) + + const migratedTable = await config.api.table.get(table._id!) + expect(migratedTable.schema["user column"]).toBeDefined() + expect(migratedTable.schema["user relationship"]).not.toBeDefined() + + const migratedRows = await config.api.row.fetch(table._id!) + + rows.sort((a, b) => a._id!.localeCompare(b._id!)) + migratedRows.sort((a, b) => a._id!.localeCompare(b._id!)) + + for (const [i, row] of rows.entries()) { + const migratedRow = migratedRows[i] + expect(migratedRow["user column"]).toBeDefined() + expect(migratedRow["user relationship"]).not.toBeDefined() + expect(row["user relationship"][0]._id).toEqual( + migratedRow["user column"][0]._id + ) + } + }) + + it("should successfully migrate a many-to-many user relationship to a users column", async () => { + const table = await config.api.table.create({ + name: "table", + type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, + schema: { + "user relationship": { + type: FieldType.LINK, + fieldName: "test", + name: "user relationship", + constraints: { + type: "array", + presence: false, + }, + relationshipType: RelationshipType.MANY_TO_MANY, + tableId: InternalTable.USER_METADATA, + }, + }, + }) + + const row1 = await config.api.row.save(table._id!, { + "user relationship": [users[0], users[1]], + }) + + const row2 = await config.api.row.save(table._id!, { + "user relationship": [users[1], users[2]], + }) + + await config.api.table.migrate(table._id!, { + oldColumn: table.schema["user relationship"], + newColumn: { + name: "user column", + type: FieldType.BB_REFERENCE, + subtype: FieldSubtype.USERS, + }, + }) + + const migratedTable = await config.api.table.get(table._id!) + expect(migratedTable.schema["user column"]).toBeDefined() + expect(migratedTable.schema["user relationship"]).not.toBeDefined() + + const row1Migrated = (await config.api.row.get(table._id!, row1._id!)) + .body as Row + expect(row1Migrated["user relationship"]).not.toBeDefined() + expect(row1Migrated["user column"].map((r: Row) => r._id)).toEqual( + expect.arrayContaining([users[0]._id, users[1]._id]) + ) + + const row2Migrated = (await config.api.row.get(table._id!, row2._id!)) + .body as Row + expect(row2Migrated["user relationship"]).not.toBeDefined() + expect(row2Migrated["user column"].map((r: Row) => r._id)).toEqual( + expect.arrayContaining([users[1]._id, users[2]._id]) + ) + }) + + it("should successfully migrate a many-to-one user relationship to a users column", async () => { + const table = await config.api.table.create({ + name: "table", + type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, + schema: { + "user relationship": { + type: FieldType.LINK, + fieldName: "test", + name: "user relationship", + constraints: { + type: "array", + presence: false, + }, + relationshipType: RelationshipType.MANY_TO_ONE, + tableId: InternalTable.USER_METADATA, + }, + }, + }) + + const row1 = await config.api.row.save(table._id!, { + "user relationship": [users[0], users[1]], + }) + + const row2 = await config.api.row.save(table._id!, { + "user relationship": [users[2]], + }) + + await config.api.table.migrate(table._id!, { + oldColumn: table.schema["user relationship"], + newColumn: { + name: "user column", + type: FieldType.BB_REFERENCE, + subtype: FieldSubtype.USERS, + }, + }) + + const migratedTable = await config.api.table.get(table._id!) + expect(migratedTable.schema["user column"]).toBeDefined() + expect(migratedTable.schema["user relationship"]).not.toBeDefined() + + const row1Migrated = (await config.api.row.get(table._id!, row1._id!)) + .body as Row + expect(row1Migrated["user relationship"]).not.toBeDefined() + expect(row1Migrated["user column"].map((r: Row) => r._id)).toEqual( + expect.arrayContaining([users[0]._id, users[1]._id]) + ) + + const row2Migrated = (await config.api.row.get(table._id!, row2._id!)) + .body as Row + expect(row2Migrated["user relationship"]).not.toBeDefined() + expect(row2Migrated["user column"].map((r: Row) => r._id)).toEqual([ + users[2]._id, + ]) + }) + + describe("unhappy paths", () => { + let table: Table + beforeAll(async () => { + table = await config.api.table.create({ + name: "table", + type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, + schema: { + "user relationship": { + type: FieldType.LINK, + fieldName: "test", + name: "user relationship", + constraints: { + type: "array", + presence: false, + }, + relationshipType: RelationshipType.MANY_TO_ONE, + tableId: InternalTable.USER_METADATA, + }, + num: { + type: FieldType.NUMBER, + name: "num", + constraints: { + type: "number", + presence: false, + }, + }, + }, + }) + }) + + it("should fail if the new column name is blank", async () => { + await config.api.table.migrate( + table._id!, + { + oldColumn: table.schema["user relationship"], + newColumn: { + name: "", + type: FieldType.BB_REFERENCE, + subtype: FieldSubtype.USERS, + }, + }, + { expectStatus: 400 } + ) + }) + + it("should fail if the new column name is a reserved name", async () => { + await config.api.table.migrate( + table._id!, + { + oldColumn: table.schema["user relationship"], + newColumn: { + name: "_id", + type: FieldType.BB_REFERENCE, + subtype: FieldSubtype.USERS, + }, + }, + { expectStatus: 400 } + ) + }) + + it("should fail if the new column name is the same as an existing column", async () => { + await config.api.table.migrate( + table._id!, + { + oldColumn: table.schema["user relationship"], + newColumn: { + name: "num", + type: FieldType.BB_REFERENCE, + subtype: FieldSubtype.USERS, + }, + }, + { expectStatus: 400 } + ) + }) + + it("should fail if the old column name isn't a column in the table", async () => { + await config.api.table.migrate( + table._id!, + { + oldColumn: { + name: "not a column", + type: FieldType.BB_REFERENCE, + subtype: FieldSubtype.USERS, + }, + newColumn: { + name: "new column", + type: FieldType.BB_REFERENCE, + subtype: FieldSubtype.USERS, + }, + }, + { expectStatus: 400 } + ) + }) + }) + }) }) diff --git a/packages/server/src/api/routes/tests/viewV2.spec.ts b/packages/server/src/api/routes/tests/viewV2.spec.ts index 40060aef48..b03a73ddda 100644 --- a/packages/server/src/api/routes/tests/viewV2.spec.ts +++ b/packages/server/src/api/routes/tests/viewV2.spec.ts @@ -3,10 +3,12 @@ import { CreateViewRequest, FieldSchema, FieldType, + INTERNAL_TABLE_SOURCE_ID, SearchQueryOperators, SortOrder, SortType, Table, + TableSourceType, UIFieldMetadata, UpdateViewRequest, ViewV2, @@ -18,6 +20,8 @@ function priceTable(): Table { return { name: "table", type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, schema: { Price: { type: FieldType.NUMBER, @@ -54,10 +58,10 @@ describe.each([ }, }) - return config.createTable({ + return config.createExternalTable({ ...priceTable(), sourceId: datasource._id, - type: "external", + sourceType: TableSourceType.EXTERNAL, }) }, ], diff --git a/packages/server/src/api/routes/tests/webhook.spec.ts b/packages/server/src/api/routes/tests/webhook.spec.ts index e7046d07c8..118bfca95f 100644 --- a/packages/server/src/api/routes/tests/webhook.spec.ts +++ b/packages/server/src/api/routes/tests/webhook.spec.ts @@ -8,11 +8,15 @@ describe("/webhooks", () => { let request = setup.getRequest() let config = setup.getConfig() let webhook: Webhook + let cleanupEnv: () => void - afterAll(setup.afterAll) + afterAll(() => { + setup.afterAll() + cleanupEnv() + }) const setupTest = async () => { - config.modeSelf() + cleanupEnv = config.setEnv({ SELF_HOSTED: "true" }) await config.init() const autoConfig = basicAutomation() autoConfig.definition.trigger.schema = { diff --git a/packages/server/src/constants/index.ts b/packages/server/src/constants/index.ts index b37a4b36c1..fb5c42e7b8 100644 --- a/packages/server/src/constants/index.ts +++ b/packages/server/src/constants/index.ts @@ -1,5 +1,11 @@ -import { objectStore, roles, constants } from "@budibase/backend-core" -import { FieldType as FieldTypes } from "@budibase/types" +import { constants, objectStore, roles } from "@budibase/backend-core" +import { + FieldType as FieldTypes, + INTERNAL_TABLE_SOURCE_ID, + Table, + TableSourceType, +} from "@budibase/types" + export { FieldType as FieldTypes, RelationshipType, @@ -70,9 +76,11 @@ export enum SortDirection { DESCENDING = "DESCENDING", } -export const USERS_TABLE_SCHEMA = { +export const USERS_TABLE_SCHEMA: Table = { _id: "ta_users", type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, views: {}, name: "Users", // TODO: ADMIN PANEL - when implemented this doesn't need to be carried out @@ -87,12 +95,10 @@ export const USERS_TABLE_SCHEMA = { }, presence: true, }, - fieldName: "email", name: "email", }, firstName: { name: "firstName", - fieldName: "firstName", type: FieldTypes.STRING, constraints: { type: FieldTypes.STRING, @@ -101,7 +107,6 @@ export const USERS_TABLE_SCHEMA = { }, lastName: { name: "lastName", - fieldName: "lastName", type: FieldTypes.STRING, constraints: { type: FieldTypes.STRING, @@ -109,7 +114,6 @@ export const USERS_TABLE_SCHEMA = { }, }, roleId: { - fieldName: "roleId", name: "roleId", type: FieldTypes.OPTIONS, constraints: { @@ -119,7 +123,6 @@ export const USERS_TABLE_SCHEMA = { }, }, status: { - fieldName: "status", name: "status", type: FieldTypes.OPTIONS, constraints: { @@ -169,3 +172,8 @@ export enum AutomationErrors { export const ObjectStoreBuckets = objectStore.ObjectStoreBuckets export const MAX_AUTOMATION_RECURRING_ERRORS = 5 export const GOOGLE_SHEETS_PRIMARY_KEY = "rowNumber" +export const DEFAULT_JOBS_TABLE_ID = "ta_bb_jobs" +export const DEFAULT_INVENTORY_TABLE_ID = "ta_bb_inventory" +export const DEFAULT_EXPENSES_TABLE_ID = "ta_bb_expenses" +export const DEFAULT_EMPLOYEE_TABLE_ID = "ta_bb_employee" +export const DEFAULT_BB_DATASOURCE_ID = "datasource_internal_bb_default" diff --git a/packages/server/src/constants/screens.ts b/packages/server/src/constants/screens.ts index 23e36a65b8..6c88b0f957 100644 --- a/packages/server/src/constants/screens.ts +++ b/packages/server/src/constants/screens.ts @@ -1,7 +1,15 @@ import { roles } from "@budibase/backend-core" import { BASE_LAYOUT_PROP_IDS } from "./layouts" -export function createHomeScreen() { +export function createHomeScreen( + config: { + roleId: string + route: string + } = { + roleId: roles.BUILTIN_ROLE_IDS.BASIC, + route: "/", + } +) { return { description: "", url: "", @@ -40,8 +48,8 @@ export function createHomeScreen() { gap: "M", }, routing: { - route: "/", - roleId: roles.BUILTIN_ROLE_IDS.BASIC, + route: config.route, + roleId: config.roleId, }, name: "home-screen", } diff --git a/packages/server/src/db/defaultData/datasource_bb_default.ts b/packages/server/src/db/defaultData/datasource_bb_default.ts index 48d4876de1..b430f9ffb6 100644 --- a/packages/server/src/db/defaultData/datasource_bb_default.ts +++ b/packages/server/src/db/defaultData/datasource_bb_default.ts @@ -1,4 +1,12 @@ -import { FieldTypes, AutoFieldSubTypes } from "../../constants" +import { + AutoFieldSubTypes, + FieldTypes, + DEFAULT_BB_DATASOURCE_ID, + DEFAULT_INVENTORY_TABLE_ID, + DEFAULT_EMPLOYEE_TABLE_ID, + DEFAULT_EXPENSES_TABLE_ID, + DEFAULT_JOBS_TABLE_ID, +} from "../../constants" import { importToRows } from "../../api/controllers/table/utils" import { cloneDeep } from "lodash/fp" import LinkDocument from "../linkedRows/LinkDocument" @@ -8,19 +16,14 @@ import { jobsImport } from "./jobsImport" import { expensesImport } from "./expensesImport" import { db as dbCore } from "@budibase/backend-core" import { - Table, - Row, - RelationshipType, FieldType, + RelationshipType, + Row, + Table, TableSchema, + TableSourceType, } from "@budibase/types" -export const DEFAULT_JOBS_TABLE_ID = "ta_bb_jobs" -export const DEFAULT_INVENTORY_TABLE_ID = "ta_bb_inventory" -export const DEFAULT_EXPENSES_TABLE_ID = "ta_bb_expenses" -export const DEFAULT_EMPLOYEE_TABLE_ID = "ta_bb_employee" -export const DEFAULT_BB_DATASOURCE_ID = "datasource_internal_bb_default" - const defaultDatasource = { _id: DEFAULT_BB_DATASOURCE_ID, type: dbCore.BUDIBASE_DATASOURCE_TYPE, @@ -89,9 +92,10 @@ const AUTO_COLUMNS: TableSchema = { export const DEFAULT_INVENTORY_TABLE_SCHEMA: Table = { _id: DEFAULT_INVENTORY_TABLE_ID, - type: "internal", + type: "table", views: {}, sourceId: DEFAULT_BB_DATASOURCE_ID, + sourceType: TableSourceType.INTERNAL, primaryDisplay: "Item Name", name: "Inventory", schema: { @@ -198,10 +202,11 @@ export const DEFAULT_INVENTORY_TABLE_SCHEMA: Table = { export const DEFAULT_EMPLOYEE_TABLE_SCHEMA: Table = { _id: DEFAULT_EMPLOYEE_TABLE_ID, - type: "internal", + type: "table", views: {}, name: "Employees", sourceId: DEFAULT_BB_DATASOURCE_ID, + sourceType: TableSourceType.INTERNAL, primaryDisplay: "First Name", schema: { "First Name": { @@ -346,9 +351,10 @@ export const DEFAULT_EMPLOYEE_TABLE_SCHEMA: Table = { export const DEFAULT_JOBS_TABLE_SCHEMA: Table = { _id: DEFAULT_JOBS_TABLE_ID, - type: "internal", + type: "table", name: "Jobs", sourceId: DEFAULT_BB_DATASOURCE_ID, + sourceType: TableSourceType.INTERNAL, primaryDisplay: "Job ID", schema: { "Job ID": { @@ -503,10 +509,11 @@ export const DEFAULT_JOBS_TABLE_SCHEMA: Table = { export const DEFAULT_EXPENSES_TABLE_SCHEMA: Table = { _id: DEFAULT_EXPENSES_TABLE_ID, - type: "internal", + type: "table", views: {}, name: "Expenses", sourceId: DEFAULT_BB_DATASOURCE_ID, + sourceType: TableSourceType.INTERNAL, primaryDisplay: "Expense ID", schema: { "Expense ID": { diff --git a/packages/server/src/db/linkedRows/linkUtils.ts b/packages/server/src/db/linkedRows/linkUtils.ts index c74674a865..db9a0dc7d5 100644 --- a/packages/server/src/db/linkedRows/linkUtils.ts +++ b/packages/server/src/db/linkedRows/linkUtils.ts @@ -2,7 +2,12 @@ import { ViewName, getQueryIndex, isRelationshipColumn } from "../utils" import { FieldTypes } from "../../constants" import { createLinkView } from "../views/staticViews" import { context, logging } from "@budibase/backend-core" -import { LinkDocument, LinkDocumentValue, Table } from "@budibase/types" +import { + DatabaseQueryOpts, + LinkDocument, + LinkDocumentValue, + Table, +} from "@budibase/types" export { createLinkView } from "../views/staticViews" @@ -36,13 +41,13 @@ export async function getLinkDocuments(args: { }): Promise<LinkDocumentValue[] | LinkDocument[]> { const { tableId, rowId, fieldName, includeDocs } = args const db = context.getAppDB() - let params: any + let params: DatabaseQueryOpts if (rowId) { params = { key: [tableId, rowId] } } // only table is known else { - params = { startKey: [tableId], endKey: [tableId, {}] } + params = { startkey: [tableId], endkey: [tableId, {}] } } if (includeDocs) { params.include_docs = true diff --git a/packages/server/src/db/utils.ts b/packages/server/src/db/utils.ts index 2c07bd8d22..d532d8a8b2 100644 --- a/packages/server/src/db/utils.ts +++ b/packages/server/src/db/utils.ts @@ -5,6 +5,7 @@ import { FieldSchema, RelationshipFieldMetadata, VirtualDocumentType, + INTERNAL_TABLE_SOURCE_ID, } from "@budibase/types" import { FieldTypes } from "../constants" export { DocumentType, VirtualDocumentType } from "@budibase/types" @@ -18,7 +19,7 @@ export const enum AppStatus { } export const BudibaseInternalDB = { - _id: "bb_internal", + _id: INTERNAL_TABLE_SOURCE_ID, type: dbCore.BUDIBASE_DATASOURCE_TYPE, name: "Budibase DB", source: "BUDIBASE", diff --git a/packages/server/src/environment.ts b/packages/server/src/environment.ts index a1701535ce..91424113ac 100644 --- a/packages/server/src/environment.ts +++ b/packages/server/src/environment.ts @@ -75,7 +75,6 @@ const environment = { }, isTest: coreEnv.isTest, isJest: coreEnv.isJest, - isDev: coreEnv.isDev, isProd: () => { return !coreEnv.isDev() diff --git a/packages/server/src/integration-test/postgres.spec.ts b/packages/server/src/integration-test/postgres.spec.ts index 90f0fc9f2c..60416853b3 100644 --- a/packages/server/src/integration-test/postgres.spec.ts +++ b/packages/server/src/integration-test/postgres.spec.ts @@ -1,6 +1,4 @@ import fetch from "node-fetch" -// @ts-ignore -fetch.mockSearch() import { generateMakeRequest, MakeRequestResponse, @@ -13,12 +11,15 @@ import { RelationshipType, Row, Table, + TableSourceType, } from "@budibase/types" import _ from "lodash" import { generator } from "@budibase/backend-core/tests" import { utils } from "@budibase/backend-core" import { databaseTestProviders } from "../integrations/tests/utils" import { Client } from "pg" +// @ts-ignore +fetch.mockSearch() const config = setup.getConfig()! @@ -52,7 +53,7 @@ describe("postgres integrations", () => { async function createAuxTable(prefix: string) { return await config.createTable({ name: `${prefix}_${generator.word({ length: 6 })}`, - type: "external", + type: "table", primary: ["id"], primaryDisplay: "title", schema: { @@ -67,6 +68,7 @@ describe("postgres integrations", () => { }, }, sourceId: postgresDatasource._id, + sourceType: TableSourceType.EXTERNAL, }) } @@ -88,7 +90,7 @@ describe("postgres integrations", () => { primaryPostgresTable = await config.createTable({ name: `p_${generator.word({ length: 6 })}`, - type: "external", + type: "table", primary: ["id"], schema: { id: { @@ -143,6 +145,7 @@ describe("postgres integrations", () => { }, }, sourceId: postgresDatasource._id, + sourceType: TableSourceType.EXTERNAL, }) }) @@ -249,7 +252,7 @@ describe("postgres integrations", () => { async function createDefaultPgTable() { return await config.createTable({ name: generator.word({ length: 10 }), - type: "external", + type: "table", primary: ["id"], schema: { id: { @@ -259,6 +262,7 @@ describe("postgres integrations", () => { }, }, sourceId: postgresDatasource._id, + sourceType: TableSourceType.EXTERNAL, }) } diff --git a/packages/server/src/integrations/googlesheets.ts b/packages/server/src/integrations/googlesheets.ts index 57b6682cc8..58c867ea0b 100644 --- a/packages/server/src/integrations/googlesheets.ts +++ b/packages/server/src/integrations/googlesheets.ts @@ -10,11 +10,12 @@ import { QueryJson, QueryType, Row, + Schema, SearchFilters, SortJson, - ExternalTable, + Table, TableRequest, - Schema, + TableSourceType, } from "@budibase/types" import { OAuth2Client } from "google-auth-library" import { @@ -262,11 +263,13 @@ class GoogleSheetsIntegration implements DatasourcePlus { id?: string ) { // base table - const table: ExternalTable = { + const table: Table = { + type: "table", name: title, primary: [GOOGLE_SHEETS_PRIMARY_KEY], schema: {}, sourceId: datasourceId, + sourceType: TableSourceType.EXTERNAL, } if (id) { table._id = id @@ -283,7 +286,7 @@ class GoogleSheetsIntegration implements DatasourcePlus { async buildSchema( datasourceId: string, - entities: Record<string, ExternalTable> + entities: Record<string, Table> ): Promise<Schema> { // not fully configured yet if (!this.config.auth) { @@ -291,7 +294,7 @@ class GoogleSheetsIntegration implements DatasourcePlus { } await this.connect() const sheets = this.client.sheetsByIndex - const tables: Record<string, ExternalTable> = {} + const tables: Record<string, Table> = {} let errors: Record<string, string> = {} await utils.parallelForeach( sheets, diff --git a/packages/server/src/integrations/microsoftSqlServer.ts b/packages/server/src/integrations/microsoftSqlServer.ts index ff68026369..c615e5ba48 100644 --- a/packages/server/src/integrations/microsoftSqlServer.ts +++ b/packages/server/src/integrations/microsoftSqlServer.ts @@ -2,7 +2,7 @@ import { DatasourceFieldType, Integration, Operation, - ExternalTable, + Table, TableSchema, QueryJson, QueryType, @@ -12,6 +12,7 @@ import { ConnectionInfo, SourceName, Schema, + TableSourceType, } from "@budibase/types" import { getSqlQuery, @@ -380,7 +381,7 @@ class SqlServerIntegration extends Sql implements DatasourcePlus { */ async buildSchema( datasourceId: string, - entities: Record<string, ExternalTable> + entities: Record<string, Table> ): Promise<Schema> { await this.connect() let tableInfo: MSSQLTablesResponse[] = await this.runSQL(this.TABLES_SQL) @@ -394,7 +395,7 @@ class SqlServerIntegration extends Sql implements DatasourcePlus { .map((record: any) => record.TABLE_NAME) .filter((name: string) => this.MASTER_TABLES.indexOf(name) === -1) - const tables: Record<string, ExternalTable> = {} + const tables: Record<string, Table> = {} for (let tableName of tableNames) { // get the column definition (type) const definition = await this.runSQL( @@ -439,7 +440,9 @@ class SqlServerIntegration extends Sql implements DatasourcePlus { } tables[tableName] = { _id: buildExternalTableId(datasourceId, tableName), + type: "table", sourceId: datasourceId, + sourceType: TableSourceType.EXTERNAL, primary: primaryKeys, name: tableName, schema, diff --git a/packages/server/src/integrations/mysql.ts b/packages/server/src/integrations/mysql.ts index 3a954da9bd..e89393d251 100644 --- a/packages/server/src/integrations/mysql.ts +++ b/packages/server/src/integrations/mysql.ts @@ -4,13 +4,14 @@ import { QueryType, QueryJson, SqlQuery, - ExternalTable, + Table, TableSchema, DatasourcePlus, DatasourceFeature, ConnectionInfo, SourceName, Schema, + TableSourceType, } from "@budibase/types" import { getSqlQuery, @@ -278,9 +279,9 @@ class MySQLIntegration extends Sql implements DatasourcePlus { async buildSchema( datasourceId: string, - entities: Record<string, ExternalTable> + entities: Record<string, Table> ): Promise<Schema> { - const tables: { [key: string]: ExternalTable } = {} + const tables: { [key: string]: Table } = {} await this.connect() try { @@ -317,8 +318,10 @@ class MySQLIntegration extends Sql implements DatasourcePlus { } if (!tables[tableName]) { tables[tableName] = { + type: "table", _id: buildExternalTableId(datasourceId, tableName), sourceId: datasourceId, + sourceType: TableSourceType.EXTERNAL, primary: primaryKeys, name: tableName, schema, diff --git a/packages/server/src/integrations/oracle.ts b/packages/server/src/integrations/oracle.ts index b3936320ac..c6a871e41f 100644 --- a/packages/server/src/integrations/oracle.ts +++ b/packages/server/src/integrations/oracle.ts @@ -5,11 +5,12 @@ import { QueryJson, QueryType, SqlQuery, - ExternalTable, + Table, DatasourcePlus, DatasourceFeature, ConnectionInfo, Schema, + TableSourceType, } from "@budibase/types" import { buildExternalTableId, @@ -263,25 +264,27 @@ class OracleIntegration extends Sql implements DatasourcePlus { */ async buildSchema( datasourceId: string, - entities: Record<string, ExternalTable> + entities: Record<string, Table> ): Promise<Schema> { const columnsResponse = await this.internalQuery<OracleColumnsResponse>({ sql: this.COLUMNS_SQL, }) const oracleTables = this.mapColumns(columnsResponse) - const tables: { [key: string]: ExternalTable } = {} + const tables: { [key: string]: Table } = {} // iterate each table Object.values(oracleTables).forEach(oracleTable => { let table = tables[oracleTable.name] if (!table) { table = { + type: "table", _id: buildExternalTableId(datasourceId, oracleTable.name), primary: [], name: oracleTable.name, schema: {}, sourceId: datasourceId, + sourceType: TableSourceType.EXTERNAL, } tables[oracleTable.name] = table } diff --git a/packages/server/src/integrations/postgres.ts b/packages/server/src/integrations/postgres.ts index 8479cd05d8..4d7dc33d75 100644 --- a/packages/server/src/integrations/postgres.ts +++ b/packages/server/src/integrations/postgres.ts @@ -5,12 +5,13 @@ import { QueryType, QueryJson, SqlQuery, - ExternalTable, + Table, DatasourcePlus, DatasourceFeature, ConnectionInfo, SourceName, Schema, + TableSourceType, } from "@budibase/types" import { getSqlQuery, @@ -273,7 +274,7 @@ class PostgresIntegration extends Sql implements DatasourcePlus { */ async buildSchema( datasourceId: string, - entities: Record<string, ExternalTable> + entities: Record<string, Table> ): Promise<Schema> { let tableKeys: { [key: string]: string[] } = {} await this.openConnection() @@ -300,7 +301,7 @@ class PostgresIntegration extends Sql implements DatasourcePlus { const columnsResponse: { rows: PostgresColumn[] } = await this.client.query(this.COLUMNS_SQL) - const tables: { [key: string]: ExternalTable } = {} + const tables: { [key: string]: Table } = {} for (let column of columnsResponse.rows) { const tableName: string = column.table_name @@ -309,11 +310,13 @@ class PostgresIntegration extends Sql implements DatasourcePlus { // table key doesn't exist yet if (!tables[tableName] || !tables[tableName].schema) { tables[tableName] = { + type: "table", _id: buildExternalTableId(datasourceId, tableName), primary: tableKeys[tableName] || [], name: tableName, schema: {}, sourceId: datasourceId, + sourceType: TableSourceType.EXTERNAL, } } diff --git a/packages/server/src/integrations/tests/googlesheets.spec.ts b/packages/server/src/integrations/tests/googlesheets.spec.ts index 748baddc39..10ec7815d6 100644 --- a/packages/server/src/integrations/tests/googlesheets.spec.ts +++ b/packages/server/src/integrations/tests/googlesheets.spec.ts @@ -30,18 +30,24 @@ GoogleSpreadsheet.mockImplementation(() => mockGoogleIntegration) import { structures } from "@budibase/backend-core/tests" import TestConfiguration from "../../tests/utilities/TestConfiguration" import GoogleSheetsIntegration from "../googlesheets" -import { FieldType, Table, TableSchema } from "@budibase/types" +import { FieldType, Table, TableSchema, TableSourceType } from "@budibase/types" +import { generateDatasourceID } from "../../db/utils" describe("Google Sheets Integration", () => { let integration: any, config = new TestConfiguration() + let cleanupEnv: () => void beforeAll(() => { - config.setGoogleAuth("test") + cleanupEnv = config.setEnv({ + GOOGLE_CLIENT_ID: "test", + GOOGLE_CLIENT_SECRET: "test", + }) }) afterAll(async () => { - await config.end() + cleanupEnv() + config.end() }) beforeEach(async () => { @@ -60,7 +66,10 @@ describe("Google Sheets Integration", () => { function createBasicTable(name: string, columns: string[]): Table { return { + type: "table", name, + sourceId: generateDatasourceID(), + sourceType: TableSourceType.EXTERNAL, schema: { ...columns.reduce((p, c) => { p[c] = { diff --git a/packages/server/src/integrations/tests/utils/postgres.ts b/packages/server/src/integrations/tests/utils/postgres.ts index b749551721..f65d33e3e0 100644 --- a/packages/server/src/integrations/tests/utils/postgres.ts +++ b/packages/server/src/integrations/tests/utils/postgres.ts @@ -1,39 +1,47 @@ import { Datasource, SourceName } from "@budibase/types" import { GenericContainer, Wait, StartedTestContainer } from "testcontainers" +import env from "../../../environment" let container: StartedTestContainer | undefined +const isMac = process.platform === "darwin" + export async function getDsConfig(): Promise<Datasource> { - if (!container) { - container = await new GenericContainer("postgres") - .withExposedPorts(5432) - .withEnv("POSTGRES_PASSWORD", "password") - .withWaitStrategy( - Wait.forLogMessage( - "PostgreSQL init process complete; ready for start up." + try { + if (!container) { + // postgres 15-bullseye safer bet on Linux + const version = isMac ? undefined : "15-bullseye" + container = await new GenericContainer("postgres", version) + .withExposedPorts(5432) + .withEnv("POSTGRES_PASSWORD", "password") + .withWaitStrategy( + Wait.forLogMessage( + "PostgreSQL init process complete; ready for start up." + ) ) - ) - .start() - } + .start() + } + const host = container.getContainerIpAddress() + const port = container.getMappedPort(5432) - const host = container.getContainerIpAddress() - const port = container.getMappedPort(5432) - - return { - type: "datasource_plus", - source: SourceName.POSTGRES, - plus: true, - config: { - host, - port, - database: "postgres", - user: "postgres", - password: "password", - schema: "public", - ssl: false, - rejectUnauthorized: false, - ca: false, - }, + return { + type: "datasource_plus", + source: SourceName.POSTGRES, + plus: true, + config: { + host, + port, + database: "postgres", + user: "postgres", + password: "password", + schema: "public", + ssl: false, + rejectUnauthorized: false, + ca: false, + }, + } + } catch (err) { + throw new Error("**UNABLE TO CREATE TO POSTGRES CONTAINER**") } } diff --git a/packages/server/src/integrations/utils.ts b/packages/server/src/integrations/utils.ts index 79b18e767c..fe8a9055b0 100644 --- a/packages/server/src/integrations/utils.ts +++ b/packages/server/src/integrations/utils.ts @@ -4,10 +4,14 @@ import { SearchFilters, Datasource, FieldType, - ExternalTable, + TableSourceType, } from "@budibase/types" import { DocumentType, SEPARATOR } from "../db/utils" -import { InvalidColumns, NoEmptyFilterStrings } from "../constants" +import { + InvalidColumns, + NoEmptyFilterStrings, + DEFAULT_BB_DATASOURCE_ID, +} from "../constants" import { helpers } from "@budibase/shared-core" const DOUBLE_SEPARATOR = `${SEPARATOR}${SEPARATOR}` @@ -83,10 +87,29 @@ export enum SqlClient { ORACLE = "oracledb", } -export function isExternalTable(tableId: string) { +export function isExternalTableID(tableId: string) { return tableId.includes(DocumentType.DATASOURCE) } +export function isInternalTableID(tableId: string) { + return !isExternalTableID(tableId) +} + +export function isExternalTable(table: Table) { + if ( + table?.sourceId && + table.sourceId.includes(DocumentType.DATASOURCE + SEPARATOR) && + table?.sourceId !== DEFAULT_BB_DATASOURCE_ID + ) { + return true + } else if (table?.sourceType === TableSourceType.EXTERNAL) { + return true + } else if (table?._id && isExternalTableID(table._id)) { + return true + } + return false +} + export function buildExternalTableId(datasourceId: string, tableName: string) { // encode spaces if (tableName.includes(" ")) { @@ -297,9 +320,9 @@ function copyExistingPropsOver( * @param entities The old list of tables, if there was any to look for definitions in. */ export function finaliseExternalTables( - tables: Record<string, ExternalTable>, - entities: Record<string, ExternalTable> -): Record<string, ExternalTable> { + tables: Record<string, Table>, + entities: Record<string, Table> +): Record<string, Table> { let finalTables: Record<string, Table> = {} const tableIds = Object.values(tables).map(table => table._id!) for (let [name, table] of Object.entries(tables)) { @@ -312,7 +335,7 @@ export function finaliseExternalTables( } export function checkExternalTables( - tables: Record<string, ExternalTable> + tables: Record<string, Table> ): Record<string, string> { const invalidColumns = Object.values(InvalidColumns) as string[] const errors: Record<string, string> = {} diff --git a/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts b/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts index bf717d5828..40ff88c1e5 100644 --- a/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts +++ b/packages/server/src/middleware/tests/trimViewRowInfo.spec.ts @@ -1,5 +1,12 @@ import { generator } from "@budibase/backend-core/tests" -import { BBRequest, FieldType, Row, Table } from "@budibase/types" +import { + BBRequest, + FieldType, + Row, + Table, + INTERNAL_TABLE_SOURCE_ID, + TableSourceType, +} from "@budibase/types" import * as utils from "../../db/utils" import trimViewRowInfoMiddleware from "../trimViewRowInfo" @@ -73,6 +80,8 @@ describe("trimViewRowInfo middleware", () => { const table: Table = { _id: tableId, name: generator.word(), + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, type: "table", schema: { name: { diff --git a/packages/server/src/middleware/trimViewRowInfo.ts b/packages/server/src/middleware/trimViewRowInfo.ts index 6a7448262b..95b085a08f 100644 --- a/packages/server/src/middleware/trimViewRowInfo.ts +++ b/packages/server/src/middleware/trimViewRowInfo.ts @@ -1,7 +1,6 @@ import { Ctx, Row } from "@budibase/types" import * as utils from "../db/utils" import sdk from "../sdk" -import { db } from "@budibase/backend-core" import { Next } from "koa" import { getTableId } from "../api/controllers/row/utils" diff --git a/packages/server/src/migrations/functions/backfill/global/configs.ts b/packages/server/src/migrations/functions/backfill/global/configs.ts index 1b76727bbe..04eb9caff2 100644 --- a/packages/server/src/migrations/functions/backfill/global/configs.ts +++ b/packages/server/src/migrations/functions/backfill/global/configs.ts @@ -11,10 +11,11 @@ import { isOIDCConfig, isSettingsConfig, ConfigType, + DatabaseQueryOpts, } from "@budibase/types" import env from "./../../../../environment" -export const getConfigParams = () => { +export function getConfigParams(): DatabaseQueryOpts { return { include_docs: true, startkey: `${DocumentType.CONFIG}${SEPARATOR}`, diff --git a/packages/server/src/sdk/app/backups/exports.ts b/packages/server/src/sdk/app/backups/exports.ts index d5ea31cdf5..c349dcb927 100644 --- a/packages/server/src/sdk/app/backups/exports.ts +++ b/packages/server/src/sdk/app/backups/exports.ts @@ -26,7 +26,6 @@ export interface DBDumpOpts { export interface ExportOpts extends DBDumpOpts { tar?: boolean excludeRows?: boolean - excludeLogs?: boolean encryptPassword?: string } @@ -83,14 +82,15 @@ export async function exportDB( }) } -function defineFilter(excludeRows?: boolean, excludeLogs?: boolean) { - const ids = [USER_METDATA_PREFIX, LINK_USER_METADATA_PREFIX] +function defineFilter(excludeRows?: boolean) { + const ids = [ + USER_METDATA_PREFIX, + LINK_USER_METADATA_PREFIX, + AUTOMATION_LOG_PREFIX, + ] if (excludeRows) { ids.push(TABLE_ROW_PREFIX) } - if (excludeLogs) { - ids.push(AUTOMATION_LOG_PREFIX) - } return (doc: any) => !ids.map(key => doc._id.includes(key)).reduce((prev, curr) => prev || curr) } @@ -118,7 +118,7 @@ export async function exportApp(appId: string, config?: ExportOpts) { fs.writeFileSync(join(tmpPath, path), contents) } } - // get all of the files + // get all the files else { tmpPath = await objectStore.retrieveDirectory( ObjectStoreBuckets.APPS, @@ -141,7 +141,7 @@ export async function exportApp(appId: string, config?: ExportOpts) { // enforce an export of app DB to the tmp path const dbPath = join(tmpPath, DB_EXPORT_FILE) await exportDB(appId, { - filter: defineFilter(config?.excludeRows, config?.excludeLogs), + filter: defineFilter(config?.excludeRows), exportPath: dbPath, }) @@ -191,7 +191,6 @@ export async function streamExportApp({ }) { const tmpPath = await exportApp(appId, { excludeRows, - excludeLogs: true, tar: true, encryptPassword, }) diff --git a/packages/server/src/sdk/app/links/index.ts b/packages/server/src/sdk/app/links/index.ts new file mode 100644 index 0000000000..6655a76656 --- /dev/null +++ b/packages/server/src/sdk/app/links/index.ts @@ -0,0 +1,5 @@ +import * as links from "./links" + +export default { + ...links, +} diff --git a/packages/server/src/sdk/app/links/links.ts b/packages/server/src/sdk/app/links/links.ts new file mode 100644 index 0000000000..fda07568f9 --- /dev/null +++ b/packages/server/src/sdk/app/links/links.ts @@ -0,0 +1,39 @@ +import { context } from "@budibase/backend-core" +import { isTableId } from "@budibase/backend-core/src/docIds" +import { + DatabaseQueryOpts, + LinkDocument, + LinkDocumentValue, +} from "@budibase/types" +import { ViewName, getQueryIndex } from "../../../../src/db/utils" + +export async function fetch(tableId: string): Promise<LinkDocumentValue[]> { + if (!isTableId(tableId)) { + throw new Error(`Invalid tableId: ${tableId}`) + } + + const db = context.getAppDB() + const params: DatabaseQueryOpts = { + startkey: [tableId], + endkey: [tableId, {}], + } + const linkRows = (await db.query(getQueryIndex(ViewName.LINK), params)).rows + return linkRows.map(row => row.value as LinkDocumentValue) +} + +export async function fetchWithDocument( + tableId: string +): Promise<LinkDocument[]> { + if (!isTableId(tableId)) { + throw new Error(`Invalid tableId: ${tableId}`) + } + + const db = context.getAppDB() + const params: DatabaseQueryOpts = { + startkey: [tableId], + endkey: [tableId, {}], + include_docs: true, + } + const linkRows = (await db.query(getQueryIndex(ViewName.LINK), params)).rows + return linkRows.map(row => row.doc as LinkDocument) +} diff --git a/packages/server/src/sdk/app/rows/external.ts b/packages/server/src/sdk/app/rows/external.ts index 8bcf89a3f5..beae02e134 100644 --- a/packages/server/src/sdk/app/rows/external.ts +++ b/packages/server/src/sdk/app/rows/external.ts @@ -1,4 +1,4 @@ -import { IncludeRelationship, Operation, Row } from "@budibase/types" +import { IncludeRelationship, Operation } from "@budibase/types" import { handleRequest } from "../../../api/controllers/row/external" import { breakRowIdField } from "../../../integrations/utils" diff --git a/packages/server/src/sdk/app/rows/search.ts b/packages/server/src/sdk/app/rows/search.ts index ced35db9be..31f7c74601 100644 --- a/packages/server/src/sdk/app/rows/search.ts +++ b/packages/server/src/sdk/app/rows/search.ts @@ -1,5 +1,5 @@ -import { SearchFilters, SearchParams, Row } from "@budibase/types" -import { isExternalTable } from "../../../integrations/utils" +import { Row, SearchFilters, SearchParams } from "@budibase/types" +import { isExternalTableID } from "../../../integrations/utils" import * as internal from "./search/internal" import * as external from "./search/external" import { Format } from "../../../api/controllers/view/exporters" @@ -12,7 +12,7 @@ export interface ViewParams { } function pickApi(tableId: any) { - if (isExternalTable(tableId)) { + if (isExternalTableID(tableId)) { return external } return internal @@ -49,6 +49,10 @@ export async function fetch(tableId: string): Promise<Row[]> { return pickApi(tableId).fetch(tableId) } +export async function fetchRaw(tableId: string): Promise<Row[]> { + return pickApi(tableId).fetchRaw(tableId) +} + export async function fetchView( tableId: string, viewName: string, diff --git a/packages/server/src/sdk/app/rows/search/external.ts b/packages/server/src/sdk/app/rows/search/external.ts index c41efad171..981ae1bf8d 100644 --- a/packages/server/src/sdk/app/rows/search/external.ts +++ b/packages/server/src/sdk/app/rows/search/external.ts @@ -186,6 +186,12 @@ export async function fetch(tableId: string): Promise<Row[]> { }) } +export async function fetchRaw(tableId: string): Promise<Row[]> { + return await handleRequest<Operation.READ>(Operation.READ, tableId, { + includeSqlRelationships: IncludeRelationship.INCLUDE, + }) +} + export async function fetchView(viewName: string) { // there are no views in external datasources, shouldn't ever be called // for now just fetch diff --git a/packages/server/src/sdk/app/rows/search/internal.ts b/packages/server/src/sdk/app/rows/search/internal.ts index 779ff5f777..1aec8a321e 100644 --- a/packages/server/src/sdk/app/rows/search/internal.ts +++ b/packages/server/src/sdk/app/rows/search/internal.ts @@ -140,14 +140,13 @@ export async function exportRows( } export async function fetch(tableId: string): Promise<Row[]> { - const db = context.getAppDB() - const table = await sdk.tables.getTable(tableId) - const rows = await getRawTableData(db, tableId) + const rows = await fetchRaw(tableId) return await outputProcessing(table, rows) } -async function getRawTableData(db: Database, tableId: string) { +export async function fetchRaw(tableId: string): Promise<Row[]> { + const db = context.getAppDB() let rows if (tableId === InternalTables.USER_METADATA) { rows = await sdk.users.fetchMetadata() @@ -182,7 +181,7 @@ export async function fetchView( }) } else { const tableId = viewInfo.meta.tableId - const data = await getRawTableData(db, tableId) + const data = await fetchRaw(tableId) response = await inMemoryViews.runView( viewInfo, calculation as string, @@ -198,11 +197,7 @@ export async function fetchView( try { table = await sdk.tables.getTable(viewInfo.meta.tableId) } catch (err) { - /* istanbul ignore next */ - table = { - name: "", - schema: {}, - } + throw new Error("Unable to retrieve view table.") } rows = await outputProcessing(table, response.rows) } diff --git a/packages/server/src/sdk/app/rows/search/tests/external.spec.ts b/packages/server/src/sdk/app/rows/search/tests/external.spec.ts index b3bddfbc97..c92155230a 100644 --- a/packages/server/src/sdk/app/rows/search/tests/external.spec.ts +++ b/packages/server/src/sdk/app/rows/search/tests/external.spec.ts @@ -7,6 +7,7 @@ import { SourceName, Table, SearchParams, + TableSourceType, } from "@budibase/types" import TestConfiguration from "../../../../../tests/utilities/TestConfiguration" @@ -15,6 +16,7 @@ import { expectAnyExternalColsAttributes, generator, } from "@budibase/backend-core/tests" +import datasource from "../../../../../api/routes/datasource" jest.unmock("mysql2/promise") @@ -23,36 +25,7 @@ jest.setTimeout(30000) describe.skip("external", () => { const config = new TestConfiguration() - let externalDatasource: Datasource - - const tableData: Table = { - name: generator.word(), - type: "external", - primary: ["id"], - schema: { - id: { - name: "id", - type: FieldType.AUTO, - autocolumn: true, - }, - name: { - name: "name", - type: FieldType.STRING, - }, - surname: { - name: "surname", - type: FieldType.STRING, - }, - age: { - name: "age", - type: FieldType.NUMBER, - }, - address: { - name: "address", - type: FieldType.STRING, - }, - }, - } + let externalDatasource: Datasource, tableData: Table beforeAll(async () => { const container = await new GenericContainer("mysql") @@ -84,12 +57,43 @@ describe.skip("external", () => { }, }, }) + + tableData = { + name: generator.word(), + type: "table", + primary: ["id"], + sourceId: externalDatasource._id!, + sourceType: TableSourceType.EXTERNAL, + schema: { + id: { + name: "id", + type: FieldType.AUTO, + autocolumn: true, + }, + name: { + name: "name", + type: FieldType.STRING, + }, + surname: { + name: "surname", + type: FieldType.STRING, + }, + age: { + name: "age", + type: FieldType.NUMBER, + }, + address: { + name: "address", + type: FieldType.STRING, + }, + }, + } }) describe("search", () => { const rows: Row[] = [] beforeAll(async () => { - const table = await config.createTable({ + const table = await config.createExternalTable({ ...tableData, sourceId: externalDatasource._id, }) diff --git a/packages/server/src/sdk/app/rows/search/tests/internal.spec.ts b/packages/server/src/sdk/app/rows/search/tests/internal.spec.ts index b3e98a1149..d82af66e3d 100644 --- a/packages/server/src/sdk/app/rows/search/tests/internal.spec.ts +++ b/packages/server/src/sdk/app/rows/search/tests/internal.spec.ts @@ -1,4 +1,11 @@ -import { FieldType, Row, Table, SearchParams } from "@budibase/types" +import { + FieldType, + Row, + Table, + SearchParams, + INTERNAL_TABLE_SOURCE_ID, + TableSourceType, +} from "@budibase/types" import TestConfiguration from "../../../../../tests/utilities/TestConfiguration" import { search } from "../internal" import { @@ -12,6 +19,8 @@ describe("internal", () => { const tableData: Table = { name: generator.word(), type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, schema: { name: { name: "name", diff --git a/packages/server/src/sdk/app/rows/search/tests/utils.spec.ts b/packages/server/src/sdk/app/rows/search/tests/utils.spec.ts index d946eea432..055628c41c 100644 --- a/packages/server/src/sdk/app/rows/search/tests/utils.spec.ts +++ b/packages/server/src/sdk/app/rows/search/tests/utils.spec.ts @@ -3,14 +3,19 @@ import { db as dbCore } from "@budibase/backend-core" import { FieldType, FieldTypeSubtypes, - Table, + INTERNAL_TABLE_SOURCE_ID, SearchParams, + Table, + TableSourceType, } from "@budibase/types" const tableId = "ta_a" const tableWithUserCol: Table = { + type: "table", _id: tableId, name: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, schema: { user: { name: "user", @@ -21,8 +26,11 @@ const tableWithUserCol: Table = { } const tableWithUsersCol: Table = { + type: "table", _id: tableId, name: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, schema: { user: { name: "user", diff --git a/packages/server/src/sdk/app/tables/external/index.ts b/packages/server/src/sdk/app/tables/external/index.ts index 402baada78..f445fcaf08 100644 --- a/packages/server/src/sdk/app/tables/external/index.ts +++ b/packages/server/src/sdk/app/tables/external/index.ts @@ -35,10 +35,10 @@ export async function save( opts?: { tableId?: string; renaming?: RenameColumn } ) { let tableToSave: TableRequest = { + ...update, type: "table", _id: buildExternalTableId(datasourceId, update.name), sourceId: datasourceId, - ...update, } const tableId = opts?.tableId || update._id diff --git a/packages/server/src/sdk/app/tables/external/utils.ts b/packages/server/src/sdk/app/tables/external/utils.ts index 10c755a7d6..bde812dd3d 100644 --- a/packages/server/src/sdk/app/tables/external/utils.ts +++ b/packages/server/src/sdk/app/tables/external/utils.ts @@ -6,6 +6,7 @@ import { RelationshipFieldMetadata, RelationshipType, Table, + TableSourceType, } from "@budibase/types" import { FieldTypes } from "../../../../constants" import { @@ -76,12 +77,16 @@ export function generateManyLinkSchema( const primary = table.name + table.primary[0] const relatedPrimary = relatedTable.name + relatedTable.primary[0] const jcTblName = generateJunctionTableName(column, table, relatedTable) + const datasourceId = datasource._id! // first create the new table - const junctionTable = { - _id: buildExternalTableId(datasource._id!, jcTblName), + const junctionTable: Table = { + type: "table", + _id: buildExternalTableId(datasourceId, jcTblName), name: jcTblName, primary: [primary, relatedPrimary], constrained: [primary, relatedPrimary], + sourceId: datasourceId, + sourceType: TableSourceType.EXTERNAL, schema: { [primary]: foreignKeyStructure(primary, { toTable: table.name, diff --git a/packages/server/src/sdk/app/tables/getters.ts b/packages/server/src/sdk/app/tables/getters.ts index 02cef748c5..c0d5fe8da8 100644 --- a/packages/server/src/sdk/app/tables/getters.ts +++ b/packages/server/src/sdk/app/tables/getters.ts @@ -1,30 +1,47 @@ import { context } from "@budibase/backend-core" -import { - BudibaseInternalDB, - getMultiIDParams, - getTableParams, -} from "../../../db/utils" +import { getMultiIDParams, getTableParams } from "../../../db/utils" import { breakExternalTableId, - isExternalTable, + isExternalTableID, isSQL, } from "../../../integrations/utils" import { - AllDocsResponse, Database, + INTERNAL_TABLE_SOURCE_ID, Table, TableResponse, + TableSourceType, TableViewsResponse, } from "@budibase/types" import datasources from "../datasources" import sdk from "../../../sdk" -function processInternalTables(docs: AllDocsResponse<Table[]>): Table[] { - return docs.rows.map((tableDoc: any) => ({ - ...tableDoc.doc, - type: "internal", - sourceId: tableDoc.doc.sourceId || BudibaseInternalDB._id, - })) +export function processTable(table: Table): Table { + if (table._id && isExternalTableID(table._id)) { + return { + ...table, + type: "table", + sourceType: TableSourceType.EXTERNAL, + } + } else { + return { + ...table, + type: "table", + sourceId: table.sourceId || INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, + } + } +} + +export function processTables(tables: Table[]): Table[] { + return tables.map(table => processTable(table)) +} + +function processEntities(tables: Record<string, Table>) { + for (let key of Object.keys(tables)) { + tables[key] = processTable(tables[key]) + } + return tables } export async function getAllInternalTables(db?: Database): Promise<Table[]> { @@ -36,7 +53,7 @@ export async function getAllInternalTables(db?: Database): Promise<Table[]> { include_docs: true, }) ) - return processInternalTables(internalTables) + return processTables(internalTables.rows.map(row => row.doc!)) } async function getAllExternalTables(): Promise<Table[]> { @@ -48,7 +65,7 @@ async function getAllExternalTables(): Promise<Table[]> { final = final.concat(Object.values(entities)) } } - return final + return processTables(final) } export async function getExternalTable( @@ -56,19 +73,21 @@ export async function getExternalTable( tableName: string ): Promise<Table> { const entities = await getExternalTablesInDatasource(datasourceId) - return entities[tableName] + return processTable(entities[tableName]) } export async function getTable(tableId: string): Promise<Table> { const db = context.getAppDB() - if (isExternalTable(tableId)) { + let output: Table + if (isExternalTableID(tableId)) { let { datasourceId, tableName } = breakExternalTableId(tableId) const datasource = await datasources.get(datasourceId!) const table = await getExternalTable(datasourceId!, tableName!) - return { ...table, sql: isSQL(datasource) } + output = { ...table, sql: isSQL(datasource) } } else { - return db.get(tableId) + output = await db.get<Table>(tableId) } + return processTable(output) } export async function getAllTables() { @@ -76,7 +95,7 @@ export async function getAllTables() { getAllInternalTables(), getAllExternalTables(), ]) - return [...internal, ...external] + return processTables([...internal, ...external]) } export async function getExternalTablesInDatasource( @@ -86,12 +105,14 @@ export async function getExternalTablesInDatasource( if (!datasource || !datasource.entities) { throw new Error("Datasource is not configured fully.") } - return datasource.entities + return processEntities(datasource.entities) } export async function getTables(tableIds: string[]): Promise<Table[]> { - const externalTableIds = tableIds.filter(tableId => isExternalTable(tableId)), - internalTableIds = tableIds.filter(tableId => !isExternalTable(tableId)) + const externalTableIds = tableIds.filter(tableId => + isExternalTableID(tableId) + ), + internalTableIds = tableIds.filter(tableId => !isExternalTableID(tableId)) let tables: Table[] = [] if (externalTableIds.length) { const externalTables = await getAllExternalTables() @@ -106,9 +127,9 @@ export async function getTables(tableIds: string[]): Promise<Table[]> { const internalTableDocs = await db.allDocs<Table[]>( getMultiIDParams(internalTableIds) ) - tables = tables.concat(processInternalTables(internalTableDocs)) + tables = tables.concat(internalTableDocs.rows.map(row => row.doc!)) } - return tables + return processTables(tables) } export function enrichViewSchemas(table: Table): TableResponse { diff --git a/packages/server/src/sdk/app/tables/index.ts b/packages/server/src/sdk/app/tables/index.ts index 8542250517..ed71498d44 100644 --- a/packages/server/src/sdk/app/tables/index.ts +++ b/packages/server/src/sdk/app/tables/index.ts @@ -2,10 +2,12 @@ import { populateExternalTableSchemas } from "./validation" import * as getters from "./getters" import * as updates from "./update" import * as utils from "./utils" +import { migrate } from "./migration" export default { populateExternalTableSchemas, ...updates, ...getters, ...utils, + migrate, } diff --git a/packages/server/src/sdk/app/tables/migration.ts b/packages/server/src/sdk/app/tables/migration.ts new file mode 100644 index 0000000000..5a6b0c5bc0 --- /dev/null +++ b/packages/server/src/sdk/app/tables/migration.ts @@ -0,0 +1,194 @@ +import { BadRequestError, context, db as dbCore } from "@budibase/backend-core" +import { + BBReferenceFieldMetadata, + FieldSchema, + FieldSubtype, + InternalTable, + isBBReferenceField, + isRelationshipField, + LinkDocument, + RelationshipFieldMetadata, + RelationshipType, + Row, + Table, +} from "@budibase/types" +import sdk from "../../../sdk" +import { isExternalTableID } from "../../../integrations/utils" +import { EventType, updateLinks } from "../../../db/linkedRows" +import { cloneDeep } from "lodash" +import { isInternalColumnName } from "@budibase/backend-core/src/db" + +export interface MigrationResult { + tablesUpdated: Table[] +} + +export async function migrate( + table: Table, + oldColumn: FieldSchema, + newColumn: FieldSchema +): Promise<MigrationResult> { + if (newColumn.name in table.schema) { + throw new BadRequestError(`Column "${newColumn.name}" already exists`) + } + + if (newColumn.name === "") { + throw new BadRequestError(`Column name cannot be empty`) + } + + if (isInternalColumnName(newColumn.name)) { + throw new BadRequestError(`Column name cannot be a reserved column name`) + } + + table.schema[newColumn.name] = newColumn + table = await sdk.tables.saveTable(table) + + let migrator = getColumnMigrator(table, oldColumn, newColumn) + try { + return await migrator.doMigration() + } catch (e) { + // If the migration fails then we need to roll back the table schema + // change. + delete table.schema[newColumn.name] + await sdk.tables.saveTable(table) + throw e + } +} + +interface ColumnMigrator { + doMigration(): Promise<MigrationResult> +} + +function getColumnMigrator( + table: Table, + oldColumn: FieldSchema, + newColumn: FieldSchema +): ColumnMigrator { + // For now, we're only supporting migrations of user relationships to user + // columns in internal tables. In the future, we may want to support other + // migrations but for now return an error if we aren't migrating a user + // relationship. + if (isExternalTableID(table._id!)) { + throw new BadRequestError("External tables cannot be migrated") + } + + if (!(oldColumn.name in table.schema)) { + throw new BadRequestError(`Column "${oldColumn.name}" does not exist`) + } + + if (!isBBReferenceField(newColumn)) { + throw new BadRequestError(`Column "${newColumn.name}" is not a user column`) + } + + if (newColumn.subtype !== "user" && newColumn.subtype !== "users") { + throw new BadRequestError(`Column "${newColumn.name}" is not a user column`) + } + + if (!isRelationshipField(oldColumn)) { + throw new BadRequestError( + `Column "${oldColumn.name}" is not a user relationship` + ) + } + + if (oldColumn.tableId !== InternalTable.USER_METADATA) { + throw new BadRequestError( + `Column "${oldColumn.name}" is not a user relationship` + ) + } + + if (oldColumn.relationshipType === RelationshipType.ONE_TO_MANY) { + if (newColumn.subtype !== FieldSubtype.USER) { + throw new BadRequestError( + `Column "${oldColumn.name}" is a one-to-many column but "${newColumn.name}" is not a single user column` + ) + } + return new SingleUserColumnMigrator(table, oldColumn, newColumn) + } + if ( + oldColumn.relationshipType === RelationshipType.MANY_TO_MANY || + oldColumn.relationshipType === RelationshipType.MANY_TO_ONE + ) { + if (newColumn.subtype !== FieldSubtype.USERS) { + throw new BadRequestError( + `Column "${oldColumn.name}" is a ${oldColumn.relationshipType} column but "${newColumn.name}" is not a multi user column` + ) + } + return new MultiUserColumnMigrator(table, oldColumn, newColumn) + } + + throw new BadRequestError(`Unknown migration type`) +} + +abstract class UserColumnMigrator implements ColumnMigrator { + constructor( + protected table: Table, + protected oldColumn: RelationshipFieldMetadata, + protected newColumn: BBReferenceFieldMetadata + ) {} + + abstract updateRow(row: Row, link: LinkDocument): void + + async doMigration(): Promise<MigrationResult> { + let oldTable = cloneDeep(this.table) + let rows = await sdk.rows.fetchRaw(this.table._id!) + let rowsById = rows.reduce((acc, row) => { + acc[row._id!] = row + return acc + }, {} as Record<string, Row>) + + let links = await sdk.links.fetchWithDocument(this.table._id!) + for (let link of links) { + if ( + link.doc1.tableId !== this.table._id || + link.doc1.fieldName !== this.oldColumn.name || + link.doc2.tableId !== InternalTable.USER_METADATA + ) { + continue + } + + let row = rowsById[link.doc1.rowId] + if (!row) { + // This can happen if the row has been deleted but the link hasn't, + // which was a state that was found during the initial testing of this + // feature. Not sure exactly what can cause it, but best to be safe. + continue + } + + this.updateRow(row, link) + } + + let db = context.getAppDB() + await db.bulkDocs(rows) + + delete this.table.schema[this.oldColumn.name] + this.table = await sdk.tables.saveTable(this.table) + await updateLinks({ + eventType: EventType.TABLE_UPDATED, + table: this.table, + oldTable, + }) + + let otherTable = await sdk.tables.getTable(this.oldColumn.tableId) + return { + tablesUpdated: [this.table, otherTable], + } + } +} + +class SingleUserColumnMigrator extends UserColumnMigrator { + updateRow(row: Row, link: LinkDocument): void { + row[this.newColumn.name] = dbCore.getGlobalIDFromUserMetadataID( + link.doc2.rowId + ) + } +} + +class MultiUserColumnMigrator extends UserColumnMigrator { + updateRow(row: Row, link: LinkDocument): void { + if (!row[this.newColumn.name]) { + row[this.newColumn.name] = [] + } + row[this.newColumn.name].push( + dbCore.getGlobalIDFromUserMetadataID(link.doc2.rowId) + ) + } +} diff --git a/packages/server/src/sdk/app/tables/tests/tables.spec.ts b/packages/server/src/sdk/app/tables/tests/tables.spec.ts index 78ebe59f01..457988c476 100644 --- a/packages/server/src/sdk/app/tables/tests/tables.spec.ts +++ b/packages/server/src/sdk/app/tables/tests/tables.spec.ts @@ -1,4 +1,10 @@ -import { FieldType, Table, ViewV2 } from "@budibase/types" +import { + FieldType, + INTERNAL_TABLE_SOURCE_ID, + Table, + TableSourceType, + ViewV2, +} from "@budibase/types" import { generator } from "@budibase/backend-core/tests" import sdk from "../../.." @@ -13,6 +19,8 @@ describe("table sdk", () => { _id: generator.guid(), name: "TestTable", type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, schema: { name: { type: FieldType.STRING, diff --git a/packages/server/src/sdk/app/tables/tests/validation.spec.ts b/packages/server/src/sdk/app/tables/tests/validation.spec.ts index 5347eede90..66b4222005 100644 --- a/packages/server/src/sdk/app/tables/tests/validation.spec.ts +++ b/packages/server/src/sdk/app/tables/tests/validation.spec.ts @@ -1,73 +1,92 @@ import { populateExternalTableSchemas } from "../validation" import { cloneDeep } from "lodash/fp" -import { AutoReason, Datasource, Table } from "@budibase/types" +import { + AutoReason, + Datasource, + FieldType, + RelationshipType, + SourceName, + Table, + TableSourceType, +} from "@budibase/types" import { isEqual } from "lodash" +import { generateDatasourceID } from "../../../../db/utils" -const SCHEMA = { +const datasourceId = generateDatasourceID() + +const SCHEMA: Datasource = { + source: SourceName.POSTGRES, + type: "datasource", + _id: datasourceId, entities: { client: { + type: "table", _id: "tableA", name: "client", primary: ["idC"], primaryDisplay: "Name", + sourceId: datasourceId, + sourceType: TableSourceType.EXTERNAL, schema: { idC: { autocolumn: true, externalType: "int unsigned", name: "idC", - type: "number", + type: FieldType.NUMBER, }, Name: { autocolumn: false, externalType: "varchar(255)", name: "Name", - type: "string", + type: FieldType.STRING, }, project: { fieldName: "idC", foreignKey: "idC", main: true, name: "project", - relationshipType: "many-to-one", + relationshipType: RelationshipType.MANY_TO_ONE, tableId: "tableB", - type: "link", + type: FieldType.LINK, }, }, }, project: { + type: "table", _id: "tableB", name: "project", primary: ["idP"], primaryDisplay: "Name", + sourceId: datasourceId, + sourceType: TableSourceType.EXTERNAL, schema: { idC: { externalType: "int unsigned", name: "idC", - type: "number", + type: FieldType.NUMBER, }, idP: { autocolumn: true, externalType: "int unsigned", name: "idProject", - type: "number", + type: FieldType.NUMBER, }, Name: { autocolumn: false, externalType: "varchar(255)", name: "Name", - type: "string", + type: FieldType.STRING, }, client: { fieldName: "idC", foreignKey: "idC", name: "client", - relationshipType: "one-to-many", + relationshipType: RelationshipType.ONE_TO_MANY, tableId: "tableA", - type: "link", + type: FieldType.LINK, }, }, sql: true, - type: "table", }, }, } @@ -95,12 +114,12 @@ describe("validation and update of external table schemas", () => { function noOtherTableChanges(response: any) { checkOtherColumns( response.entities!.client!, - SCHEMA.entities.client as Table, + SCHEMA.entities!.client, OTHER_CLIENT_COLS ) checkOtherColumns( response.entities!.project!, - SCHEMA.entities.project as Table, + SCHEMA.entities!.project, OTHER_PROJECT_COLS ) } diff --git a/packages/server/src/sdk/app/tables/update.ts b/packages/server/src/sdk/app/tables/update.ts index 9bba4a967e..5c762e628b 100644 --- a/packages/server/src/sdk/app/tables/update.ts +++ b/packages/server/src/sdk/app/tables/update.ts @@ -1,23 +1,30 @@ import { Table, RenameColumn } from "@budibase/types" -import { isExternalTable } from "../../../integrations/utils" +import { isExternalTableID } from "../../../integrations/utils" import sdk from "../../index" import { context } from "@budibase/backend-core" import { isExternal } from "./utils" +import { DocumentInsertResponse } from "@budibase/nano" import * as external from "./external" import * as internal from "./internal" +import { cloneDeep } from "lodash" export * as external from "./external" export * as internal from "./internal" -export async function saveTable(table: Table) { +export async function saveTable(table: Table): Promise<Table> { const db = context.getAppDB() - if (isExternalTable(table._id!)) { + let resp: DocumentInsertResponse + if (isExternalTableID(table._id!)) { const datasource = await sdk.datasources.get(table.sourceId!) datasource.entities![table.name] = table - await db.put(datasource) + resp = await db.put(datasource) } else { - await db.put(table) + resp = await db.put(table) } + + let tableClone = cloneDeep(table) + tableClone._rev = resp.rev + return tableClone } export async function update(table: Table, renaming?: RenameColumn) { diff --git a/packages/server/src/sdk/app/tables/utils.ts b/packages/server/src/sdk/app/tables/utils.ts index 88543e7c4c..b8e3d888af 100644 --- a/packages/server/src/sdk/app/tables/utils.ts +++ b/packages/server/src/sdk/app/tables/utils.ts @@ -1,10 +1,10 @@ -import { Table } from "@budibase/types" -import { isExternalTable } from "../../../integrations/utils" +import { Table, TableSourceType } from "@budibase/types" +import { isExternalTableID } from "../../../integrations/utils" export function isExternal(opts: { table?: Table; tableId?: string }): boolean { - if (opts.table && opts.table.type === "external") { + if (opts.table && opts.table.sourceType === TableSourceType.EXTERNAL) { return true - } else if (opts.tableId && isExternalTable(opts.tableId)) { + } else if (opts.tableId && isExternalTableID(opts.tableId)) { return true } return false diff --git a/packages/server/src/sdk/app/views/index.ts b/packages/server/src/sdk/app/views/index.ts index 927f82cc68..67e7158f21 100644 --- a/packages/server/src/sdk/app/views/index.ts +++ b/packages/server/src/sdk/app/views/index.ts @@ -4,13 +4,13 @@ import { cloneDeep } from "lodash" import sdk from "../../../sdk" import * as utils from "../../../db/utils" -import { isExternalTable } from "../../../integrations/utils" +import { isExternalTableID } from "../../../integrations/utils" import * as internal from "./internal" import * as external from "./external" function pickApi(tableId: any) { - if (isExternalTable(tableId)) { + if (isExternalTableID(tableId)) { return external } return internal diff --git a/packages/server/src/sdk/app/views/tests/views.spec.ts b/packages/server/src/sdk/app/views/tests/views.spec.ts index 8fcc6405ef..508285651a 100644 --- a/packages/server/src/sdk/app/views/tests/views.spec.ts +++ b/packages/server/src/sdk/app/views/tests/views.spec.ts @@ -2,8 +2,10 @@ import _ from "lodash" import { FieldSchema, FieldType, + INTERNAL_TABLE_SOURCE_ID, Table, TableSchema, + TableSourceType, ViewV2, } from "@budibase/types" import { generator } from "@budibase/backend-core/tests" @@ -14,6 +16,8 @@ describe("table sdk", () => { _id: generator.guid(), name: "TestTable", type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, schema: { name: { type: FieldType.STRING, diff --git a/packages/server/src/sdk/index.ts b/packages/server/src/sdk/index.ts index 24eb1ebf3c..c3057e3d4f 100644 --- a/packages/server/src/sdk/index.ts +++ b/packages/server/src/sdk/index.ts @@ -5,6 +5,7 @@ import { default as applications } from "./app/applications" import { default as datasources } from "./app/datasources" import { default as queries } from "./app/queries" import { default as rows } from "./app/rows" +import { default as links } from "./app/links" import { default as users } from "./users" import { default as plugins } from "./plugins" import * as views from "./app/views" @@ -22,6 +23,7 @@ const sdk = { plugins, views, permissions, + links, } // default export for TS diff --git a/packages/server/src/sdk/users/tests/utils.spec.ts b/packages/server/src/sdk/users/tests/utils.spec.ts index 5c6777df59..f7c9413ebd 100644 --- a/packages/server/src/sdk/users/tests/utils.spec.ts +++ b/packages/server/src/sdk/users/tests/utils.spec.ts @@ -39,12 +39,12 @@ describe("syncGlobalUsers", () => { expect(metadata).toHaveLength(3) expect(metadata).toContainEqual( expect.objectContaining({ - _id: db.generateUserMetadataID(user1._id), + _id: db.generateUserMetadataID(user1._id!), }) ) expect(metadata).toContainEqual( expect.objectContaining({ - _id: db.generateUserMetadataID(user2._id), + _id: db.generateUserMetadataID(user2._id!), }) ) }) @@ -59,7 +59,7 @@ describe("syncGlobalUsers", () => { expect(metadata).toHaveLength(1) expect(metadata).not.toContainEqual( expect.objectContaining({ - _id: db.generateUserMetadataID(user._id), + _id: db.generateUserMetadataID(user._id!), }) ) }) @@ -70,7 +70,7 @@ describe("syncGlobalUsers", () => { const group = await proSdk.groups.save(structures.userGroups.userGroup()) const user1 = await config.createUser({ admin: false, builder: false }) const user2 = await config.createUser({ admin: false, builder: false }) - await proSdk.groups.addUsers(group.id, [user1._id, user2._id]) + await proSdk.groups.addUsers(group.id, [user1._id!, user2._id!]) await config.doInContext(config.appId, async () => { await syncGlobalUsers() @@ -87,12 +87,12 @@ describe("syncGlobalUsers", () => { expect(metadata).toHaveLength(3) expect(metadata).toContainEqual( expect.objectContaining({ - _id: db.generateUserMetadataID(user1._id), + _id: db.generateUserMetadataID(user1._id!), }) ) expect(metadata).toContainEqual( expect.objectContaining({ - _id: db.generateUserMetadataID(user2._id), + _id: db.generateUserMetadataID(user2._id!), }) ) }) @@ -109,7 +109,7 @@ describe("syncGlobalUsers", () => { { appId: config.prodAppId!, roleId: roles.BUILTIN_ROLE_IDS.BASIC }, ], }) - await proSdk.groups.addUsers(group.id, [user1._id, user2._id]) + await proSdk.groups.addUsers(group.id, [user1._id!, user2._id!]) await config.doInContext(config.appId, async () => { await syncGlobalUsers() diff --git a/packages/server/src/tests/utilities/TestConfiguration.ts b/packages/server/src/tests/utilities/TestConfiguration.ts index cec8c8aa12..04c0552457 100644 --- a/packages/server/src/tests/utilities/TestConfiguration.ts +++ b/packages/server/src/tests/utilities/TestConfiguration.ts @@ -2,37 +2,31 @@ import { generator, mocks, structures } from "@budibase/backend-core/tests" // init the licensing mock import * as pro from "@budibase/pro" -mocks.licenses.init(pro) - -// use unlimited license by default -mocks.licenses.useUnlimited() - import { init as dbInit } from "../../db" -dbInit() import env from "../../environment" import { - basicTable, - basicRow, - basicRole, basicAutomation, - basicDatasource, - basicQuery, - basicScreen, - basicLayout, - basicWebhook, basicAutomationResults, + basicDatasource, + basicLayout, + basicQuery, + basicRole, + basicRow, + basicScreen, + basicTable, + basicWebhook, } from "./structures" import { - constants, - tenancy, - sessions, + auth, cache, + constants, context, db as dbCore, encryption, - auth, - roles, env as coreEnv, + roles, + sessions, + tenancy, } from "@budibase/backend-core" import * as controllers from "./controllers" import { cleanup } from "../../utilities/fileSystem" @@ -43,21 +37,32 @@ import supertest from "supertest" import { App, AuthToken, + Automation, + CreateViewRequest, Datasource, + FieldType, + INTERNAL_TABLE_SOURCE_ID, + RelationshipFieldMetadata, + RelationshipType, Row, + SearchFilters, SourceName, Table, - SearchFilters, + TableSourceType, + User, UserRoles, - Automation, View, - FieldType, - RelationshipType, - CreateViewRequest, - RelationshipFieldMetadata, } from "@budibase/types" import API from "./api" +import { cloneDeep } from "lodash" + +mocks.licenses.init(pro) + +// use unlimited license by default +mocks.licenses.useUnlimited() + +dbInit() type DefaultUserValues = { globalUserId: string @@ -67,6 +72,11 @@ type DefaultUserValues = { csrfToken: string } +interface TableToBuild extends Omit<Table, "sourceId" | "sourceType"> { + sourceId?: string + sourceType?: TableSourceType +} + class TestConfiguration { server: any request: supertest.SuperTest<supertest.Test> | undefined @@ -188,30 +198,38 @@ class TestConfiguration { } } - // MODES - setMultiTenancy = (value: boolean) => { - env._set("MULTI_TENANCY", value) - coreEnv._set("MULTI_TENANCY", value) + async withEnv(newEnvVars: Partial<typeof env>, f: () => Promise<void>) { + let cleanup = this.setEnv(newEnvVars) + try { + await f() + } finally { + cleanup() + } } - setSelfHosted = (value: boolean) => { - env._set("SELF_HOSTED", value) - coreEnv._set("SELF_HOSTED", value) - } + /* + * Sets the environment variables to the given values and returns a function + * that can be called to reset the environment variables to their original values. + */ + setEnv(newEnvVars: Partial<typeof env>): () => void { + const oldEnv = cloneDeep(env) + const oldCoreEnv = cloneDeep(coreEnv) - setGoogleAuth = (value: string) => { - env._set("GOOGLE_CLIENT_ID", value) - env._set("GOOGLE_CLIENT_SECRET", value) - coreEnv._set("GOOGLE_CLIENT_ID", value) - coreEnv._set("GOOGLE_CLIENT_SECRET", value) - } + let key: keyof typeof newEnvVars + for (key in newEnvVars) { + env._set(key, newEnvVars[key]) + coreEnv._set(key, newEnvVars[key]) + } - modeCloud = () => { - this.setSelfHosted(false) - } + return () => { + for (const [key, value] of Object.entries(oldEnv)) { + env._set(key, value) + } - modeSelf = () => { - this.setSelfHosted(true) + for (const [key, value] of Object.entries(oldCoreEnv)) { + coreEnv._set(key, value) + } + } } // UTILS @@ -254,7 +272,7 @@ class TestConfiguration { } catch (err) { existing = { email } } - const user = { + const user: User = { _id: id, ...existing, roles: roles || {}, @@ -294,7 +312,7 @@ class TestConfiguration { admin?: boolean roles?: UserRoles } = {} - ) { + ): Promise<User> { let { id, firstName, lastName, email, builder, admin, roles } = user firstName = firstName || this.defaultUserValues.firstName lastName = lastName || this.defaultUserValues.lastName @@ -314,10 +332,7 @@ class TestConfiguration { roles, }) await cache.user.invalidateUser(globalId) - return { - ...resp, - globalId, - } + return resp } async createGroup(roleId: string = roles.BUILTIN_ROLE_IDS.BASIC) { @@ -540,10 +555,12 @@ class TestConfiguration { // TABLE async updateTable( - config?: Table, + config?: TableToBuild, { skipReassigning } = { skipReassigning: false } ): Promise<Table> { config = config || basicTable() + config.sourceType = config.sourceType || TableSourceType.INTERNAL + config.sourceId = config.sourceId || INTERNAL_TABLE_SOURCE_ID const response = await this._req(config, null, controllers.table.save) if (!skipReassigning) { this.table = response @@ -551,18 +568,32 @@ class TestConfiguration { return response } - async createTable(config?: Table, options = { skipReassigning: false }) { + async createTable( + config?: TableToBuild, + options = { skipReassigning: false } + ) { if (config != null && config._id) { delete config._id } config = config || basicTable() - if (this.datasource && !config.sourceId) { - config.sourceId = this.datasource._id - if (this.datasource.plus) { - config.type = "external" - } + if (!config.sourceId) { + config.sourceId = INTERNAL_TABLE_SOURCE_ID } + return this.updateTable(config, options) + } + async createExternalTable( + config?: TableToBuild, + options = { skipReassigning: false } + ) { + if (config != null && config._id) { + delete config._id + } + config = config || basicTable() + if (this.datasource?._id) { + config.sourceId = this.datasource._id + config.sourceType = TableSourceType.EXTERNAL + } return this.updateTable(config, options) } @@ -574,12 +605,15 @@ class TestConfiguration { async createLinkedTable( relationshipType = RelationshipType.ONE_TO_MANY, links: any = ["link"], - config?: Table + config?: TableToBuild ) { if (!this.table) { throw "Must have created a table first." } const tableConfig = config || basicTable() + if (!tableConfig.sourceId) { + tableConfig.sourceId = INTERNAL_TABLE_SOURCE_ID + } tableConfig.primaryDisplay = "name" for (let link of links) { tableConfig.schema[link] = { @@ -591,15 +625,12 @@ class TestConfiguration { } as RelationshipFieldMetadata } - if (this.datasource && !tableConfig.sourceId) { + if (this.datasource?._id) { tableConfig.sourceId = this.datasource._id - if (this.datasource.plus) { - tableConfig.type = "external" - } + tableConfig.sourceType = TableSourceType.EXTERNAL } - const linkedTable = await this.createTable(tableConfig) - return linkedTable + return await this.createTable(tableConfig) } async createAttachmentTable() { @@ -774,8 +805,9 @@ class TestConfiguration { // AUTOMATION LOG - async createAutomationLog(automation: Automation) { - return await context.doInAppContext(this.getProdAppId(), async () => { + async createAutomationLog(automation: Automation, appId?: string) { + appId = appId || this.getProdAppId() + return await context.doInAppContext(appId!, async () => { return await pro.sdk.automations.logs.storeLog( automation, basicAutomationResults(automation._id!) diff --git a/packages/server/src/tests/utilities/api/attachment.ts b/packages/server/src/tests/utilities/api/attachment.ts new file mode 100644 index 0000000000..a466f1a67e --- /dev/null +++ b/packages/server/src/tests/utilities/api/attachment.ts @@ -0,0 +1,35 @@ +import { + APIError, + Datasource, + ProcessAttachmentResponse, +} from "@budibase/types" +import TestConfiguration from "../TestConfiguration" +import { TestAPI } from "./base" +import fs from "fs" + +export class AttachmentAPI extends TestAPI { + constructor(config: TestConfiguration) { + super(config) + } + + process = async ( + name: string, + file: Buffer | fs.ReadStream | string, + { expectStatus } = { expectStatus: 200 } + ): Promise<ProcessAttachmentResponse> => { + const result = await this.request + .post(`/api/attachments/process`) + .attach("file", file, name) + .set(this.config.defaultHeaders()) + + if (result.statusCode !== expectStatus) { + throw new Error( + `Expected status ${expectStatus} but got ${ + result.statusCode + }, body: ${JSON.stringify(result.body)}` + ) + } + + return result.body + } +} diff --git a/packages/server/src/tests/utilities/api/backup.ts b/packages/server/src/tests/utilities/api/backup.ts new file mode 100644 index 0000000000..f9cbc7086e --- /dev/null +++ b/packages/server/src/tests/utilities/api/backup.ts @@ -0,0 +1,45 @@ +import { + CreateAppBackupResponse, + ImportAppBackupResponse, +} from "@budibase/types" +import TestConfiguration from "../TestConfiguration" +import { TestAPI } from "./base" + +export class BackupAPI extends TestAPI { + constructor(config: TestConfiguration) { + super(config) + } + + exportBasicBackup = async (appId: string) => { + const result = await this.request + .post(`/api/backups/export?appId=${appId}`) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /application\/gzip/) + .expect(200) + return { + body: result.body as Buffer, + headers: result.headers, + } + } + + createBackup = async (appId: string) => { + const result = await this.request + .post(`/api/apps/${appId}/backups`) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(200) + return result.body as CreateAppBackupResponse + } + + importBackup = async ( + appId: string, + backupId: string + ): Promise<ImportAppBackupResponse> => { + const result = await this.request + .post(`/api/apps/${appId}/backups/${backupId}/import`) + .set(this.config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(200) + return result.body as ImportAppBackupResponse + } +} diff --git a/packages/server/src/tests/utilities/api/index.ts b/packages/server/src/tests/utilities/api/index.ts index fce8237760..c553e7b8f4 100644 --- a/packages/server/src/tests/utilities/api/index.ts +++ b/packages/server/src/tests/utilities/api/index.ts @@ -7,6 +7,8 @@ import { DatasourceAPI } from "./datasource" import { LegacyViewAPI } from "./legacyView" import { ScreenAPI } from "./screen" import { ApplicationAPI } from "./application" +import { BackupAPI } from "./backup" +import { AttachmentAPI } from "./attachment" export default class API { table: TableAPI @@ -17,6 +19,8 @@ export default class API { datasource: DatasourceAPI screen: ScreenAPI application: ApplicationAPI + backup: BackupAPI + attachment: AttachmentAPI constructor(config: TestConfiguration) { this.table = new TableAPI(config) @@ -27,5 +31,7 @@ export default class API { this.datasource = new DatasourceAPI(config) this.screen = new ScreenAPI(config) this.application = new ApplicationAPI(config) + this.backup = new BackupAPI(config) + this.attachment = new AttachmentAPI(config) } } diff --git a/packages/server/src/tests/utilities/api/table.ts b/packages/server/src/tests/utilities/api/table.ts index 04432a788a..b80c940697 100644 --- a/packages/server/src/tests/utilities/api/table.ts +++ b/packages/server/src/tests/utilities/api/table.ts @@ -1,4 +1,10 @@ -import { SaveTableRequest, SaveTableResponse, Table } from "@budibase/types" +import { + MigrateRequest, + MigrateResponse, + SaveTableRequest, + SaveTableResponse, + Table, +} from "@budibase/types" import TestConfiguration from "../TestConfiguration" import { TestAPI } from "./base" @@ -42,4 +48,23 @@ export class TableAPI extends TestAPI { .expect(expectStatus) return res.body } + + migrate = async ( + tableId: string, + data: MigrateRequest, + { expectStatus } = { expectStatus: 200 } + ): Promise<MigrateResponse> => { + const res = await this.request + .post(`/api/tables/${tableId}/migrate`) + .send(data) + .set(this.config.defaultHeaders()) + if (res.status !== expectStatus) { + throw new Error( + `Expected status ${expectStatus} but got ${ + res.status + } with body ${JSON.stringify(res.body)}` + ) + } + return res.body + } } diff --git a/packages/server/src/tests/utilities/structures.ts b/packages/server/src/tests/utilities/structures.ts index d3e92ea34d..b680c6ff19 100644 --- a/packages/server/src/tests/utilities/structures.ts +++ b/packages/server/src/tests/utilities/structures.ts @@ -19,12 +19,17 @@ import { FieldType, SourceName, Table, + INTERNAL_TABLE_SOURCE_ID, + TableSourceType, } from "@budibase/types" +const { BUILTIN_ROLE_IDS } = roles export function basicTable(): Table { return { name: "TestTable", type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, schema: { name: { type: FieldType.STRING, @@ -322,8 +327,22 @@ export function basicUser(role: string) { } } -export function basicScreen() { - return createHomeScreen() +export function basicScreen(route: string = "/") { + return createHomeScreen({ + roleId: BUILTIN_ROLE_IDS.BASIC, + route, + }) +} + +export function powerScreen(route: string = "/") { + return createHomeScreen({ + roleId: BUILTIN_ROLE_IDS.POWER, + route, + }) +} + +export function customScreen(config: { roleId: string; route: string }) { + return createHomeScreen(config) } export function basicLayout() { diff --git a/packages/server/src/utilities/rowProcessor/index.ts b/packages/server/src/utilities/rowProcessor/index.ts index cf3875b2ea..098962c646 100644 --- a/packages/server/src/utilities/rowProcessor/index.ts +++ b/packages/server/src/utilities/rowProcessor/index.ts @@ -17,7 +17,7 @@ import { processInputBBReferences, processOutputBBReferences, } from "./bbReferenceProcessor" -import { isExternalTable } from "../../integrations/utils" +import { isExternalTableID } from "../../integrations/utils" export * from "./utils" type AutoColumnProcessingOpts = { @@ -51,7 +51,7 @@ function getRemovedAttachmentKeys( /** * This will update any auto columns that are found on the row/table with the correct information based on * time now and the current logged in user making the request. - * @param user The user to be used for an appId as well as the createdBy and createdAt fields. + * @param userId The user to be used for an appId as well as the createdBy and createdAt fields. * @param table The table which is to be used for the schema, as well as handling auto IDs incrementing. * @param row The row which is to be updated with information for the auto columns. * @param opts specific options for function to carry out optional features. @@ -241,7 +241,7 @@ export async function outputProcessing<T extends Row[] | Row>( continue } row[property].forEach((attachment: RowAttachment) => { - attachment.url = objectStore.getAppFileUrl(attachment.key) + attachment.url ??= objectStore.getAppFileUrl(attachment.key) }) } } else if ( @@ -267,7 +267,7 @@ export async function outputProcessing<T extends Row[] | Row>( )) as Row[] } // remove null properties to match internal API - if (isExternalTable(table._id!)) { + if (isExternalTableID(table._id!)) { for (let row of enriched) { for (let key of Object.keys(row)) { if (row[key] === null) { diff --git a/packages/server/src/utilities/rowProcessor/tests/inputProcessing.spec.ts b/packages/server/src/utilities/rowProcessor/tests/inputProcessing.spec.ts index 18d5128986..b6c1db9159 100644 --- a/packages/server/src/utilities/rowProcessor/tests/inputProcessing.spec.ts +++ b/packages/server/src/utilities/rowProcessor/tests/inputProcessing.spec.ts @@ -1,6 +1,12 @@ import { inputProcessing } from ".." import { generator, structures } from "@budibase/backend-core/tests" -import { FieldType, FieldTypeSubtypes, Table } from "@budibase/types" +import { + FieldType, + FieldTypeSubtypes, + INTERNAL_TABLE_SOURCE_ID, + Table, + TableSourceType, +} from "@budibase/types" import * as bbReferenceProcessor from "../bbReferenceProcessor" jest.mock("../bbReferenceProcessor", (): typeof bbReferenceProcessor => ({ @@ -20,6 +26,8 @@ describe("rowProcessor - inputProcessing", () => { _id: generator.guid(), name: "TestTable", type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, schema: { name: { type: FieldType.STRING, @@ -70,6 +78,8 @@ describe("rowProcessor - inputProcessing", () => { _id: generator.guid(), name: "TestTable", type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, schema: { name: { type: FieldType.STRING, @@ -110,6 +120,8 @@ describe("rowProcessor - inputProcessing", () => { _id: generator.guid(), name: "TestTable", type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, schema: { name: { type: FieldType.STRING, @@ -150,6 +162,8 @@ describe("rowProcessor - inputProcessing", () => { _id: generator.guid(), name: "TestTable", type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, schema: { name: { type: FieldType.STRING, diff --git a/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts b/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts index ecb8856c88..03584ef53b 100644 --- a/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts +++ b/packages/server/src/utilities/rowProcessor/tests/outputProcessing.spec.ts @@ -2,7 +2,9 @@ import { FieldSubtype, FieldType, FieldTypeSubtypes, + INTERNAL_TABLE_SOURCE_ID, Table, + TableSourceType, } from "@budibase/types" import { outputProcessing } from ".." import { generator, structures } from "@budibase/backend-core/tests" @@ -26,6 +28,8 @@ describe("rowProcessor - outputProcessing", () => { _id: generator.guid(), name: "TestTable", type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, schema: { name: { type: FieldType.STRING, @@ -71,6 +75,8 @@ describe("rowProcessor - outputProcessing", () => { _id: generator.guid(), name: "TestTable", type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, schema: { name: { type: FieldType.STRING, @@ -108,6 +114,8 @@ describe("rowProcessor - outputProcessing", () => { _id: generator.guid(), name: "TestTable", type: "table", + sourceId: INTERNAL_TABLE_SOURCE_ID, + sourceType: TableSourceType.INTERNAL, schema: { name: { type: FieldType.STRING, diff --git a/packages/server/src/websockets/builder.ts b/packages/server/src/websockets/builder.ts index d2fdbca20c..a47d3048d3 100644 --- a/packages/server/src/websockets/builder.ts +++ b/packages/server/src/websockets/builder.ts @@ -1,5 +1,5 @@ import authorized from "../middleware/authorized" -import { BaseSocket } from "./websocket" +import { BaseSocket, EmitOptions } from "./websocket" import { permissions, events, context } from "@budibase/backend-core" import http from "http" import Koa from "koa" @@ -16,6 +16,7 @@ import { gridSocket } from "./index" import { clearLock, updateLock } from "../utilities/redis" import { Socket } from "socket.io" import { BuilderSocketEvent } from "@budibase/shared-core" +import { processTable } from "../sdk/app/tables/getters" export default class BuilderSocket extends BaseSocket { constructor(app: Koa, server: http.Server) { @@ -100,11 +101,22 @@ export default class BuilderSocket extends BaseSocket { }) } - emitTableUpdate(ctx: any, table: Table) { - this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.TableChange, { - id: table._id, - table, - }) + emitTableUpdate(ctx: any, table: Table, options?: EmitOptions) { + // This was added to make sure that sourceId is always present when + // sending this message to clients. Without this, tables without a + // sourceId (e.g. ta_users) won't get correctly updated client-side. + table = processTable(table) + + this.emitToRoom( + ctx, + ctx.appId, + BuilderSocketEvent.TableChange, + { + id: table._id, + table, + }, + options + ) gridSocket?.emitTableUpdate(ctx, table) } diff --git a/packages/server/src/websockets/websocket.ts b/packages/server/src/websockets/websocket.ts index ffaf9e2763..1dba108d24 100644 --- a/packages/server/src/websockets/websocket.ts +++ b/packages/server/src/websockets/websocket.ts @@ -11,6 +11,14 @@ import { SocketSession } from "@budibase/types" import { v4 as uuid } from "uuid" import { createContext, runMiddlewares } from "./middleware" +export interface EmitOptions { + // Whether to include the originator of the request from the broadcast, + // defaults to false because it is assumed that the user who triggered + // an action will already have the changes of that action reflected in their + // own UI, so there is no need to send them again. + includeOriginator?: boolean +} + const anonUser = () => ({ _id: uuid(), email: "user@mail.com", @@ -270,10 +278,17 @@ export class BaseSocket { // Emit an event to everyone in a room, including metadata of whom // the originator of the request was - emitToRoom(ctx: any, room: string | string[], event: string, payload: any) { - this.io.in(room).emit(event, { - ...payload, - apiSessionId: ctx.headers?.[Header.SESSION_ID], - }) + emitToRoom( + ctx: any, + room: string | string[], + event: string, + payload: any, + options?: EmitOptions + ) { + let emitPayload = { ...payload } + if (!options?.includeOriginator) { + emitPayload.apiSessionId = ctx.headers?.[Header.SESSION_ID] + } + this.io.in(room).emit(event, emitPayload) } } diff --git a/packages/shared-core/src/constants.ts b/packages/shared-core/src/constants.ts index 725c246e2f..e7c6feb20a 100644 --- a/packages/shared-core/src/constants.ts +++ b/packages/shared-core/src/constants.ts @@ -96,3 +96,45 @@ export enum BuilderSocketEvent { export const SocketSessionTTL = 60 export const ValidQueryNameRegex = /^[^()]*$/ export const ValidColumnNameRegex = /^[_a-zA-Z0-9\s]*$/g +export const ValidFileExtensions = [ + "avif", + "css", + "csv", + "docx", + "drawio", + "editorconfig", + "edl", + "enc", + "export", + "geojson", + "gif", + "htm", + "html", + "ics", + "iqy", + "jfif", + "jpeg", + "jpg", + "json", + "log", + "md", + "mid", + "odt", + "pdf", + "png", + "ris", + "rtf", + "svg", + "tex", + "toml", + "twig", + "txt", + "url", + "wav", + "webp", + "xls", + "xlsx", + "xml", + "yaml", + "yml", +] diff --git a/packages/types/package.json b/packages/types/package.json index 1db667e669..1b602097c7 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -15,7 +15,7 @@ }, "jest": {}, "devDependencies": { - "@budibase/nano": "10.1.2", + "@budibase/nano": "10.1.3", "@types/koa": "2.13.4", "@types/node": "18.17.0", "@types/pouchdb": "6.4.0", diff --git a/packages/types/src/api/web/app/attachment.ts b/packages/types/src/api/web/app/attachment.ts new file mode 100644 index 0000000000..792bdf3885 --- /dev/null +++ b/packages/types/src/api/web/app/attachment.ts @@ -0,0 +1,9 @@ +export interface Upload { + size: number + name: string + url: string + extension: string + key: string +} + +export type ProcessAttachmentResponse = Upload[] diff --git a/packages/types/src/api/web/app/backup.ts b/packages/types/src/api/web/app/backup.ts index c9a8d07f5e..f77707e9c6 100644 --- a/packages/types/src/api/web/app/backup.ts +++ b/packages/types/src/api/web/app/backup.ts @@ -20,3 +20,8 @@ export interface CreateAppBackupResponse { export interface UpdateAppBackupRequest { name: string } + +export interface ImportAppBackupResponse { + restoreId: string + message: string +} diff --git a/packages/types/src/api/web/app/index.ts b/packages/types/src/api/web/app/index.ts index 276d7fa7c1..f5b876009b 100644 --- a/packages/types/src/api/web/app/index.ts +++ b/packages/types/src/api/web/app/index.ts @@ -5,3 +5,4 @@ export * from "./view" export * from "./rows" export * from "./table" export * from "./permission" +export * from "./attachment" diff --git a/packages/types/src/api/web/app/table.ts b/packages/types/src/api/web/app/table.ts index cb5faaa9ea..f4d6720516 100644 --- a/packages/types/src/api/web/app/table.ts +++ b/packages/types/src/api/web/app/table.ts @@ -1,4 +1,5 @@ import { + FieldSchema, Row, Table, TableRequest, @@ -33,3 +34,12 @@ export interface BulkImportRequest { export interface BulkImportResponse { message: string } + +export interface MigrateRequest { + oldColumn: FieldSchema + newColumn: FieldSchema +} + +export interface MigrateResponse { + message: string +} diff --git a/packages/types/src/documents/app/table/schema.ts b/packages/types/src/documents/app/table/schema.ts index e529a8e8b7..755ccf61e7 100644 --- a/packages/types/src/documents/app/table/schema.ts +++ b/packages/types/src/documents/app/table/schema.ts @@ -164,3 +164,33 @@ export type FieldSchema = export interface TableSchema { [key: string]: FieldSchema } + +export function isRelationshipField( + field: FieldSchema +): field is RelationshipFieldMetadata { + return field.type === FieldType.LINK +} + +export function isManyToMany( + field: RelationshipFieldMetadata +): field is ManyToManyRelationshipFieldMetadata { + return field.relationshipType === RelationshipType.MANY_TO_MANY +} + +export function isOneToMany( + field: RelationshipFieldMetadata +): field is OneToManyRelationshipFieldMetadata { + return field.relationshipType === RelationshipType.ONE_TO_MANY +} + +export function isManyToOne( + field: RelationshipFieldMetadata +): field is ManyToOneRelationshipFieldMetadata { + return field.relationshipType === RelationshipType.MANY_TO_ONE +} + +export function isBBReferenceField( + field: FieldSchema +): field is BBReferenceFieldMetadata { + return field.type === FieldType.BB_REFERENCE +} diff --git a/packages/types/src/documents/app/table/table.ts b/packages/types/src/documents/app/table/table.ts index 5174ec608f..f3b8e6df8d 100644 --- a/packages/types/src/documents/app/table/table.ts +++ b/packages/types/src/documents/app/table/table.ts @@ -3,14 +3,22 @@ import { View, ViewV2 } from "../view" import { RenameColumn } from "../../../sdk" import { TableSchema } from "./schema" +export const INTERNAL_TABLE_SOURCE_ID = "bb_internal" + +export enum TableSourceType { + EXTERNAL = "external", + INTERNAL = "internal", +} + export interface Table extends Document { - type?: string + type: "table" + sourceType: TableSourceType views?: { [key: string]: View | ViewV2 } name: string + sourceId: string primary?: string[] schema: TableSchema primaryDisplay?: string - sourceId?: string relatedFormula?: string[] constrained?: string[] sql?: boolean @@ -19,10 +27,6 @@ export interface Table extends Document { rowHeight?: number } -export interface ExternalTable extends Table { - sourceId: string -} - export interface TableRequest extends Table { _rename?: RenameColumn created?: boolean diff --git a/packages/types/src/sdk/datasources.ts b/packages/types/src/sdk/datasources.ts index 39a10961de..7a335eb3b9 100644 --- a/packages/types/src/sdk/datasources.ts +++ b/packages/types/src/sdk/datasources.ts @@ -1,4 +1,4 @@ -import { ExternalTable, Table } from "../documents" +import { Table } from "../documents" export const PASSWORD_REPLACEMENT = "--secret-value--" @@ -176,7 +176,7 @@ export interface IntegrationBase { } export interface Schema { - tables: Record<string, ExternalTable> + tables: Record<string, Table> errors: Record<string, string> } @@ -187,7 +187,7 @@ export interface DatasourcePlus extends IntegrationBase { getStringConcat(parts: string[]): string buildSchema( datasourceId: string, - entities: Record<string, ExternalTable> + entities: Record<string, Table> ): Promise<Schema> getTableNames(): Promise<string[]> } diff --git a/packages/types/src/sdk/db.ts b/packages/types/src/sdk/db.ts index 36141cc15b..05f72f5524 100644 --- a/packages/types/src/sdk/db.ts +++ b/packages/types/src/sdk/db.ts @@ -54,15 +54,18 @@ export type DatabaseDeleteIndexOpts = { type?: string | undefined } +type DBPrimitiveKey = string | number | {} +export type DatabaseKey = DBPrimitiveKey | DBPrimitiveKey[] + export type DatabaseQueryOpts = { include_docs?: boolean - startkey?: string - endkey?: string + startkey?: DatabaseKey + endkey?: DatabaseKey limit?: number skip?: number descending?: boolean - key?: string - keys?: string[] + key?: DatabaseKey + keys?: DatabaseKey[] group?: boolean startkey_docid?: string } diff --git a/packages/types/src/sdk/featureFlag.ts b/packages/types/src/sdk/featureFlag.ts index 53aa4842c4..e3935bc7ee 100644 --- a/packages/types/src/sdk/featureFlag.ts +++ b/packages/types/src/sdk/featureFlag.ts @@ -1,5 +1,8 @@ export enum FeatureFlag { LICENSING = "LICENSING", + // Feature IDs in Posthog + PER_CREATOR_PER_USER_PRICE = "18873", + PER_CREATOR_PER_USER_PRICE_ALERT = "18530", } export interface TenantFeatureFlags { diff --git a/packages/types/src/sdk/licensing/billing.ts b/packages/types/src/sdk/licensing/billing.ts index 35f366c811..bcbc7abd18 100644 --- a/packages/types/src/sdk/licensing/billing.ts +++ b/packages/types/src/sdk/licensing/billing.ts @@ -5,10 +5,17 @@ export interface Customer { currency: string | null | undefined } +export interface SubscriptionItems { + user: number | undefined + creator: number | undefined +} + export interface Subscription { amount: number + amounts: SubscriptionItems | undefined currency: string quantity: number + quantities: SubscriptionItems | undefined duration: PriceDuration cancelAt: number | null | undefined currentPeriodStart: number diff --git a/packages/types/src/sdk/licensing/plan.ts b/packages/types/src/sdk/licensing/plan.ts index 3e214a01ff..1604dfb8af 100644 --- a/packages/types/src/sdk/licensing/plan.ts +++ b/packages/types/src/sdk/licensing/plan.ts @@ -4,7 +4,9 @@ export enum PlanType { PRO = "pro", /** @deprecated */ TEAM = "team", + /** @deprecated */ PREMIUM = "premium", + PREMIUM_PLUS = "premium_plus", BUSINESS = "business", ENTERPRISE = "enterprise", } @@ -26,10 +28,12 @@ export interface AvailablePrice { currency: string duration: PriceDuration priceId: string + type?: string } export enum PlanModel { PER_USER = "perUser", + PER_CREATOR_PER_USER = "per_creator_per_user", DAY_PASS = "dayPass", } diff --git a/packages/worker/Dockerfile b/packages/worker/Dockerfile index 4230ee86f8..50f1bb78b9 100644 --- a/packages/worker/Dockerfile +++ b/packages/worker/Dockerfile @@ -14,7 +14,7 @@ RUN yarn global add pm2 COPY package.json . COPY dist/yarn.lock . -RUN yarn install --production=true +RUN yarn install --production=true --network-timeout 1000000 # Remove unneeded data from file system to reduce image size RUN apk del .gyp \ && yarn cache clean diff --git a/packages/worker/Dockerfile.v2 b/packages/worker/Dockerfile.v2 index a8be432827..4706ca155a 100644 --- a/packages/worker/Dockerfile.v2 +++ b/packages/worker/Dockerfile.v2 @@ -19,7 +19,7 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh WORKDIR /string-templates COPY packages/string-templates/package.json package.json RUN ../scripts/removeWorkspaceDependencies.sh package.json -RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true +RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000 COPY packages/string-templates . @@ -30,7 +30,7 @@ RUN cd ../string-templates && yarn link && cd - && yarn link @budibase/string-te RUN ../scripts/removeWorkspaceDependencies.sh package.json -RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true +RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000 # Remove unneeded data from file system to reduce image size RUN apk del .gyp \ && yarn cache clean diff --git a/packages/worker/package.json b/packages/worker/package.json index 205bf3309a..ec86575395 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -20,7 +20,6 @@ "run:docker": "node dist/index.js", "debug": "yarn build && node --expose-gc --inspect=9223 dist/index.js", "run:docker:cluster": "pm2-runtime start pm2.config.js", - "build:docker": "yarn build && docker build . -t worker-service --label version=$BUDIBASE_RELEASE_VERSION --build-arg BUDIBASE_VERSION=$BUDIBASE_RELEASE_VERSION", "dev:stack:init": "node ./scripts/dev/manage.js init", "dev:builder": "npm run dev:stack:init && nodemon", "dev:built": "yarn run dev:stack:init && yarn run run:docker", diff --git a/packages/worker/src/api/routes/global/tests/groups.spec.ts b/packages/worker/src/api/routes/global/tests/groups.spec.ts index afeaae952c..8f0739a812 100644 --- a/packages/worker/src/api/routes/global/tests/groups.spec.ts +++ b/packages/worker/src/api/routes/global/tests/groups.spec.ts @@ -1,7 +1,7 @@ import { events } from "@budibase/backend-core" import { generator } from "@budibase/backend-core/tests" import { structures, TestConfiguration, mocks } from "../../../../tests" -import { UserGroup } from "@budibase/types" +import { User, UserGroup } from "@budibase/types" mocks.licenses.useGroups() @@ -231,4 +231,39 @@ describe("/api/global/groups", () => { }) }) }) + + describe("with global builder role", () => { + let builder: User + let group: UserGroup + + beforeAll(async () => { + builder = await config.createUser({ + builder: { global: true }, + admin: { global: false }, + }) + await config.createSession(builder) + + let resp = await config.api.groups.saveGroup( + structures.groups.UserGroup() + ) + group = resp.body as UserGroup + }) + + it("find should return 200", async () => { + await config.withUser(builder, async () => { + await config.api.groups.searchUsers(group._id!, { + emailSearch: `user1`, + }) + }) + }) + + it("update should return 200", async () => { + await config.withUser(builder, async () => { + await config.api.groups.updateGroupUsers(group._id!, { + add: [builder._id!], + remove: [], + }) + }) + }) + }) }) diff --git a/packages/worker/src/tests/TestConfiguration.ts b/packages/worker/src/tests/TestConfiguration.ts index 7e9792c9e3..d4fcbeebd6 100644 --- a/packages/worker/src/tests/TestConfiguration.ts +++ b/packages/worker/src/tests/TestConfiguration.ts @@ -190,6 +190,16 @@ class TestConfiguration { } } + async withUser(user: User, f: () => Promise<void>) { + const oldUser = this.user + this.user = user + try { + await f() + } finally { + this.user = oldUser + } + } + authHeaders(user: User) { const authToken: AuthToken = { userId: user._id!, @@ -257,9 +267,10 @@ class TestConfiguration { }) } - async createUser(user?: User) { - if (!user) { - user = structures.users.user() + async createUser(opts?: Partial<User>) { + let user = structures.users.user() + if (user) { + user = { ...user, ...opts } } const response = await this._req(user, null, controllers.users.save) const body = response as SaveUserResponse diff --git a/packages/worker/src/tests/structures/groups.ts b/packages/worker/src/tests/structures/groups.ts index b0d6bb8fc0..d39dd74eb8 100644 --- a/packages/worker/src/tests/structures/groups.ts +++ b/packages/worker/src/tests/structures/groups.ts @@ -1,8 +1,8 @@ import { generator } from "@budibase/backend-core/tests" import { db } from "@budibase/backend-core" -import { UserGroupRoles } from "@budibase/types" +import { UserGroup as UserGroupType, UserGroupRoles } from "@budibase/types" -export const UserGroup = () => { +export function UserGroup(): UserGroupType { const appsCount = generator.integer({ min: 0, max: 3 }) const roles = Array.from({ length: appsCount }).reduce( (p: UserGroupRoles, v) => { @@ -14,13 +14,11 @@ export const UserGroup = () => { {} ) - let group = { - apps: [], + return { color: generator.color(), icon: generator.word(), name: generator.word(), roles: roles, users: [], } - return group } diff --git a/scripts/updateWorkspaceVersions.V2.sh b/scripts/updateWorkspaceVersions.V2.sh new file mode 100755 index 0000000000..634bcbcfb0 --- /dev/null +++ b/scripts/updateWorkspaceVersions.V2.sh @@ -0,0 +1,8 @@ +#!/bin/bash +version=$1 +echo "Setting version $version" +yarn lerna exec "yarn version --no-git-tag-version --new-version=$version" +echo "Updating dependencies" +node scripts/syncLocalDependencies.js $version +echo "Syncing yarn workspace" +yarn diff --git a/yarn.lock b/yarn.lock index e3629a8dbc..f573046394 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2099,10 +2099,10 @@ striptags "^3.1.1" to-gfm-code-block "^0.1.1" -"@budibase/nano@10.1.2": - version "10.1.2" - resolved "https://registry.yarnpkg.com/@budibase/nano/-/nano-10.1.2.tgz#10fae5a1ab39be6a81261f40e7b7ec6d21cbdd4a" - integrity sha512-1w+YN2n/M5aZ9hBKCP4NEjdQbT8BfCLRizkdvm0Je665eEHw3aE1hvo8mon9Ro9QuDdxj1DfDMMFnym6/QUwpQ== +"@budibase/nano@10.1.3": + version "10.1.3" + resolved "https://registry.yarnpkg.com/@budibase/nano/-/nano-10.1.3.tgz#81b99d76b5c256a393e6ee0e284a6aecc517e4b8" + integrity sha512-UuhwjKCfVO+oVB0dbKpssZfTfb5k3CTrbrjqdx0kd971zzSRMFJ0TwvBB/2Z7kgOOA+Evoq4BSd747meEz21YA== dependencies: "@types/tough-cookie" "^4.0.2" axios "^1.1.3"