1
0
Fork 0
mirror of synced 2024-08-09 07:08:01 +12:00

Merge remote-tracking branch 'origin/master' into fix/selected-by-user-obscured-nav-item

This commit is contained in:
Dean 2023-10-11 10:13:42 +01:00
commit 059bed1cfa
228 changed files with 6553 additions and 5503 deletions

View file

@ -30,8 +30,8 @@ jobs:
with:
root-reserve-mb: 35000
swap-size-mb: 1024
remove-android: 'true'
remove-dotnet: 'true'
remove-android: "true"
remove-dotnet: "true"
- name: Checkout repo and submodules
uses: actions/checkout@v3
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
@ -121,7 +121,7 @@ jobs:
name: codecov-umbrella
verbose: true
test-services:
test-worker:
runs-on: ubuntu-latest
steps:
- name: Checkout repo and submodules
@ -143,12 +143,48 @@ jobs:
node-version: 18.x
cache: "yarn"
- run: yarn --frozen-lockfile
- name: Test worker and server
- name: Test worker
run: |
if ${{ env.USE_NX_AFFECTED }}; then
yarn test --scope=@budibase/worker --scope=@budibase/server --since=${{ env.NX_BASE_BRANCH }}
yarn test --scope=@budibase/worker --since=${{ env.NX_BASE_BRANCH }}
else
yarn test --scope=@budibase/worker --scope=@budibase/server
yarn test --scope=@budibase/worker
fi
- uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN || github.token }} # not required for public repos
name: codecov-umbrella
verbose: true
test-server:
runs-on: ubuntu-latest
steps:
- name: Checkout repo and submodules
uses: actions/checkout@v3
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'Budibase/budibase'
with:
submodules: true
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
fetch-depth: 0
- name: Checkout repo only
uses: actions/checkout@v3
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != 'Budibase/budibase'
with:
fetch-depth: 0
- name: Use Node.js 18.x
uses: actions/setup-node@v3
with:
node-version: 18.x
cache: "yarn"
- run: yarn --frozen-lockfile
- name: Test server
run: |
if ${{ env.USE_NX_AFFECTED }}; then
yarn test --scope=@budibase/server --since=${{ env.NX_BASE_BRANCH }}
else
yarn test --scope=@budibase/server
fi
- uses: codecov/codecov-action@v3
@ -259,4 +295,4 @@ jobs:
process.exit(1);
} else {
console.log('All good, the submodule had been merged and setup correctly!')
}
}

View file

@ -0,0 +1,21 @@
name: close-featurebranch
on:
pull_request:
types: [closed]
branches:
- develop
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: passeidireto/trigger-external-workflow-action@main
env:
PAYLOAD_BRANCH: ${{ github.head_ref }}
PAYLOAD_PR_NUMBER: ${{ github.ref }}
with:
repository: budibase/budibase-deploys
event: featurebranch-qa-close
github_pat: ${{ secrets.GH_ACCESS_TOKEN }}

View file

@ -4,6 +4,7 @@ on:
pull_request:
branches:
- develop
- master
jobs:
release:
@ -12,7 +13,8 @@ jobs:
- uses: actions/checkout@v3
- uses: passeidireto/trigger-external-workflow-action@main
env:
BRANCH: ${{ github.head_ref }}
PAYLOAD_BRANCH: ${{ github.head_ref }}
PAYLOAD_PR_NUMBER: ${{ github.event.pull_request.number }}
with:
repository: budibase/budibase-deploys
event: featurebranch-qa-deploy

View file

@ -2,7 +2,7 @@ name: Close stale issues and PRs # https://github.com/actions/stale
on:
workflow_dispatch:
schedule:
- cron: '30 1 * * *' # 1:30 every morning
- cron: '*/30 * * * *' # Every 30 mins
jobs:
stale:

View file

@ -9,4 +9,5 @@ packages/backend-core/coverage
packages/server/client
packages/server/src/definitions/openapi.ts
packages/builder/.routify
packages/sdk/sdk
packages/sdk/sdk
packages/pro/coverage

View file

