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
with:
node-version: 18.x
cache: yarn
- run: yarn install --frozen-lockfile
- name: Update versions
@ -63,14 +64,64 @@ jobs:
echo "Using tag $version"
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Build/release Docker images
- name: Setup Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Docker login
run: |
docker login -u $DOCKER_USER -p $DOCKER_PASSWORD
yarn build:docker
env:
DOCKER_USER: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_API_KEY }}
BUDIBASE_RELEASE_VERSION: ${{ steps.currenttag.outputs.version }}
- name: Build worker docker
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
build-args: |
BUDIBASE_VERSION=${{ env.BUDIBASE_VERSION }}
tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
file: ./packages/worker/Dockerfile.v2
cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:latest
cache-to: type=inline
env:
IMAGE_NAME: budibase/worker
IMAGE_TAG: ${{ steps.currenttag.outputs.version }}
BUDIBASE_VERSION: ${{ steps.currenttag.outputs.version }}
- name: Build server docker
uses: docker/build-push-action@v5
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
build-args: |
BUDIBASE_VERSION=${{ env.BUDIBASE_VERSION }}
tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
file: ./packages/server/Dockerfile.v2
cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:latest
cache-to: type=inline
env:
IMAGE_NAME: budibase/apps
IMAGE_TAG: ${{ steps.currenttag.outputs.version }}
BUDIBASE_VERSION: ${{ steps.currenttag.outputs.version }}
- name: Build proxy docker
uses: docker/build-push-action@v5
with:
context: ./hosting/proxy
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }}
file: ./hosting/proxy/Dockerfile
cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:latest
cache-to: type=inline
env:
IMAGE_NAME: budibase/proxy
IMAGE_TAG: ${{ steps.currenttag.outputs.version }}
release-helm-chart:
needs: [release-images]

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

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/)
<p align="center">
<img alt="Budibase data" src="https://res.cloudinary.com/daog6scxm/image/upload/v1647858558/Feb%20release/Start_building_with_Budibase_s_API_3_rhlzhv.png">
</p>
<br /><br />
<br /><br /><br />
## 🏁 Get started
Deploy Budibase self-hosted in your existing infrastructure, using Docker, Kubernetes, and Digital Ocean.

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",
"packages": [
"packages/*"

View File

@ -33,7 +33,6 @@
"build:sdk": "lerna run --stream build:sdk",
"deps:circular": "madge packages/server/dist/index.js packages/worker/src/index.ts packages/backend-core/dist/src/index.js packages/cli/src/index.js --circular",
"release": "lerna publish from-package --yes --force-publish --no-git-tag-version --no-push --no-git-reset",
"release:develop": "yarn release --dist-tag develop",
"restore": "yarn run clean && yarn && yarn run build",
"nuke": "yarn run nuke:packages && yarn run nuke:docker",
"nuke:packages": "yarn run restore",
@ -55,10 +54,6 @@
"lint:fix:prettier": "prettier --write \"packages/**/*.{js,ts,svelte}\" && prettier --write \"examples/**/*.{js,ts,svelte}\" && prettier --write \"qa-core/**/*.{js,ts,svelte}\"",
"lint:fix": "yarn run lint:fix:prettier && yarn run lint:fix:eslint",
"build:specs": "lerna run --stream specs",
"build:docker": "lerna run --stream build:docker && yarn build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh $BUDIBASE_RELEASE_VERSION && cd -",
"build:docker:proxy": "docker build hosting/proxy -t proxy-service",
"build:docker:selfhost": "lerna run --stream build:docker && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh latest && cd -",
"build:docker:develop": "node scripts/pinVersions && lerna run --stream build:docker && yarn build:docker:proxy && cd hosting/scripts/linux/ && ./release-to-docker-hub.sh develop && cd -",
"build:docker:airgap": "node hosting/scripts/airgapped/airgappedDockerBuild",
"build:docker:airgap:single": "SINGLE_IMAGE=1 node hosting/scripts/airgapped/airgappedDockerBuild",
"build:digitalocean": "cd hosting/digitalocean && ./build.sh && cd -",

View File

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

View File

@ -122,7 +122,9 @@ export async function roleToNumber(id?: string) {
if (isBuiltin(id)) {
return builtinRoleToNumber(id)
}
const hierarchy = (await getUserRoleHierarchy(id)) as RoleDoc[]
const hierarchy = (await getUserRoleHierarchy(id, {
defaultPublic: true,
})) as RoleDoc[]
for (let role of hierarchy) {
if (isBuiltin(role?.inherits)) {
return builtinRoleToNumber(role.inherits) + 1
@ -192,12 +194,15 @@ export async function getRole(
/**
* Simple function to get all the roles based on the top level user role ID.
*/
async function getAllUserRoles(userRoleId?: string): Promise<RoleDoc[]> {
async function getAllUserRoles(
userRoleId?: string,
opts?: { defaultPublic?: boolean }
): Promise<RoleDoc[]> {
// admins have access to all roles
if (userRoleId === BUILTIN_IDS.ADMIN) {
return getAllRoles()
}
let currentRole = await getRole(userRoleId)
let currentRole = await getRole(userRoleId, opts)
let roles = currentRole ? [currentRole] : []
let roleIds = [userRoleId]
// get all the inherited roles
@ -226,12 +231,16 @@ export async function getUserRoleIdHierarchy(
* Returns an ordered array of the user's inherited role IDs, this can be used
* to determine if a user can access something that requires a specific role.
* @param userRoleId The user's role ID, this can be found in their access token.
* @param opts optional - if want to default to public use this.
* @returns returns an ordered array of the roles, with the first being their
* highest level of access and the last being the lowest level.
*/
export async function getUserRoleHierarchy(userRoleId?: string) {
export async function getUserRoleHierarchy(
userRoleId?: string,
opts?: { defaultPublic?: boolean }
) {
// special case, if they don't have a role then they are a public user
return getAllUserRoles(userRoleId)
return getAllUserRoles(userRoleId, opts)
}
// this function checks that the provided permissions are in an array format

View File

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

View File

@ -14,11 +14,11 @@ import {
} from "../db"
import {
BulkDocsResponse,
ContextUser,
SearchQuery,
SearchQueryOperators,
SearchUsersRequest,
User,
ContextUser,
DatabaseQueryOpts,
} from "@budibase/types"
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 {
return {
amount: 10000,
amounts: {
user: 10000,
creator: 0,
},
cancelAt: undefined,
currency: "usd",
currentPeriodEnd: 0,
@ -131,6 +135,10 @@ export function subscription(): Subscription {
duration: PriceDuration.MONTHLY,
pastDueAt: undefined,
quantity: 0,
quantities: {
user: 0,
creator: 0,
},
status: "active",
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -39,7 +39,15 @@
allowCreator
) => {
if (allowedRoles?.length) {
return roles.filter(role => allowedRoles.includes(role._id))
const filteredRoles = roles.filter(role =>
allowedRoles.includes(role._id)
)
return [
...filteredRoles,
...(allowedRoles.includes(Constants.Roles.CREATOR)
? [{ _id: Constants.Roles.CREATOR, name: "Creator", enabled: false }]
: []),
]
}
let newRoles = [...roles]
@ -129,8 +137,9 @@
getOptionColour={getColor}
getOptionIcon={getIcon}
isOptionEnabled={option =>
option._id !== Constants.Roles.CREATOR ||
$licensing.perAppBuildersEnabled}
(option._id !== Constants.Roles.CREATOR ||
$licensing.perAppBuildersEnabled) &&
option.enabled !== false}
{placeholder}
{error}
/>

View File

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

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

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 { cloneDeep } from "lodash/fp"
import { createEventDispatcher } from "svelte"
import ComponentSettingsSection from "../../../../../pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
import ComponentSettingsSection from "../../../../pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
import { getContext } from "svelte"
export let anchor
export let field
export let componentInstance
export let componentBindings
export let bindings
export let parseSettings
const draggable = getContext("draggable")
const dispatch = createEventDispatcher()
let popover
let drawers = []
let pseudoComponentInstance
let open = false
$: if (open && $draggable.selected && $draggable.selected != field._id) {
// Auto hide the component when another item is selected
$: if (open && $draggable.selected != componentInstance._id) {
popover.hide()
}
$: if (field) {
pseudoComponentInstance = field
// Open automatically if the component is marked as selected
$: if (!open && $draggable.selected === componentInstance._id && popover) {
popover.show()
open = true
}
$: componentDef = store.actions.components.getDefinition(
pseudoComponentInstance._component
componentInstance._component
)
$: parsedComponentDef = processComponentDefinitionSettings(componentDef)
@ -36,17 +40,16 @@
return {}
}
const clone = cloneDeep(componentDef)
const updatedSettings = clone.settings
.filter(setting => setting.key !== "field")
.map(setting => {
return { ...setting, nested: true }
})
clone.settings = updatedSettings
if (typeof parseSettings === "function") {
clone.settings = parseSettings(clone.settings)
}
return clone
}
const updateSetting = async (setting, value) => {
const nestedComponentInstance = cloneDeep(pseudoComponentInstance)
const nestedComponentInstance = cloneDeep(componentInstance)
const patchFn = store.actions.components.updateComponentSetting(
setting.key,
@ -54,12 +57,26 @@
)
patchFn(nestedComponentInstance)
const update = {
...nestedComponentInstance,
active: pseudoComponentInstance.active,
dispatch("change", nestedComponentInstance)
}
const customPositionHandler = (anchorBounds, eleBounds, cfg) => {
let { left, top } = cfg
let percentageOffset = 30
// left-outside
left = anchorBounds.left - eleBounds.width - 18
// shift up from the anchor, if space allows
let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
let defaultTop = anchorBounds.top - offsetPos
if (window.innerHeight - defaultTop < eleBounds.height) {
top = window.innerHeight - eleBounds.height - 5
} else {
top = anchorBounds.top - offsetPos
}
dispatch("change", update)
return { ...cfg, left, top }
}
</script>
@ -79,11 +96,11 @@
bind:this={popover}
on:open={() => {
drawers = []
$draggable.actions.select(field._id)
$draggable.actions.select(componentInstance._id)
}}
on:close={() => {
open = false
if ($draggable.selected == field._id) {
if ($draggable.selected == componentInstance._id) {
$draggable.actions.select()
}
}}
@ -92,33 +109,13 @@
showPopover={drawers.length == 0}
clickOutsideOverride={drawers.length > 0}
maxHeight={600}
handlePostionUpdate={(anchorBounds, eleBounds, cfg) => {
let { left, top } = cfg
let percentageOffset = 30
// left-outside
left = anchorBounds.left - eleBounds.width - 18
// shift up from the anchor, if space allows
let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
let defaultTop = anchorBounds.top - offsetPos
if (window.innerHeight - defaultTop < eleBounds.height) {
top = window.innerHeight - eleBounds.height - 5
} else {
top = anchorBounds.top - offsetPos
}
return { ...cfg, left, top }
}}
handlePostionUpdate={customPositionHandler}
>
<span class="popover-wrap">
<Layout noPadding noGap>
<div class="type-icon">
<Icon name={parsedComponentDef.icon} />
<span>{field.field}</span>
</div>
<slot name="header" />
<ComponentSettingsSection
componentInstance={pseudoComponentInstance}
{componentInstance}
componentDefinition={parsedComponentDef}
isScreen={false}
onUpdateSetting={updateSetting}
@ -141,20 +138,4 @@
.popover-wrap {
background-color: var(--spectrum-alias-background-color-primary);
}
.type-icon {
display: flex;
gap: var(--spacing-m);
margin: var(--spacing-xl);
margin-bottom: 0px;
height: var(--spectrum-alias-item-height-m);
padding: 0px var(--spectrum-alias-item-padding-m);
border-width: var(--spectrum-actionbutton-border-size);
border-radius: var(--spectrum-alias-border-radius-regular);
border: 1px solid
var(
--spectrum-actionbutton-m-border-color,
var(--spectrum-alias-border-color)
);
align-items: center;
}
</style>

View File

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

View File

@ -1,8 +1,11 @@
<script>
import EditFieldPopover from "./EditFieldPopover.svelte"
import { Toggle } from "@budibase/bbui"
import EditComponentPopover from "../EditComponentPopover.svelte"
import { Toggle, Icon } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { cloneDeep } from "lodash/fp"
import { store } from "builderStore"
import { runtimeToReadableBinding } from "builderStore/dataBinding"
import { isJSBinding } from "@budibase/string-templates"
export let item
export let componentBindings
@ -16,18 +19,43 @@
dispatch("change", { ...cloneDeep(item), active: e.detail })
}
}
const getReadableText = () => {
if (item.label) {
return isJSBinding(item.label)
? "(JavaScript function)"
: runtimeToReadableBinding([...bindings, componentBindings], item.label)
}
return item.field
}
const parseSettings = settings => {
return settings
.filter(setting => setting.key !== "field")
.map(setting => {
return { ...setting, nested: true }
})
}
$: readableText = getReadableText(item)
$: componentDef = store.actions.components.getDefinition(item._component)
</script>
<div class="list-item-body">
<div class="list-item-left">
<EditFieldPopover
<EditComponentPopover
{anchor}
field={item}
componentInstance={item}
{componentBindings}
{bindings}
{parseSettings}
on:change
/>
<div class="field-label">{item.label || item.field}</div>
>
<div slot="header" class="type-icon">
<Icon name={componentDef.icon} />
<span>{item.field}</span>
</div>
</EditComponentPopover>
<div class="field-label">{readableText}</div>
</div>
<div class="list-item-right">
<Toggle on:change={onToggle(item)} text="" value={item.active} thin />
@ -53,4 +81,20 @@
.list-item-body {
justify-content: space-between;
}
.type-icon {
display: flex;
gap: var(--spacing-m);
margin: var(--spacing-xl);
margin-bottom: 0px;
height: var(--spectrum-alias-item-height-m);
padding: 0px var(--spectrum-alias-item-padding-m);
border-width: var(--spectrum-actionbutton-border-size);
border-radius: var(--spectrum-alias-border-radius-regular);
border: 1px solid
var(
--spectrum-actionbutton-m-border-color,
var(--spectrum-alias-border-color)
);
align-items: center;
}
</style>

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,12 @@
<script>
import StyleSection from "./StyleSection.svelte"
import * as ComponentStyles from "./componentStyles"
import ComponentSettingsSection from "./ComponentSettingsSection.svelte"
export let componentDefinition
export let componentInstance
export let bindings
export let componentBindings
const getStyles = def => {
if (!def?.styles?.length) {
@ -22,6 +24,19 @@
$: styles = getStyles(componentDefinition)
</script>
<!--
Load any general settings or sections tagged as "style"
-->
<ComponentSettingsSection
{componentInstance}
{componentDefinition}
isScreen={false}
showInstanceName={false}
{bindings}
{componentBindings}
tag="style"
/>
{#if styles?.length > 0}
{#each styles as style}
<StyleSection

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -3,6 +3,7 @@
import CreationPage from "components/common/CreationPage.svelte"
import blankImage from "./blank.png"
import tableImage from "./table.png"
import gridImage from "./grid.png"
import CreateScreenModal from "./CreateScreenModal.svelte"
import { store } from "builderStore"
@ -43,6 +44,16 @@
<Body size="XS">View, edit and delete rows on a table</Body>
</div>
</div>
<div class="card" on:click={() => createScreenModal.show("grid")}>
<div class="image">
<img alt="" src={gridImage} />
</div>
<div class="text">
<Body size="S">Grid</Body>
<Body size="XS">View and manipulate rows on a grid</Body>
</div>
</div>
</div>
</CreationPage>
</div>

View File

@ -258,6 +258,186 @@
"description": "Contains your app screens",
"static": true
},
"buttongroup": {
"name": "Button group",
"icon": "Button",
"hasChildren": false,
"settings": [
{
"section": true,
"name": "Buttons",
"settings": [
{
"type": "buttonConfiguration",
"key": "buttons",
"nested": true,
"defaultValue": [
{
"type": "cta",
"text": "Button 1"
},
{
"type": "primary",
"text": "Button 2"
}
]
}
]
},
{
"section": true,
"name": "Layout",
"settings": [
{
"type": "select",
"label": "Direction",
"key": "direction",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Column",
"value": "column",
"barIcon": "ViewColumn",
"barTitle": "Column layout"
},
{
"label": "Row",
"value": "row",
"barIcon": "ViewRow",
"barTitle": "Row layout"
}
],
"defaultValue": "row"
},
{
"type": "select",
"label": "Horiz. align",
"key": "hAlign",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Left",
"value": "left",
"barIcon": "AlignLeft",
"barTitle": "Align left"
},
{
"label": "Center",
"value": "center",
"barIcon": "AlignCenter",
"barTitle": "Align center"
},
{
"label": "Right",
"value": "right",
"barIcon": "AlignRight",
"barTitle": "Align right"
},
{
"label": "Stretch",
"value": "stretch",
"barIcon": "MoveLeftRight",
"barTitle": "Align stretched horizontally"
}
],
"defaultValue": "left"
},
{
"type": "select",
"label": "Vert. align",
"key": "vAlign",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Top",
"value": "top",
"barIcon": "AlignTop",
"barTitle": "Align top"
},
{
"label": "Middle",
"value": "middle",
"barIcon": "AlignMiddle",
"barTitle": "Align middle"
},
{
"label": "Bottom",
"value": "bottom",
"barIcon": "AlignBottom",
"barTitle": "Align bottom"
},
{
"label": "Stretch",
"value": "stretch",
"barIcon": "MoveUpDown",
"barTitle": "Align stretched vertically"
}
],
"defaultValue": "top"
},
{
"type": "select",
"label": "Size",
"key": "size",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Shrink",
"value": "shrink",
"barIcon": "Minimize",
"barTitle": "Shrink container"
},
{
"label": "Grow",
"value": "grow",
"barIcon": "Maximize",
"barTitle": "Grow container"
}
],
"defaultValue": "shrink"
},
{
"type": "select",
"label": "Gap",
"key": "gap",
"showInBar": true,
"barStyle": "picker",
"options": [
{
"label": "None",
"value": "N"
},
{
"label": "Small",
"value": "S"
},
{
"label": "Medium",
"value": "M"
},
{
"label": "Large",
"value": "L"
}
],
"defaultValue": "M"
},
{
"type": "boolean",
"label": "Wrap",
"key": "wrap",
"showInBar": true,
"barIcon": "ModernGridView",
"barTitle": "Wrap"
}
]
}
]
},
"button": {
"name": "Button",
"description": "A basic html button that is ready for styling",
@ -2409,7 +2589,6 @@
"key": "disabled",
"defaultValue": false
},
{
"type": "text",
"label": "Initial form step",
@ -5391,38 +5570,6 @@
"section": true,
"name": "Fields",
"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",
"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": [
@ -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 screenslot } from "./ScreenSlot.svelte"
export { default as button } from "./Button.svelte"
export { default as buttongroup } from "./ButtonGroup.svelte"
export { default as repeater } from "./Repeater.svelte"
export { default as text } from "./Text.svelte"
export { default as layout } from "./Layout.svelte"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -44,7 +44,7 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
WORKDIR /string-templates
COPY packages/string-templates/package.json package.json
RUN ../scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000
COPY packages/string-templates .
@ -57,7 +57,7 @@ COPY scripts/removeWorkspaceDependencies.sh scripts/removeWorkspaceDependencies.
RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
RUN ./scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true \
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000 \
# Remove unneeded data from file system to reduce image size
&& yarn cache clean && apt-get remove -y --purge --auto-remove g++ make python jq \
&& rm -rf /tmp/* /root/.node-gyp /usr/local/lib/node_modules/npm/node_modules/node-gyp

View File

@ -18,7 +18,6 @@
"test": "bash scripts/test.sh",
"test:memory": "jest --maxWorkers=2 --logHeapUsage --forceExit",
"test:watch": "jest --watch",
"build:docker": "yarn build && docker build . -t app-service --label version=$BUDIBASE_RELEASE_VERSION --build-arg BUDIBASE_VERSION=$BUDIBASE_RELEASE_VERSION",
"run:docker": "node dist/index.js",
"run:docker:cluster": "pm2-runtime start pm2.config.js",
"dev:stack:up": "node scripts/dev/manage.js up",

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 { Database, Role, UserCtx, UserRoles } from "@budibase/types"
import { sdk as sharedSdk } from "@budibase/shared-core"
@ -143,4 +149,20 @@ export async function accessible(ctx: UserCtx) {
} else {
ctx.body = await roles.getUserRoleIdHierarchy(roleId!)
}
// If a custom role is provided in the header, filter out higher level roles
const roleHeader = ctx.header?.[Header.PREVIEW_ROLE] as string
if (roleHeader && !Object.keys(roles.BUILTIN_ROLE_IDS).includes(roleHeader)) {
const inherits = (await roles.getRole(roleHeader))?.inherits
const orderedRoles = ctx.body.reverse()
let filteredRoles = [roleHeader]
for (let role of orderedRoles) {
filteredRoles = [role, ...filteredRoles]
if (role === inherits) {
break
}
}
filteredRoles.pop()
ctx.body = [roleHeader, ...filteredRoles]
}
}

View File

@ -1,3 +1,5 @@
import { ValidFileExtensions } from "@budibase/shared-core"
require("svelte/register")
import { join } from "../../../utilities/centralPath"
@ -11,34 +13,21 @@ import {
} from "../../../utilities/fileSystem"
import env from "../../../environment"
import { DocumentType } from "../../../db/utils"
import { context, objectStore, utils, configs } from "@budibase/backend-core"
import {
context,
objectStore,
utils,
configs,
BadRequestError,
} from "@budibase/backend-core"
import AWS from "aws-sdk"
import fs from "fs"
import sdk from "../../../sdk"
import * as pro from "@budibase/pro"
import { App, Ctx } from "@budibase/types"
import { App, Ctx, ProcessAttachmentResponse, Upload } from "@budibase/types"
const send = require("koa-send")
async function prepareUpload({ s3Key, bucket, metadata, file }: any) {
const response = await objectStore.upload({
bucket,
metadata,
filename: s3Key,
path: file.path,
type: file.type,
})
// don't store a URL, work this out on the way out as the URL could change
return {
size: file.size,
name: file.name,
url: objectStore.getAppFileUrl(s3Key),
extension: [...file.name.split(".")].pop(),
key: response.Key,
}
}
export const toggleBetaUiFeature = async function (ctx: Ctx) {
const cookieName = `beta:${ctx.params.feature}`
@ -72,23 +61,58 @@ export const serveBuilder = async function (ctx: Ctx) {
await send(ctx, ctx.file, { root: builderPath })
}
export const uploadFile = async function (ctx: Ctx) {
export const uploadFile = async function (
ctx: Ctx<{}, ProcessAttachmentResponse>
) {
const file = ctx.request?.files?.file
if (!file) {
throw new BadRequestError("No file provided")
}
let files = file && Array.isArray(file) ? Array.from(file) : [file]
const uploads = files.map(async (file: any) => {
const fileExtension = [...file.name.split(".")].pop()
// filenames converted to UUIDs so they are unique
const processedFileName = `${uuid.v4()}.${fileExtension}`
ctx.body = await Promise.all(
files.map(async file => {
if (!file.name) {
throw new BadRequestError(
"Attempted to upload a file without a filename"
)
}
return prepareUpload({
file,
s3Key: `${context.getProdAppId()}/attachments/${processedFileName}`,
bucket: ObjectStoreBuckets.APPS,
const extension = [...file.name.split(".")].pop()
if (!extension) {
throw new BadRequestError(
`File "${file.name}" has no extension, an extension is required to upload a file`
)
}
if (!env.SELF_HOSTED && !ValidFileExtensions.includes(extension)) {
throw new BadRequestError(
`File "${file.name}" has an invalid extension: "${extension}"`
)
}
// filenames converted to UUIDs so they are unique
const processedFileName = `${uuid.v4()}.${extension}`
const s3Key = `${context.getProdAppId()}/attachments/${processedFileName}`
const response = await objectStore.upload({
bucket: ObjectStoreBuckets.APPS,
filename: s3Key,
path: file.path,
type: file.type,
})
return {
size: file.size,
name: file.name,
url: objectStore.getAppFileUrl(s3Key),
extension,
key: response.Key,
}
})
})
ctx.body = await Promise.all(uploads)
)
}
export const deleteObjects = async function (ctx: Ctx) {

View File

@ -11,128 +11,24 @@ const { PermissionType, PermissionLevel } = permissions
const router: Router = new Router()
router
/**
* @api {get} /api/:sourceId/:rowId/enrich Get an enriched row
* @apiName Get an enriched row
* @apiGroup rows
* @apiPermission table read access
* @apiDescription This API is only useful when dealing with rows that have relationships.
* Normally when a row is a returned from the API relationships will only have the structure
* `{ primaryDisplay: "name", _id: ... }` but this call will return the full related rows
* for each relationship instead.
*
* @apiParam {string} rowId The ID of the row which is to be retrieved and enriched.
*
* @apiSuccess {object} row The response body will be the enriched row.
*/
.get(
"/api/:sourceId/:rowId/enrich",
paramSubResource("sourceId", "rowId"),
authorized(PermissionType.TABLE, PermissionLevel.READ),
rowController.fetchEnrichedRow
)
/**
* @api {get} /api/:sourceId/rows Get all rows in a table
* @apiName Get all rows in a table
* @apiGroup rows
* @apiPermission table read access
* @apiDescription This is a deprecated endpoint that should not be used anymore, instead use the search endpoint.
* This endpoint gets all of the rows within the specified table - it is not heavily used
* due to its lack of support for pagination. With SQL tables this will retrieve up to a limit and then
* will simply stop.
*
* @apiParam {string} sourceId The ID of the table to retrieve all rows within.
*
* @apiSuccess {object[]} rows The response body will be an array of all rows found.
*/
.get(
"/api/:sourceId/rows",
paramResource("sourceId"),
authorized(PermissionType.TABLE, PermissionLevel.READ),
rowController.fetch
)
/**
* @api {get} /api/:sourceId/rows/:rowId Retrieve a single row
* @apiName Retrieve a single row
* @apiGroup rows
* @apiPermission table read access
* @apiDescription This endpoint retrieves only the specified row. If you wish to retrieve
* a row by anything other than its _id field, use the search endpoint.
*
* @apiParam {string} sourceId The ID of the table to retrieve a row from.
* @apiParam {string} rowId The ID of the row to retrieve.
*
* @apiSuccess {object} body The response body will be the row that was found.
*/
.get(
"/api/:sourceId/rows/:rowId",
paramSubResource("sourceId", "rowId"),
authorized(PermissionType.TABLE, PermissionLevel.READ),
rowController.find
)
/**
* @api {post} /api/:sourceId/search Search for rows in a table
* @apiName Search for rows in a table
* @apiGroup rows
* @apiPermission table read access
* @apiDescription This is the primary method of accessing rows in Budibase, the data provider
* and data UI in the builder are built atop this. All filtering, sorting and pagination is
* handled through this, for internal and external (datasource plus, e.g. SQL) tables.
*
* @apiParam {string} sourceId The ID of the table to retrieve rows from.
*
* @apiParam (Body) {boolean} [paginate] If pagination is required then this should be set to true,
* defaults to false.
* @apiParam (Body) {object} [query] This contains a set of filters which should be applied, if none
* specified then the request will be unfiltered. An example with all of the possible query
* options has been supplied below.
* @apiParam (Body) {number} [limit] This sets a limit for the number of rows that will be returned,
* this will be implemented at the database level if supported for performance reasons. This
* is useful when paginating to set exactly how many rows per page.
* @apiParam (Body) {string} [bookmark] If pagination is enabled then a bookmark will be returned
* with each successful search request, this should be supplied back to get the next page.
* @apiParam (Body) {object} [sort] If sort is desired this should contain the name of the column to
* sort on.
* @apiParam (Body) {string} [sortOrder] If sort is enabled then this can be either "descending" or
* "ascending" as required.
* @apiParam (Body) {string} [sortType] If sort is enabled then you must specify the type of search
* being used, either "string" or "number". This is only used for internal tables.
*
* @apiParamExample {json} Example:
* {
* "tableId": "ta_70260ff0b85c467ca74364aefc46f26d",
* "query": {
* "string": {},
* "fuzzy": {},
* "range": {
* "columnName": {
* "high": 20,
* "low": 10,
* }
* },
* "equal": {
* "columnName": "someValue"
* },
* "notEqual": {},
* "empty": {},
* "notEmpty": {},
* "oneOf": {
* "columnName": ["value"]
* }
* },
* "limit": 10,
* "sort": "name",
* "sortOrder": "descending",
* "sortType": "string",
* "paginate": true
* }
*
* @apiSuccess {object[]} rows An array of rows that was found based on the supplied parameters.
* @apiSuccess {boolean} hasNextPage If pagination was enabled then this specifies whether or
* not there is another page after this request.
* @apiSuccess {string} bookmark The bookmark to be sent with the next request to get the next
* page.
*/
.post(
"/api/:sourceId/search",
internalSearchValidator(),
@ -148,30 +44,6 @@ router
authorized(PermissionType.TABLE, PermissionLevel.READ),
rowController.search
)
/**
* @api {post} /api/:sourceId/rows Creates a new row
* @apiName Creates a new row
* @apiGroup rows
* @apiPermission table write access
* @apiDescription This API will create a new row based on the supplied body. If the
* body includes an "_id" field then it will update an existing row if the field
* links to one. Please note that "_id", "_rev" and "tableId" are fields that are
* already used by Budibase tables and cannot be used for columns.
*
* @apiParam {string} sourceId The ID of the table to save a row to.
*
* @apiParam (Body) {string} [_id] If the row exists already then an ID for the row must be provided.
* @apiParam (Body) {string} [_rev] If working with an existing row for an internal table its revision
* must also be provided.
* @apiParam (Body) {string} tableId The ID of the table should also be specified in the row body itself.
* @apiParam (Body) {any} [any] Any field supplied in the body will be assessed to see if it matches
* a column in the specified table. All other fields will be dropped and not stored.
*
* @apiSuccess {string} _id The ID of the row that was just saved, if it was just created this
* is the rows new ID.
* @apiSuccess {string} [_rev] If saving to an internal table a revision will also be returned.
* @apiSuccess {object} body The contents of the row that was saved will be returned as well.
*/
.post(
"/api/:sourceId/rows",
paramResource("sourceId"),
@ -179,14 +51,6 @@ router
trimViewRowInfo,
rowController.save
)
/**
* @api {patch} /api/:sourceId/rows Updates a row
* @apiName Update a row
* @apiGroup rows
* @apiPermission table write access
* @apiDescription This endpoint is identical to the row creation endpoint but instead it will
* error if an _id isn't provided, it will only function for existing rows.
*/
.patch(
"/api/:sourceId/rows",
paramResource("sourceId"),
@ -194,52 +58,12 @@ router
trimViewRowInfo,
rowController.patch
)
/**
* @api {post} /api/:sourceId/rows/validate Validate inputs for a row
* @apiName Validate inputs for a row
* @apiGroup rows
* @apiPermission table write access
* @apiDescription When attempting to save a row you may want to check if the row is valid
* given the table schema, this will iterate through all the constraints on the table and
* check if the request body is valid.
*
* @apiParam {string} sourceId The ID of the table the row is to be validated for.
*
* @apiParam (Body) {any} [any] Any fields provided in the request body will be tested
* against the table schema and constraints.
*
* @apiSuccess {boolean} valid If inputs provided are acceptable within the table schema this
* will be true, if it is not then then errors property will be populated.
* @apiSuccess {object} [errors] A key value map of information about fields on the input
* which do not match the table schema. The key name will be the column names that have breached
* the schema.
*/
.post(
"/api/:sourceId/rows/validate",
paramResource("sourceId"),
authorized(PermissionType.TABLE, PermissionLevel.WRITE),
rowController.validate
)
/**
* @api {delete} /api/:sourceId/rows Delete rows
* @apiName Delete rows
* @apiGroup rows
* @apiPermission table write access
* @apiDescription This endpoint can delete a single row, or delete them in a bulk
* fashion.
*
* @apiParam {string} sourceId The ID of the table the row is to be deleted from.
*
* @apiParam (Body) {object[]} [rows] If bulk deletion is desired then provide the rows in this
* key of the request body that are to be deleted.
* @apiParam (Body) {string} [_id] If deleting a single row then provide its ID in this field.
* @apiParam (Body) {string} [_rev] If deleting a single row from an internal table then provide its
* revision here.
*
* @apiSuccess {object[]|object} body If deleting bulk then the response body will be an array
* of the deleted rows, if deleting a single row then the body will contain a "row" property which
* is the deleted row.
*/
.delete(
"/api/:sourceId/rows",
paramResource("sourceId"),
@ -247,20 +71,6 @@ router
trimViewRowInfo,
rowController.destroy
)
/**
* @api {post} /api/:sourceId/rows/exportRows Export Rows
* @apiName Export rows
* @apiGroup rows
* @apiPermission table write access
* @apiDescription This API can export a number of provided rows
*
* @apiParam {string} sourceId The ID of the table the row is to be deleted from.
*
* @apiParam (Body) {object[]} [rows] The row IDs which are to be exported
*
* @apiSuccess {object[]|object}
*/
.post(
"/api/:sourceId/rows/exportRows",
paramResource("sourceId"),

View File

@ -9,99 +9,13 @@ const { BUILDER, PermissionLevel, PermissionType } = permissions
const router: Router = new Router()
router
/**
* @api {get} /api/tables Fetch all tables
* @apiName Fetch all tables
* @apiGroup tables
* @apiPermission table read access
* @apiDescription This endpoint retrieves all of the tables which have been created in
* an app. This includes all of the external and internal tables; to tell the difference
* between these look for the "type" property on each table, either being "internal" or "external".
*
* @apiSuccess {object[]} body The response body will be the list of tables that was found - as
* this does not take any parameters the only error scenario is no access.
*/
.get("/api/tables", authorized(BUILDER), tableController.fetch)
/**
* @api {get} /api/tables/:id Fetch a single table
* @apiName Fetch a single table
* @apiGroup tables
* @apiPermission table read access
* @apiDescription Retrieves a single table this could be be internal or external based on
* the provided table ID.
*
* @apiParam {string} id The ID of the table which is to be retrieved.
*
* @apiSuccess {object[]} body The response body will be the table that was found.
*/
.get(
"/api/tables/:tableId",
paramResource("tableId"),
authorized(PermissionType.TABLE, PermissionLevel.READ, { schema: true }),
tableController.find
)
/**
* @api {post} /api/tables Save a table
* @apiName Save a table
* @apiGroup tables
* @apiPermission builder
* @apiDescription Create or update a table with this endpoint, this will function for both internal
* external tables.
*
* @apiParam (Body) {string} [_id] If updating an existing table then the ID of the table must be specified.
* @apiParam (Body) {string} [_rev] If updating an existing internal table then the revision must also be specified.
* @apiParam (Body) {string} type] This should either be "internal" or "external" depending on the table type -
* this will default to internal.
* @apiParam (Body) {string} [sourceId] If creating an external table then this should be set to the datasource ID. If
* building an internal table this does not need to be set, although it will be returned as "bb_internal".
* @apiParam (Body) {string} name The name of the table, this will be used in the UI. To rename the table simply
* supply the table structure to this endpoint with the name changed.
* @apiParam (Body) {object} schema A key value object which has all of the columns in the table as the keys in this
* object. For each column a "type" and "constraints" must be specified, with some types requiring further information.
* More information about the schema structure can be found in the Typescript definitions.
* @apiParam (Body) {string} [primaryDisplay] The name of the column which should be used when displaying rows
* from this table as relationships.
* @apiParam (Body) {object[]} [indexes] Specifies the search indexes - this is deprecated behaviour with the introduction
* of lucene indexes. This functionality is only available for internal tables.
* @apiParam (Body) {object} [_rename] If a column is to be renamed then the "old" column name should be set in this
* structure, and the "updated", new column name should also be supplied. The schema should also be updated, this field
* lets the server know that a field hasn't just been deleted, that the data has moved to a new name, this will fix
* the rows in the table. This functionality is only available for internal tables.
* @apiParam (Body) {object[]} [rows] When creating a table using a compatible data source, an array of objects to be imported into the new table can be provided.
*
* @apiParamExample {json} Example:
* {
* "_id": "ta_05541307fa0f4044abee071ca2a82119",
* "_rev": "10-0fbe4e78f69b255d79f1017e2eeef807",
* "type": "internal",
* "views": {},
* "name": "tableName",
* "schema": {
* "column": {
* "type": "string",
* "constraints": {
* "type": "string",
* "length": {
* "maximum": null
* },
* "presence": false
* },
* "name": "column"
* },
* },
* "primaryDisplay": "column",
* "indexes": [],
* "sourceId": "bb_internal",
* "_rename": {
* "old": "columnName",
* "updated": "newColumnName",
* },
* "rows": []
* }
*
* @apiSuccess {object} table The response body will contain the table structure after being cleaned up and
* saved to the database.
*/
.post(
"/api/tables",
// allows control over updating a table
@ -125,41 +39,12 @@ router
authorized(BUILDER),
tableController.validateExistingTableImport
)
/**
* @api {post} /api/tables/:tableId/:revId Delete a table
* @apiName Delete a table
* @apiGroup tables
* @apiPermission builder
* @apiDescription This endpoint will delete a table and all of its associated data, for this reason it is
* quite dangerous - it will work for internal and external tables.
*
* @apiParam {string} tableId The ID of the table which is to be deleted.
* @apiParam {string} [revId] If deleting an internal table then the revision must also be supplied (_rev), for
* external tables this can simply be set to anything, e.g. "external".
*
* @apiSuccess {string} message A message stating that the table was deleted successfully.
*/
.delete(
"/api/tables/:tableId/:revId",
paramResource("tableId"),
authorized(BUILDER),
tableController.destroy
)
/**
* @api {post} /api/tables/:tableId/:revId Import CSV to existing table
* @apiName Import CSV to existing table
* @apiGroup tables
* @apiPermission builder
* @apiDescription This endpoint will import data to existing tables, internal or external. It is used in combination
* with the CSV validation endpoint. Take the output of the CSV validation endpoint and pass it to this endpoint to
* import the data; please note this will only import fields that already exist on the table/match the type.
*
* @apiParam {string} tableId The ID of the table which the data should be imported to.
*
* @apiParam (Body) {object[]} rows An array of objects representing the rows to be imported, key-value pairs not matching the table schema will be ignored.
*
* @apiSuccess {string} message A message stating that the data was imported successfully.
*/
.post(
"/api/tables/:tableId/import",
paramResource("tableId"),

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@ -20,6 +20,7 @@ import {
SourceName,
Table,
} from "@budibase/types"
const { BUILTIN_ROLE_IDS } = roles
export function basicTable(): Table {
return {
@ -322,8 +323,22 @@ export function basicUser(role: string) {
}
}
export function basicScreen() {
return createHomeScreen()
export function basicScreen(route: string = "/") {
return createHomeScreen({
roleId: BUILTIN_ROLE_IDS.BASIC,
route,
})
}
export function powerScreen(route: string = "/") {
return createHomeScreen({
roleId: BUILTIN_ROLE_IDS.POWER,
route,
})
}
export function customScreen(config: { roleId: string; route: string }) {
return createHomeScreen(config)
}
export function basicLayout() {

View File

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

View File

@ -96,3 +96,45 @@ export enum BuilderSocketEvent {
export const SocketSessionTTL = 60
export const ValidQueryNameRegex = /^[^()]*$/
export const ValidColumnNameRegex = /^[_a-zA-Z0-9\s]*$/g
export const ValidFileExtensions = [
"avif",
"css",
"csv",
"docx",
"drawio",
"editorconfig",
"edl",
"enc",
"export",
"geojson",
"gif",
"htm",
"html",
"ics",
"iqy",
"jfif",
"jpeg",
"jpg",
"json",
"log",
"md",
"mid",
"odt",
"pdf",
"png",
"ris",
"rtf",
"svg",
"tex",
"toml",
"twig",
"txt",
"url",
"wav",
"webp",
"xls",
"xlsx",
"xml",
"yaml",
"yml",
]

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 "./table"
export * from "./permission"
export * from "./attachment"

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@ RUN yarn global add pm2
COPY package.json .
COPY dist/yarn.lock .
RUN yarn install --production=true
RUN yarn install --production=true --network-timeout 1000000
# Remove unneeded data from file system to reduce image size
RUN apk del .gyp \
&& yarn cache clean

View File

@ -19,7 +19,7 @@ RUN chmod +x ./scripts/removeWorkspaceDependencies.sh
WORKDIR /string-templates
COPY packages/string-templates/package.json package.json
RUN ../scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000
COPY packages/string-templates .
@ -30,7 +30,7 @@ RUN cd ../string-templates && yarn link && cd - && yarn link @budibase/string-te
RUN ../scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production=true --network-timeout 1000000
# Remove unneeded data from file system to reduce image size
RUN apk del .gyp \
&& yarn cache clean

View File

@ -20,7 +20,6 @@
"run:docker": "node dist/index.js",
"debug": "yarn build && node --expose-gc --inspect=9223 dist/index.js",
"run:docker:cluster": "pm2-runtime start pm2.config.js",
"build:docker": "yarn build && docker build . -t worker-service --label version=$BUDIBASE_RELEASE_VERSION --build-arg BUDIBASE_VERSION=$BUDIBASE_RELEASE_VERSION",
"dev:stack:init": "node ./scripts/dev/manage.js init",
"dev:builder": "npm run dev:stack:init && nodemon",
"dev:built": "yarn run dev:stack:init && yarn run run:docker",

View File

@ -1,7 +1,7 @@
import { events } from "@budibase/backend-core"
import { generator } from "@budibase/backend-core/tests"
import { structures, TestConfiguration, mocks } from "../../../../tests"
import { UserGroup } from "@budibase/types"
import { User, UserGroup } from "@budibase/types"
mocks.licenses.useGroups()
@ -231,4 +231,39 @@ describe("/api/global/groups", () => {
})
})
})
describe("with global builder role", () => {
let builder: User
let group: UserGroup
beforeAll(async () => {
builder = await config.createUser({
builder: { global: true },
admin: { global: false },
})
await config.createSession(builder)
let resp = await config.api.groups.saveGroup(
structures.groups.UserGroup()
)
group = resp.body as UserGroup
})
it("find should return 200", async () => {
await config.withUser(builder, async () => {
await config.api.groups.searchUsers(group._id!, {
emailSearch: `user1`,
})
})
})
it("update should return 200", async () => {
await config.withUser(builder, async () => {
await config.api.groups.updateGroupUsers(group._id!, {
add: [builder._id!],
remove: [],
})
})
})
})
})

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

View File

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

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