Merge branch 'master' into add-select-states-to-dropdown-data-provider-select

This commit is contained in:
Conor Webb 2023-10-31 07:31:05 +00:00 committed by GitHub
commit 3d3523b2c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
88 changed files with 1464 additions and 811 deletions

View File

@ -36,6 +36,7 @@ jobs:
- uses: actions/setup-node@v1 - uses: actions/setup-node@v1
with: with:
node-version: 18.x node-version: 18.x
cache: yarn
- run: yarn install --frozen-lockfile - run: yarn install --frozen-lockfile
- name: Update versions - name: Update versions
@ -63,14 +64,64 @@ jobs:
echo "Using tag $version" echo "Using tag $version"
echo "version=$version" >> "$GITHUB_OUTPUT" 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: | run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
yarn build:docker
env: env:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }} DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }} 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: release-helm-chart:
needs: [release-images] needs: [release-images]

View File

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

View File

@ -67,7 +67,7 @@ jobs:
push: true push: true
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: budibase/budibase,budibase/budibase:${{ env.RELEASE_VERSION }} 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 - name: Tag and release Budibase Azure App Service docker image
uses: docker/build-push-action@v2 uses: docker/build-push-action@v2
with: with:
@ -76,4 +76,4 @@ jobs:
platforms: linux/amd64 platforms: linux/amd64
build-args: TARGETBUILD=aas build-args: TARGETBUILD=aas
tags: budibase/budibase-aas,budibase/budibase-aas:${{ env.RELEASE_VERSION }} tags: budibase/budibase-aas,budibase/budibase-aas:${{ env.RELEASE_VERSION }}
file: ./hosting/single/Dockerfile file: ./hosting/single/Dockerfile.v2

View File