@ -55,7 +55,7 @@ http {
set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com";
set $csp_object "object-src 'none'";
set $csp_base_uri "base-uri 'self'";
set $csp_connect "connect-src 'self' https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com";
set $csp_connect "connect-src 'self' https://*.budibase.app https://*.budibaseqa.app https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com";
set $csp_font "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com https://js.intercomcdn.com https://fonts.intercomcdn.com";
set $csp_frame "frame-src 'self' https:";
set $csp_img "img-src http: https: data: blob:";

View file

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

View file

@ -5,7 +5,7 @@
"@esbuild-plugins/tsconfig-paths": "^0.1.2",
"@nx/js": "16.4.3",
"@rollup/plugin-json": "^4.0.2",
"@typescript-eslint/parser": "5.45.0",
"@typescript-eslint/parser": "6.7.2",
"esbuild": "^0.18.17",
"esbuild-node-externals": "^1.8.0",
"eslint": "^8.44.0",
@ -21,8 +21,8 @@
"prettier-plugin-svelte": "^2.3.0",
"rimraf": "^3.0.2",
"rollup-plugin-replace": "^2.2.0",
"svelte": "^3.38.2",
"typescript": "4.7.3",
"svelte": "3.49.0",
"typescript": "5.2.2",
"@babel/core": "^7.22.5",
"@babel/eslint-parser": "^7.22.5",
"@babel/preset-env": "^7.22.5",

View file

@ -33,17 +33,14 @@
"bull": "4.10.1",
"correlation-id": "4.0.0",
"dotenv": "16.0.1",
"emitter-listener": "1.1.2",
"ioredis": "5.3.2",
"joi": "17.6.0",
"jsonwebtoken": "9.0.0",
"koa-passport": "4.1.4",
"koa-pino-logger": "4.0.0",
"lodash": "4.17.21",
"lodash.isarguments": "3.1.0",
"node-fetch": "2.6.7",
"passport-google-oauth": "2.0.0",
"passport-jwt": "4.0.0",
"passport-local": "1.0.0",
"passport-oauth2-refresh": "^2.1.0",
"pino": "8.11.0",
@ -59,14 +56,13 @@
"uuid": "8.3.2"
},
"devDependencies": {
"@jest/test-sequencer": "29.6.2",
"@shopify/jest-koa-mocks": "5.1.1",
"@swc/core": "1.3.71",
"@swc/jest": "0.2.27",
"@trendyol/jest-testcontainers": "^2.1.1",
"@types/chance": "1.1.3",
"@types/cookies": "0.7.8",
"@types/jest": "29.5.3",
"@types/koa": "2.13.4",
"@types/lodash": "4.14.180",
"@types/node": "18.17.0",
"@types/node-fetch": "2.6.4",
@ -80,14 +76,10 @@
"jest": "29.6.2",
"jest-environment-node": "29.6.2",
"jest-serial-runner": "1.2.1",
"koa": "2.13.4",
"nodemon": "2.0.16",
"pino-pretty": "10.0.0",
"pouchdb-adapter-memory": "7.2.2",
"timekeeper": "2.2.0",
"ts-node": "10.8.1",
"tsconfig-paths": "4.0.0",
"typescript": "4.7.3"
"typescript": "5.2.2"
},
"nx": {
"targets": {

View file

@ -0,0 +1,145 @@
import { User } from "@budibase/types"
import { generator, structures } from "../../../tests"
import { DBTestConfiguration } from "../../../tests/extra"
import { getUsers } from "../user"
import { getGlobalDB } from "../../context"
import _ from "lodash"
import * as redis from "../../redis/init"
import { UserDB } from "../../users"
const config = new DBTestConfiguration()
describe("user cache", () => {
describe("getUsers", () => {
const users: User[] = []
beforeAll(async () => {
const userCount = 10
const userIds = generator.arrayOf(() => generator.guid(), {
min: userCount,
max: userCount,
})
await config.doInTenant(async () => {
const db = getGlobalDB()
for (const userId of userIds) {
const user = structures.users.user({ _id: userId })
await db.put(user)
users.push(user)
}
})
})
beforeEach(async () => {
jest.clearAllMocks()
const redisClient = await redis.getUserClient()
await redisClient.clear()
})
it("when no user is in cache, all of them are retrieved from db", async () => {
const usersToRequest = _.sampleSize(users, 5)
const userIdsToRequest = usersToRequest.map(x => x._id!)
jest.spyOn(UserDB, "bulkGet")
const results = await config.doInTenant(() => getUsers(userIdsToRequest))
expect(results.users).toHaveLength(5)
expect(results).toEqual({
users: usersToRequest.map(u => ({
...u,
budibaseAccess: true,
_rev: expect.any(String),
})),
})
expect(UserDB.bulkGet).toBeCalledTimes(1)
expect(UserDB.bulkGet).toBeCalledWith(userIdsToRequest)
})
it("on a second all, all of them are retrieved from cache", async () => {
const usersToRequest = _.sampleSize(users, 5)
const userIdsToRequest = usersToRequest.map(x => x._id!)
jest.spyOn(UserDB, "bulkGet")
await config.doInTenant(() => getUsers(userIdsToRequest))
const resultsFromCache = await config.doInTenant(() =>
getUsers(userIdsToRequest)
)
expect(resultsFromCache.users).toHaveLength(5)
expect(resultsFromCache).toEqual({
users: expect.arrayContaining(
usersToRequest.map(u => ({
...u,
budibaseAccess: true,
_rev: expect.any(String),
}))
),
})
expect(UserDB.bulkGet).toBeCalledTimes(1)
})
it("when some users are cached, only the missing ones are retrieved from db", async () => {
const usersToRequest = _.sampleSize(users, 5)
const userIdsToRequest = usersToRequest.map(x => x._id!)
jest.spyOn(UserDB, "bulkGet")
await config.doInTenant(() =>
getUsers([userIdsToRequest[0], userIdsToRequest[3]])
)
;(UserDB.bulkGet as jest.Mock).mockClear()
const results = await config.doInTenant(() => getUsers(userIdsToRequest))
expect(results.users).toHaveLength(5)
expect(results).toEqual({
users: expect.arrayContaining(
usersToRequest.map(u => ({
...u,
budibaseAccess: true,
_rev: expect.any(String),
}))
),
})
expect(UserDB.bulkGet).toBeCalledTimes(1)
expect(UserDB.bulkGet).toBeCalledWith([
userIdsToRequest[1],
userIdsToRequest[2],
userIdsToRequest[4],
])
})
it("requesting existing and unexisting ids will return found ones", async () => {
const usersToRequest = _.sampleSize(users, 3)
const missingIds = [generator.guid(), generator.guid()]
const userIdsToRequest = _.shuffle([
...missingIds,
...usersToRequest.map(x => x._id!),
])
const results = await config.doInTenant(() => getUsers(userIdsToRequest))
expect(results.users).toHaveLength(3)
expect(results).toEqual({
users: expect.arrayContaining(
usersToRequest.map(u => ({
...u,
budibaseAccess: true,
_rev: expect.any(String),
}))
),
notFoundIds: expect.arrayContaining(missingIds),
})
})
})
})

View file

@ -6,6 +6,7 @@ import env from "../environment"
import * as accounts from "../accounts"
import { UserDB } from "../users"
import { sdk } from "@budibase/shared-core"
import { User } from "@budibase/types"
const EXPIRY_SECONDS = 3600
@ -27,6 +28,35 @@ async function populateFromDB(userId: string, tenantId: string) {
return user
}
async function populateUsersFromDB(
userIds: string[]
): Promise<{ users: User[]; notFoundIds?: string[] }> {
const getUsersResponse = await UserDB.bulkGet(userIds)
// Handle missed user ids
const notFoundIds = userIds.filter((uid, i) => !getUsersResponse[i])
const users = getUsersResponse.filter(x => x)
await Promise.all(
users.map(async (user: any) => {
user.budibaseAccess = true
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const account = await accounts.getAccount(user.email)
if (account) {
user.account = account
user.accountPortalAccess = true
}
}
})
)
if (notFoundIds.length) {
return { users, notFoundIds }
}
return { users }
}
/**
* Get the requested user by id.
* Use redis cache to first read the user.
@ -77,6 +107,36 @@ export async function getUser(
return user
}
/**
* Get the requested users by id.
* Use redis cache to first read the users.
* If not present fallback to loading the users directly and re-caching.
* @param {*} userIds the ids of the user to get
* @param {*} tenantId the tenant of the users to get
* @returns
*/
export async function getUsers(
userIds: string[]
): Promise<{ users: User[]; notFoundIds?: string[] }> {
const client = await redis.getUserClient()
// try cache
let usersFromCache = await client.bulkGet<User>(userIds)
const missingUsersFromCache = userIds.filter(uid => !usersFromCache[uid])
const users = Object.values(usersFromCache)
let notFoundIds
if (missingUsersFromCache.length) {
const usersFromDb = await populateUsersFromDB(missingUsersFromCache)
notFoundIds = usersFromDb.notFoundIds
for (const userToCache of usersFromDb.users) {
await client.store(userToCache._id!, userToCache, EXPIRY_SECONDS)
}
users.push(...usersFromDb.users)
}
return { users, notFoundIds: notFoundIds }
}
export async function invalidateUser(userId: string) {
const client = await redis.getUserClient()
await client.delete(userId)

View file

@ -102,6 +102,7 @@ describe("sso", () => {
// modified external id to match user format
ssoUser._id = "us_" + details.userId
delete ssoUser.userId
// new sso user won't have a password
delete ssoUser.password

View file

@ -242,7 +242,7 @@ class RedisWrapper {
}
}
async bulkGet(keys: string[]) {
async bulkGet<T>(keys: string[]) {
const db = this._db
if (keys.length === 0) {
return {}
@ -250,7 +250,7 @@ class RedisWrapper {
const prefixedKeys = keys.map(key => addDbPrefix(db, key))
let response = await this.getClient().mget(prefixedKeys)
if (Array.isArray(response)) {
let final: any = {}
let final: Record<string, T> = {}
let count = 0
for (let result of response) {
if (result) {

View file

@ -1,8 +1,9 @@
import { PermissionType, PermissionLevel } from "@budibase/types"
export { PermissionType, PermissionLevel } from "@budibase/types"
import { PermissionLevel, PermissionType } from "@budibase/types"
import flatten from "lodash/flatten"
import cloneDeep from "lodash/fp/cloneDeep"
export { PermissionType, PermissionLevel } from "@budibase/types"
export type RoleHierarchy = {
permissionId: string
}[]
@ -78,6 +79,7 @@ export const BUILTIN_PERMISSIONS = {
permissions: [
new Permission(PermissionType.QUERY, PermissionLevel.READ),
new Permission(PermissionType.TABLE, PermissionLevel.READ),
new Permission(PermissionType.APP, PermissionLevel.READ),
],
},
WRITE: {
@ -88,6 +90,7 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.TABLE, PermissionLevel.WRITE),
new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE),
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
new Permission(PermissionType.APP, PermissionLevel.READ),
],
},
POWER: {
@ -99,6 +102,7 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE),
new Permission(PermissionType.WEBHOOK, PermissionLevel.READ),
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
new Permission(PermissionType.APP, PermissionLevel.READ),
],
},
ADMIN: {
@ -111,6 +115,7 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.WEBHOOK, PermissionLevel.READ),
new Permission(PermissionType.QUERY, PermissionLevel.ADMIN),
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
new Permission(PermissionType.APP, PermissionLevel.READ),
],
},
}

View file

@ -215,21 +215,23 @@ async function getAllUserRoles(userRoleId?: string): Promise<RoleDoc[]> {
return roles
}
export async function getUserRoleIdHierarchy(
userRoleId?: string
): Promise<string[]> {
const roles = await getUserRoleHierarchy(userRoleId)
return roles.map(role => role._id!)
}
/**
* 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 {string} userRoleId The user's role ID, this can be found in their access token.
* @param {object} opts Various options, such as whether to only retrieve the IDs (default true).
* @returns {Promise<string[]|object[]>} returns an ordered array of the roles, with the first being their
* @returns {Promise<object[]>} 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,
opts = { idOnly: true }
) {
export async function getUserRoleHierarchy(userRoleId?: string) {
// special case, if they don't have a role then they are a public user
const roles = await getAllUserRoles(userRoleId)
return opts.idOnly ? roles.map(role => role._id) : roles
return getAllUserRoles(userRoleId)
}
// this function checks that the provided permissions are in an array format
@ -249,6 +251,11 @@ export function checkForRoleResourceArray(
return rolePerms
}
export async function getAllRoleIds(appId?: string) {
const roles = await getAllRoles(appId)
return roles.map(role => role._id)
}
/**
* Given an app ID this will retrieve all of the roles that are currently within that app.
* @return {Promise<object[]>} An array of the role objects that were found.
@ -332,9 +339,7 @@ export class AccessController {
}
let roleIds = userRoleId ? this.userHierarchies[userRoleId] : null
if (!roleIds && userRoleId) {
roleIds = (await getUserRoleHierarchy(userRoleId, {
idOnly: true,
})) as string[]
roleIds = await getUserRoleIdHierarchy(userRoleId)
this.userHierarchies[userRoleId] = roleIds
}

View file

@ -10,7 +10,7 @@ import {
Event,
TenantResolutionStrategy,
} from "@budibase/types"
import { SetOption } from "cookies"
import type { SetOption } from "cookies"
const jwt = require("jsonwebtoken")
const APP_PREFIX = DocumentType.APP + SEPARATOR

View file

@ -86,8 +86,8 @@ export const useAuditLogs = () => {
return useFeature(Feature.AUDIT_LOGS)
}
export const usePublicApiUserRoles = () => {
return useFeature(Feature.USER_ROLE_PUBLIC_API)
export const useExpandedPublicApi = () => {
return useFeature(Feature.EXPANDED_PUBLIC_API)
}
export const useScimIntegration = () => {

View file

@ -1,19 +0,0 @@
import { User } from "@budibase/types"
import { generator } from "./generator"
import { uuid } from "./common"
export const newEmail = () => {
return `${uuid()}@test.com`
}
export const user = (userProps?: any): User => {
return {
email: newEmail(),
password: "test",
roles: { app_test: "admin" },
firstName: generator.first(),
lastName: generator.last(),
pictureUrl: "http://test.com",
...userProps,
}
}

View file

@ -13,8 +13,7 @@ import {
} from "@budibase/types"
import { generator } from "./generator"
import { email, uuid } from "./common"
import * as shared from "./shared"
import { user } from "./shared"
import * as users from "./users"
import sample from "lodash/sample"
export function OAuth(): OAuth2 {
@ -26,7 +25,7 @@ export function OAuth(): OAuth2 {
export function authDetails(userDoc?: User): SSOAuthDetails {
if (!userDoc) {
userDoc = user()
userDoc = users.user()
}
const userId = userDoc._id || uuid()
@ -52,7 +51,7 @@ export function providerType(): SSOProviderType {
export function ssoProfile(user?: User): SSOProfile {
if (!user) {
user = shared.user()
user = users.user()
}
return {
id: user._id!,

View file

@ -4,11 +4,32 @@ import {
BuilderUser,
SSOAuthDetails,
SSOUser,
User,
} from "@budibase/types"
import { user } from "./shared"
import { authDetails } from "./sso"
import { uuid } from "./common"
import { generator } from "./generator"
import { tenant } from "."
export { user, newEmail } from "./shared"
export const newEmail = () => {
return `${uuid()}@test.com`
}
export const user = (userProps?: Partial<Omit<User, "userId">>): User => {
const userId = userProps?._id
return {
_id: userId,
userId,
email: newEmail(),
password: "test",
roles: { app_test: "admin" },
firstName: generator.first(),
lastName: generator.last(),
pictureUrl: "http://test.com",
tenantId: tenant.id(),
...userProps,
}
}
export const adminUser = (userProps?: any): AdminUser => {
return {
@ -31,7 +52,7 @@ export const adminOnlyUser = (userProps?: any): AdminOnlyUser => {
}
}
export const builderUser = (userProps?: any): BuilderUser => {
export const builderUser = (userProps?: Partial<User>): BuilderUser => {
return {
...user(userProps),
builder: {

View file

@ -18,7 +18,7 @@ class DBTestConfiguration {
// TENANCY
doInTenant(task: any) {
doInTenant<T>(task: () => Promise<T>) {
return context.doInTenant(this.tenantId, () => {
return task()
})

View file

@ -1 +1,2 @@
export * from "./core/utilities"
export * from "./extra"

View file

@ -20,14 +20,12 @@
"@rollup/plugin-commonjs": "^16.0.0",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^11.2.1",
"cross-env": "^7.0.2",
"nollup": "^0.14.1",
"postcss": "^8.2.9",
"rollup": "^2.45.2",
"rollup-plugin-postcss": "^4.0.0",
"rollup-plugin-svelte": "^7.1.0",
"rollup-plugin-terser": "^7.0.2",
"svelte": "^3.38.2"
"svelte": "3.49.0"
},
"keywords": [
"svelte"
@ -82,7 +80,7 @@
"@spectrum-css/typography": "3.0.1",
"@spectrum-css/underlay": "2.0.9",
"@spectrum-css/vars": "3.0.1",
"dayjs": "^1.10.4",
"dayjs": "^1.10.8",
"easymde": "^2.16.1",
"svelte-flatpickr": "3.2.3",
"svelte-portal": "^1.0.0",

View file

@ -66,6 +66,10 @@
pointer-events: all;
width: 100%;
}
.spectrum-Toast--neutral {
background-color: var(--grey-2);
}
.spectrum-Button {
border: 1px solid rgba(255, 255, 255, 0.2);
}

View file

@ -14,12 +14,12 @@
export let autocomplete = false
export let sort = false
export let autoWidth = false
export let fetchTerm = null
export let useFetch = false
export let searchTerm = null
export let customPopoverHeight
export let customPopoverOffsetBelow
export let customPopoverMaxHeight
export let open = false
export let loading
const dispatch = createEventDispatcher()
@ -82,6 +82,7 @@
</script>
<Picker
on:loadMore
{id}
{error}
{disabled}
@ -90,9 +91,8 @@
{options}
isPlaceholder={!arrayValue.length}
{autocomplete}
bind:fetchTerm
bind:searchTerm
bind:open
{useFetch}
{isOptionSelected}
{getOptionLabel}
{getOptionValue}
@ -102,4 +102,5 @@
{customPopoverHeight}
{customPopoverOffsetBelow}
{customPopoverMaxHeight}
{loading}
/>

View file

@ -2,7 +2,7 @@
import "@spectrum-css/picker/dist/index-vars.css"
import "@spectrum-css/popover/dist/index-vars.css"
import "@spectrum-css/menu/dist/index-vars.css"
import { createEventDispatcher } from "svelte"
import { createEventDispatcher, onDestroy } from "svelte"
import clickOutside from "../../Actions/click_outside"
import Search from "./Search.svelte"
import Icon from "../../Icon/Icon.svelte"
@ -10,6 +10,7 @@
import Popover from "../../Popover/Popover.svelte"
import Tags from "../../Tags/Tags.svelte"
import Tag from "../../Tags/Tag.svelte"
import ProgressCircle from "../../ProgressCircle/ProgressCircle.svelte"
export let id = null
export let disabled = false
@ -35,19 +36,20 @@
export let autoWidth = false
export let autocomplete = false
export let sort = false
export let fetchTerm = null
export let useFetch = false
export let searchTerm = null
export let customPopoverHeight
export let customPopoverOffsetBelow
export let customPopoverMaxHeight
export let align = "left"
export let footer = null
export let customAnchor = null
export let loading
const dispatch = createEventDispatcher()
let searchTerm = null
let button
let popover
let component
$: sortedOptions = getSortedOptions(options, getOptionLabel, sort)
$: filteredOptions = getFilteredOptions(
@ -82,7 +84,7 @@
}
const getFilteredOptions = (options, term, getLabel) => {
if (autocomplete && term && !fetchTerm) {
if (autocomplete && term) {
const lowerCaseTerm = term.toLowerCase()
return options.filter(option => {
return `${getLabel(option)}`.toLowerCase().includes(lowerCaseTerm)
@ -90,6 +92,20 @@
}
return options
}
const onScroll = e => {
const scrollPxThreshold = 100
const scrollPositionFromBottom =
e.target.scrollHeight - e.target.clientHeight - e.target.scrollTop
if (scrollPositionFromBottom < scrollPxThreshold) {
dispatch("loadMore")
}
}
$: component?.addEventListener("scroll", onScroll)
onDestroy(() => {
component?.removeEventListener("scroll", null)
})
</script>
<button
@ -163,14 +179,13 @@
>
{#if autocomplete}
<Search
value={useFetch ? fetchTerm : searchTerm}
on:change={event =>
useFetch ? (fetchTerm = event.detail) : (searchTerm = event.detail)}
value={searchTerm}
on:change={event => (searchTerm = event.detail)}
{disabled}
placeholder="Search"
/>
{/if}
<ul class="spectrum-Menu" role="listbox">
<ul class="spectrum-Menu" role="listbox" bind:this={component}>
{#if placeholderOption}
<li
class="spectrum-Menu-item placeholder"
@ -248,6 +263,12 @@
{/if}
</ul>
{#if loading}
<div class="loading" class:loading--withAutocomplete={autocomplete}>
<ProgressCircle size="S" />
</div>
{/if}
{#if footer}
<div class="footer">
{footer}
@ -323,18 +344,19 @@
/* Search styles inside popover */
.popover-content :global(.spectrum-Search) {
margin-top: -1px;
margin-left: -1px;
width: calc(100% + 2px);
width: 100%;
}
.popover-content :global(.spectrum-Search input) {
height: auto;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
border-left: 0;
border-right: 0;
padding-top: var(--spectrum-global-dimension-size-100);
padding-bottom: var(--spectrum-global-dimension-size-100);
}
.popover-content :global(.spectrum-Search .spectrum-ClearButton) {
right: 1px;
right: 2px;
top: 2px;
}
.popover-content :global(.spectrum-Search .spectrum-Textfield-icon) {
@ -359,4 +381,14 @@
.option-tag :global(.spectrum-Tags-item > .spectrum-Icon) {
margin-top: 2px;
}
.loading {
position: fixed;
justify-content: center;
right: var(--spacing-s);
top: var(--spacing-s);
}
.loading--withAutocomplete {
top: calc(34px + var(--spacing-m));
}
</style>

View file

@ -25,6 +25,8 @@
export let tag = null
export let customPopoverOffsetBelow
export let customPopoverMaxHeight
export let searchTerm = null
export let loading
const dispatch = createEventDispatcher()
@ -65,6 +67,8 @@
<Picker
on:click
bind:open
bind:searchTerm
on:loadMore
{quiet}
{id}
{error}
@ -92,4 +96,5 @@
placeholderOption={placeholder === false ? null : placeholder}
isOptionSelected={option => option === value}
onSelectOption={selectOption}
{loading}
/>

View file

@ -96,8 +96,8 @@
{disabled}
{readonly}
{id}
value={value || ""}
placeholder={placeholder || ""}
value={value ?? ""}
placeholder={placeholder ?? ""}
on:click
on:blur
on:focus

View file

@ -16,8 +16,7 @@
export let sort = false
export let autoWidth = false
export let autocomplete = false
export let fetchTerm = null
export let useFetch = false
export let searchTerm = null
export let customPopoverHeight
const dispatch = createEventDispatcher()
@ -41,8 +40,7 @@
{autoWidth}
{autocomplete}
{customPopoverHeight}
bind:fetchTerm
{useFetch}
bind:searchTerm
on:change={onChange}
on:click
/>

View file

@ -27,7 +27,11 @@
<div class="spectrum-Toast-body" class:actionBody={!!action}>
<div class="wrap spectrum-Toast-content">{message || ""}</div>
{#if action}
<ActionButton quiet emphasized on:click={action}>
<ActionButton
quiet
emphasized
on:click={() => action(() => dispatch("dismiss"))}
>
<div style="color: white; font-weight: 600;">{actionMessage}</div>
</ActionButton>
{/if}

View file

@ -8,7 +8,7 @@
<Portal target=".modal-container">
<div class="notifications">
{#each $notifications as { type, icon, message, id, dismissable, action, wide } (id)}
{#each $notifications as { type, icon, message, id, dismissable, action, actionMessage, wide } (id)}
<div transition:fly={{ y: 30 }}>
<Notification
{type}
@ -16,6 +16,7 @@
{message}
{dismissable}
{action}
{actionMessage}
{wide}
on:dismiss={() => notifications.dismiss(id)}
/>

View file

@ -21,14 +21,6 @@
"hsla(240, 90%, 75%, 0.3)",
"hsla(320, 90%, 75%, 0.3)",
]
$: {
if (constraints.inclusion.length) {
options = constraints.inclusion.map(value => ({
name: value,
id: Math.random(),
}))
}
}
const removeInput = idx => {
delete optionColors[options[idx].name]
constraints.inclusion = constraints.inclusion.filter((e, i) => i !== idx)
@ -80,6 +72,11 @@
// Initialize anchor arrays on mount, assuming 'options' is already populated
colorPopovers = constraints.inclusion.map(() => undefined)
anchors = constraints.inclusion.map(() => undefined)
options = constraints.inclusion.map(value => ({
name: value,
id: Math.random(),
}))
})
</script>

View file

@ -1,6 +1,7 @@
import { writable } from "svelte/store"
export const BANNER_TYPES = {
NEUTRAL: "neutral",
INFO: "info",
NEGATIVE: "negative",
WARNING: "warning",

View file

@ -27,7 +27,9 @@ export const createNotificationStore = () => {
icon = "",
autoDismiss = true,
action = null,
actionMessage = null,
wide = false,
dismissTimeout = NOTIFICATION_TIMEOUT,
}
) => {
if (block) {
@ -44,14 +46,16 @@ export const createNotificationStore = () => {
icon,
dismissable: !autoDismiss,
action,
actionMessage,
wide,
dismissTimeout,
},
]
})
if (autoDismiss) {
const timeoutId = setTimeout(() => {
dismissNotification(_id)
}, NOTIFICATION_TIMEOUT)
}, dismissTimeout)
timeoutIds.add(timeoutId)
}
}

View file

@ -25,6 +25,7 @@
longform: StringRenderer,
array: ArrayRenderer,
internal: InternalRenderer,
bb_reference: RelationshipRenderer,
}
$: type = getType(schema)
$: customRenderer = customRenderers?.find(x => x.column === schema?.name)

View file

@ -126,8 +126,9 @@
transition: top 130ms ease-out, left 130ms ease-out;
}
.spectrum-Tooltip-label {
text-overflow: ellipsis;
white-space: nowrap;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 12px;
font-weight: 600;

View file

@ -65,28 +65,26 @@
"@fortawesome/free-brands-svg-icons": "^6.2.1",
"@fortawesome/free-solid-svg-icons": "^6.2.1",
"@sentry/browser": "5.19.1",
"@spectrum-css/accordion": "^3.0.24",
"@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1",
"codemirror": "^5.59.0",
"dayjs": "^1.11.2",
"dayjs": "^1.10.8",
"downloadjs": "1.4.7",
"fast-json-patch": "^3.1.1",
"lodash": "4.17.21",
"posthog-js": "^1.36.0",
"remixicon": "2.5.0",
"sanitize-html": "^2.7.0",
"shortid": "2.2.15",
"svelte-dnd-action": "^0.9.8",
"svelte-loading-spinners": "^0.1.1",
"svelte-portal": "1.0.0",
"uuid": "8.3.1",
"yup": "0.29.2"
},
"devDependencies": {
"@babel/core": "^7.12.14",
"@babel/plugin-transform-runtime": "^7.13.10",
"@babel/preset-env": "^7.13.12",
"@babel/runtime": "^7.13.10",
"@rollup/plugin-replace": "^2.4.2",
"@roxi/routify": "2.18.5",
"@sveltejs/vite-plugin-svelte": "1.0.1",
@ -96,19 +94,10 @@
"identity-obj-proxy": "^3.0.0",
"jest": "29.6.2",
"jsdom": "^21.1.1",
"mochawesome": "^7.1.3",
"mochawesome-merge": "^4.2.1",
"mochawesome-report-generator": "^6.2.0",
"ncp": "^2.0.0",
"rimraf": "^3.0.2",
"rollup": "^2.44.0",
"rollup-plugin-copy": "^3.4.0",
"start-server-and-test": "^1.12.1",
"svelte": "^3.48.0",
"svelte-jester": "^1.3.2",
"ts-node": "10.8.1",
"tsconfig-paths": "4.0.0",
"typescript": "4.7.3",
"vite": "^3.0.8",
"vite-plugin-static-copy": "^0.16.0",
"vitest": "^0.29.2"

View file

@ -2,6 +2,7 @@ export const Events = {
COMPONENT_CREATED: "component:created",
COMPONENT_UPDATED: "component:updated",
APP_VIEW_PUBLISHED: "app:view_published",
BLOCK_EJECTED: "block:ejected",
}
export const EventSource = {

View file

@ -948,12 +948,15 @@ export const buildFormSchema = (component, asset) => {
if (component._component.endsWith("formblock")) {
let schema = {}
const datasource = getDatasourceForProvider(asset, component)
const info = getSchemaForDatasource(component, datasource)
if (!info?.schema) {
return schema
}
if (!component.fields) {
Object.values(info?.schema)
Object.values(info.schema)
.filter(
({ autocolumn, name }) =>
!autocolumn && !["_rev", "_id"].includes(name)

View file

@ -221,18 +221,6 @@ const automationActions = store => ({
newAutomation.definition.steps.splice(blockIdx, 0, block)
await store.actions.save(newAutomation)
},
/**
* "rowControl" appears to be the name of the flag used to determine whether
* a certain automation block uses values or bindings as inputs
*/
toggleRowControl: async (block, rowControl) => {
const newBlock = { ...block, rowControl }
const newAutomation = store.actions.getUpdatedDefinition(
get(selectedAutomation),
newBlock
)
await store.actions.save(newAutomation)
},
deleteAutomationBlock: async block => {
const automation = get(selectedAutomation)
let newAutomation = cloneDeep(automation)

View file

@ -64,6 +64,7 @@ const INITIAL_FRONTEND_STATE = {
},
features: {
componentValidation: false,
disableUserMetadata: false,
},
errors: [],
hasAppPackage: false,
@ -1287,6 +1288,11 @@ export const getFrontendStore = () => {
return false
}
// Log event
analytics.captureEvent(Events.BLOCK_EJECTED, {
block: block._component,
})
// Attach block children back into ejected definition, using the
// _containsSlot flag to know where to insert them
const slotContainer = findAllMatchingComponents(

View file

@ -7,7 +7,6 @@
Detail,
Modal,
Button,
Select,
ActionButton,
notifications,
Label,
@ -39,9 +38,6 @@
step => step.stepId === ActionStepID.COLLECT
)
$: automationId = $selectedAutomation?._id
$: showBindingPicker =
block.stepId === ActionStepID.CREATE_ROW ||
block.stepId === ActionStepID.UPDATE_ROW
$: isTrigger = block.type === "TRIGGER"
$: steps = $selectedAutomation?.definition?.steps ?? []
$: blockIdx = steps.findIndex(step => step.id === block.id)
@ -96,15 +92,6 @@
}
}
/**
* "rowControl" appears to be the name of the flag used to determine whether
* a certain automation block uses values or bindings as inputs
*/
function toggleRowControl(evt) {
const rowControl = evt.detail !== "Use values"
automationStore.actions.toggleRowControl(block, rowControl)
}
async function addLooping() {
const loopDefinition = $automationStore.blockDefinitions.ACTION.LOOP
const loopBlock = automationStore.actions.constructBlock(
@ -189,16 +176,6 @@
Add Looping
</ActionButton>
{/if}
{#if showBindingPicker}
<Select
on:change={toggleRowControl}
defaultValue="Use values"
autoWidth
value={block.rowControl ? "Use bindings" : "Use values"}
options={["Use values", "Use bindings"]}
placeholder={null}
/>
{/if}
<ActionButton
on:click={() => deleteStep()}
icon="DeleteOutline"

View file

@ -23,6 +23,7 @@
import { environment, licensing } from "stores/portal"
import WebhookDisplay from "../Shared/WebhookDisplay.svelte"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
import CodeEditorModal from "./CodeEditorModal.svelte"
import QuerySelector from "./QuerySelector.svelte"
@ -82,33 +83,6 @@
? [hbAutocomplete([...bindingsToCompletions(bindings, codeMode)])]
: []
/**
* TODO - Remove after November 2023
* *******************************
* Code added to provide backwards compatibility between Values 1,2,3,4,5
* and the new JSON body.
*/
let deprecatedSchemaProperties
$: {
if (block?.stepId === "integromat" || block?.stepId === "zapier") {
deprecatedSchemaProperties = schemaProperties.filter(
prop => !prop[0].startsWith("value")
)
if (!deprecatedSchemaProperties.map(entry => entry[0]).includes("body")) {
deprecatedSchemaProperties.push([
"body",
{
title: "Payload",
type: "json",
},
])
}
} else {
deprecatedSchemaProperties = schemaProperties
}
}
/****************************************************/
const getInputData = (testData, blockInputs) => {
// Test data is not cloned for reactivity
let newInputData = testData || cloneDeep(blockInputs)
@ -118,30 +92,6 @@
newInputData = cloneDeep(blockInputs)
}
/**
* TODO - Remove after November 2023
* *******************************
* Code added to provide backwards compatibility between Values 1,2,3,4,5
* and the new JSON body.
*/
if (
(block?.stepId === "integromat" || block?.stepId === "zapier") &&
!newInputData?.body?.value
) {
let deprecatedValues = {
...newInputData,
}
delete deprecatedValues.url
delete deprecatedValues.body
newInputData = {
url: newInputData.url,
body: {
value: JSON.stringify(deprecatedValues),
},
}
}
/**********************************/
inputData = newInputData
setDefaultEnumValues()
}
@ -337,7 +287,7 @@
</script>
<div class="fields">
{#each deprecatedSchemaProperties as [key, value]}
{#each schemaProperties as [key, value]}
{#if canShowField(key, value)}
<div class="block-field">
{#if key !== "fields" && value.type !== "boolean"}
@ -362,18 +312,6 @@
mode="json"
value={inputData[key]?.value}
on:change={e => {
/**
* TODO - Remove after November 2023
* *******************************
* Code added to provide backwards compatibility between Values 1,2,3,4,5
* and the new JSON body.
*/
delete inputData.value1
delete inputData.value2
delete inputData.value3
delete inputData.value4
delete inputData.value5
/***********************/
onChange(e, key)
}}
/>
@ -386,10 +324,23 @@
/>
</div>
{:else if value.type === "date"}
<DatePicker
<DrawerBindableSlot
fillWidth
title={value.title}
panel={AutomationBindingPanel}
type={"date"}
value={inputData[key]}
on:change={e => onChange(e, key)}
/>
{bindings}
allowJS={true}
updateOnChange={false}
drawerLeft="260px"
>
<DatePicker
value={inputData[key]}
on:change={e => onChange(e, key)}
/>
</DrawerBindableSlot>
{:else if value.customType === "column"}
<Select
on:change={e => onChange(e, key)}
@ -469,7 +420,6 @@
/>
{:else if value.customType === "row"}
<RowSelector
{block}
value={inputData[key]}
meta={inputData["meta"] || {}}
on:change={e => {

View file

@ -1,18 +1,16 @@
<script>
import { tables } from "stores/backend"
import { Select, Checkbox } from "@budibase/bbui"
import DrawerBindableInput from "../../common/bindings/DrawerBindableInput.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
import { createEventDispatcher } from "svelte"
import RowSelectorTypes from "./RowSelectorTypes.svelte"
import ModalBindableInput from "../../common/bindings/ModalBindableInput.svelte"
import DrawerBindableSlot from "../../common/bindings/DrawerBindableSlot.svelte"
import AutomationBindingPanel from "../../common/bindings/ServerBindingPanel.svelte"
const dispatch = createEventDispatcher()
export let value
export let meta
export let bindings
export let block
export let isTestModal
export let isUpdateRow
@ -25,16 +23,6 @@
let table
let schemaFields
let placeholders = {
number: 10,
boolean: "true",
datetime: "2022-02-16T12:00:00.000Z ",
options: "1",
array: "1 2 3 4",
link: "ro_ta_123_456",
longform: "long form text",
}
$: rowControl = block.rowControl
$: {
table = $tables.list.find(table => table._id === value?.tableId)
schemaFields = Object.entries(table?.schema ?? {})
@ -57,19 +45,13 @@
return value
}
if (type === "boolean") {
if (typeof value === "boolean") {
return value
}
return value === "true"
}
if (type === "number") {
if (typeof value === "number") {
return value
}
return Number(value)
}
if (type === "options") {
if (type === "options" || type === "boolean") {
return value
}
if (type === "array") {
@ -83,8 +65,7 @@
if (Array.isArray(value)) {
return value
}
return [value]
return value.split(",").map(x => x.trim())
}
if (type === "json") {
@ -128,9 +109,31 @@
{#if schemaFields.length}
<div class="schema-fields">
{#each schemaFields as [field, schema]}
{#if !schema.autocolumn}
{#if schema.type !== "attachment"}
{#if !rowControl}
{#if !schema.autocolumn && schema.type !== "attachment"}
{#if isTestModal}
<RowSelectorTypes
{isTestModal}
{field}
{schema}
bindings={parsedBindings}
{value}
{onChange}
/>
{:else}
<DrawerBindableSlot
fillWidth
title={value.title}
label={field}
panel={AutomationBindingPanel}
type={schema.type}
{schema}
value={value[field]}
on:change={e => onChange(e, field)}
{bindings}
allowJS={true}
updateOnChange={false}
drawerLeft="260px"
>
<RowSelectorTypes
{isTestModal}
{field}
@ -139,37 +142,19 @@
{value}
{onChange}
/>
{:else}
<div>
<svelte:component
this={isTestModal ? ModalBindableInput : DrawerBindableInput}
placeholder={placeholders[schema.type]}
panel={AutomationBindingPanel}
value={Array.isArray(value[field])
? value[field].join(" ")
: value[field]}
on:change={e => onChange(e, field, schema.type)}
label={field}
type="string"
bindings={parsedBindings}
fillWidth={true}
allowJS={true}
updateOnChange={false}
/>
{#if isUpdateRow && schema.type === "link"}
<div class="checkbox-field">
<Checkbox
value={meta.fields?.[field]?.clearRelationships}
text={"Clear relationships if empty?"}
size={"S"}
on:change={e => onChangeSetting(e, field)}
/>
</div>
{/if}
</div>
{/if}
</DrawerBindableSlot>
{/if}
{/if}
{#if isUpdateRow && schema.type === "link"}
<div class="checkbox-field">
<Checkbox
value={meta.fields?.[field]?.clearRelationships}
text={"Clear relationships if empty?"}
size={"S"}
on:change={e => onChangeSetting(e, field)}
/>
</div>
{/if}
{/each}
</div>
{/if}

View file

@ -1,7 +1,6 @@
<script>
import {
Select,
Toggle,
DatePicker,
Multiselect,
TextArea,
@ -45,19 +44,28 @@
on:change={e => onChange(e, field)}
/>
{:else if schema.type === "boolean"}
<Toggle
text={field}
value={value[field]}
<Select
on:change={e => onChange(e, field)}
label={field}
value={value[field]}
options={[
{ label: "True", value: "true" },
{ label: "False", value: "false" },
]}
/>
{:else if schema.type === "array"}
<Multiselect
bind:value={value[field]}
label={field}
options={schema.constraints.inclusion}
on:change={e => onChange(e, field)}
/>
{:else if schema.type === "longform"}
<TextArea label={field} bind:value={value[field]} />
<TextArea
label={field}
bind:value={value[field]}
on:change={e => onChange(e, field)}
/>
{:else if schema.type === "json"}
<span>
<Label>{field}</Label>
@ -73,7 +81,11 @@
/>
</span>
{:else if schema.type === "link"}
<LinkedRowSelector bind:linkedRows={value[field]} {schema} />
<LinkedRowSelector
bind:linkedRows={value[field]}
{schema}
on:change={e => onChange(e, field)}
/>
{:else if schema.type === "string" || schema.type === "number"}
<svelte:component
this={isTestModal ? ModalBindableInput : DrawerBindableInput}

View file

@ -1,33 +0,0 @@
<script>
import Table from "./Table.svelte"
export let query = {}
export let data = []
export let editRows = false
let loading = false
let error = false
let type = "external"
</script>
{#if error}
<div class="errors">{error}</div>
{/if}
<Table
schema={query.schema}
{data}
{loading}
{type}
rowCount={5}
allowEditing={editRows}
/>
<style>
.errors {
color: var(--red);
background: var(--red-light);
padding: var(--spacing-m);
border-radius: var(--border-radius-m);
margin-bottom: var(--spacing-m);
}
</style>

View file

@ -4,6 +4,7 @@
import { TableNames } from "constants"
import { Grid } from "@budibase/frontend-core"
import { API } from "api"
import { store } from "builderStore"
import GridAddColumnModal from "components/backend/DataTable/modals/grid/GridCreateColumnModal.svelte"
import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte"
import GridEditUserModal from "components/backend/DataTable/modals/grid/GridEditUserModal.svelte"
@ -14,6 +15,7 @@
import GridManageAccessButton from "components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte"
import GridRelationshipButton from "components/backend/DataTable/buttons/grid/GridRelationshipButton.svelte"
import GridEditColumnModal from "components/backend/DataTable/modals/grid/GridEditColumnModal.svelte"
import GridUsersTableButton from "components/backend/DataTable/modals/grid/GridUsersTableButton.svelte"
const userSchemaOverrides = {
firstName: { displayName: "First name", disabled: true },
@ -59,22 +61,22 @@
datasource={gridDatasource}
canAddRows={!isUsersTable}
canDeleteRows={!isUsersTable}
canEditRows={!isUsersTable || !$store.features.disableUserMetadata}
canEditColumns={!isUsersTable || !$store.features.disableUserMetadata}
schemaOverrides={isUsersTable ? userSchemaOverrides : null}
showAvatars={false}
on:updatedatasource={handleGridTableUpdate}
>
<svelte:fragment slot="filter">
{#if isUsersTable && $store.features.disableUserMetadata}
<GridUsersTableButton />
{/if}
<GridFilterButton />
</svelte:fragment>
<svelte:fragment slot="edit-column">
<GridEditColumnModal />
</svelte:fragment>
<svelte:fragment slot="add-column">
<GridAddColumnModal />
</svelte:fragment>
<svelte:fragment slot="controls">
<GridCreateViewButton />
{#if !isUsersTable}
<GridCreateViewButton />
{/if}
<GridManageAccessButton />
{#if relationshipsEnabled}
<GridRelationshipButton />
@ -84,7 +86,6 @@
{:else}
<GridImportButton />
{/if}
<GridExportButton />
{#if isUsersTable}
<GridEditUserModal />
@ -92,6 +93,12 @@
<GridCreateEditRowModal />
{/if}
</svelte:fragment>
<svelte:fragment slot="edit-column">
<GridEditColumnModal />
</svelte:fragment>
<svelte:fragment slot="add-column">
<GridAddColumnModal />
</svelte:fragment>
</Grid>
</div>

View file

@ -13,7 +13,13 @@
let modal
$: tempValue = filters || []
$: schemaFields = Object.values(schema || {})
$: schemaFields = Object.entries(schema || {}).map(
([fieldName, fieldSchema]) => ({
name: fieldName, // Using the key as name if not defined in the schema, for example in some autogenerated columns
...fieldSchema,
})
)
$: text = getText(filters)
$: selected = tempValue.filter(x => !x.onEmptyFilter)?.length > 0

View file

@ -5,7 +5,6 @@
Label,
Select,
Toggle,
RadioGroup,
Icon,
DatePicker,
Modal,
@ -26,14 +25,16 @@
ALLOWABLE_STRING_TYPES,
ALLOWABLE_NUMBER_TYPES,
SWITCHABLE_TYPES,
PrettyRelationshipDefinitions,
} from "constants/backend"
import { getAutoColumnInformation, buildAutoColumn } from "builderStore/utils"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { truncate } from "lodash"
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import { getBindings } from "components/backend/DataTable/formula"
import JSONSchemaModal from "./JSONSchemaModal.svelte"
import { ValidColumnNameRegex } from "@budibase/shared-core"
import { FieldType } from "@budibase/types"
import RelationshipSelector from "components/common/RelationshipSelector.svelte"
const AUTO_TYPE = "auto"
const FORMULA_TYPE = FIELDS.FORMULA.type
@ -42,6 +43,7 @@
const NUMBER_TYPE = FIELDS.NUMBER.type
const JSON_TYPE = FIELDS.JSON.type
const DATE_TYPE = FIELDS.DATETIME.type
const USER_REFRENCE_TYPE = FIELDS.BB_REFERENCE_USER.compositeType
const dispatch = createEventDispatcher()
const PROHIBITED_COLUMN_NAMES = ["type", "_id", "_rev", "tableId"]
@ -57,6 +59,12 @@
let indexes = [...($tables.selected.indexes || [])]
let isCreating = undefined
let relationshipPart1 = PrettyRelationshipDefinitions.Many
let relationshipPart2 = PrettyRelationshipDefinitions.One
let relationshipTableIdPrimary = null
let relationshipTableIdSecondary = null
let table = $tables.selected
let confirmDeleteDialog
let savingColumn
@ -64,18 +72,65 @@
let jsonSchemaModal
let allowedTypes = []
let editableColumn = {
type: "string",
type: fieldDefinitions.STRING.type,
constraints: fieldDefinitions.STRING.constraints,
// Initial value for column name in other table for linked records
fieldName: $tables.selected.name,
}
let relationshipOpts1 = Object.values(PrettyRelationshipDefinitions)
let relationshipOpts2 = Object.values(PrettyRelationshipDefinitions)
$: if (primaryDisplay) {
editableColumn.constraints.presence = { allowEmpty: false }
}
let relationshipMap = {
[RelationshipType.ONE_TO_MANY]: {
part1: PrettyRelationshipDefinitions.MANY,
part2: PrettyRelationshipDefinitions.ONE,
},
[RelationshipType.MANY_TO_MANY]: {
part1: PrettyRelationshipDefinitions.MANY,
part2: PrettyRelationshipDefinitions.MANY,
},
[RelationshipType.MANY_TO_ONE]: {
part1: PrettyRelationshipDefinitions.ONE,
part2: PrettyRelationshipDefinitions.MANY,
},
}
$: {
// this parses any changes the user has made when creating a new internal relationship
// into what we expect the schema to look like
if (editableColumn.type === LINK_TYPE) {
relationshipTableIdPrimary = table._id
if (relationshipPart1 === PrettyRelationshipDefinitions.ONE) {
relationshipOpts2 = relationshipOpts2.filter(
opt => opt !== PrettyRelationshipDefinitions.ONE
)
} else {
relationshipOpts2 = Object.values(PrettyRelationshipDefinitions)
}
if (relationshipPart2 === PrettyRelationshipDefinitions.ONE) {
relationshipOpts1 = relationshipOpts1.filter(
opt => opt !== PrettyRelationshipDefinitions.ONE
)
} else {
relationshipOpts1 = Object.values(PrettyRelationshipDefinitions)
}
// Determine the relationship type based on the selected values of both parts
editableColumn.relationshipType = Object.entries(relationshipMap).find(
([_, parts]) =>
parts.part1 === relationshipPart1 && parts.part2 === relationshipPart2
)?.[0]
// Set the tableId based on the selected table
editableColumn.tableId = relationshipTableIdSecondary
}
}
const initialiseField = (field, savingColumn) => {
isCreating = !field
if (field && !savingColumn) {
editableColumn = cloneDeep(field)
originalName = editableColumn.name ? editableColumn.name + "" : null
@ -83,6 +138,23 @@
primaryDisplay =
$tables.selected.primaryDisplay == null ||
$tables.selected.primaryDisplay === editableColumn.name
if (editableColumn.type === FieldType.BB_REFERENCE) {
editableColumn.type = `${editableColumn.type}_${editableColumn.subtype}`
}
// Here we are setting the relationship values based on the editableColumn
// This part of the code is used when viewing an existing field hence the check
// for the tableId
if (editableColumn.type === LINK_TYPE && editableColumn.tableId) {
relationshipTableIdPrimary = table._id
relationshipTableIdSecondary = editableColumn.tableId
if (editableColumn.relationshipType in relationshipMap) {
const { part1, part2 } =
relationshipMap[editableColumn.relationshipType]
relationshipPart1 = part1
relationshipPart2 = part2
}
}
} else if (!savingColumn) {
let highestNumber = 0
Object.keys(table.schema).forEach(columnName => {
@ -99,6 +171,7 @@
editableColumn.name = "Column 01"
}
}
allowedTypes = getAllowedTypes()
}
@ -157,14 +230,10 @@
!uneditable &&
editableColumn?.type !== AUTO_TYPE &&
!editableColumn.autocolumn
$: relationshipOptions = getRelationshipOptions(editableColumn)
$: external = table.type === "external"
// in the case of internal tables the sourceId will just be undefined
$: tableOptions = $tables.list.filter(
opt =>
opt._id !== $tables.selected._id &&
opt.type === table.type &&
table.sourceId === opt.sourceId
opt => opt.type === table.type && table.sourceId === opt.sourceId
)
$: typeEnabled =
!originalName ||
@ -180,6 +249,14 @@
let saveColumn = cloneDeep(editableColumn)
// Handle types on composite types
const definition = fieldDefinitions[saveColumn.type.toUpperCase()]
if (definition && saveColumn.type === definition.compositeType) {
saveColumn.type = definition.type
saveColumn.subtype = definition.subtype
delete saveColumn.compositeType
}
if (saveColumn.type === AUTO_TYPE) {
saveColumn = buildAutoColumn(
$tables.selected.name,
@ -200,10 +277,7 @@
dispatch("updatecolumns")
gridDispatch("close-edit-column")
if (
saveColumn.type === LINK_TYPE &&
saveColumn.relationshipType === RelationshipType.MANY_TO_MANY
) {
if (saveColumn.type === LINK_TYPE) {
// Fetching the new tables
tables.fetch()
// Fetching the new relationships
@ -235,6 +309,11 @@
confirmDeleteDialog.hide()
dispatch("updatecolumns")
gridDispatch("close-edit-column")
if (editableColumn.type === LINK_TYPE) {
// Updating the relationships
datasources.fetch()
}
}
} catch (error) {
notifications.error(`Error deleting column: ${error.message}`)
@ -258,9 +337,10 @@
// Default relationships many to many
if (editableColumn.type === LINK_TYPE) {
editableColumn.relationshipType = RelationshipType.MANY_TO_MANY
}
if (editableColumn.type === FORMULA_TYPE) {
} else if (editableColumn.type === FORMULA_TYPE) {
editableColumn.formulaType = "dynamic"
} else if (editableColumn.type === USER_REFRENCE_TYPE) {
editableColumn.relationshipType = RelationshipType.ONE_TO_MANY
}
}
@ -288,35 +368,6 @@
return match ? parseInt(match[1]) : 0
}
function getRelationshipOptions(field) {
if (!field || !field.tableId) {
return null
}
const linkTable = tableOptions?.find(table => table._id === field.tableId)
if (!linkTable) {
return null
}
const thisName = truncate(table.name, { length: 14 }),
linkName = truncate(linkTable.name, { length: 14 })
return [
{
name: `Many ${thisName} rows → many ${linkName} rows`,
alt: `Many ${table.name} rows → many ${linkTable.name} rows`,
value: RelationshipType.MANY_TO_MANY,
},
{
name: `One ${linkName} row → many ${thisName} rows`,
alt: `One ${linkTable.name} rows → many ${table.name} rows`,
value: RelationshipType.ONE_TO_MANY,
},
{
name: `One ${thisName} row → many ${linkName} rows`,
alt: `One ${table.name} rows → many ${linkTable.name} rows`,
value: RelationshipType.MANY_TO_ONE,
},
]
}
function getAllowedTypes() {
if (
originalName &&
@ -328,7 +379,9 @@
ALLOWABLE_NUMBER_TYPES.indexOf(editableColumn.type) !== -1
) {
return ALLOWABLE_NUMBER_OPTIONS
} else if (!external) {
}
if (!external) {
return [
...Object.values(fieldDefinitions),
{ name: "Auto Column", type: AUTO_TYPE },
@ -344,6 +397,7 @@
FIELDS.BOOLEAN,
FIELDS.FORMULA,
FIELDS.BIGINT,
FIELDS.BB_REFERENCE_USER,
]
// no-sql or a spreadsheet
if (!external || table.sql) {
@ -357,8 +411,9 @@
if (!fieldToCheck) {
return
}
// most types need this, just make sure its always present
if (fieldToCheck && !fieldToCheck.constraints) {
if (!fieldToCheck.constraints) {
fieldToCheck.constraints = {}
}
// some string types may have been built by server, may not always have constraints
@ -438,7 +493,7 @@
on:change={handleTypeChange}
options={allowedTypes}
getOptionLabel={field => field.name}
getOptionValue={field => field.type}
getOptionValue={field => field.compositeType || field.type}
getOptionIcon={field => field.icon}
isOptionEnabled={option => {
if (option.type == AUTO_TYPE) {
@ -500,9 +555,9 @@
<DatePicker bind:value={editableColumn.constraints.datetime.latest} />
</div>
</div>
{#if datasource?.source !== "ORACLE" && datasource?.source !== "SQL_SERVER"}
{#if datasource?.source !== "ORACLE" && datasource?.source !== "SQL_SERVER" && !editableColumn.dateOnly}
<div>
<div>
<div class="row">
<Label>Time zones</Label>
<AbsTooltip
position="top"
@ -520,10 +575,11 @@
/>
</div>
{/if}
<Toggle bind:value={editableColumn.dateOnly} text="Date only" />
{:else if editableColumn.type === "number" && !editableColumn.autocolumn}
<div class="split-label">
<div class="label-length">
<Label size="M">Max Value</Label>
<Label size="M">Min Value</Label>
</div>
<div class="input-length">
<Input
@ -546,30 +602,17 @@
</div>
</div>
{:else if editableColumn.type === "link"}
<Select
label="Table"
disabled={linkEditDisabled}
bind:value={editableColumn.tableId}
options={tableOptions}
getOptionLabel={table => table.name}
getOptionValue={table => table._id}
/>
{#if relationshipOptions && relationshipOptions.length > 0}
<RadioGroup
disabled={linkEditDisabled}
label="Define the relationship"
bind:value={editableColumn.relationshipType}
options={relationshipOptions}
getOptionLabel={option => option.name}
getOptionValue={option => option.value}
getOptionTitle={option => option.alt}
/>
{/if}
<Input
disabled={linkEditDisabled}
label={`Column name in other table`}
bind:value={editableColumn.fieldName}
error={errors.relatedName}
<RelationshipSelector
bind:relationshipPart1
bind:relationshipPart2
bind:relationshipTableIdPrimary
bind:relationshipTableIdSecondary
bind:editableColumn
{relationshipOpts1}
{relationshipOpts2}
{linkEditDisabled}
{tableOptions}
{errors}
/>
{:else if editableColumn.type === FORMULA_TYPE}
{#if !table.sql}
@ -616,6 +659,18 @@
<Button primary text on:click={openJsonSchemaEditor}
>Open schema editor</Button
>
{:else if editableColumn.type === USER_REFRENCE_TYPE}
<!-- Disabled temporally -->
<!-- <Toggle
value={editableColumn.relationshipType === RelationshipType.MANY_TO_MANY}
on:change={e =>
(editableColumn.relationshipType = e.detail
? RelationshipType.MANY_TO_MANY
: RelationshipType.ONE_TO_MANY)}
disabled={!isCreating}
thin
text="Allow multiple users"
/> -->
{/if}
{#if editableColumn.type === AUTO_TYPE || editableColumn.autocolumn}
<Select
@ -690,18 +745,19 @@
display: flex;
align-items: center;
}
.tooltip-alignment {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.label-length {
flex-basis: 40%;
}
.input-length {
flex-grow: 1;
}
.row {
gap: 8px;
display: flex;
}
</style>

View file

@ -0,0 +1,54 @@
<script>
import { ActionButton, Popover, Heading, Body, Button } from "@budibase/bbui"
import { store } from "builderStore"
let anchor
let open = false
const openSidePanel = () => {
store.update(state => ({
...state,
builderSidePanel: true,
}))
open = false
}
</script>
<div bind:this={anchor}>
<ActionButton on:click={() => (open = true)} icon="Help" quiet>
Why can't I edit this table?
</ActionButton>
</div>
<Popover bind:open {anchor} align="left">
<div class="content">
<Heading size="XS">The app users table is read only</Heading>
<Body size="S">
You can continue to view the users that have access to your application.
</Body>
<Body size="S">
Manage and invite more application users using the user side panel in the
top right of your screen.
</Body>
<div class="button">
<Button cta on:click={openSidePanel}>Open users panel</Button>
</div>
</div>
</Popover>
<style>
.content {
width: 300px;
padding: var(--spacing-l);
display: flex;
flex-direction: column;
gap: var(--spacing-l);
}
.content :global(.spectrum-Heading) {
font-weight: 400;
}
.button {
display: flex;
justify-content: flex-end;
}
</style>

View file

@ -180,7 +180,7 @@
<div class="hierarchy-items-container">
<NavItem
icon="UserGroup"
text="Users"
text="App users"
selected={$isActive("./table/:tableId") &&
$tables.selected?._id === TableNames.USERS}
on:click={() => selectTable(TableNames.USERS)}

View file

@ -57,7 +57,8 @@
label: table.name,
value: table._id,
}))
$: valid = getErrorCount(errors) === 0 && allRequiredAttributesSet()
$: valid =
getErrorCount(errors) === 0 && allRequiredAttributesSet(relationshipType)
$: isManyToMany = relationshipType === RelationshipType.MANY_TO_MANY
$: isManyToOne = relationshipType === RelationshipType.MANY_TO_ONE
@ -114,7 +115,7 @@
return Object.entries(errors).filter(entry => !!entry[1]).length
}
function allRequiredAttributesSet() {
function allRequiredAttributesSet(relationshipType) {
const base = getTable(fromId) && getTable(toId) && fromColumn && toColumn
if (relationshipType === RelationshipType.MANY_TO_ONE) {
return base && fromPrimary && fromForeign
@ -124,9 +125,10 @@
}
function validate() {
if (!allRequiredAttributesSet() && !hasValidated) {
if (!allRequiredAttributesSet(relationshipType) && !hasValidated) {
return
}
hasValidated = true
errorChecker.setType(relationshipType)
const fromTable = getTable(fromId),

View file

@ -3,16 +3,19 @@
import { API } from "api"
import { Select, Label, Multiselect } from "@budibase/bbui"
import { capitalise } from "../../helpers"
import { createEventDispatcher } from "svelte"
export let schema
export let linkedRows = []
const dispatch = createEventDispatcher()
let rows = []
let linkedIds = (Array.isArray(linkedRows) ? linkedRows : [])?.map(
let linkedIds = []
$: linkedIds = (Array.isArray(linkedRows) ? linkedRows : [])?.map(
row => row?._id || row
)
$: linkedRows = linkedIds
$: label = capitalise(schema.name)
$: linkedTableId = schema.tableId
$: linkedTable = $tables.list.find(table => table._id === linkedTableId)
@ -44,7 +47,10 @@
options={rows}
getOptionLabel={getPrettyName}
getOptionValue={row => row._id}
on:change={e => (linkedIds = e.detail ? [e.detail] : [])}
on:change={e => {
linkedIds = e.detail ? [e.detail] : []
dispatch("change", linkedIds)
}}
{label}
sort
/>
@ -56,5 +62,6 @@
getOptionLabel={getPrettyName}
getOptionValue={row => row._id}
sort
on:change={() => dispatch("change", linkedIds)}
/>
{/if}

View file

@ -0,0 +1,74 @@
<script>
import { Select, Input } from "@budibase/bbui"
export let relationshipPart1
export let relationshipPart2
export let relationshipTableIdPrimary
export let relationshipTableIdSecondary
export let editableColumn
export let linkEditDisabled
export let tableOptions
export let errors
export let relationshipOpts1
export let relationshipOpts2
</script>
<div class="relationship-container">
<div class="relationship-part">
<Select
disabled={linkEditDisabled}
bind:value={relationshipPart1}
options={relationshipOpts1}
/>
</div>
<div class="relationship-label">in</div>
<div class="relationship-part">
<Select
disabled
options={tableOptions}
getOptionLabel={table => table.name}
getOptionValue={table => table._id}
bind:value={relationshipTableIdPrimary}
/>
</div>
</div>
<div class="relationship-container">
<div class="relationship-part">
<Select
disabled={linkEditDisabled}
bind:value={relationshipPart2}
options={relationshipOpts2}
getOptionLabel={option => "To " + option.toLowerCase()}
/>
</div>
<div class="relationship-label">in</div>
<div class="relationship-part">
<Select
disabled={linkEditDisabled}
bind:value={relationshipTableIdSecondary}
options={tableOptions.filter(
table => table._id !== relationshipTableIdPrimary
)}
getOptionLabel={table => table.name}
getOptionValue={table => table._id}
/>
</div>
</div>
<Input
disabled={linkEditDisabled}
label={`Column name in other table`}
bind:value={editableColumn.fieldName}
error={errors.relatedName}
/>
<style>
.relationship-container {
display: flex;
align-items: center;
gap: 20px;
}
.relationship-part {
flex-basis: 60%;
}
</style>

View file

@ -38,14 +38,12 @@
hoverable
on:click={store.undo}
disabled={!$store.canUndo}
tooltip="Undo latest change"
/>
<Icon
name="Redo"
hoverable
on:click={store.redo}
disabled={!$store.canRedo}
tooltip="Redo latest undo"
/>
</div>

View file

@ -0,0 +1,102 @@
<script>
import { notifications, Button, Icon, Body } from "@budibase/bbui"
import { admin, auth } from "stores/portal"
$: user = $auth.user
let loading = false
let complete = false
const resetPassword = async () => {
if (loading || complete) return
loading = true
try {
await fetch(`${$admin.accountPortalUrl}/api/auth/reset`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email: user.email }),
})
complete = true
} catch (e) {
notifications.error("There was an issue sending your validation email.")
} finally {
loading = false
}
}
</script>
{#if user?.account?.verified === false}
<section class="banner">
<div class="icon">
<Icon name="Info" />
</div>
<div class="copy">
<Body size="S">
Please verify your account. We've sent the verification link to <span
class="email">{user.email}</span
></Body
>
</div>
<div class="button" class:disabled={loading || complete}>
<Button on:click={resetPassword}
>{complete ? "Email sent" : "Resend email"}</Button
>
</div>
</section>
{/if}
<style>
.banner {
flex-shrink: 0;
display: flex;
background-color: var(--grey-2);
height: 48px;
align-items: center;
padding: 0 15px;
}
.icon {
margin-right: 15px;
color: var(--ink);
display: flex;
}
.copy {
flex-grow: 1;
overflow: hidden;
}
.copy :global(p) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.email {
font-weight: 600;
}
.button {
margin-left: 15px;
}
.button :global(button) {
color: var(--ink);
background-color: var(--grey-3);
border: none;
}
.button :global(button):hover {
background-color: var(--grey-4);
}
.disabled :global(button) {
pointer-events: none;
color: var(--ink);
background-color: var(--grey-4);
border: none;
}
</style>

View file

@ -0,0 +1,250 @@
<script>
import { Icon, Input, Drawer, Button } from "@budibase/bbui"
import {
readableToRuntimeBinding,
runtimeToReadableBinding,
} from "builderStore/dataBinding"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { createEventDispatcher, setContext } from "svelte"
import { isJSBinding } from "@budibase/string-templates"
export let panel = ClientBindingPanel
export let value = ""
export let bindings = []
export let title = "Bindings"
export let placeholder
export let label
export let disabled = false
export let fillWidth
export let allowJS = true
export let allowHelpers = true
export let updateOnChange = true
export let drawerLeft
export let type
export let schema
const dispatch = createEventDispatcher()
let bindingDrawer
let valid = true
let currentVal = value
$: readableValue = runtimeToReadableBinding(bindings, value)
$: tempValue = readableValue
$: isJS = isJSBinding(value)
const saveBinding = () => {
onChange(tempValue)
onBlur()
bindingDrawer.hide()
}
setContext("binding-drawer-actions", {
save: saveBinding,
})
const onChange = value => {
if (type === "link" && value && hasValidLinks(value)) {
currentVal = value.split(",")
} else if (type === "array" && value && hasValidOptions(value)) {
currentVal = value.split(",")
} else {
currentVal = readableToRuntimeBinding(bindings, value)
}
dispatch("change", currentVal)
}
const onBlur = () => {
dispatch("blur", currentVal)
}
const isValidDate = value => {
return !value || !isNaN(new Date(value).valueOf())
}
const hasValidLinks = value => {
let links = []
if (Array.isArray(value)) {
links = value
} else if (value && typeof value === "string") {
links = value.split(",")
} else {
return !value
}
return links.every(link => link.startsWith("ro_"))
}
const hasValidOptions = value => {
let links = []
if (Array.isArray(value)) {
links = value
} else if (value && typeof value === "string") {
links = value.split(",")
} else {
return !value
}
return links.every(link => schema?.constraints?.inclusion?.includes(link))
}
const isValidBoolean = value => {
return value === "false" || value === "true" || value == ""
}
const validationMap = {
date: isValidDate,
datetime: isValidDate,
link: hasValidLinks,
array: hasValidOptions,
longform: value => !isJSBinding(value),
json: value => !isJSBinding(value),
boolean: isValidBoolean,
}
const isValid = value => {
const validate = validationMap[type]
return validate ? validate(value) : true
}
const getIconClass = (value, type) => {
if (type === "longform" && !isJSBinding(value)) {
return "text-area-slot-icon"
}
if (type === "json" && !isJSBinding(value)) {
return "json-slot-icon"
}
if (type !== "string" && type !== "number") {
return "slot-icon"
}
return ""
}
</script>
<div class="control" class:disabled>
{#if !isValid(value)}
<Input
{label}
{disabled}
readonly={isJS}
value={isJS ? "(JavaScript function)" : readableValue}
on:change={event => onChange(event.detail)}
on:blur={onBlur}
{placeholder}
{updateOnChange}
/>
<div
class="icon"
on:click={() => {
if (!isJS) {
dispatch("change", "")
}
}}
>
<Icon disabled={isJS} size="S" name="Close" />
</div>
{:else}
<slot
{label}
{disabled}
readonly={isJS}
value={isJS ? "(JavaScript function)" : readableValue}
{placeholder}
{updateOnChange}
/>
{/if}
{#if !disabled && type !== "formula"}
<div
class={`icon ${getIconClass(value, type)}`}
on:click={() => {
bindingDrawer.show()
}}
>
<Icon size="S" name="FlashOn" />
</div>
{/if}
</div>
<Drawer
on:drawerHide
on:drawerShow
{fillWidth}
bind:this={bindingDrawer}
{title}
left={drawerLeft}
headless
>
<svelte:fragment slot="description">
Add the objects on the left to enrich your text.
</svelte:fragment>
<Button cta slot="buttons" disabled={!valid} on:click={saveBinding}>
Save
</Button>
<svelte:component
this={panel}
slot="body"
bind:valid
value={readableValue}
on:change={event => (tempValue = event.detail)}
{bindings}
{allowJS}
{allowHelpers}
/>
</Drawer>
<style>
.control {
flex: 1;
position: relative;
}
.slot-icon {
right: 31px !important;
border-right: 1px solid var(--spectrum-alias-border-color);
border-top-right-radius: 0px !important;
border-bottom-right-radius: 0px !important;
}
.text-area-slot-icon {
border-bottom: 1px solid var(--spectrum-alias-border-color);
border-bottom-right-radius: 0px !important;
top: 26px !important;
}
.json-slot-icon {
border-bottom: 1px solid var(--spectrum-alias-border-color);
border-bottom-right-radius: 0px !important;
top: 23px !important;
right: 0px !important;
}
.icon {
right: 1px;
bottom: 1px;
position: absolute;
justify-content: center;
align-items: center;
display: flex;
flex-direction: row;
box-sizing: border-box;
border-left: 1px solid var(--spectrum-alias-border-color);
border-top-right-radius: var(--spectrum-alias-border-radius-regular);
border-bottom-right-radius: var(--spectrum-alias-border-radius-regular);
width: 31px;
color: var(--spectrum-alias-text-color);
background-color: var(--spectrum-global-color-gray-75);
transition: background-color
var(--spectrum-global-animation-duration-100, 130ms),
box-shadow var(--spectrum-global-animation-duration-100, 130ms),
border-color var(--spectrum-global-animation-duration-100, 130ms);
height: calc(var(--spectrum-alias-item-height-m) - 2px);
}
.icon:hover {
cursor: pointer;
color: var(--spectrum-alias-text-color-hover);
background-color: var(--spectrum-global-color-gray-50);
border-color: var(--spectrum-alias-border-color-hover);
}
.control:not(.disabled) :global(.spectrum-Textfield-input) {
padding-right: 40px;
}
</style>

View file

@ -224,10 +224,10 @@
<span
class="app-link"
on:click={() => {
appActionPopover.hide()
if (isPublished) {
viewApp()
} else {
appActionPopover.hide()
updateAppModal.show()
}
}}

View file

@ -12,11 +12,19 @@
export let borderLeft = false
export let borderRight = false
export let wide = false
export let extraWide = false
export let closeButtonIcon = "Close"
$: customHeaderContent = $$slots["panel-header-content"]
</script>
<div class="panel" class:wide class:borderLeft class:borderRight>
<div
class="panel"
class:wide
class:extraWide
class:borderLeft
class:borderRight
>
<div class="header" class:custom={customHeaderContent}>
{#if showBackButton}
<Icon name="ArrowLeft" hoverable on:click={onClickBackButton} />
@ -33,7 +41,7 @@
</div>
{/if}
{#if showCloseButton}
<Icon name="Close" hoverable on:click={onClickCloseButton} />
<Icon name={closeButtonIcon} hoverable on:click={onClickCloseButton} />
{/if}
</div>
@ -70,6 +78,10 @@
width: 310px;
flex: 0 0 310px;
}
.panel.extraWide {
width: 450px;
flex: 0 0 450px;
}
.header {
flex: 0 0 48px;
display: flex;

View file

@ -65,6 +65,7 @@ const componentMap = {
"field/array": FormFieldSelect,
"field/json": FormFieldSelect,
"field/barcodeqr": FormFieldSelect,
"field/bb_reference": FormFieldSelect,
// Some validation types are the same as others, so not all types are
// explicitly listed here. e.g. options uses string validation
"validation/string": ValidationEditor,
@ -74,6 +75,7 @@ const componentMap = {
"validation/datetime": ValidationEditor,
"validation/attachment": ValidationEditor,
"validation/link": ValidationEditor,
"validation/bb_reference": ValidationEditor,
}
export const getComponentForSetting = setting => {

View file

@ -3,7 +3,6 @@
import { createEventDispatcher } from "svelte"
import { notifications } from "@budibase/bbui"
import ButtonActionDrawer from "./ButtonActionDrawer.svelte"
import { automationStore } from "builderStore"
import { cloneDeep } from "lodash/fp"
const dispatch = createEventDispatcher()
@ -24,47 +23,11 @@
}
const saveEventData = async () => {
// any automations that need created from event triggers
const automationsToCreate = tmpValue.filter(
action => action["##eventHandlerType"] === "Trigger Automation"
)
for (let action of automationsToCreate) {
await createAutomation(action.parameters)
}
dispatch("change", tmpValue)
notifications.success("Component actions saved.")
drawer.hide()
}
// called by the parent modal when actions are saved
const createAutomation = async parameters => {
if (parameters.automationId || !parameters.newAutomationName) {
return
}
try {
let trigger = automationStore.actions.constructBlock(
"TRIGGER",
"APP",
$automationStore.blockDefinitions.TRIGGER.APP
)
trigger.inputs = {
fields: Object.keys(parameters.fields ?? {}).reduce((fields, key) => {
fields[key] = "string"
return fields
}, {}),
}
const automation = await automationStore.actions.create(
parameters.newAutomationName,
trigger
)
parameters.automationId = automation._id
delete parameters.newAutomationName
} catch (error) {
notifications.error("Error creating automation")
}
}
$: actionCount = value?.length
$: actionText = `${actionCount || "No"} action${
actionCount !== 1 ? "s" : ""

View file

@ -1,6 +1,5 @@
<script>
import { currentAsset, store } from "builderStore"
import { onMount } from "svelte"
import { Label, Combobox, Select } from "@budibase/bbui"
import {
getActionProviderComponents,
@ -10,12 +9,6 @@
export let parameters
onMount(() => {
if (!parameters.type) {
parameters.type = "top"
}
})
$: formComponent = findComponent($currentAsset.props, parameters.componentId)
$: formSchema = buildFormSchema(formComponent)
$: fieldOptions = Object.keys(formSchema || {})

View file

@ -37,7 +37,7 @@
}
$: datasource = getDatasourceForProvider($currentAsset, componentInstance)
$: resourceId = datasource.resourceId || datasource.tableId
$: resourceId = datasource?.resourceId || datasource?.tableId
$: if (!isEqual(value, cachedValue)) {
cachedValue = cloneDeep(value)

View file

@ -43,4 +43,5 @@ export const FieldTypeToComponentMap = {
link: "relationshipfield",
json: "jsonfield",
barcodeqr: "codescanner",
bb_reference: "bbreferencefield",
}

View file

@ -3,21 +3,24 @@
Body,
Button,
Combobox,
Multiselect,
DatePicker,
DrawerContent,
Icon,
Input,
Layout,
Select,
Label,
Layout,
Multiselect,
Select,
} from "@budibase/bbui"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import { generate } from "shortid"
import { LuceneUtils, Constants } from "@budibase/frontend-core"
import { Constants, LuceneUtils } from "@budibase/frontend-core"
import { getFields } from "helpers/searchFields"
import { FieldType } from "@budibase/types"
import { createEventDispatcher, onMount } from "svelte"
import FilterUsers from "./FilterUsers.svelte"
import { RelationshipType } from "constants/backend"
export let schemaFields
export let filters = []
@ -29,7 +32,6 @@
const dispatch = createEventDispatcher()
const { OperatorOptions } = Constants
const { getValidOperatorsForType } = LuceneUtils
const KeyedFieldRegex = /\d[0-9]*:/g
const behaviourOptions = [
{ value: "and", label: "Match all filters" },
@ -120,7 +122,7 @@
return enrichedSchemaFields.find(field => field.name === filter.field)
}
const santizeTypes = filter => {
const sanitizeTypes = filter => {
// Update type based on field
const fieldSchema = enrichedSchemaFields.find(x => x.name === filter.field)
filter.type = fieldSchema?.type
@ -129,13 +131,9 @@
filter.externalType = getSchema(filter)?.externalType
}
const santizeOperator = filter => {
const sanitizeOperator = filter => {
// Ensure a valid operator is selected
const operators = getValidOperatorsForType(
filter.type,
filter.field,
datasource
).map(x => x.value)
const operators = getValidOperatorsForType(filter).map(x => x.value)
if (!operators.includes(filter.operator)) {
filter.operator = operators[0] ?? OperatorOptions.Equals.value
}
@ -148,7 +146,7 @@
filter.noValue = noValueOptions.includes(filter.operator)
}
const santizeValue = filter => {
const sanitizeValue = (filter, previousType) => {
// Check if the operator allows a value at all
if (filter.noValue) {
filter.value = null
@ -162,28 +160,47 @@
}
} else if (filter.type === "array" && filter.valueType === "Value") {
filter.value = []
} else if (
previousType !== filter.type &&
(previousType === FieldType.BB_REFERENCE ||
filter.type === FieldType.BB_REFERENCE)
) {
filter.value = filter.type === "array" ? [] : null
}
}
const onFieldChange = filter => {
santizeTypes(filter)
santizeOperator(filter)
santizeValue(filter)
const previousType = filter.type
sanitizeTypes(filter)
sanitizeOperator(filter)
sanitizeValue(filter, previousType)
}
const onOperatorChange = filter => {
santizeOperator(filter)
santizeValue(filter)
sanitizeOperator(filter)
sanitizeValue(filter, filter.type)
}
const onValueTypeChange = filter => {
santizeValue(filter)
sanitizeValue(filter)
}
const getFieldOptions = field => {
const schema = enrichedSchemaFields.find(x => x.name === field)
return schema?.constraints?.inclusion || []
}
const getValidOperatorsForType = filter => {
if (!filter?.field) {
return []
}
return LuceneUtils.getValidOperatorsForType(
filter.type,
filter.field,
datasource
)
}
</script>
<DrawerContent>
@ -228,11 +245,7 @@
/>
<Select
disabled={!filter.field}
options={getValidOperatorsForType(
filter.type,
filter.field,
datasource
)}
options={getValidOperatorsForType(filter)}
bind:value={filter.operator}
on:change={() => onOperatorChange(filter)}
placeholder={null}
@ -285,6 +298,14 @@
timeOnly={getSchema(filter)?.timeOnly}
bind:value={filter.value}
/>
{:else if filter.type === FieldType.BB_REFERENCE}
<FilterUsers
bind:value={filter.value}
multiselect={getSchema(filter).relationshipType ===
RelationshipType.MANY_TO_MANY ||
filter.operator === OperatorOptions.In.value}
disabled={filter.noValue}
/>
{:else}
<DrawerBindableInput disabled />
{/if}

View file

@ -0,0 +1,34 @@
<script>
import { Select, Multiselect } from "@budibase/bbui"
import { fetchData } from "@budibase/frontend-core"
import { API } from "api"
export let value = null
export let disabled
export let multiselect = false
$: fetch = fetchData({
API,
datasource: {
type: "user",
},
options: {
limit: 100,
},
})
$: options = $fetch.rows
$: component = multiselect ? Multiselect : Select
</script>
<svelte:component
this={component}
bind:value
autocomplete
{options}
getOptionLabel={option => option.email}
getOptionValue={option => option._id}
{disabled}
/>

View file

@ -18,31 +18,20 @@
</script>
{#each extraFields as { key, displayName, type }}
<div class="config-field">
<Label>{displayName}</Label>
{#if type === "string"}
<Input
on:change={() => populateExtraQuery(extraQueryFields)}
bind:value={extraQueryFields[key]}
/>
{/if}
<Label>{displayName}</Label>
{#if type === "string"}
<Input
on:change={() => populateExtraQuery(extraQueryFields)}
bind:value={extraQueryFields[key]}
/>
{/if}
{#if type === "list"}
<Select
on:change={() => populateExtraQuery(extraQueryFields)}
bind:value={extraQueryFields[key]}
options={config[key].data[query.queryVerb]}
getOptionLabel={current => current}
/>
{/if}
</div>
{#if type === "list"}
<Select
on:change={() => populateExtraQuery(extraQueryFields)}
bind:value={extraQueryFields[key]}
options={config[key].data[query.queryVerb]}
getOptionLabel={current => current}
/>
{/if}
{/each}
<style>
.config-field {
display: grid;
grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
</style>

View file

@ -34,6 +34,7 @@
export let bindings = []
export let bindingDrawerLeft
export let allowHelpers = true
export let customButtonText = null
let fields = Object.entries(object || {}).map(([name, value]) => ({
name,
@ -158,9 +159,13 @@
{/if}
{#if !readOnly && !noAddButton}
<div>
<ActionButton icon="Add" secondary thin outline on:click={addEntry}
>Add{name ? ` ${lowercase(name)}` : ""}</ActionButton
>
<ActionButton icon="Add" secondary thin outline on:click={addEntry}>
{#if customButtonText}
{customButtonText}
{:else}
{`Add${name ? ` ${lowercase(name)}` : ""}`}
{/if}
</ActionButton>
</div>
{/if}

View file

@ -1,364 +1,444 @@
<script>
import { goto, beforeUrlChange } from "@roxi/routify"
import { goto } from "@roxi/routify"
import { datasources, integrations, queries } from "stores/backend"
import {
Icon,
Select,
Button,
ButtonGroup,
Body,
Label,
Layout,
Input,
Heading,
Tabs,
Tab,
Modal,
ModalContent,
Label,
notifications,
Heading,
Body,
Divider,
Button,
} from "@budibase/bbui"
import ExtraQueryConfig from "./ExtraQueryConfig.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte"
import ExternalDataSourceTable from "components/backend/DataTable/ExternalDataSourceTable.svelte"
import BindingBuilder from "components/integration/QueryViewerBindingBuilder.svelte"
import { datasources, integrations, queries } from "stores/backend"
import { capitalise } from "../../helpers"
import CodeMirrorEditor from "components/common/CodeMirrorEditor.svelte"
import JSONPreview from "./JSONPreview.svelte"
import { SchemaTypeOptions } from "constants/backend"
import KeyValueBuilder from "./KeyValueBuilder.svelte"
import { fieldsToSchema, schemaToFields } from "helpers/data/utils"
import { capitalise } from "helpers"
import AccessLevelSelect from "./AccessLevelSelect.svelte"
import IntegrationQueryEditor from "components/integration/index.svelte"
import QueryViewerSidePanel from "./QueryViewerSidePanel/index.svelte"
import { cloneDeep } from "lodash/fp"
import BindingBuilder from "components/integration/QueryViewerBindingBuilder.svelte"
import CodeMirrorEditor from "components/common/CodeMirrorEditor.svelte"
import { ValidQueryNameRegex } from "@budibase/shared-core"
import ExtraQueryConfig from "./ExtraQueryConfig.svelte"
import QueryViewerSavePromptModal from "./QueryViewerSavePromptModal.svelte"
import { Utils } from "@budibase/frontend-core"
export let query
let queryHash
const resumeNavigation = () => {
if (typeof navigateTo == "string") {
$goto(typeof navigateTo == "string" ? `${navigateTo}` : navigateTo)
}
let loading = false
let modified = false
let scrolling = false
let showSidePanel = false
let nameError
let newQuery
let datasource
let integration
let schemaType
let autoSchema = {}
let rows = []
const parseQuery = query => {
modified = false
datasource = $datasources.list.find(ds => ds._id === query.datasourceId)
integration = $integrations[datasource.source]
schemaType = integration.query[query.queryVerb].type
newQuery = cloneDeep(query)
// Set the location where the query code will be written to an empty string so that it doesn't
// get changed from undefined -> "" by the input, breaking our unsaved changes checks
newQuery.fields[schemaType] ??= ""
queryHash = JSON.stringify(newQuery)
}
const transformerDocs = "https://docs.budibase.com/docs/transformers"
$: parseQuery(query)
let fields = query?.schema ? schemaToFields(query.schema) : []
let parameters
let data = []
let saveId
let currentTab = "JSON"
let saveModal
let override = false
let navigateTo = null
let nameError = null
const checkIsModified = newQuery => {
const newQueryHash = JSON.stringify(newQuery)
modified = newQueryHash !== queryHash
// seed the transformer
if (query && !query.transformer) {
query.transformer = "return data"
return modified
}
// initialise a new empty schema
if (query && !query.schema) {
query.schema = {}
}
const debouncedCheckIsModified = Utils.debounce(checkIsModified, 1000)
let queryStr = JSON.stringify(query)
$: debouncedCheckIsModified(newQuery)
$beforeUrlChange(event => {
const updated = JSON.stringify(query)
if (updated !== queryStr && !override) {
navigateTo = event.type == "pushstate" ? event.url : null
saveModal.show()
return false
} else return true
})
$: datasource = $datasources.list.find(ds => ds._id === query.datasourceId)
$: query.schema = fieldsToSchema(fields)
$: datasourceType = datasource?.source
$: integrationInfo = datasourceType ? $integrations[datasourceType] : null
$: queryConfig = integrationInfo?.query
$: shouldShowQueryConfig = queryConfig && query.queryVerb
$: readQuery = query.queryVerb === "read" || query.readable
$: queryInvalid = !query.name || nameError || (readQuery && data.length === 0)
//Cast field in query preview response to number if specified by schema
$: {
for (let i = 0; i < data.length; i++) {
let row = data[i]
for (let fieldName of Object.keys(fields)) {
if (fields[fieldName] === "number" && !isNaN(Number(row[fieldName]))) {
row[fieldName] = Number(row[fieldName])
} else {
row[fieldName] = row[fieldName]?.toString()
}
}
}
}
function resetDependentFields() {
if (query.fields.extra) {
query.fields.extra = {}
}
}
function populateExtraQuery(extraQueryFields) {
query.fields.extra = extraQueryFields
}
async function previewQuery() {
async function runQuery({ suppressErrors = true }) {
try {
const response = await queries.preview(query)
showSidePanel = true
loading = true
const response = await queries.preview(newQuery)
if (response.rows.length === 0) {
notifications.info(
"Query results empty. Please execute a query with results to create your schema."
)
return
}
data = response.rows
// need to merge fields that already exist/might have changed
if (fields) {
for (let key of Object.keys(response.schema)) {
if (fields[key]) {
response.schema[key] = fields[key]
}
}
if (Object.keys(newQuery.schema).length === 0) {
// Assign this to a variable instead of directly to the newQuery.schema so that a user
// can change the table they're querying and have the schema update until they first
// edit it
autoSchema = response.schema
}
fields = response.schema
currentTab = "JSON"
rows = response.rows
notifications.success("Query executed successfully")
} catch (error) {
notifications.error(`Query Error: ${error.message}`)
if (!suppressErrors) {
throw error
}
} finally {
loading = false
}
}
// return the query.
async function saveQuery() {
try {
const response = await queries.save(query.datasourceId, query)
saveId = response._id
if (response?._rev) {
queryStr = JSON.stringify(query)
}
showSidePanel = true
loading = true
const response = await queries.save(newQuery.datasourceId, {
...newQuery,
schema:
Object.keys(newQuery.schema).length === 0
? autoSchema
: newQuery.schema,
})
notifications.success("Query saved successfully")
return response
} catch (error) {
notifications.error(error.message || "Error saving query")
} finally {
loading = false
}
}
function resetDependentFields() {
if (newQuery.fields.extra) {
newQuery.fields.extra = {}
}
}
function populateExtraQuery(extraQueryFields) {
newQuery.fields.extra = extraQueryFields
}
const handleScroll = e => {
scrolling = e.target.scrollTop !== 0
}
</script>
<Modal
bind:this={saveModal}
on:hide={() => {
navigateTo = null
}}
>
<ModalContent
title="You have unsaved changes"
confirmText="Save and Continue"
cancelText="Discard Changes"
size="L"
onConfirm={async () => {
await saveQuery()
override = true
resumeNavigation()
}}
onCancel={async () => {
override = true
resumeNavigation()
}}
>
<Body>Leaving this section will mean losing and changes to your query</Body>
</ModalContent>
</Modal>
<div class="wrapper">
<Layout gap="S" noPadding>
<Heading size="M">Query {integrationInfo?.friendlyName}</Heading>
<Divider />
<Heading size="S">Config</Heading>
<div class="config">
<div class="config-field">
<Label>Query Name</Label>
<Input
value={query.name}
on:input={e => {
let newValue = e.target.value || ""
if (newValue.match(ValidQueryNameRegex)) {
query.name = newValue.trim()
nameError = null
} else {
nameError = "Invalid query name"
}
}}
error={nameError}
/>
</div>
{#if queryConfig}
<div class="config-field">
<Label>Function</Label>
<Select
bind:value={query.queryVerb}
on:change={resetDependentFields}
options={Object.keys(queryConfig)}
getOptionLabel={verb =>
queryConfig[verb]?.displayName || capitalise(verb)}
/>
</div>
<div class="config-field">
<AccessLevelSelect {saveId} {query} label="Access Level" />
</div>
{#if integrationInfo?.extra && query.queryVerb}
<ExtraQueryConfig
{query}
{populateExtraQuery}
config={integrationInfo.extra}
/>
{/if}
{#key query.parameters}
<div class="binding-wrap">
<BindingBuilder
queryBindings={query.parameters}
bindable={false}
on:change={e => {
query.parameters = e.detail.map(binding => {
return {
name: binding.name,
default: binding.value,
}
})
}}
/>
</div>
{/key}
{/if}
</div>
{#if shouldShowQueryConfig}
<Divider />
<div class="config">
<Heading size="S">Fields</Heading>
<Body size="S">Fill in the fields specific to this query.</Body>
<IntegrationQueryEditor
{datasource}
{query}
height={200}
schema={queryConfig[query.queryVerb]}
bind:parameters
/>
<Divider />
</div>
<div class="config">
<div class="help-heading">
<Heading size="S">Transformer</Heading>
<Icon
on:click={() => window.open(transformerDocs)}
hoverable
name="Help"
size="L"
/>
</div>
<QueryViewerSavePromptModal
checkIsModified={() => checkIsModified(newQuery)}
attemptSave={() => runQuery({ suppressErrors: false }).then(saveQuery)}
/>
<div class="queryViewer">
<div class="main">
<div class="header" class:scrolling>
<div class="title">
<Body size="S">
Add a JavaScript function to transform the query result.
{newQuery.name || "Untitled query"}<span class="unsaved"
>{modified ? "*" : ""}</span
>
</Body>
<CodeMirrorEditor
height={200}
label="Transformer"
value={query.transformer}
resize="vertical"
on:change={e => (query.transformer = e.detail)}
/>
<Divider />
</div>
<div class="viewer-controls">
<Heading size="S">Results</Heading>
<ButtonGroup gap="S">
<div class="controls">
<Button disabled={loading} on:click={runQuery} overBackground>
<Icon size="S" name="Play" />
Run query</Button
>
<div class="tooltip" title="Run your query to enable saving">
<Button
cta
disabled={queryInvalid}
on:click={async () => {
await saveQuery()
// Go to the correct URL if we just created a new query
if (!query._rev) {
$goto(`../../${query._id}`)
const response = await saveQuery()
// When creating a new query the initally passed in query object will have no id.
if (response._id && !newQuery._id) {
// Set the comparison query hash to match the new query so that the user doesn't
// get nagged when navigating to the edit view
queryHash = JSON.stringify(newQuery)
$goto(`../../${response._id}`)
}
}}
disabled={loading ||
!newQuery.name ||
nameError ||
rows.length === 0}
overBackground
>
Save Query
<Icon size="S" name="SaveFloppy" />
Save
</Button>
<Button secondary on:click={previewQuery}>Run Query</Button>
</ButtonGroup>
</div>
</div>
<Body size="S">
Below, you can preview the results from your query and change the
schema.
</Body>
<section class="viewer">
{#if data}
<Tabs bind:selected={currentTab}>
<Tab title="JSON">
<JSONPreview data={data[0]} minHeight="120" />
</Tab>
<Tab title="Schema">
<KeyValueBuilder
bind:object={fields}
name="field"
headings
options={SchemaTypeOptions}
</div>
<div class="body" on:scroll={handleScroll}>
<div class="bodyInner">
<div class="configField">
<Label>Name</Label>
<Input
value={newQuery.name}
on:input={e => {
let newValue = e.target.value || ""
if (newValue.match(ValidQueryNameRegex)) {
newQuery.name = newValue.trim()
nameError = null
} else {
nameError = "Invalid query name"
}
}}
error={nameError}
/>
{#if integration.query}
<Label>Function</Label>
<Select
bind:value={newQuery.queryVerb}
on:change={resetDependentFields}
options={Object.keys(integration.query)}
getOptionLabel={verb =>
integration.query[verb]?.displayName || capitalise(verb)}
/>
<Label>Access</Label>
<AccessLevelSelect query={newQuery} />
{#if integration?.extra && newQuery.queryVerb}
<ExtraQueryConfig
query={newQuery}
{populateExtraQuery}
config={integration.extra}
/>
</Tab>
<Tab title="Preview">
<ExternalDataSourceTable {query} {data} />
</Tab>
</Tabs>
{/if}
</section>
{/if}
</Layout>
{/if}
{/if}
</div>
<Divider />
<div class="heading">
<Heading weight="L" size="XS">Query</Heading>
</div>
<div class="copy">
<Body size="S">
{#if schemaType === "sql"}
Add some SQL to query your data
{:else if schemaType === "json"}
Add some JSON to query your data
{:else if schemaType === "fields"}
Add some fields to query your data
{:else}
Enter your query below
{/if}
</Body>
</div>
<IntegrationQueryEditor
noLabel
{datasource}
bind:query={newQuery}
height={200}
schema={integration.query[newQuery.queryVerb]}
/>
<Divider />
<div class="heading">
<Heading weight="L" size="XS">Bindings</Heading>
</div>
<div class="copy">
<Body size="S">
Bindings come in two parts: the binding name, and a default/fallback
value. These bindings can be used as Handlebars expressions
throughout the query.
</Body>
</div>
{#key newQuery.parameters}
<BindingBuilder
hideHeading
queryBindings={newQuery.parameters}
on:change={e => {
newQuery.parameters = e.detail.map(binding => {
return {
name: binding.name,
default: binding.value,
}
})
}}
/>
{/key}
<Divider />
<div class="heading">
<Heading weight="L" size="XS">Transformer</Heading>
</div>
<div class="copy">
<Body size="S">
Add a JavaScript function to transform the query result.
</Body>
</div>
<CodeMirrorEditor
height={200}
value={newQuery.transformer}
resize="vertical"
on:change={e => (newQuery.transformer = e.detail)}
/>
</div>
</div>
</div>
<div class:showSidePanel class="sidePanel">
<QueryViewerSidePanel
onClose={() => (showSidePanel = false)}
onSchemaChange={newSchema => {
newQuery.schema = newSchema
}}
{rows}
schema={Object.keys(newQuery.schema).length === 0
? autoSchema
: newQuery.schema}
/>
</div>
</div>
<style>
.wrapper {
width: 640px;
.unsaved {
color: var(--grey-5);
font-style: italic;
}
.queryViewer {
height: 100%;
margin: -28px -40px -40px -40px;
display: flex;
flex: 1;
}
.queryViewer :global(.spectrum-Divider) {
margin: 35px 0;
}
.main {
flex-grow: 1;
height: 100%;
display: flex;
flex-direction: column;
}
.header {
align-items: center;
padding: 8px 10px 8px 16px;
display: flex;
border-bottom: 2px solid transparent;
transition: border-bottom 130ms ease-out, background 130ms ease-out;
}
.header.scrolling {
border-bottom: var(--border-light);
background: var(--background);
}
.body {
flex-grow: 1;
overflow-y: scroll;
padding: 23px 23px 80px;
box-sizing: border-box;
}
.bodyInner {
max-width: 520px;
margin: auto;
}
.config {
display: grid;
grid-gap: var(--spacing-s);
z-index: 1;
.title {
/* width 0 paired with flex-grow necessary here for the truncation to work properly*/
width: 0;
flex-grow: 1;
}
.config-field {
.title :global(p) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.controls {
flex-shrink: 0;
}
.tooltip {
display: inline-block;
}
.controls :global(button) {
border: none;
color: var(--grey-7);
font-weight: 300;
}
.controls :global(button):hover {
background-color: transparent;
color: var(--ink);
}
.controls :global(.is-disabled) {
pointer-events: none;
background-color: transparent;
color: var(--grey-3);
}
.controls :global(span) {
display: flex;
align-items: center;
}
.controls :global(.icon) {
margin-right: 8px;
}
.configField {
display: grid;
grid-template-columns: 20% 1fr;
grid-gap: var(--spacing-l);
align-items: center;
}
.help-heading {
display: flex;
justify-content: space-between;
.configField :global(label) {
color: var(--grey-6);
}
.viewer {
min-height: 200px;
width: 640px;
.heading {
margin-bottom: 8px;
}
.viewer-controls {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: var(--spacing-m);
min-width: 150px;
align-items: center;
.copy {
margin-bottom: 14px;
}
.binding-wrap :global(div.container) {
padding-left: 0px;
padding-right: 0px;
.copy :global(p) {
color: var(--grey-7);
}
.sidePanel {
flex-shrink: 0;
height: 100%;
width: 0;
overflow: hidden;
transition: width 150ms;
}
.sidePanel :global(.panel) {
height: 100%;
}
.showSidePanel {
width: 450px;
}
</style>

View file

@ -1,9 +1,9 @@
<script>
import { Body, Heading, Layout } from "@budibase/bbui"
import KeyValueBuilder from "components/integration/KeyValueBuilder.svelte"
import { getUserBindings } from "builderStore/dataBinding"
export let bindable = true
export let queryBindings = []
export let hideHeading = false
const userBindings = getUserBindings()
@ -13,44 +13,16 @@
}, {})
</script>
<Layout noPadding={bindable} gap="S">
<div class="controls" class:height={!bindable}>
<Heading size="XS">Bindings</Heading>
</div>
<Body size="S">
{#if !bindable}
Bindings come in two parts: the binding name, and a default/fallback
value. These bindings can be used as Handlebars expressions throughout the
query.
{:else}
Enter a value for each binding. The default values will be used for any
values left blank.
{/if}
</Body>
<div class="bindings" class:bindable>
<KeyValueBuilder
bind:object={internalBindings}
tooltip="Set the name of the binding which can be used in Handlebars statements throughout your query"
name="binding"
headings
keyPlaceholder="Binding name"
valuePlaceholder="Default"
bindings={[...userBindings]}
bindingDrawerLeft="260px"
allowHelpers={false}
on:change
/>
</div>
</Layout>
<style>
.controls {
display: flex;
align-items: center;
justify-content: space-between;
}
.height {
height: 40px;
}
</style>
<KeyValueBuilder
bind:object={internalBindings}
tooltip="Set the name of the binding which can be used in Handlebars statements throughout your query"
name="binding"
customButtonText="Bindings"
headings
keyPlaceholder="Binding name"
valuePlaceholder="Default"
bindings={[...userBindings]}
bindingDrawerLeft="260px"
allowHelpers={false}
on:change
/>

View file

@ -0,0 +1,53 @@
<script>
import { goto, beforeUrlChange } from "@roxi/routify"
import { Body, Modal, ModalContent } from "@budibase/bbui"
export let checkIsModified = () => {}
export let attemptSave = () => {}
let modal
let navigateTo
let override = false
$beforeUrlChange(event => {
if (checkIsModified() && !override) {
navigateTo = event.type == "pushstate" ? event.url : null
modal.show()
return false
} else return true
})
const resumeNavigation = () => {
if (typeof navigateTo == "string") {
$goto(typeof navigateTo == "string" ? `${navigateTo}` : navigateTo)
}
}
</script>
<Modal
bind:this={modal}
on:hide={() => {
navigateTo = null
}}
>
<ModalContent
title="You have unsaved changes"
confirmText="Save and Continue"
cancelText="Discard Changes"
size="L"
onConfirm={async () => {
try {
await attemptSave()
override = true
resumeNavigation()
} catch (e) {
navigateTo = false
}
}}
onCancel={async () => {
override = true
resumeNavigation()
}}
>
<Body>Leaving this section will mean losing any changes to your query</Body>
</ModalContent>
</Modal>

View file

@ -0,0 +1,22 @@
<script>
export let data
$: string = JSON.stringify(data || {}, null, 2)
</script>
<textarea class="json" disabled value={string} />
<style>
.json {
width: 100%;
height: 100%;
color: var(--ink);
padding: 12px;
box-sizing: border-box;
resize: none;
background-color: var(
--spectrum-textfield-m-background-color,
var(--spectrum-global-color-gray-50)
);
}
</style>

View file

@ -0,0 +1,33 @@
<script>
import Table from "components/backend/DataTable/Table.svelte"
import { cloneDeep } from "lodash/fp"
export let schema = {}
export let rows = []
$: rowsCopy = cloneDeep(rows)
// Cast field in query preview response to number if specified by schema
$: {
for (let i = 0; i < rowsCopy.length; i++) {
let row = rowsCopy[i]
for (let fieldName of Object.keys(schema)) {
if (schema[fieldName] === "number" && !isNaN(Number(row[fieldName]))) {
row[fieldName] = Number(row[fieldName])
} else {
row[fieldName] = row[fieldName]?.toString()
}
}
}
}
</script>
<div class="table">
<Table {schema} data={rowsCopy} type="external" allowEditing={false} />
</div>
<style>
.table :global(.spectrum-Table-cell) {
min-width: 100px;
}
</style>

View file

@ -0,0 +1,29 @@
<script>
import KeyValueBuilder from "../KeyValueBuilder.svelte"
import { SchemaTypeOptions } from "constants/backend"
export let schema
export let onSchemaChange = () => {}
const handleChange = e => {
let newSchema = {}
// KeyValueBuilder on change event returns an array of objects with each object
// containing a field
e.detail.forEach(({ name, value }) => {
newSchema[name] = value
})
onSchemaChange(newSchema)
}
</script>
{#key schema}
<KeyValueBuilder
on:change={handleChange}
object={schema}
name="field"
headings
options={SchemaTypeOptions}
/>
{/key}

View file

@ -0,0 +1,66 @@
<script>
import Panel from "components/design/Panel.svelte"
import { ActionButton } from "@budibase/bbui"
import JSONPanel from "./JSONPanel.svelte"
import SchemaPanel from "./SchemaPanel.svelte"
import PreviewPanel from "./PreviewPanel.svelte"
export let rows
export let schema
export let onSchemaChange = () => {}
export let onClose = () => {}
const tabs = ["JSON", "Schema", "Preview"]
let activeTab = "JSON"
</script>
<Panel
showCloseButton
closeButtonIcon="RailRightClose"
onClickCloseButton={onClose}
title="Query results"
icon={"SQLQuery"}
borderLeft
extraWide
>
<div slot="panel-header-content">
<div class="settings-tabs">
{#each tabs as tab}
<ActionButton
size="M"
quiet
selected={activeTab === tab}
on:click={() => {
activeTab = tab
}}
>
{tab}
</ActionButton>
{/each}
</div>
</div>
<div class="content">
{#if activeTab === "JSON"}
<JSONPanel data={rows[0] || {}} />
{:else if activeTab === "Schema"}
<SchemaPanel {onSchemaChange} {schema} />
{:else}
<PreviewPanel {schema} {rows} />
{/if}
</div>
</Panel>
<style>
.settings-tabs {
display: flex;
gap: var(--spacing-s);
padding: 0 var(--spacing-l);
padding-bottom: var(--spacing-l);
}
.content {
padding: 14px;
height: 100%;
overflow: scroll;
}
</style>

View file

@ -23,6 +23,7 @@
export let schema
export let editable = true
export let height = 500
export let noLabel = false
let stepEditors = []
@ -75,7 +76,7 @@
{#if schema.type === QueryTypes.SQL}
<Editor
editorHeight={height}
label="Query"
label={noLabel ? null : "Query"}
mode="sql"
on:change={updateQuery}
readOnly={!editable}
@ -85,7 +86,7 @@
{:else if shouldDisplayJsonBox}
<Editor
editorHeight={height}
label="Query"
label={noLabel ? null : "Query"}
mode="json"
on:change={updateQuery}
readOnly={!editable}

View file

@ -0,0 +1,69 @@
<script>
import {
ModalContent,
Toggle,
Input,
Layout,
Dropzone,
notifications,
Body,
} from "@budibase/bbui"
import { API } from "api"
import { automationStore, store } from "../../builderStore"
export let app
$: disabled = (encrypted && !password) || !file
let encrypted = false,
password
let file
async function updateApp() {
try {
let data = new FormData()
data.append("appExport", file)
if (encrypted) {
data.append("encryptionPassword", password.trim())
}
const appId = app.devId
await API.updateAppFromExport(appId, data)
const pkg = await API.fetchAppPackage(appId)
await store.actions.initialise(pkg)
await automationStore.actions.fetch()
notifications.success("App updated successfully")
} catch (err) {
notifications.error(`Failed to update app - ${err.message || err}`)
}
}
</script>
<ModalContent
title={`Update ${app.name}`}
confirmText="Update"
onConfirm={updateApp}
bind:disabled
>
<Body size="S"
>Updating an app using an app export will replace all tables, datasources,
queries, screens and automations. It is recommended to perform a backup
before running this operation.</Body
>
<Layout noPadding gap="XS">
<Dropzone
gallery={false}
label="App export"
on:change={e => {
file = e.detail?.[0]
}}
/>
<Toggle text="Encrypted" bind:value={encrypted} />
{#if encrypted}
<Input
type="password"
label="Password"
placeholder="Type here..."
bind:value={password}
/>
{/if}
</Layout>
</ModalContent>

View file

@ -120,6 +120,13 @@ export const FIELDS = {
presence: false,
},
},
BB_REFERENCE_USER: {
name: "User",
type: "bb_reference",
subtype: "user",
compositeType: "bb_reference_user", // Used for working with the subtype on CreateEditColumn as is it was a primary type
icon: "User",
},
}
export const AUTO_COLUMN_SUB_TYPES = {
@ -170,6 +177,11 @@ export const RelationshipType = {
MANY_TO_ONE: "many-to-one",
}
export const PrettyRelationshipDefinitions = {
MANY: "Many rows",
ONE: "One row",
}
export const ALLOWABLE_STRING_OPTIONS = [
FIELDS.STRING,
FIELDS.OPTIONS,

View file

@ -22,6 +22,7 @@
import { isActive, goto, layout, redirect } from "@roxi/routify"
import { capitalise } from "helpers"
import { onMount, onDestroy } from "svelte"
import VerificationPromptBanner from "components/common/VerificationPromptBanner.svelte"
import CommandPalette from "components/commandPalette/CommandPalette.svelte"
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
import TourPopover from "components/portal/onboarding/TourPopover.svelte"
@ -136,6 +137,7 @@
{/if}
<div class="root" class:blur={$store.showPreview}>
<VerificationPromptBanner />
<div class="top-nav">
{#if $store.initialised}
<div class="topleftnav">

View file

@ -21,15 +21,22 @@
function getRelationships(tables) {
const relatedColumns = {}
tables.forEach(({ name: tableName, schema }) => {
tables.forEach(({ name: tableName, schema, _id: tableId }) => {
Object.values(schema).forEach(column => {
if (column.type !== "link") return
relatedColumns[column._id] ??= {}
relatedColumns[column._id].through =
relatedColumns[column._id].through || column.through
const columnId =
column.through ||
column._id ||
(column.main
? `${tableId}_${column.fieldName}__${column.tableId}_${column.foreignKey}`
: `${column.tableId}_${column.foreignKey}__${tableId}_${column.fieldName}`)
relatedColumns[column._id][column.main ? "from" : "to"] = {
relatedColumns[columnId] ??= {}
relatedColumns[columnId].through =
relatedColumns[columnId].through || column.through
relatedColumns[columnId][column.main ? "from" : "to"] = {
...column,
tableName,
}

View file

@ -16,6 +16,9 @@
const buildNewQuery = isRestQuery => {
let query = {
name: "Untitled query",
transformer: "return data",
schema: {},
datasourceId: $params.datasourceId,
parameters: [],
fields: {},

View file

@ -61,10 +61,12 @@
key: "_css",
type: "text",
})
$: settingOptions = settings.map(setting => ({
label: makeLabel(setting),
value: setting.key,
}))
$: settingOptions = settings
.filter(setting => setting.supportsConditions !== false)
.map(setting => ({
label: makeLabel(setting),
value: setting.key,
}))
$: conditions.forEach(link => {
if (!link.id) {
link.id = generate()

View file

@ -36,10 +36,7 @@
</script>
<DetailSummary name={"Conditions"} collapsible={false}>
<div class="conditionCount">{conditionText}</div>
<div>
<ActionButton on:click={openDrawer}>Configure conditions</ActionButton>
</div>
<ActionButton on:click={openDrawer}>{conditionText}</ActionButton>
</DetailSummary>
<Drawer bind:this={drawer} title="Conditions">
<svelte:fragment slot="description">
@ -48,10 +45,3 @@
<Button cta slot="buttons" on:click={() => save()}>Save</Button>
<ConditionalUIDrawer slot="body" bind:conditions={tempValue} {bindings} />
</Drawer>
<style>
.conditionCount {
font-weight: 600;
margin-top: -5px;
}
</style>

View file

@ -69,7 +69,8 @@
"datetimefield",
"multifieldselect",
"s3upload",
"codescanner"
"codescanner",
"bbreferencefield"
]
},
{

View file

@ -32,9 +32,9 @@
active={$isActive("./embed")}
/>
<SideNavItem
text="Export"
url={$url("./export")}
active={$isActive("./export")}
text="Export/Import"
url={$url("./exportImport")}
active={$isActive("./exportImport")}
/>
<SideNavItem
text="Name and URL"

View file

@ -11,31 +11,39 @@
import { apps } from "stores/portal"
import { store } from "builderStore"
import ExportAppModal from "components/start/ExportAppModal.svelte"
import ImportAppModal from "components/start/ImportAppModal.svelte"
$: filteredApps = $apps.filter(app => app.devId == $store.appId)
$: app = filteredApps.length ? filteredApps[0] : {}
$: appDeployed = app?.status === AppStatus.DEPLOYED
let exportModal
let exportModal, importModal
let exportPublishedVersion = false
const exportApp = opts => {
exportPublishedVersion = !!opts?.published
exportModal.show()
}
const importApp = () => {
importModal.show()
}
</script>
<Modal bind:this={exportModal} padding={false}>
<ExportAppModal {app} published={exportPublishedVersion} />
</Modal>
<Modal bind:this={importModal} padding={false}>
<ImportAppModal {app} />
</Modal>
<Layout noPadding>
<Layout gap="XS" noPadding>
<Heading>Export your app</Heading>
<Body>Export your latest edited or published app</Body>
</Layout>
<Divider />
<div class="export-body">
<div class="body">
<ActionButton secondary on:click={() => exportApp({ published: false })}>
Export latest edited app
</ActionButton>
@ -47,10 +55,20 @@
Export latest published app
</ActionButton>
</div>
<Divider />
<Layout gap="XS" noPadding>
<Heading>Import your app</Heading>
<Body>Import an export to update this app</Body>
</Layout>
<div class="body">
<ActionButton secondary on:click={() => importApp()}>
Import app
</ActionButton>
</div>
</Layout>
<style>
.export-body {
.body {
display: flex;
gap: var(--spacing-l);
}

View file

@ -8,6 +8,7 @@
import Logo from "./_components/Logo.svelte"
import UserDropdown from "./_components/UserDropdown.svelte"
import HelpMenu from "components/common/HelpMenu.svelte"
import VerificationPromptBanner from "components/common/VerificationPromptBanner.svelte"
import { sdk } from "@budibase/shared-core"
let loaded = false
@ -55,6 +56,7 @@
{:else}
<HelpMenu />
<div class="container">
<VerificationPromptBanner />
<div class="nav">
<div class="branding">
<Logo />
@ -99,7 +101,7 @@
justify-content: flex-start;
align-items: center;
border-bottom: var(--border-light);
padding: 0 24px;
padding: 0 var(--spacing-l);
gap: 24px;
position: relative;
}

View file

@ -86,8 +86,16 @@
$: userPage = $userPageInfo.page
$: logsPage = $logsPageInfo.page
let usersObj = {}
$: usersObj = {
...usersObj,
...$users.data?.reduce((accumulator, user) => {
accumulator[user._id] = user
return accumulator
}, {}),
}
$: sortedUsers = sort(
enrich($users.data || [], selectedUsers, "_id"),
enrich(Object.values(usersObj), selectedUsers, "_id"),
"email"
)
$: sortedEvents = sort(
@ -256,8 +264,7 @@
<div class="controls">
<div class="select">
<Multiselect
bind:fetchTerm={userSearchTerm}
useFetch
bind:searchTerm={userSearchTerm}
placeholder="All users"
label="Users"
autocomplete

View file

@ -0,0 +1,15 @@
<script>
import { params, redirect } from "@roxi/routify"
import { apps } from "stores/portal"
$: app = $apps.find(app => app.appId === $params.appId)
$: {
if (!app) {
$redirect("../")
}
}
</script>
{#if app}
<slot />
{/if}

View file

@ -0,0 +1,79 @@
<script>
import { params, goto } from "@roxi/routify"
import { apps, auth, sideBarCollapsed } from "stores/portal"
import { ActionButton } from "@budibase/bbui"
import { sdk } from "@budibase/shared-core"
$: app = $apps.find(app => app.appId === $params.appId)
$: iframeUrl = getIframeURL(app)
$: isBuilder = sdk.users.isBuilder($auth.user, app?.devId)
const getIframeURL = app => {
if (app.status === "published") {
return `/app${app.url}`
}
return `/${app.devId}`
}
</script>
<div class="container">
<div class="header">
{#if $sideBarCollapsed}
<ActionButton
quiet
icon="Rail"
on:click={() => sideBarCollapsed.set(false)}
>
Menu
</ActionButton>
{:else}
<ActionButton
quiet
icon="RailRightOpen"
on:click={() => sideBarCollapsed.set(true)}
>
Collapse
</ActionButton>
{/if}
{#if isBuilder}
<ActionButton
quiet
icon="Edit"
on:click={() => $goto(`/builder/app/${app.devId}`)}
>
Edit
</ActionButton>
{/if}
<ActionButton
quiet
icon="LinkOut"
on:click={() => window.open(iframeUrl, "_blank")}
>
Fullscreen
</ActionButton>
</div>
<iframe src={iframeUrl} title={app.name} />
</div>
<style>
.container {
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
padding: 0 var(--spacing-l) var(--spacing-l) var(--spacing-l);
}
.header {
display: flex;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-xs);
flex: 0 0 50px;
}
iframe {
flex: 1 1 auto;
border-radius: var(--spacing-s);
border: 1px solid var(--spectrum-global-color-gray-300);
}
</style>

View file

@ -0,0 +1,140 @@
<script>
import { Icon, Body } from "@budibase/bbui"
import { apps, sideBarCollapsed } from "stores/portal"
import { params, goto } from "@roxi/routify"
import { tick } from "svelte"
import NavItem from "components/common/NavItem.svelte"
let searchInput
let searchString
let searching = false
$: filteredApps = $apps.filter(app => {
return (
!searchString ||
app.name.toLowerCase().includes(searchString.toLowerCase())
)
})
const startSearching = async () => {
searching = true
searchString = ""
await tick()
searchInput.focus()
}
const stopSearching = () => {
searching = false
searchString = ""
}
</script>
<div class="side-bar" class:collapsed={$sideBarCollapsed}>
<div class="side-bar-controls">
{#if searching}
<input
bind:this={searchInput}
bind:value={searchString}
placeholder="Search for apps"
/>
{:else}
<Body size="S">Apps</Body>
<Icon name="Search" size="S" hoverable on:click={startSearching} />
{/if}
<div class="rotational" class:rotated={searching}>
<Icon
name="Add"
hoverable
on:click={searching ? stopSearching : () => $goto("./create")}
/>
</div>
</div>
<div class="side-bar-nav">
<NavItem
icon="WebPages"
text="All apps"
on:click={() => $goto("./")}
selected={!$params.appId}
/>
{#each filteredApps as app}
<NavItem
text={app.name}
icon={app.icon?.name || "Apps"}
iconColor={app.icon?.color}
selected={$params.appId === app.appId}
on:click={() => $goto(`./${app.appId}`)}
/>
{/each}
</div>
</div>
<style>
.side-bar {
flex: 0 0 260px;
display: flex;
flex-direction: column;
align-items: stretch;
border-right: var(--border-light);
background: var(--spectrum-global-color-gray-100);
overflow: hidden;
transition: margin-left 300ms ease-out;
}
.side-bar.collapsed {
margin-left: -262px;
}
@media (max-width: 640px) {
.side-bar {
margin-left: -262px;
}
}
.side-bar-controls {
flex: 0 0 50px;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-l);
padding: 0 var(--spacing-l);
}
.side-bar-controls :global(.spectrum-Body),
.side-bar-controls input {
flex: 1 1 auto;
}
.side-bar-controls :global(.spectrum-Icon) {
color: var(--spectrum-global-color-gray-700);
}
input {
outline: none;
border: none;
max-width: none;
flex: 1 1 auto;
color: var(--spectrum-global-color-gray-800);
font-size: 14px;
padding: 0;
transition: border 130ms ease-out;
font-family: var(--font-sans);
background: inherit;
}
input::placeholder {
color: var(--spectrum-global-color-gray-700);
transition: color 130ms ease-out;
}
input:hover::placeholder {
color: var(--spectrum-global-color-gray-800);
}
.side-bar-nav {
flex: 1 1 auto;
overflow: auto;
overflow-x: hidden;
}
div.rotational {
transition: transform 130ms ease-out;
}
div.rotational.rotated {
transform: rotate(45deg);
}
</style>

View file

@ -11,6 +11,7 @@
import { onMount } from "svelte"
import { redirect } from "@roxi/routify"
import { sdk } from "@budibase/shared-core"
import PortalSideBar from "./_components/PortalSideBar.svelte"
// Don't block loading if we've already hydrated state
let loaded = $apps.length != null
@ -44,5 +45,20 @@
</script>
{#if loaded}
<slot />
<div class="page">
{#if $apps.length > 0}
<PortalSideBar />
{/if}
<slot />
</div>
{/if}
<style>
.page {
flex: 1 1 auto;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: stretch;
}
</style>

View file

@ -63,8 +63,7 @@
)
const githubResponse = await githubCheck.json()
//Get tag and remove the v infront of the tage name e.g. v1.0.0 is 1.0.0
githubVersion = githubResponse.tag_name.slice(1)
githubVersion = githubResponse.tag_name
//Get the release date and output it in the local time format
githubPublishedDate = new Date(githubResponse.published_at)

View file

@ -1,7 +1,7 @@
import { get, writable, derived } from "svelte/store"
import { cloneDeep } from "lodash/fp"
import { API } from "api"
import { SWITCHABLE_TYPES } from "constants/backend"
import { SWITCHABLE_TYPES, FIELDS } from "constants/backend"
export function createTablesStore() {
const store = writable({
@ -21,6 +21,23 @@ export function createTablesStore() {
}))
}
const singleFetch = async tableId => {
const table = await API.getTable(tableId)
store.update(state => {
const list = []
// update the list, keep order accurate
for (let tbl of state.list) {
if (table._id === tbl._id) {
list.push(table)
} else {
list.push(tbl)
}
}
state.list = list
return state
})
}
const select = tableId => {
store.update(state => ({
...state,
@ -63,6 +80,20 @@ export function createTablesStore() {
const savedTable = await API.saveTable(updatedTable)
replaceTable(savedTable._id, savedTable)
select(savedTable._id)
// make sure tables up to date (related)
let tableIdsToFetch = []
for (let column of Object.values(updatedTable?.schema || {})) {
if (column.type === FIELDS.LINK.type) {
tableIdsToFetch.push(column.tableId)
}
}
tableIdsToFetch = [...new Set(tableIdsToFetch)]
// too many tables to fetch, just get all
if (tableIdsToFetch.length > 3) {
await fetch()
} else {
await Promise.all(tableIdsToFetch.map(id => singleFetch(id)))
}
return savedTable
}

View file

@ -1,3 +1,5 @@
import { writable } from "svelte/store"
export { organisation } from "./organisation"
export { users } from "./users"
export { admin } from "./admin"
@ -14,3 +16,5 @@ export { environment } from "./environment"
export { menu } from "./menu"
export { auditLogs } from "./auditLogs"
export { features } from "./features"
export const sideBarCollapsed = writable(false)

View file

@ -5,8 +5,5 @@
"declaration": true,
"sourceMap": true,
"baseUrl": "."
},
"ts-node": {
"require": ["tsconfig-paths/register"]
}
}

View file

@ -32,7 +32,6 @@
"@budibase/backend-core": "0.0.0",
"@budibase/string-templates": "0.0.0",
"@budibase/types": "0.0.0",
"axios": "0.21.2",
"chalk": "4.1.0",
"cli-progress": "3.11.2",
"commander": "7.1.0",
@ -41,7 +40,6 @@
"download": "8.0.0",
"find-free-port": "^2.0.0",
"inquirer": "8.0.0",
"joi": "17.6.0",
"lookpath": "1.1.0",
"node-fetch": "2.6.7",
"pkg": "5.8.0",
@ -53,15 +51,11 @@
"yaml": "^2.1.1"
},
"devDependencies": {
"@swc/core": "1.3.71",
"@swc/jest": "0.2.27",
"@types/jest": "29.5.3",
"@types/node-fetch": "2.6.4",
"@types/pouchdb": "^6.4.0",
"copyfiles": "^2.4.1",
"eslint": "^7.20.0",
"renamer": "^4.0.0",
"ts-node": "^10.9.1",
"typescript": "4.7.3"
"typescript": "5.2.2"
}
}

View file

@ -57,7 +57,8 @@ export async function checkDockerConfigured() {
"docker/docker-compose has not been installed, please follow instructions at: https://docs.budibase.com/docs/docker-compose"
const docker = await lookpath("docker")
const compose = await lookpath("docker-compose")
if (!docker || !compose) {
const composeV2 = await lookpath("docker compose")
if (!docker || (!compose && !composeV2)) {
throw error
}
}

View file

@ -49,8 +49,8 @@ async function askAboutTopLevel(name: string) {
}
}
async function init(opts: PluginOpts) {
const type = opts["init"] || (opts as PluginType)
async function init(opts: PluginOpts | PluginType) {
const type = (opts as PluginOpts).init || (opts as PluginType)
if (!type || !PLUGIN_TYPE_ARR.includes(type)) {
console.log(
error(

View file

@ -12,6 +12,10 @@ if (!process.argv[0].includes("node")) {
checkForBinaries()
}
function localPrebuildPath() {
return join(process.execPath, "..", PREBUILDS)
}
function checkForBinaries() {
const readDir = join(__filename, "..", "..", "..", "cli", PREBUILDS, ARCH)
if (fs.existsSync(PREBUILD_DIR) || !fs.existsSync(readDir)) {
@ -19,17 +23,21 @@ function checkForBinaries() {
}
const natives = fs.readdirSync(readDir)
if (fs.existsSync(readDir)) {
const writePath = join(process.execPath, PREBUILDS, ARCH)
const writePath = join(localPrebuildPath(), ARCH)
fs.mkdirSync(writePath, { recursive: true })
for (let native of natives) {
const filename = `${native.split(".fake")[0]}.node`
fs.cpSync(join(readDir, native), join(writePath, filename))
}
console.log("copied something")
}
}
function cleanup(evt?: number) {
// cleanup prebuilds first
const path = localPrebuildPath()
if (fs.existsSync(path)) {
fs.rmSync(path, { recursive: true })
}
if (evt && !isNaN(evt)) {
return
}
@ -41,10 +49,6 @@ function cleanup(evt?: number) {
)
console.error(error(evt))
}
const path = join(process.execPath, PREBUILDS)
if (fs.existsSync(path)) {
fs.rmSync(path, { recursive: true })
}
}
const events = ["exit", "SIGINT", "SIGUSR1", "SIGUSR2", "uncaughtException"]

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