@ -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/) - [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 ## 🏁 Get started
Deploy Budibase self-hosted in your existing infrastructure, using Docker, Kubernetes, and Digital Ocean. Deploy Budibase self-hosted in your existing infrastructure, using Docker, Kubernetes, and Digital Ocean.

View File

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

View File

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

View File

@ -33,7 +33,6 @@
"build:sdk": "lerna run --stream build:sdk", "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", "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": "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", "restore": "yarn run clean && yarn && yarn run build",
"nuke": "yarn run nuke:packages && yarn run nuke:docker", "nuke": "yarn run nuke:packages && yarn run nuke:docker",
"nuke:packages": "yarn run restore", "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: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", "lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint",
"build:specs": "lerna run --stream specs", "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": "node hosting/scripts/airgapped/airgappedDockerBuild",
"build:docker:airgap:single": "SINGLE_IMAGE=1 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 -", "build:digitalocean": "cd hosting/digitalocean && ./build.sh && cd -",

View File

@ -119,8 +119,8 @@ export class Writethrough {
this.writeRateMs = writeRateMs this.writeRateMs = writeRateMs
} }
async put(doc: any) { async put(doc: any, writeRateMs: number = this.writeRateMs) {
return put(this.db, doc, this.writeRateMs) return put(this.db, doc, writeRateMs)
} }
async get(id: string) { async get(id: string) {

View File

@ -122,7 +122,9 @@ export async function roleToNumber(id?: string) {
if (isBuiltin(id)) { if (isBuiltin(id)) {
return builtinRoleToNumber(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) { for (let role of hierarchy) {
if (isBuiltin(role?.inherits)) { if (isBuiltin(role?.inherits)) {
return builtinRoleToNumber(role.inherits) + 1 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. * 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 // admins have access to all roles
if (userRoleId === BUILTIN_IDS.ADMIN) { if (userRoleId === BUILTIN_IDS.ADMIN) {
return getAllRoles() return getAllRoles()
} }
let currentRole = await getRole(userRoleId) let currentRole = await getRole(userRoleId, opts)
let roles = currentRole ? [currentRole] : [] let roles = currentRole ? [currentRole] : []
let roleIds = [userRoleId] let roleIds = [userRoleId]
// get all the inherited roles // 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 * 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. * 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 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 * @returns returns an ordered array of the roles, with the first being their
* highest level of access and the last being the lowest level. * highest level of access and the last being the lowest level.
*/ */
export async function getUserRoleHierarchy(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 // 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 // this function checks that the provided permissions are in an array format

View File

@ -25,12 +25,17 @@ import {
import { import {
getAccountHolderFromUserIds, getAccountHolderFromUserIds,
isAdmin, isAdmin,
isCreator,
validateUniqueUser, validateUniqueUser,
} from "./utils" } from "./utils"
import { searchExistingEmails } from "./lookup" import { searchExistingEmails } from "./lookup"
import { hash } from "../utils" 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 GroupUpdateFn = (groupId: string, userIds: string[]) => Promise<any>
type FeatureFn = () => Promise<Boolean> type FeatureFn = () => Promise<Boolean>
type GroupGetFn = (ids: string[]) => Promise<UserGroup[]> type GroupGetFn = (ids: string[]) => Promise<UserGroup[]>
@ -245,7 +250,8 @@ export class UserDB {
} }
const change = dbUser ? 0 : 1 // no change if there is existing user 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) await validateUniqueUser(email, tenantId)
let builtUser = await UserDB.buildUser(user, opts, tenantId, dbUser) let builtUser = await UserDB.buildUser(user, opts, tenantId, dbUser)
@ -307,6 +313,7 @@ export class UserDB {
let usersToSave: any[] = [] let usersToSave: any[] = []
let newUsers: any[] = [] let newUsers: any[] = []
let newCreators: any[] = []
const emails = newUsersRequested.map((user: User) => user.email) const emails = newUsersRequested.map((user: User) => user.email)
const existingEmails = await searchExistingEmails(emails) const existingEmails = await searchExistingEmails(emails)
@ -327,59 +334,66 @@ export class UserDB {
} }
newUser.userGroups = groups newUser.userGroups = groups
newUsers.push(newUser) newUsers.push(newUser)
if (isCreator(newUser)) {
newCreators.push(newUser)
}
} }
const account = await accountSdk.getAccountByTenantId(tenantId) const account = await accountSdk.getAccountByTenantId(tenantId)
return UserDB.quotas.addUsers(newUsers.length, async () => { return UserDB.quotas.addUsers(
// create the promises array that will be called by bulkDocs newUsers.length,
newUsers.forEach((user: any) => { newCreators.length,
usersToSave.push( async () => {
UserDB.buildUser( // create the promises array that will be called by bulkDocs
user, newUsers.forEach((user: any) => {
{ usersToSave.push(
hashPassword: true, UserDB.buildUser(
requirePassword: user.requirePassword, user,
}, {
tenantId, hashPassword: true,
undefined, // no dbUser requirePassword: user.requirePassword,
account },
tenantId,
undefined, // no dbUser
account
)
) )
) })
})
const usersToBulkSave = await Promise.all(usersToSave) const usersToBulkSave = await Promise.all(usersToSave)
await usersCore.bulkUpdateGlobalUsers(usersToBulkSave) await usersCore.bulkUpdateGlobalUsers(usersToBulkSave)
// Post-processing of bulk added users, e.g. events and cache operations // Post-processing of bulk added users, e.g. events and cache operations
for (const user of usersToBulkSave) { for (const user of usersToBulkSave) {
// TODO: Refactor to bulk insert users into the info db // TODO: Refactor to bulk insert users into the info db
// instead of relying on looping tenant creation // instead of relying on looping tenant creation
await platform.users.addUser(tenantId, user._id, user.email) await platform.users.addUser(tenantId, user._id, user.email)
await eventHelpers.handleSaveEvents(user, undefined) 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 { return {
_id: user._id, successful: saved,
email: user.email, 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> { static async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> {
@ -419,11 +433,12 @@ export class UserDB {
_deleted: true, _deleted: true,
})) }))
const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete) const dbResponse = await usersCore.bulkUpdateGlobalUsers(toDelete)
const creatorsToDelete = usersToDelete.filter(isCreator)
await UserDB.quotas.removeUsers(toDelete.length)
for (let user of usersToDelete) { for (let user of usersToDelete) {
await bulkDeleteProcessing(user) await bulkDeleteProcessing(user)
} }
await UserDB.quotas.removeUsers(toDelete.length, creatorsToDelete.length)
// Build Response // Build Response
// index users by id // index users by id
@ -472,7 +487,8 @@ export class UserDB {
await db.remove(userId, dbUser._rev) 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 eventHelpers.handleDeleteEvents(dbUser)
await cache.user.invalidateUser(userId) await cache.user.invalidateUser(userId)
await sessions.invalidateSessions(userId, { reason: "deletion" }) await sessions.invalidateSessions(userId, { reason: "deletion" })

View File

@ -14,11 +14,11 @@ import {
} from "../db" } from "../db"
import { import {
BulkDocsResponse, BulkDocsResponse,
ContextUser,
SearchQuery, SearchQuery,
SearchQueryOperators, SearchQueryOperators,
SearchUsersRequest, SearchUsersRequest,
User, User,
ContextUser,
DatabaseQueryOpts, DatabaseQueryOpts,
} from "@budibase/types" } from "@budibase/types"
import { getGlobalDB } from "../context" import { getGlobalDB } from "../context"

View File

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

View File

@ -123,6 +123,10 @@ export function customer(): Customer {
export function subscription(): Subscription { export function subscription(): Subscription {
return { return {
amount: 10000, amount: 10000,
amounts: {
user: 10000,
creator: 0,
},
cancelAt: undefined, cancelAt: undefined,
currency: "usd", currency: "usd",
currentPeriodEnd: 0, currentPeriodEnd: 0,
@ -131,6 +135,10 @@ export function subscription(): Subscription {
duration: PriceDuration.MONTHLY, duration: PriceDuration.MONTHLY,
pastDueAt: undefined, pastDueAt: undefined,
quantity: 0, quantity: 0,
quantities: {
user: 0,
creator: 0,
},
status: "active", status: "active",
} }
} }

View File

@ -159,8 +159,10 @@
{#if selectedImage.size} {#if selectedImage.size}
<div class="filesize"> <div class="filesize">
{#if selectedImage.size <= BYTES_IN_MB} {#if selectedImage.size <= BYTES_IN_MB}
{`${selectedImage.size / BYTES_IN_KB} KB`} {`${(selectedImage.size / BYTES_IN_KB).toFixed(1)} KB`}
{:else}{`${selectedImage.size / BYTES_IN_MB} MB`}{/if} {:else}{`${(selectedImage.size / BYTES_IN_MB).toFixed(
1
)} MB`}{/if}
</div> </div>
{/if} {/if}
{#if !disabled} {#if !disabled}
@ -203,8 +205,8 @@
{#if file.size} {#if file.size}
<div class="filesize"> <div class="filesize">
{#if file.size <= BYTES_IN_MB} {#if file.size <= BYTES_IN_MB}
{`${file.size / BYTES_IN_KB} KB`} {`${(file.size / BYTES_IN_KB).toFixed(1)} KB`}
{:else}{`${file.size / BYTES_IN_MB} MB`}{/if} {:else}{`${(file.size / BYTES_IN_MB).toFixed(1)} MB`}{/if}
</div> </div>
{/if} {/if}
{#if !disabled} {#if !disabled}

View File

@ -2,14 +2,14 @@ import sanitizeUrl from "./utils/sanitizeUrl"
import { Screen } from "./utils/Screen" import { Screen } from "./utils/Screen"
import { Component } from "./utils/Component" import { Component } from "./utils/Component"
export default function (datasources) { export default function (datasources, mode = "table") {
if (!Array.isArray(datasources)) { if (!Array.isArray(datasources)) {
return [] return []
} }
return datasources.map(datasource => { return datasources.map(datasource => {
return { return {
name: `${datasource.label} - List`, name: `${datasource.label} - List`,
create: () => createScreen(datasource), create: () => createScreen(datasource, mode),
id: ROW_LIST_TEMPLATE, id: ROW_LIST_TEMPLATE,
resourceId: datasource.resourceId, resourceId: datasource.resourceId,
} }
@ -40,10 +40,24 @@ const generateTableBlock = datasource => {
return tableBlock 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() return new Screen()
.route(rowListUrl(datasource)) .route(rowListUrl(datasource))
.instanceName(`${datasource.label} - List`) .instanceName(`${datasource.label} - List`)
.addChild(generateTableBlock(datasource)) .addChild(
mode === "table"
? generateTableBlock(datasource)
: generateGridBlock(datasource)
)
.json() .json()
} }

View File

@ -24,17 +24,23 @@
let selectedRows = [] let selectedRows = []
let customRenderers = [] 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) $: selectedRows, dispatch("selectionUpdated", selectedRows)
$: isUsersTable = tableId === TableNames.USERS $: isUsersTable = tableId === TableNames.USERS
$: data && resetSelectedRows() $: data && resetSelectedRows()
$: {
Object.values(schema || {}).forEach(col => {
if (!canBeSortColumn(col.type)) {
col.sortable = false
}
})
}
$: { $: {
if (isUsersTable) { if (isUsersTable) {
customRenderers = [ customRenderers = [
@ -44,24 +50,24 @@
}, },
] ]
UNEDITABLE_USER_FIELDS.forEach(field => { UNEDITABLE_USER_FIELDS.forEach(field => {
if (schema[field]) { if (parsedSchema[field]) {
schema[field].editable = false parsedSchema[field].editable = false
} }
}) })
if (schema.email) { if (parsedSchema.email) {
schema.email.displayName = "Email" parsedSchema.email.displayName = "Email"
} }
if (schema.roleId) { if (parsedSchema.roleId) {
schema.roleId.displayName = "Role" parsedSchema.roleId.displayName = "Role"
} }
if (schema.firstName) { if (parsedSchema.firstName) {
schema.firstName.displayName = "First Name" parsedSchema.firstName.displayName = "First Name"
} }
if (schema.lastName) { if (parsedSchema.lastName) {
schema.lastName.displayName = "Last Name" parsedSchema.lastName.displayName = "Last Name"
} }
if (schema.status) { if (parsedSchema.status) {
schema.status.displayName = "Status" parsedSchema.status.displayName = "Status"
} }
} }
} }
@ -97,7 +103,7 @@
<div class="table-wrapper"> <div class="table-wrapper">
<Table <Table
{data} {data}
{schema} schema={parsedSchema}
{loading} {loading}
{customRenderers} {customRenderers}
{rowCount} {rowCount}

View File

@ -20,6 +20,7 @@
let type = "internal" let type = "internal"
$: name = view.name $: name = view.name
$: schema = view.schema
$: calculation = view.calculation $: calculation = view.calculation
$: supportedFormats = Object.values(ROW_EXPORT_FORMATS).filter(key => { $: supportedFormats = Object.values(ROW_EXPORT_FORMATS).filter(key => {
@ -61,7 +62,7 @@
<Table <Table
title={decodeURI(name)} title={decodeURI(name)}
schema={view.schema} {schema}
tableId={view.tableId} tableId={view.tableId}
{data} {data}
{loading} {loading}

View File

@ -777,7 +777,8 @@
disabled={deleteColName !== originalName} disabled={deleteColName !== originalName}
> >
<p> <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 Your data will be deleted and this action cannot be undone - enter the column
name to confirm. name to confirm.
</p> </p>
@ -810,4 +811,11 @@
gap: 8px; gap: 8px;
display: flex; display: flex;
} }
b {
transition: color 130ms ease-out;
}
b:hover {
cursor: pointer;
color: var(--spectrum-global-color-gray-900);
}
</style> </style>

View File

@ -23,7 +23,7 @@
try { try {
return await API.uploadBuilderAttachment(data) return await API.uploadBuilderAttachment(data)
} catch (error) { } catch (error) {
notifications.error("Failed to upload attachment") notifications.error(error.message || "Failed to upload attachment")
return [] return []
} }
} }

View File

@ -39,7 +39,15 @@
allowCreator allowCreator
) => { ) => {
if (allowedRoles?.length) { 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] let newRoles = [...roles]
@ -129,8 +137,9 @@
getOptionColour={getColor} getOptionColour={getColor}
getOptionIcon={getIcon} getOptionIcon={getIcon}
isOptionEnabled={option => isOptionEnabled={option =>
option._id !== Constants.Roles.CREATOR || (option._id !== Constants.Roles.CREATOR ||
$licensing.perAppBuildersEnabled} $licensing.perAppBuildersEnabled) &&
option.enabled !== false}
{placeholder} {placeholder}
{error} {error}
/> />

View File

@ -23,6 +23,7 @@ import BasicColumnEditor from "./controls/ColumnEditor/BasicColumnEditor.svelte"
import GridColumnEditor from "./controls/ColumnEditor/GridColumnEditor.svelte" import GridColumnEditor from "./controls/ColumnEditor/GridColumnEditor.svelte"
import BarButtonList from "./controls/BarButtonList.svelte" import BarButtonList from "./controls/BarButtonList.svelte"
import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte" import FieldConfiguration from "./controls/FieldConfiguration/FieldConfiguration.svelte"
import ButtonConfiguration from "./controls/ButtonConfiguration/ButtonConfiguration.svelte"
import RelationshipFilterEditor from "./controls/RelationshipFilterEditor.svelte" import RelationshipFilterEditor from "./controls/RelationshipFilterEditor.svelte"
const componentMap = { const componentMap = {
@ -48,6 +49,7 @@ const componentMap = {
"filter/relationship": RelationshipFilterEditor, "filter/relationship": RelationshipFilterEditor,
url: URLSelect, url: URLSelect,
fieldConfiguration: FieldConfiguration, fieldConfiguration: FieldConfiguration,
buttonConfiguration: ButtonConfiguration,
columns: ColumnEditor, columns: ColumnEditor,
"columns/basic": BasicColumnEditor, "columns/basic": BasicColumnEditor,
"columns/grid": GridColumnEditor, "columns/grid": GridColumnEditor,

View File

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

View File

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

View File

@ -1,10 +1,10 @@
<script> <script>
import { Icon } from "@budibase/bbui"
import { dndzone } from "svelte-dnd-action" import { dndzone } from "svelte-dnd-action"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { generate } from "shortid" import { generate } from "shortid"
import { setContext } from "svelte" 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 items = []
export let showHandle = true export let showHandle = true
@ -12,6 +12,7 @@
export let listTypeProps = {} export let listTypeProps = {}
export let listItemKey export let listItemKey
export let draggable = true export let draggable = true
export let focus
let store = writable({ let store = writable({
selected: null, selected: null,
@ -27,6 +28,10 @@
setContext("draggable", store) setContext("draggable", store)
$: if (focus && store) {
get(store).actions.select(focus)
}
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const flipDurationMs = 150 const flipDurationMs = 150
@ -82,13 +87,16 @@
> >
{#each draggableItems as draggable (draggable.id)} {#each draggableItems as draggable (draggable.id)}
<li <li
on:mousedown={() => {
get(store).actions.select()
}}
bind:this={anchors[draggable.id]} bind:this={anchors[draggable.id]}
class:highlighted={draggable.id === $store.selected} class:highlighted={draggable.id === $store.selected}
> >
<div class="left-content"> <div class="left-content">
{#if showHandle} {#if showHandle}
<div class="handle" aria-label="drag-handle"> <div class="handle">
<Icon name="DragHandle" size="XL" /> <DragHandle />
</div> </div>
{/if} {/if}
</div> </div>
@ -142,8 +150,9 @@
border-top-right-radius: 4px; border-top-right-radius: 4px;
} }
.list-wrap > li:last-child { .list-wrap > li:last-child {
border-top-left-radius: var(--spectrum-table-regular-border-radius); border-bottom-left-radius: 4px;
border-top-right-radius: var(--spectrum-table-regular-border-radius); border-bottom-right-radius: 4px;
border-bottom: 0px;
} }
.right-content { .right-content {
flex: 1; flex: 1;
@ -153,4 +162,15 @@
padding-left: var(--spacing-s); padding-left: var(--spacing-s);
padding-right: 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> </style>

View File

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

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -3,31 +3,35 @@
import { store } from "builderStore" import { store } from "builderStore"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { createEventDispatcher } from "svelte" 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" import { getContext } from "svelte"
export let anchor export let anchor
export let field export let componentInstance
export let componentBindings export let componentBindings
export let bindings export let bindings
export let parseSettings
const draggable = getContext("draggable") const draggable = getContext("draggable")
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let popover let popover
let drawers = [] let drawers = []
let pseudoComponentInstance
let open = false 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() popover.hide()
} }
$: if (field) { // Open automatically if the component is marked as selected
pseudoComponentInstance = field $: if (!open && $draggable.selected === componentInstance._id && popover) {
popover.show()
open = true
} }
$: componentDef = store.actions.components.getDefinition( $: componentDef = store.actions.components.getDefinition(
pseudoComponentInstance._component componentInstance._component
) )
$: parsedComponentDef = processComponentDefinitionSettings(componentDef) $: parsedComponentDef = processComponentDefinitionSettings(componentDef)
@ -36,17 +40,16 @@
return {} return {}
} }
const clone = cloneDeep(componentDef) const clone = cloneDeep(componentDef)
const updatedSettings = clone.settings
.filter(setting => setting.key !== "field") if (typeof parseSettings === "function") {
.map(setting => { clone.settings = parseSettings(clone.settings)
return { ...setting, nested: true } }
})
clone.settings = updatedSettings
return clone return clone
} }
const updateSetting = async (setting, value) => { const updateSetting = async (setting, value) => {
const nestedComponentInstance = cloneDeep(pseudoComponentInstance) const nestedComponentInstance = cloneDeep(componentInstance)
const patchFn = store.actions.components.updateComponentSetting( const patchFn = store.actions.components.updateComponentSetting(
setting.key, setting.key,
@ -54,12 +57,26 @@
) )
patchFn(nestedComponentInstance) patchFn(nestedComponentInstance)
const update = { dispatch("change", nestedComponentInstance)
...nestedComponentInstance, }
active: pseudoComponentInstance.active,
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> </script>
@ -79,11 +96,11 @@
bind:this={popover} bind:this={popover}
on:open={() => { on:open={() => {
drawers = [] drawers = []
$draggable.actions.select(field._id) $draggable.actions.select(componentInstance._id)
}} }}
on:close={() => { on:close={() => {
open = false open = false
if ($draggable.selected == field._id) { if ($draggable.selected == componentInstance._id) {
$draggable.actions.select() $draggable.actions.select()
} }
}} }}
@ -92,33 +109,13 @@
showPopover={drawers.length == 0} showPopover={drawers.length == 0}
clickOutsideOverride={drawers.length > 0} clickOutsideOverride={drawers.length > 0}
maxHeight={600} maxHeight={600}
handlePostionUpdate={(anchorBounds, eleBounds, cfg) => { handlePostionUpdate={customPositionHandler}
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 }
}}
> >
<span class="popover-wrap"> <span class="popover-wrap">
<Layout noPadding noGap> <Layout noPadding noGap>
<div class="type-icon"> <slot name="header" />
<Icon name={parsedComponentDef.icon} />
<span>{field.field}</span>
</div>
<ComponentSettingsSection <ComponentSettingsSection
componentInstance={pseudoComponentInstance} {componentInstance}
componentDefinition={parsedComponentDef} componentDefinition={parsedComponentDef}
isScreen={false} isScreen={false}
onUpdateSetting={updateSetting} onUpdateSetting={updateSetting}
@ -141,20 +138,4 @@
.popover-wrap { .popover-wrap {
background-color: var(--spectrum-alias-background-color-primary); 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> </style>

View File

@ -7,7 +7,7 @@
getComponentBindableProperties, getComponentBindableProperties,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import { currentAsset } from "builderStore" import { currentAsset } from "builderStore"
import DraggableList from "../DraggableList.svelte" import DraggableList from "../DraggableList/DraggableList.svelte"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { store, selectedScreen } from "builderStore" import { store, selectedScreen } from "builderStore"
import FieldSetting from "./FieldSetting.svelte" import FieldSetting from "./FieldSetting.svelte"
@ -50,7 +50,7 @@
updateSanitsedFields(sanitisedValue) updateSanitsedFields(sanitisedValue)
unconfigured = buildUnconfiguredOptions(schema, sanitisedFields) unconfigured = buildUnconfiguredOptions(schema, sanitisedFields)
fieldList = [...sanitisedFields, ...unconfigured] fieldList = [...sanitisedFields, ...unconfigured]
.map(buildSudoInstance) .map(buildPseudoInstance)
.filter(x => x != null) .filter(x => x != null)
} }
@ -104,7 +104,7 @@
}) })
} }
const buildSudoInstance = instance => { const buildPseudoInstance = instance => {
if (instance._component) { if (instance._component) {
return instance return instance
} }

View File

@ -1,8 +1,11 @@
<script> <script>
import EditFieldPopover from "./EditFieldPopover.svelte" import EditComponentPopover from "../EditComponentPopover.svelte"
import { Toggle } from "@budibase/bbui" import { Toggle, Icon } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import { store } from "builderStore"
import { runtimeToReadableBinding } from "builderStore/dataBinding"
import { isJSBinding } from "@budibase/string-templates"
export let item export let item
export let componentBindings export let componentBindings
@ -16,18 +19,43 @@
dispatch("change", { ...cloneDeep(item), active: e.detail }) 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> </script>
<div class="list-item-body"> <div class="list-item-body">
<div class="list-item-left"> <div class="list-item-left">
<EditFieldPopover <EditComponentPopover
{anchor} {anchor}
field={item} componentInstance={item}
{componentBindings} {componentBindings}
{bindings} {bindings}
{parseSettings}
on:change 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>
<div class="list-item-right"> <div class="list-item-right">
<Toggle on:change={onToggle(item)} text="" value={item.active} thin /> <Toggle on:change={onToggle(item)} text="" value={item.active} thin />
@ -53,4 +81,20 @@
.list-item-body { .list-item-body {
justify-content: space-between; 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> </style>

View File

@ -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() { async function runQuery() {
try { try {
await validateQuery()
response = await queries.preview(buildQuery()) response = await queries.preview(buildQuery())
if (response.rows.length === 0) { if (response.rows.length === 0) {
notifications.info("Request did not return any data") notifications.info("Request did not return any data")

View File

@ -516,6 +516,13 @@
} }
return null return null
} }
const parseRole = user => {
if (user.isAdminOrGlobalBuilder) {
return Constants.Roles.CREATOR
}
return user.role
}
</script> </script>
<svelte:window on:keydown={handleKeyDown} /> <svelte:window on:keydown={handleKeyDown} />
@ -725,7 +732,7 @@
<RoleSelect <RoleSelect
footer={getRoleFooter(user)} footer={getRoleFooter(user)}
placeholder={false} placeholder={false}
value={user.role} value={parseRole(user)}
allowRemove={user.role && !user.group} allowRemove={user.role && !user.group}
allowPublic={false} allowPublic={false}
allowCreator={true} allowCreator={true}
@ -744,7 +751,7 @@
autoWidth autoWidth
align="right" align="right"
allowedRoles={user.isAdminOrGlobalBuilder allowedRoles={user.isAdminOrGlobalBuilder
? [Constants.Roles.ADMIN] ? [Constants.Roles.CREATOR]
: null} : null}
/> />
</div> </div>

View File

@ -53,7 +53,8 @@
} }
.alert-wrap { .alert-wrap {
display: flex; display: flex;
width: 100%; flex: 0 0 auto;
margin: -28px -40px 14px -40px;
} }
.alert-wrap :global(> *) { .alert-wrap :global(> *) {
flex: 1; flex: 1;

View File

@ -91,7 +91,12 @@
/> />
{/if} {/if}
{#if section == "styles"} {#if section == "styles"}
<DesignSection {componentInstance} {componentDefinition} {bindings} /> <DesignSection
{componentInstance}
{componentBindings}
{componentDefinition}
{bindings}
/>
<CustomStylesSection <CustomStylesSection
{componentInstance} {componentInstance}
{componentDefinition} {componentDefinition}

View File

@ -16,18 +16,32 @@
export let isScreen = false export let isScreen = false
export let onUpdateSetting export let onUpdateSetting
export let showSectionTitle = true 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 settings = definition?.settings ?? []
const generalSettings = settings.filter(setting => !setting.section) const generalSettings = settings.filter(
const customSections = settings.filter(setting => setting.section) setting => !setting.section && setting.tag === tag
)
const customSections = settings.filter(
setting => setting.section && setting.tag === tag
)
let sections = [ let sections = [
{ ...(generalSettings?.length
name: "General", ? [
settings: generalSettings, {
}, name: "General",
settings: generalSettings,
},
]
: []),
...(customSections || []), ...(customSections || []),
] ]
@ -132,7 +146,7 @@
<div class="section-info"> <div class="section-info">
<InfoDisplay body={section.info} /> <InfoDisplay body={section.info} />
</div> </div>
{:else if idx === 0 && section.name === "General" && componentDefinition.info} {:else if idx === 0 && section.name === "General" && componentDefinition?.info && !tag}
<InfoDisplay <InfoDisplay
title={componentDefinition.name} title={componentDefinition.name}
body={componentDefinition.info} body={componentDefinition.info}
@ -181,7 +195,7 @@
</DetailSummary> </DetailSummary>
{/if} {/if}
{/each} {/each}
{#if componentDefinition?.block} {#if componentDefinition?.block && !tag}
<DetailSummary name="Eject" collapsible={false}> <DetailSummary name="Eject" collapsible={false}>
<EjectBlockButton /> <EjectBlockButton />
</DetailSummary> </DetailSummary>

View File

@ -1,10 +1,12 @@
<script> <script>
import StyleSection from "./StyleSection.svelte" import StyleSection from "./StyleSection.svelte"
import * as ComponentStyles from "./componentStyles" import * as ComponentStyles from "./componentStyles"
import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
export let componentDefinition export let componentDefinition
export let componentInstance export let componentInstance
export let bindings export let bindings
export let componentBindings
const getStyles = def => { const getStyles = def => {
if (!def?.styles?.length) { if (!def?.styles?.length) {
@ -22,6 +24,19 @@
$: styles = getStyles(componentDefinition) $: styles = getStyles(componentDefinition)
</script> </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} {#if styles?.length > 0}
{#each styles as style} {#each styles as style}
<StyleSection <StyleSection

View File

@ -36,6 +36,7 @@
"heading", "heading",
"text", "text",
"button", "button",
"buttongroup",
"tag", "tag",
"spectrumcard", "spectrumcard",
"cardstat", "cardstat",

View File

@ -12,6 +12,7 @@
import { capitalise } from "helpers" import { capitalise } from "helpers"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
let mode
let pendingScreen let pendingScreen
// Modal refs // Modal refs
@ -100,14 +101,15 @@
} }
// Handler for NewScreenModal // Handler for NewScreenModal
export const show = mode => { export const show = newMode => {
mode = newMode
selectedTemplates = null selectedTemplates = null
blankScreenUrl = null blankScreenUrl = null
screenMode = mode screenMode = mode
pendingScreen = null pendingScreen = null
screenAccessRole = Roles.BASIC screenAccessRole = Roles.BASIC
if (mode === "table") { if (mode === "table" || mode === "grid") {
datasourceModal.show() datasourceModal.show()
} else if (mode === "blank") { } else if (mode === "blank") {
let templates = getTemplates($tables.list) let templates = getTemplates($tables.list)
@ -123,6 +125,7 @@
// Handler for DatasourceModal confirmation, move to screen access select // Handler for DatasourceModal confirmation, move to screen access select
const confirmScreenDatasources = async ({ templates }) => { const confirmScreenDatasources = async ({ templates }) => {
console.log(templates)
selectedTemplates = templates selectedTemplates = templates
screenAccessRoleModal.show() screenAccessRoleModal.show()
} }
@ -177,6 +180,7 @@
<Modal bind:this={datasourceModal} autoFocus={false}> <Modal bind:this={datasourceModal} autoFocus={false}>
<DatasourceModal <DatasourceModal
{mode}
onConfirm={confirmScreenDatasources} onConfirm={confirmScreenDatasources}
initialScreens={!selectedTemplates ? [] : [...selectedTemplates]} initialScreens={!selectedTemplates ? [] : [...selectedTemplates]}
/> />

View File

@ -7,6 +7,7 @@
import rowListScreen from "builderStore/store/screenTemplates/rowListScreen" import rowListScreen from "builderStore/store/screenTemplates/rowListScreen"
import DatasourceTemplateRow from "./DatasourceTemplateRow.svelte" import DatasourceTemplateRow from "./DatasourceTemplateRow.svelte"
export let mode
export let onCancel export let onCancel
export let onConfirm export let onConfirm
export let initialScreens = [] export let initialScreens = []
@ -24,7 +25,10 @@
screen => screen.resourceId !== resourceId screen => screen.resourceId !== resourceId
) )
} else { } else {
selectedScreens = [...selectedScreens, rowListScreen([datasource])[0]] selectedScreens = [
...selectedScreens,
rowListScreen([datasource], mode)[0],
]
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -3,6 +3,7 @@
import CreationPage from "components/common/CreationPage.svelte" import CreationPage from "components/common/CreationPage.svelte"
import blankImage from "./blank.png" import blankImage from "./blank.png"
import tableImage from "./table.png" import tableImage from "./table.png"
import gridImage from "./grid.png"
import CreateScreenModal from "./CreateScreenModal.svelte" import CreateScreenModal from "./CreateScreenModal.svelte"
import { store } from "builderStore" import { store } from "builderStore"
@ -43,6 +44,16 @@
<Body size="XS">View, edit and delete rows on a table</Body> <Body size="XS">View, edit and delete rows on a table</Body>
</div> </div>
</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> </div>
</CreationPage> </CreationPage>
</div> </div>

View File

@ -258,6 +258,186 @@
"description": "Contains your app screens", "description": "Contains your app screens",
"static": true "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": { "button": {
"name": "Button", "name": "Button",
"description": "A basic html button that is ready for styling", "description": "A basic html button that is ready for styling",
@ -2409,7 +2589,6 @@
"key": "disabled", "key": "disabled",
"defaultValue": false "defaultValue": false
}, },
{ {
"type": "text", "type": "text",
"label": "Initial form step", "label": "Initial form step",
@ -5391,38 +5570,6 @@
"section": true, "section": true,
"name": "Fields", "name": "Fields",
"settings": [ "settings": [
{
"type": "select",
"label": "Align labels",
"key": "labelPosition",
"defaultValue": "left",
"options": [
{
"label": "Left",
"value": "left"
},
{
"label": "Above",
"value": "above"
}
]
},
{
"type": "select",
"label": "Size",
"key": "size",
"options": [
{
"label": "Medium",
"value": "spectrum--medium"
},
{
"label": "Large",
"value": "spectrum--large"
}
],
"defaultValue": "spectrum--medium"
},
{ {
"type": "fieldConfiguration", "type": "fieldConfiguration",
"key": "fields", "key": "fields",
@ -5442,6 +5589,40 @@
} }
} }
] ]
},
{
"tag": "style",
"type": "select",
"label": "Align labels",
"key": "labelPosition",
"defaultValue": "left",
"options": [
{
"label": "Left",
"value": "left"
},
{
"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": [ "context": [
@ -5743,4 +5924,4 @@
} }
] ]
} }
} }

View File

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

View File

@ -19,6 +19,7 @@ export { default as dataprovider } from "./DataProvider.svelte"
export { default as divider } from "./Divider.svelte" export { default as divider } from "./Divider.svelte"
export { default as screenslot } from "./ScreenSlot.svelte" export { default as screenslot } from "./ScreenSlot.svelte"
export { default as button } from "./Button.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 repeater } from "./Repeater.svelte"
export { default as text } from "./Text.svelte" export { default as text } from "./Text.svelte"
export { default as layout } from "./Layout.svelte" export { default as layout } from "./Layout.svelte"

View File

@ -103,7 +103,6 @@ const fetchRowHandler = async action => {
const deleteRowHandler = async action => { const deleteRowHandler = async action => {
const { tableId, rowId: rowConfig, notificationOverride } = action.parameters const { tableId, rowId: rowConfig, notificationOverride } = action.parameters
if (tableId && rowConfig) { if (tableId && rowConfig) {
try { try {
let requestConfig let requestConfig
@ -129,9 +128,11 @@ const deleteRowHandler = async action => {
requestConfig = [parsedRowConfig] requestConfig = [parsedRowConfig]
} else if (Array.isArray(parsedRowConfig)) { } else if (Array.isArray(parsedRowConfig)) {
requestConfig = 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") notificationStore.actions.warning("No valid rows were supplied")
return false return false
} }

View File

@ -55,7 +55,7 @@
try { try {
return await API.uploadBuilderAttachment(data) return await API.uploadBuilderAttachment(data)
} catch (error) { } catch (error) {
$notifications.error("Failed to upload attachment") $notifications.error(error.message || "Failed to upload attachment")
return [] return []
} }
} }

View File

@ -21,6 +21,7 @@
export let invertX = false export let invertX = false
export let invertY = false export let invertY = false
export let contentLines = 1 export let contentLines = 1
export let hidden = false
const emptyError = writable(null) const emptyError = writable(null)
@ -78,6 +79,7 @@
{focused} {focused}
{selectedUser} {selectedUser}
{readonly} {readonly}
{hidden}
error={$error} error={$error}
on:click={() => focusedCellId.set(cellId)} on:click={() => focusedCellId.set(cellId)}
on:contextmenu={e => menu.actions.open(cellId, e)} on:contextmenu={e => menu.actions.open(cellId, e)}

View File

@ -10,6 +10,7 @@
export let defaultHeight = false export let defaultHeight = false
export let center = false export let center = false
export let readonly = false export let readonly = false
export let hidden = false
$: style = getStyle(width, selectedUser) $: style = getStyle(width, selectedUser)
@ -30,6 +31,7 @@
class:error class:error
class:center class:center
class:readonly class:readonly
class:hidden
class:default-height={defaultHeight} class:default-height={defaultHeight}
class:selected-other={selectedUser != null} class:selected-other={selectedUser != null}
class:alt={rowIdx % 2 === 1} class:alt={rowIdx % 2 === 1}
@ -81,6 +83,9 @@
.cell.center { .cell.center {
align-items: center; align-items: center;
} }
.cell.hidden {
content-visibility: hidden;
}
/* Cell border */ /* Cell border */
.cell.focused:after, .cell.focused:after,

View File

@ -17,7 +17,7 @@
isResizing, isResizing,
rand, rand,
sort, sort,
renderedColumns, visibleColumns,
dispatch, dispatch,
subscribe, subscribe,
config, config,
@ -50,7 +50,7 @@
$: sortedBy = column.name === $sort.column $: sortedBy = column.name === $sort.column
$: canMoveLeft = orderable && idx > 0 $: canMoveLeft = orderable && idx > 0
$: canMoveRight = orderable && idx < $renderedColumns.length - 1 $: canMoveRight = orderable && idx < $visibleColumns.length - 1
$: sortingLabels = getSortingLabels(column.schema?.type) $: sortingLabels = getSortingLabels(column.schema?.type)
$: searchable = isColumnSearchable(column) $: searchable = isColumnSearchable(column)
$: resetSearchValue(column.name) $: resetSearchValue(column.name)

View File

@ -7,7 +7,7 @@
const { const {
bounds, bounds,
renderedRows, renderedRows,
renderedColumns, visibleColumns,
rowVerticalInversionIndex, rowVerticalInversionIndex,
hoveredRowId, hoveredRowId,
dispatch, dispatch,
@ -17,7 +17,7 @@
let body let body
$: renderColumnsWidth = $renderedColumns.reduce( $: columnsWidth = $visibleColumns.reduce(
(total, col) => (total += col.width), (total, col) => (total += col.width),
0 0
) )
@ -47,7 +47,7 @@
<div <div
class="blank" class="blank"
class:highlighted={$hoveredRowId === BlankRowID} class:highlighted={$hoveredRowId === BlankRowID}
style="width:{renderColumnsWidth}px" style="width:{columnsWidth}px"
on:mouseenter={$isDragging ? null : () => ($hoveredRowId = BlankRowID)} on:mouseenter={$isDragging ? null : () => ($hoveredRowId = BlankRowID)}
on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)} on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
on:click={() => dispatch("add-row-inline")} on:click={() => dispatch("add-row-inline")}

View File

@ -10,7 +10,7 @@
focusedCellId, focusedCellId,
reorder, reorder,
selectedRows, selectedRows,
renderedColumns, visibleColumns,
hoveredRowId, hoveredRowId,
selectedCellMap, selectedCellMap,
focusedRow, focusedRow,
@ -19,6 +19,7 @@
isDragging, isDragging,
dispatch, dispatch,
rows, rows,
columnRenderMap,
} = getContext("grid") } = getContext("grid")
$: rowSelected = !!$selectedRows[row._id] $: rowSelected = !!$selectedRows[row._id]
@ -34,7 +35,7 @@
on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)} on:mouseleave={$isDragging ? null : () => ($hoveredRowId = null)}
on:click={() => dispatch("rowclick", rows.actions.cleanRow(row))} 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}`} {@const cellId = `${row._id}-${column.name}`}
<DataCell <DataCell
{cellId} {cellId}
@ -51,6 +52,7 @@
selectedUser={$selectedCellMap[cellId]} selectedUser={$selectedCellMap[cellId]}
width={column.width} width={column.width}
contentLines={$contentLines} contentLines={$contentLines}
hidden={!$columnRenderMap[column.name]}
/> />
{/each} {/each}
</div> </div>

View File

@ -11,7 +11,6 @@
maxScrollLeft, maxScrollLeft,
bounds, bounds,
hoveredRowId, hoveredRowId,
hiddenColumnsWidth,
menu, menu,
} = getContext("grid") } = getContext("grid")
@ -23,10 +22,10 @@
let initialTouchX let initialTouchX
let initialTouchY let initialTouchY
$: style = generateStyle($scroll, $rowHeight, $hiddenColumnsWidth) $: style = generateStyle($scroll, $rowHeight)
const generateStyle = (scroll, rowHeight, hiddenWidths) => { const generateStyle = (scroll, rowHeight) => {
const offsetX = scrollHorizontally ? -1 * scroll.left + hiddenWidths : 0 const offsetX = scrollHorizontally ? -1 * scroll.left : 0
const offsetY = scrollVertically ? -1 * (scroll.top % rowHeight) : 0 const offsetY = scrollVertically ? -1 * (scroll.top % rowHeight) : 0
return `transform: translate3d(${offsetX}px, ${offsetY}px, 0);` return `transform: translate3d(${offsetX}px, ${offsetY}px, 0);`
} }

View File

@ -5,14 +5,14 @@
import HeaderCell from "../cells/HeaderCell.svelte" import HeaderCell from "../cells/HeaderCell.svelte"
import { TempTooltip, TooltipType } from "@budibase/bbui" import { TempTooltip, TooltipType } from "@budibase/bbui"
const { renderedColumns, config, hasNonAutoColumn, datasource, loading } = const { visibleColumns, config, hasNonAutoColumn, datasource, loading } =
getContext("grid") getContext("grid")
</script> </script>
<div class="header"> <div class="header">
<GridScrollWrapper scrollHorizontally> <GridScrollWrapper scrollHorizontally>
<div class="row"> <div class="row">
{#each $renderedColumns as column, idx} {#each $visibleColumns as column, idx}
<HeaderCell {column} {idx}> <HeaderCell {column} {idx}>
<slot name="edit-column" /> <slot name="edit-column" />
</HeaderCell> </HeaderCell>

View File

@ -2,17 +2,16 @@
import { getContext, onMount } from "svelte" import { getContext, onMount } from "svelte"
import { Icon, Popover, clickOutside } from "@budibase/bbui" import { Icon, Popover, clickOutside } from "@budibase/bbui"
const { renderedColumns, scroll, hiddenColumnsWidth, width, subscribe } = const { visibleColumns, scroll, width, subscribe } = getContext("grid")
getContext("grid")
let anchor let anchor
let open = false let open = false
$: columnsWidth = $renderedColumns.reduce( $: columnsWidth = $visibleColumns.reduce(
(total, col) => (total += col.width), (total, col) => (total += col.width),
0 0
) )
$: end = $hiddenColumnsWidth + columnsWidth - 1 - $scroll.left $: end = columnsWidth - 1 - $scroll.left
$: left = Math.min($width - 40, end) $: left = Math.min($width - 40, end)
const close = () => { const close = () => {
@ -34,7 +33,7 @@
<Popover <Popover
bind:open bind:open
{anchor} {anchor}
align={$renderedColumns.length ? "right" : "left"} align={$visibleColumns.length ? "right" : "left"}
offset={0} offset={0}
popoverTarget={document.getElementById(`add-column-button`)} popoverTarget={document.getElementById(`add-column-button`)}
customZindex={100} customZindex={100}

View File

@ -20,7 +20,7 @@
datasource, datasource,
subscribe, subscribe,
renderedRows, renderedRows,
renderedColumns, visibleColumns,
rowHeight, rowHeight,
hasNextPage, hasNextPage,
maxScrollTop, maxScrollTop,
@ -31,6 +31,7 @@
refreshing, refreshing,
config, config,
filter, filter,
columnRenderMap,
} = getContext("grid") } = getContext("grid")
let visible = false let visible = false
@ -38,7 +39,7 @@
let newRow let newRow
let offset = 0 let offset = 0
$: firstColumn = $stickyColumn || $renderedColumns[0] $: firstColumn = $stickyColumn || $visibleColumns[0]
$: width = GutterWidth + ($stickyColumn?.width || 0) $: width = GutterWidth + ($stickyColumn?.width || 0)
$: $datasource, (visible = false) $: $datasource, (visible = false)
$: invertY = shouldInvertY(offset, $rowVerticalInversionIndex, $renderedRows) $: invertY = shouldInvertY(offset, $rowVerticalInversionIndex, $renderedRows)
@ -211,29 +212,28 @@
<div class="normal-columns" transition:fade|local={{ duration: 130 }}> <div class="normal-columns" transition:fade|local={{ duration: 130 }}>
<GridScrollWrapper scrollHorizontally attachHandlers> <GridScrollWrapper scrollHorizontally attachHandlers>
<div class="row"> <div class="row">
{#each $renderedColumns as column, columnIdx} {#each $visibleColumns as column, columnIdx}
{@const cellId = `new-${column.name}`} {@const cellId = `new-${column.name}`}
{#key cellId} <DataCell
<DataCell {cellId}
{cellId} {column}
{column} {updateValue}
{updateValue} rowFocused
rowFocused row={newRow}
row={newRow} focused={$focusedCellId === cellId}
focused={$focusedCellId === cellId} width={column.width}
width={column.width} topRow={offset === 0}
topRow={offset === 0} invertX={columnIdx >= $columnHorizontalInversionIndex}
invertX={columnIdx >= $columnHorizontalInversionIndex} {invertY}
{invertY} hidden={!$columnRenderMap[column.name]}
> >
{#if column?.schema?.autocolumn} {#if column?.schema?.autocolumn}
<div class="readonly-overlay">Can't edit auto column</div> <div class="readonly-overlay">Can't edit auto column</div>
{/if} {/if}
{#if isAdding} {#if isAdding}
<div in:fade={{ duration: 130 }} class="loading-overlay" /> <div in:fade={{ duration: 130 }} class="loading-overlay" />
{/if} {/if}
</DataCell> </DataCell>
{/key}
{/each} {/each}
</div> </div>
</GridScrollWrapper> </GridScrollWrapper>

View File

@ -2,7 +2,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import { GutterWidth } from "../lib/constants" import { GutterWidth } from "../lib/constants"
const { resize, renderedColumns, stickyColumn, isReordering, scrollLeft } = const { resize, visibleColumns, stickyColumn, isReordering, scrollLeft } =
getContext("grid") getContext("grid")
$: offset = GutterWidth + ($stickyColumn?.width || 0) $: offset = GutterWidth + ($stickyColumn?.width || 0)
@ -26,7 +26,7 @@
<div class="resize-indicator" /> <div class="resize-indicator" />
</div> </div>
{/if} {/if}
{#each $renderedColumns as column} {#each $visibleColumns as column}
<div <div
class="resize-slider" class="resize-slider"
class:visible={activeColumn === column.name} class:visible={activeColumn === column.name}

View File

@ -1,4 +1,4 @@
import { derived, get } from "svelte/store" import { derived } from "svelte/store"
import { import {
MaxCellRenderHeight, MaxCellRenderHeight,
MaxCellRenderWidthOverflow, MaxCellRenderWidthOverflow,
@ -50,12 +50,11 @@ export const deriveStores = context => {
const interval = MinColumnWidth const interval = MinColumnWidth
return Math.round($scrollLeft / interval) * interval return Math.round($scrollLeft / interval) * interval
}) })
const renderedColumns = derived( const columnRenderMap = derived(
[visibleColumns, scrollLeftRounded, width], [visibleColumns, scrollLeftRounded, width],
([$visibleColumns, $scrollLeft, $width], set) => { ([$visibleColumns, $scrollLeft, $width]) => {
if (!$visibleColumns.length) { if (!$visibleColumns.length) {
set([]) return {}
return
} }
let startColIdx = 0 let startColIdx = 0
let rightEdge = $visibleColumns[0].width let rightEdge = $visibleColumns[0].width
@ -75,34 +74,16 @@ export const deriveStores = context => {
leftEdge += $visibleColumns[endColIdx].width leftEdge += $visibleColumns[endColIdx].width
endColIdx++ 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( // Only update the store if different
[renderedColumns, visibleColumns], let next = {}
([$renderedColumns, $visibleColumns]) => { $visibleColumns
const idx = $visibleColumns.findIndex( .slice(Math.max(0, startColIdx), endColIdx)
col => col.name === $renderedColumns[0]?.name .forEach(col => {
) next[col.name] = true
let width = 0 })
if (idx > 0) { return next
for (let i = 0; i < idx; i++) { }
width += $visibleColumns[i].width
}
}
return width
},
0
) )
// Determine the row index at which we should start vertically inverting cell // 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 // Determine the column index at which we should start horizontally inverting
// cell dropdowns // cell dropdowns
const columnHorizontalInversionIndex = derived( const columnHorizontalInversionIndex = derived(
[renderedColumns, scrollLeft, width], [visibleColumns, scrollLeft, width],
([$renderedColumns, $scrollLeft, $width]) => { ([$visibleColumns, $scrollLeft, $width]) => {
const cutoff = $width + $scrollLeft - ScrollBarSize * 3 const cutoff = $width + $scrollLeft - ScrollBarSize * 3
let inversionIdx = $renderedColumns.length let inversionIdx = $visibleColumns.length
for (let i = $renderedColumns.length - 1; i >= 0; i--, inversionIdx--) { for (let i = $visibleColumns.length - 1; i >= 0; i--, inversionIdx--) {
const rightEdge = $renderedColumns[i].left + $renderedColumns[i].width const rightEdge = $visibleColumns[i].left + $visibleColumns[i].width
if (rightEdge + MaxCellRenderWidthOverflow <= cutoff) { if (rightEdge + MaxCellRenderWidthOverflow <= cutoff) {
break break
} }
@ -148,8 +129,7 @@ export const deriveStores = context => {
scrolledRowCount, scrolledRowCount,
visualRowCapacity, visualRowCapacity,
renderedRows, renderedRows,
renderedColumns, columnRenderMap,
hiddenColumnsWidth,
rowVerticalInversionIndex, rowVerticalInversionIndex,
columnHorizontalInversionIndex, columnHorizontalInversionIndex,
} }

@ -1 +1 @@
Subproject commit d24c0dc3a30014cbe61860252aa48104cad36376 Subproject commit 3820c0c93a3e448e10a60a9feb5396844b537ca8

View File

@ -38,7 +38,7 @@ RUN apt update && apt upgrade -y \
COPY package.json . COPY package.json .
COPY dist/yarn.lock . 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 # Remove unneeded data from file system to reduce image size
&& yarn cache clean && apt-get remove -y --purge --auto-remove g++ make python \ && 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 && rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp

View File

@ -44,7 +44,7 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
WORKDIR /string-templates WORKDIR /string-templates
COPY packages/string-templates/package.json package.json COPY packages/string-templates/package.json package.json
RUN ../scripts/removeWorkspaceDependencies.sh 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 . COPY packages/string-templates .
@ -57,7 +57,7 @@ COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.
RUN chmod +x ./scripts/removeWorkspaceDependencies.sh RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
RUN ./scripts/removeWorkspaceDependencies.sh 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 \
# Remove unneeded data from file system to reduce image size # Remove unneeded data from file system to reduce image size
&& yarn cache clean && apt-get remove -y --purge --auto-remove g++ make python jq \ && 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 && rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp

View File

@ -18,7 +18,6 @@
"test": "bash scripts/test.sh", "test": "bash scripts/test.sh",
"test:memory": "jest --maxWorkers=2 --logHeapUsage --forceExit", "test:memory": "jest --maxWorkers=2 --logHeapUsage --forceExit",
"test:watch": "jest --watch", "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": "node dist/index.js",
"run:docker:cluster": "pm2-runtime start pm2.config.js", "run:docker:cluster": "pm2-runtime start pm2.config.js",
"dev:stack:up": "node scripts/dev/manage.js up", "dev:stack:up": "node scripts/dev/manage.js up",

View File

@ -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 { getUserMetadataParams, InternalTables } from "../../db/utils"
import { Database, Role, UserCtx, UserRoles } from "@budibase/types" import { Database, Role, UserCtx, UserRoles } from "@budibase/types"
import { sdk as sharedSdk } from "@budibase/shared-core" import { sdk as sharedSdk } from "@budibase/shared-core"
@ -143,4 +149,20 @@ export async function accessible(ctx: UserCtx) {
} else { } else {
ctx.body = await roles.getUserRoleIdHierarchy(roleId!) 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]
}
} }

View File

@ -1,3 +1,5 @@
import { ValidFileExtensions } from "@budibase/shared-core"
require("svelte/register") require("svelte/register")
import { join } from "../../../utilities/centralPath" import { join } from "../../../utilities/centralPath"
@ -11,34 +13,21 @@ import {
} from "../../../utilities/fileSystem" } from "../../../utilities/fileSystem"
import env from "../../../environment" import env from "../../../environment"
import { DocumentType } from "../../../db/utils" 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 AWS from "aws-sdk"
import fs from "fs" import fs from "fs"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import * as pro from "@budibase/pro" 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") 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) { export const toggleBetaUiFeature = async function (ctx: Ctx) {
const cookieName = `beta:${ctx.params.feature}` const cookieName = `beta:${ctx.params.feature}`
@ -72,23 +61,58 @@ export const serveBuilder = async function (ctx: Ctx) {
await send(ctx, ctx.file, { root: builderPath }) 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 const file = ctx.request?.files?.file
if (!file) {
throw new BadRequestError("No file provided")
}
let files = file && Array.isArray(file) ? Array.from(file) : [file] let files = file && Array.isArray(file) ? Array.from(file) : [file]
const uploads = files.map(async (file: any) => { ctx.body = await Promise.all(
const fileExtension = [...file.name.split(".")].pop() files.map(async file => {
// filenames converted to UUIDs so they are unique if (!file.name) {
const processedFileName = `${uuid.v4()}.${fileExtension}` throw new BadRequestError(
"Attempted to upload a file without a filename"
)
}
return prepareUpload({ const extension = [...file.name.split(".")].pop()
file, if (!extension) {
s3Key: `${context.getProdAppId()}/attachments/${processedFileName}`, throw new BadRequestError(
bucket: ObjectStoreBuckets.APPS, `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) { export const deleteObjects = async function (ctx: Ctx) {

View File

@ -11,128 +11,24 @@ const { PermissionType, PermissionLevel } = permissions
const router: Router = new Router() const router: Router = new Router()
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( .get(
"/api/:sourceId/:rowId/enrich", "/api/:sourceId/:rowId/enrich",
paramSubResource("sourceId", "rowId"), paramSubResource("sourceId", "rowId"),
authorized(PermissionType.TABLE, PermissionLevel.READ), authorized(PermissionType.TABLE, PermissionLevel.READ),
rowController.fetchEnrichedRow 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( .get(
"/api/:sourceId/rows", "/api/:sourceId/rows",
paramResource("sourceId"), paramResource("sourceId"),
authorized(PermissionType.TABLE, PermissionLevel.READ), authorized(PermissionType.TABLE, PermissionLevel.READ),
rowController.fetch 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( .get(
"/api/:sourceId/rows/:rowId", "/api/:sourceId/rows/:rowId",
paramSubResource("sourceId", "rowId"), paramSubResource("sourceId", "rowId"),
authorized(PermissionType.TABLE, PermissionLevel.READ), authorized(PermissionType.TABLE, PermissionLevel.READ),
rowController.find 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( .post(
"/api/:sourceId/search", "/api/:sourceId/search",
internalSearchValidator(), internalSearchValidator(),
@ -148,30 +44,6 @@ router
authorized(PermissionType.TABLE, PermissionLevel.READ), authorized(PermissionType.TABLE, PermissionLevel.READ),
rowController.search 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( .post(
"/api/:sourceId/rows", "/api/:sourceId/rows",
paramResource("sourceId"), paramResource("sourceId"),
@ -179,14 +51,6 @@ router
trimViewRowInfo, trimViewRowInfo,
rowController.save 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( .patch(
"/api/:sourceId/rows", "/api/:sourceId/rows",
paramResource("sourceId"), paramResource("sourceId"),
@ -194,52 +58,12 @@ router
trimViewRowInfo, trimViewRowInfo,
rowController.patch 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( .post(
"/api/:sourceId/rows/validate", "/api/:sourceId/rows/validate",
paramResource("sourceId"), paramResource("sourceId"),
authorized(PermissionType.TABLE, PermissionLevel.WRITE), authorized(PermissionType.TABLE, PermissionLevel.WRITE),
rowController.validate 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( .delete(
"/api/:sourceId/rows", "/api/:sourceId/rows",
paramResource("sourceId"), paramResource("sourceId"),
@ -247,20 +71,6 @@ router
trimViewRowInfo, trimViewRowInfo,
rowController.destroy 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( .post(
"/api/:sourceId/rows/exportRows", "/api/:sourceId/rows/exportRows",
paramResource("sourceId"), paramResource("sourceId"),

View File

@ -9,99 +9,13 @@ const { BUILDER, PermissionLevel, PermissionType } = permissions
const router: Router = new Router() const router: Router = new Router()
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) .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( .get(
"/api/tables/:tableId", "/api/tables/:tableId",
paramResource("tableId"), paramResource("tableId"),
authorized(PermissionType.TABLE, PermissionLevel.READ, { schema: true }), authorized(PermissionType.TABLE, PermissionLevel.READ, { schema: true }),
tableController.find 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( .post(
"/api/tables", "/api/tables",
// allows control over updating a table // allows control over updating a table
@ -125,41 +39,12 @@ router
authorized(BUILDER), authorized(BUILDER),
tableController.validateExistingTableImport 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( .delete(
"/api/tables/:tableId/:revId", "/api/tables/:tableId/:revId",
paramResource("tableId"), paramResource("tableId"),
authorized(BUILDER), authorized(BUILDER),
tableController.destroy 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( .post(
"/api/tables/:tableId/import", "/api/tables/:tableId/import",
paramResource("tableId"), paramResource("tableId"),

View File

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

View File

@ -158,5 +158,25 @@ describe("/roles", () => {
expect(res.body.length).toBe(1) expect(res.body.length).toBe(1)
expect(res.body[0]).toBe("PUBLIC") 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")
})
}) })
}) })

View File

@ -1,5 +1,5 @@
const setup = require("./utilities") const setup = require("./utilities")
const { basicScreen } = setup.structures const { basicScreen, powerScreen } = setup.structures
const { checkBuilderEndpoint, runInProd } = require("./utilities/TestFunctions") const { checkBuilderEndpoint, runInProd } = require("./utilities/TestFunctions")
const { roles } = require("@budibase/backend-core") const { roles } = require("@budibase/backend-core")
const { BUILTIN_ROLE_IDS } = roles const { BUILTIN_ROLE_IDS } = roles
@ -12,19 +12,14 @@ const route = "/test"
describe("/routing", () => { describe("/routing", () => {
let request = setup.getRequest() let request = setup.getRequest()
let config = setup.getConfig() let config = setup.getConfig()
let screen, screen2 let basic, power
afterAll(setup.afterAll) afterAll(setup.afterAll)
beforeAll(async () => { beforeAll(async () => {
await config.init() await config.init()
screen = basicScreen() basic = await config.createScreen(basicScreen(route))
screen.routing.route = route power = await config.createScreen(powerScreen(route))
screen = await config.createScreen(screen)
screen2 = basicScreen()
screen2.routing.roleId = BUILTIN_ROLE_IDS.POWER
screen2.routing.route = route
screen2 = await config.createScreen(screen2)
await config.publish() await config.publish()
}) })
@ -61,8 +56,8 @@ describe("/routing", () => {
expect(res.body.routes[route]).toEqual({ expect(res.body.routes[route]).toEqual({
subpaths: { subpaths: {
[route]: { [route]: {
screenId: screen._id, screenId: basic._id,
roleId: screen.routing.roleId roleId: basic.routing.roleId
} }
} }
}) })
@ -80,8 +75,8 @@ describe("/routing", () => {
expect(res.body.routes[route]).toEqual({ expect(res.body.routes[route]).toEqual({
subpaths: { subpaths: {
[route]: { [route]: {
screenId: screen2._id, screenId: power._id,
roleId: screen2.routing.roleId roleId: power.routing.roleId
} }
} }
}) })
@ -101,8 +96,8 @@ describe("/routing", () => {
expect(res.body.routes).toBeDefined() expect(res.body.routes).toBeDefined()
expect(res.body.routes[route].subpaths[route]).toBeDefined() expect(res.body.routes[route].subpaths[route]).toBeDefined()
const subpath = res.body.routes[route].subpaths[route] const subpath = res.body.routes[route].subpaths[route]
expect(subpath.screens[screen2.routing.roleId]).toEqual(screen2._id) expect(subpath.screens[power.routing.roleId]).toEqual(power._id)
expect(subpath.screens[screen.routing.roleId]).toEqual(screen._id) expect(subpath.screens[basic.routing.roleId]).toEqual(basic._id)
}) })
it("make sure it is a builder only endpoint", async () => { it("make sure it is a builder only endpoint", async () => {

View File

@ -5,11 +5,15 @@ describe("/static", () => {
let request = setup.getRequest() let request = setup.getRequest()
let config = setup.getConfig() let config = setup.getConfig()
let app let app
let cleanupEnv
afterAll(setup.afterAll) afterAll(() => {
setup.afterAll()
cleanupEnv()
})
beforeAll(async () => { beforeAll(async () => {
config.modeSelf() cleanupEnv = config.setEnv({ SELF_HOSTED: "true" })
app = await config.init() app = await config.init()
}) })

View File

@ -8,11 +8,15 @@ describe("/webhooks", () => {
let request = setup.getRequest() let request = setup.getRequest()
let config = setup.getConfig() let config = setup.getConfig()
let webhook: Webhook let webhook: Webhook
let cleanupEnv: () => void
afterAll(setup.afterAll) afterAll(() => {
setup.afterAll()
cleanupEnv()
})
const setupTest = async () => { const setupTest = async () => {
config.modeSelf() cleanupEnv = config.setEnv({ SELF_HOSTED: "true" })
await config.init() await config.init()
const autoConfig = basicAutomation() const autoConfig = basicAutomation()
autoConfig.definition.trigger.schema = { autoConfig.definition.trigger.schema = {

View File

@ -1,7 +1,15 @@
import { roles } from "@budibase/backend-core" import { roles } from "@budibase/backend-core"
import { BASE_LAYOUT_PROP_IDS } from "./layouts" 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 { return {
description: "", description: "",
url: "", url: "",
@ -40,8 +48,8 @@ export function createHomeScreen() {
gap: "M", gap: "M",
}, },
routing: { routing: {
route: "/", route: config.route,
roleId: roles.BUILTIN_ROLE_IDS.BASIC, roleId: config.roleId,
}, },
name: "home-screen", name: "home-screen",
} }

View File

@ -35,13 +35,18 @@ import { FieldType, Table, TableSchema } from "@budibase/types"
describe("Google Sheets Integration", () => { describe("Google Sheets Integration", () => {
let integration: any, let integration: any,
config = new TestConfiguration() config = new TestConfiguration()
let cleanupEnv: () => void
beforeAll(() => { beforeAll(() => {
config.setGoogleAuth("test") cleanupEnv = config.setEnv({
GOOGLE_CLIENT_ID: "test",
GOOGLE_CLIENT_SECRET: "test",
})
}) })
afterAll(async () => { afterAll(async () => {
await config.end() cleanupEnv()
config.end()
}) })
beforeEach(async () => { beforeEach(async () => {

View File

@ -58,6 +58,7 @@ import {
} from "@budibase/types" } from "@budibase/types"
import API from "./api" import API from "./api"
import { cloneDeep } from "lodash"
type DefaultUserValues = { type DefaultUserValues = {
globalUserId: string globalUserId: string
@ -188,30 +189,38 @@ class TestConfiguration {
} }
} }
// MODES async withEnv(newEnvVars: Partial<typeof env>, f: () => Promise<void>) {
setMultiTenancy = (value: boolean) => { let cleanup = this.setEnv(newEnvVars)
env._set("MULTI_TENANCY", value) try {
coreEnv._set("MULTI_TENANCY", value) await f()
} finally {
cleanup()
}
} }
setSelfHosted = (value: boolean) => { /*
env._set("SELF_HOSTED", value) * Sets the environment variables to the given values and returns a function
coreEnv._set("SELF_HOSTED", value) * 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) => { let key: keyof typeof newEnvVars
env._set("GOOGLE_CLIENT_ID", value) for (key in newEnvVars) {
env._set("GOOGLE_CLIENT_SECRET", value) env._set(key, newEnvVars[key])
coreEnv._set("GOOGLE_CLIENT_ID", value) coreEnv._set(key, newEnvVars[key])
coreEnv._set("GOOGLE_CLIENT_SECRET", value) }
}
modeCloud = () => { return () => {
this.setSelfHosted(false) for (const [key, value] of Object.entries(oldEnv)) {
} env._set(key, value)
}
modeSelf = () => { for (const [key, value] of Object.entries(oldCoreEnv)) {
this.setSelfHosted(true) coreEnv._set(key, value)
}
}
} }
// UTILS // UTILS

View File

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

View File

@ -7,6 +7,7 @@ import { DatasourceAPI } from "./datasource"
import { LegacyViewAPI } from "./legacyView" import { LegacyViewAPI } from "./legacyView"
import { ScreenAPI } from "./screen" import { ScreenAPI } from "./screen"
import { ApplicationAPI } from "./application" import { ApplicationAPI } from "./application"
import { AttachmentAPI } from "./attachment"
export default class API { export default class API {
table: TableAPI table: TableAPI
@ -17,6 +18,7 @@ export default class API {
datasource: DatasourceAPI datasource: DatasourceAPI
screen: ScreenAPI screen: ScreenAPI
application: ApplicationAPI application: ApplicationAPI
attachment: AttachmentAPI
constructor(config: TestConfiguration) { constructor(config: TestConfiguration) {
this.table = new TableAPI(config) this.table = new TableAPI(config)
@ -27,5 +29,6 @@ export default class API {
this.datasource = new DatasourceAPI(config) this.datasource = new DatasourceAPI(config)
this.screen = new ScreenAPI(config) this.screen = new ScreenAPI(config)
this.application = new ApplicationAPI(config) this.application = new ApplicationAPI(config)
this.attachment = new AttachmentAPI(config)
} }
} }

View File

@ -20,6 +20,7 @@ import {
SourceName, SourceName,
Table, Table,
} from "@budibase/types" } from "@budibase/types"
const { BUILTIN_ROLE_IDS } = roles
export function basicTable(): Table { export function basicTable(): Table {
return { return {
@ -322,8 +323,22 @@ export function basicUser(role: string) {
} }
} }
export function basicScreen() { export function basicScreen(route: string = "/") {
return createHomeScreen() 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() { export function basicLayout() {

View File

@ -241,7 +241,7 @@ export async function outputProcessing<T extends Row[] | Row>(
continue continue
} }
row[property].forEach((attachment: RowAttachment) => { row[property].forEach((attachment: RowAttachment) => {
attachment.url = objectStore.getAppFileUrl(attachment.key) attachment.url ??= objectStore.getAppFileUrl(attachment.key)
}) })
} }
} else if ( } else if (

View File

@ -96,3 +96,45 @@ export enum BuilderSocketEvent {
export const SocketSessionTTL = 60 export const SocketSessionTTL = 60
export const ValidQueryNameRegex = /^[^()]*$/ export const ValidQueryNameRegex = /^[^()]*$/
export const ValidColumnNameRegex = /^[_a-zA-Z0-9\s]*$/g 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",
]

View File

@ -0,0 +1,9 @@
export interface Upload {
size: number
name: string
url: string
extension: string
key: string
}
export type ProcessAttachmentResponse = Upload[]

View File

@ -5,3 +5,4 @@ export * from "./view"
export * from "./rows" export * from "./rows"
export * from "./table" export * from "./table"
export * from "./permission" export * from "./permission"
export * from "./attachment"

View File

@ -1,5 +1,8 @@
export enum FeatureFlag { export enum FeatureFlag {
LICENSING = "LICENSING", LICENSING = "LICENSING",
// Feature IDs in Posthog
PER_CREATOR_PER_USER_PRICE = "18873",
PER_CREATOR_PER_USER_PRICE_ALERT = "18530",
} }
export interface TenantFeatureFlags { export interface TenantFeatureFlags {

View File

@ -5,10 +5,17 @@ export interface Customer {
currency: string | null | undefined currency: string | null | undefined
} }
export interface SubscriptionItems {
user: number | undefined
creator: number | undefined
}
export interface Subscription { export interface Subscription {
amount: number amount: number
amounts: SubscriptionItems | undefined
currency: string currency: string
quantity: number quantity: number
quantities: SubscriptionItems | undefined
duration: PriceDuration duration: PriceDuration
cancelAt: number | null | undefined cancelAt: number | null | undefined
currentPeriodStart: number currentPeriodStart: number

View File

@ -4,7 +4,9 @@ export enum PlanType {
PRO = "pro", PRO = "pro",
/** @deprecated */ /** @deprecated */
TEAM = "team", TEAM = "team",
/** @deprecated */
PREMIUM = "premium", PREMIUM = "premium",
PREMIUM_PLUS = "premium_plus",
BUSINESS = "business", BUSINESS = "business",
ENTERPRISE = "enterprise", ENTERPRISE = "enterprise",
} }
@ -26,10 +28,12 @@ export interface AvailablePrice {
currency: string currency: string
duration: PriceDuration duration: PriceDuration
priceId: string priceId: string
type?: string
} }
export enum PlanModel { export enum PlanModel {
PER_USER = "perUser", PER_USER = "perUser",
PER_CREATOR_PER_USER = "per_creator_per_user",
DAY_PASS = "dayPass", DAY_PASS = "dayPass",
} }

View File

@ -14,7 +14,7 @@ RUN yarn global add pm2
COPY package.json . COPY package.json .
COPY dist/yarn.lock . 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 # Remove unneeded data from file system to reduce image size
RUN apk del .gyp \ RUN apk del .gyp \
&& yarn cache clean && yarn cache clean

View File

@ -19,7 +19,7 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
WORKDIR /string-templates WORKDIR /string-templates
COPY packages/string-templates/package.json package.json COPY packages/string-templates/package.json package.json
RUN ../scripts/removeWorkspaceDependencies.sh 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 . 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 ../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 # Remove unneeded data from file system to reduce image size
RUN apk del .gyp \ RUN apk del .gyp \
&& yarn cache clean && yarn cache clean

View File

@ -20,7 +20,6 @@
"run:docker": "node dist/index.js", "run:docker": "node dist/index.js",
"debug": "yarn build && node --expose-gc --inspect=9223 dist/index.js", "debug": "yarn build && node --expose-gc --inspect=9223 dist/index.js",
"run:docker:cluster": "pm2-runtime start pm2.config.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:stack:init": "node ./scripts/dev/manage.js init",
"dev:builder": "npm run dev:stack:init && nodemon", "dev:builder": "npm run dev:stack:init && nodemon",
"dev:built": "yarn run dev:stack:init && yarn run run:docker", "dev:built": "yarn run dev:stack:init && yarn run run:docker",

View File

@ -1,7 +1,7 @@
import { events } from "@budibase/backend-core" import { events } from "@budibase/backend-core"
import { generator } from "@budibase/backend-core/tests" import { generator } from "@budibase/backend-core/tests"
import { structures, TestConfiguration, mocks } from "../../../../tests" import { structures, TestConfiguration, mocks } from "../../../../tests"
import { UserGroup } from "@budibase/types" import { User, UserGroup } from "@budibase/types"
mocks.licenses.useGroups() 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: [],
})
})
})
})
}) })

View File

@ -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) { authHeaders(user: User) {
const authToken: AuthToken = { const authToken: AuthToken = {
userId: user._id!, userId: user._id!,
@ -257,9 +267,10 @@ class TestConfiguration {
}) })
} }
async createUser(user?: User) { async createUser(opts?: Partial<User>) {
if (!user) { let user = structures.users.user()
user = structures.users.user() if (user) {
user = { ...user, ...opts }
} }
const response = await this._req(user, null, controllers.users.save) const response = await this._req(user, null, controllers.users.save)
const body = response as SaveUserResponse const body = response as SaveUserResponse

View File

@ -1,8 +1,8 @@
import { generator } from "@budibase/backend-core/tests" import { generator } from "@budibase/backend-core/tests"
import { db } from "@budibase/backend-core" 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 appsCount = generator.integer({ min: 0, max: 3 })
const roles = Array.from({ length: appsCount }).reduce( const roles = Array.from({ length: appsCount }).reduce(
(p: UserGroupRoles, v) => { (p: UserGroupRoles, v) => {
@ -14,13 +14,11 @@ export const UserGroup = () => {
{} {}
) )
let group = { return {
apps: [],
color: generator.color(), color: generator.color(),
icon: generator.word(), icon: generator.word(),
name: generator.word(), name: generator.word(),
roles: roles, roles: roles,
users: [], users: [],
} }
return group
} }

View File

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