Merge from master

This commit is contained in:
Adria Navarro 2024-11-04 10:25:38 +01:00
parent 96ec39bbbf
commit 7235fd9d5c
409 changed files with 12538 additions and 5700 deletions
.github/workflows
.gitignore
hosting/single
lerna.json
packages
account-portal
backend-core
bbui/src
builder
package.json
src/components
automation
backend
commandPalette
common

View File

@ -309,3 +309,27 @@ jobs:
} else {
console.log('All good, the submodule had been merged and setup correctly!')
}
check-lockfile:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
submodules: ${{ env.IS_OSS_CONTRIBUTOR == 'false' }}
token: ${{ secrets.PERSONAL_ACCESS_TOKEN || github.token }}
- name: Use Node.js 20.x
uses: actions/setup-node@v4
with:
node-version: 20.x
cache: yarn
- run: yarn install
- name: Check for yarn.lock changes
run: |
if [[ $(git status --porcelain) == *"yarn.lock"* ]]; then
echo "yarn.lock file needs to be modified. Please update it locally and commit the changes."
exit 1
else
echo "yarn.lock file is unchanged."
fi

View File

@ -3,7 +3,7 @@ name: Deploy QA
on:
push:
branches:
- v3-ui
- master
workflow_dispatch:
jobs:

3
.gitignore vendored
View File

@ -4,11 +4,10 @@ packages/server/runtime_apps/
.idea/
bb-airgapped.tar.gz
*.iml
packages/server/build/oldClientVersions/**/*
packages/builder/src/components/deploy/clientVersions.json
packages/server/src/integrations/tests/utils/*.lock
packages/builder/vite.config.mjs.timestamp*
# Logs
logs

View File

@ -22,7 +22,8 @@ RUN ./scripts/removeWorkspaceDependencies.sh packages/worker/package.json
RUN jq 'del(.scripts.postinstall)' package.json > temp.json && mv temp.json package.json
RUN ./scripts/removeWorkspaceDependencies.sh package.json
RUN --mount=type=cache,target=/root/.yarn YARN_CACHE_FOLDER=/root/.yarn yarn install --production --frozen-lockfile
ARG TARGETPLATFORM
RUN --mount=type=cache,target=/root/.yarn/${TARGETPLATFORM} YARN_CACHE_FOLDER=/root/.yarn/${TARGETPLATFORM} yarn install --production --frozen-lockfile
# copy the actual code
COPY packages/server/dist packages/server/dist

View File

@ -1,6 +1,6 @@
{
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "2.33.2",
"version": "3.0.0",
"npmClient": "yarn",
"packages": [
"packages/*",

@ -1 +1 @@
Subproject commit 8cd052ce8288f343812a514d06c5a9459b3ba1a8
Subproject commit 9bef5d1656b4f3c991447ded6d65b0eba393a140

View File

@ -28,6 +28,7 @@ export enum Config {
OIDC = "oidc",
OIDC_LOGOS = "logos_oidc",
SCIM = "scim",
AI = "AI",
}
export const MIN_VALID_DATE = new Date(-2147483647000)

View File

@ -27,7 +27,7 @@ export function doInUserContext(user: User, ctx: Ctx, task: any) {
hostInfo: {
ipAddress: ctx.request.ip,
// filled in by koa-useragent package
userAgent: ctx.userAgent._agent.source,
userAgent: ctx.userAgent.source,
},
}
return doInIdentityContext(userContext, task)

View File

@ -83,7 +83,7 @@ function getPackageJsonFields(): {
if (isDev() && !isTest()) {
try {
const lerna = getParentFile("lerna.json")
localVersion = lerna.version
localVersion = `${lerna.version}+local`
} catch {
//
}
@ -223,6 +223,8 @@ const environment = {
BB_ADMIN_USER_EMAIL: process.env.BB_ADMIN_USER_EMAIL,
BB_ADMIN_USER_PASSWORD: process.env.BB_ADMIN_USER_PASSWORD,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
MIN_VERSION_WITHOUT_POWER_ROLE:
process.env.MIN_VERSION_WITHOUT_POWER_ROLE || "3.0.0",
}
export function setEnv(newEnvVars: Partial<typeof environment>): () => void {

View File

@ -171,9 +171,9 @@ const identifyUser = async (
if (isSSOUser(user)) {
providerType = user.providerType
}
const accountHolder = account?.budibaseUserId === user._id || false
const verified =
account && account?.budibaseUserId === user._id ? account.verified : false
const accountHolder = await users.getExistingAccounts([user.email])
const isAccountHolder = accountHolder.length > 0
const verified = !!account && isAccountHolder && account.verified
const installationId = await getInstallationId()
const hosting = account ? account.hosting : getHostingFromEnv()
const environment = getDeploymentEnvironment()
@ -185,7 +185,7 @@ const identifyUser = async (
installationId,
tenantId,
verified,
accountHolder,
accountHolder: isAccountHolder,
providerType,
builder,
admin,
@ -207,9 +207,10 @@ const identifyAccount = async (account: Account) => {
const environment = getDeploymentEnvironment()
if (isCloudAccount(account)) {
if (account.budibaseUserId) {
const user = await users.getGlobalUserByEmail(account.email)
if (user?._id) {
// use the budibase user as the id if set
id = account.budibaseUserId
id = user._id
}
}

View File

@ -1,20 +1,26 @@
import { Cookie, Header } from "../constants"
import {
getCookie,
clearCookie,
openJwt,
getCookie,
isValidInternalAPIKey,
openJwt,
} from "../utils"
import { getUser } from "../cache/user"
import { getSession, updateSessionTTL } from "../security/sessions"
import { buildMatcherRegex, matches } from "./matchers"
import { SEPARATOR, queryGlobalView, ViewName } from "../db"
import { getGlobalDB, doInTenant } from "../context"
import { queryGlobalView, SEPARATOR, ViewName } from "../db"
import { doInTenant, getGlobalDB } from "../context"
import { decrypt } from "../security/encryption"
import * as identity from "../context/identity"
import env from "../environment"
import { Ctx, EndpointMatcher, SessionCookie, User } from "@budibase/types"
import { InvalidAPIKeyError, ErrorCode } from "../errors"
import {
Ctx,
EndpointMatcher,
LoginMethod,
SessionCookie,
User,
} from "@budibase/types"
import { ErrorCode, InvalidAPIKeyError } from "../errors"
import tracer from "dd-trace"
const ONE_MINUTE = env.SESSION_UPDATE_PERIOD
@ -26,16 +32,18 @@ interface FinaliseOpts {
internal?: boolean
publicEndpoint?: boolean
version?: string
user?: any
user?: User | { tenantId: string }
loginMethod?: LoginMethod
}
function timeMinusOneMinute() {
return new Date(Date.now() - ONE_MINUTE).toISOString()
}
function finalise(ctx: any, opts: FinaliseOpts = {}) {
function finalise(ctx: Ctx, opts: FinaliseOpts = {}) {
ctx.publicEndpoint = opts.publicEndpoint || false
ctx.isAuthenticated = opts.authenticated || false
ctx.loginMethod = opts.loginMethod
ctx.user = opts.user
ctx.internal = opts.internal || false
ctx.version = opts.version
@ -120,9 +128,10 @@ export default function (
}
const tenantId = ctx.request.headers[Header.TENANT_ID]
let authenticated = false,
user = null,
internal = false
let authenticated: boolean = false,
user: User | { tenantId: string } | undefined = undefined,
internal: boolean = false,
loginMethod: LoginMethod | undefined = undefined
if (authCookie && !apiKey) {
const sessionId = authCookie.sessionId
const userId = authCookie.userId
@ -146,6 +155,7 @@ export default function (
}
// @ts-ignore
user.csrfToken = session.csrfToken
loginMethod = LoginMethod.COOKIE
if (session?.lastAccessedAt < timeMinusOneMinute()) {
// make sure we denote that the session is still in use
@ -170,17 +180,16 @@ export default function (
apiKey,
populateUser
)
if (valid && foundUser) {
if (valid) {
authenticated = true
loginMethod = LoginMethod.API_KEY
user = foundUser
} else if (valid) {
authenticated = true
internal = true
internal = !foundUser
}
}
if (!user && tenantId) {
user = { tenantId }
} else if (user) {
} else if (user && "password" in user) {
delete user.password
}
// be explicit
@ -204,7 +213,14 @@ export default function (
}
// isAuthenticated is a function, so use a variable to be able to check authed state
finalise(ctx, { authenticated, user, internal, version, publicEndpoint })
finalise(ctx, {
authenticated,
user,
internal,
version,
publicEndpoint,
loginMethod,
})
if (isUser(user)) {
return identity.doInUserContext(user, ctx, next)

View File

@ -1,4 +1,8 @@
import { PermissionLevel, PermissionType } from "@budibase/types"
import {
PermissionLevel,
PermissionType,
BuiltinPermissionID,
} from "@budibase/types"
import flatten from "lodash/flatten"
import cloneDeep from "lodash/fp/cloneDeep"
@ -57,14 +61,6 @@ export function getAllowedLevels(userPermLevel: PermissionLevel): string[] {
}
}
export enum BuiltinPermissionID {
PUBLIC = "public",
READ_ONLY = "read_only",
WRITE = "write",
ADMIN = "admin",
POWER = "power",
}
export const BUILTIN_PERMISSIONS: {
[key in keyof typeof BuiltinPermissionID]: {
_id: (typeof BuiltinPermissionID)[key]

View File

@ -1,5 +1,4 @@
import semver from "semver"
import { BuiltinPermissionID, PermissionLevel } from "./permissions"
import {
prefixRoleID,
getRoleParams,
@ -14,10 +13,13 @@ import {
RoleUIMetadata,
Database,
App,
BuiltinPermissionID,
PermissionLevel,
} from "@budibase/types"
import cloneDeep from "lodash/fp/cloneDeep"
import { RoleColor, helpers } from "@budibase/shared-core"
import { uniqBy } from "lodash"
import { default as env } from "../environment"
export const BUILTIN_ROLE_IDS = {
ADMIN: "ADMIN",
@ -50,7 +52,7 @@ export class Role implements RoleDoc {
_id: string
_rev?: string
name: string
permissionId: string
permissionId: BuiltinPermissionID
inherits?: string | string[]
version?: string
permissions: Record<string, PermissionLevel[]> = {}
@ -59,7 +61,7 @@ export class Role implements RoleDoc {
constructor(
id: string,
name: string,
permissionId: string,
permissionId: BuiltinPermissionID,
uiMetadata?: RoleUIMetadata
) {
this._id = id
@ -213,13 +215,32 @@ export function getBuiltinRole(roleId: string): Role | undefined {
return cloneDeep(role)
}
export function validInherits(
allRoles: RoleDoc[],
inherits?: string | string[]
): boolean {
if (!inherits) {
return false
}
const find = (id: string) => allRoles.find(r => roleIDsAreEqual(r._id!, id))
if (Array.isArray(inherits)) {
const filtered = inherits.filter(roleId => find(roleId))
return inherits.length !== 0 && filtered.length === inherits.length
} else {
return !!find(inherits)
}
}
/**
* Works through the inheritance ranks to see how far up the builtin stack this ID is.
*/
export function builtinRoleToNumber(id: string) {
const builtins = getBuiltinRoles()
const MAX = Object.values(builtins).length + 1
if (id === BUILTIN_IDS.ADMIN || id === BUILTIN_IDS.BUILDER) {
if (
roleIDsAreEqual(id, BUILTIN_IDS.ADMIN) ||
roleIDsAreEqual(id, BUILTIN_IDS.BUILDER)
) {
return MAX
}
let role = builtins[id],
@ -256,7 +277,9 @@ export async function roleToNumber(id: string) {
// find the built-in roles, get their number, sort it, then get the last one
const highestBuiltin: number | undefined = role.inherits
.map(roleId => {
const foundRole = hierarchy.find(role => role._id === roleId)
const foundRole = hierarchy.find(role =>
roleIDsAreEqual(role._id!, roleId)
)
if (foundRole) {
return findNumber(foundRole) + 1
}
@ -290,7 +313,7 @@ export function lowerBuiltinRoleID(roleId1?: string, roleId2?: string): string {
: roleId1
}
export function compareRoleIds(roleId1: string, roleId2: string) {
export function roleIDsAreEqual(roleId1: string, roleId2: string) {
// make sure both role IDs are prefixed correctly
return prefixRoleID(roleId1) === prefixRoleID(roleId2)
}
@ -323,7 +346,7 @@ export function findRole(
roleId = prefixRoleID(roleId)
}
const dbRole = roles.find(
role => role._id && compareRoleIds(role._id, roleId)
role => role._id && roleIDsAreEqual(role._id, roleId)
)
if (!dbRole && !isBuiltin(roleId) && opts?.defaultPublic) {
return cloneDeep(BUILTIN_ROLES.PUBLIC)
@ -380,7 +403,7 @@ async function getAllUserRoles(
): Promise<RoleDoc[]> {
const allRoles = await getAllRoles()
// admins have access to all roles
if (userRoleId === BUILTIN_IDS.ADMIN) {
if (roleIDsAreEqual(userRoleId, BUILTIN_IDS.ADMIN)) {
return allRoles
}
@ -491,17 +514,21 @@ export async function getAllRoles(appId?: string): Promise<RoleDoc[]> {
// need to combine builtin with any DB record of them (for sake of permissions)
for (let builtinRoleId of externalBuiltinRoles) {
const builtinRole = builtinRoles[builtinRoleId]
const dbBuiltin = roles.filter(
dbRole =>
getExternalRoleID(dbRole._id!, dbRole.version) === builtinRoleId
const dbBuiltin = roles.filter(dbRole =>
roleIDsAreEqual(dbRole._id!, builtinRoleId)
)[0]
if (dbBuiltin == null) {
roles.push(builtinRole || builtinRoles.BASIC)
} else {
// remove role and all back after combining with the builtin
roles = roles.filter(role => role._id !== dbBuiltin._id)
dbBuiltin._id = getExternalRoleID(dbBuiltin._id!, dbBuiltin.version)
roles.push(Object.assign(builtinRole, dbBuiltin))
dbBuiltin._id = getExternalRoleID(builtinRole._id!, dbBuiltin.version)
roles.push({
...builtinRole,
...dbBuiltin,
name: builtinRole.name,
_id: getExternalRoleID(builtinRole._id!, builtinRole.version),
})
}
}
// check permissions
@ -528,7 +555,10 @@ async function shouldIncludePowerRole(db: Database) {
return true
}
const isGreaterThan3x = semver.gte(creationVersion, "3.0.0")
const isGreaterThan3x = semver.gte(
creationVersion,
env.MIN_VERSION_WITHOUT_POWER_ROLE
)
return !isGreaterThan3x
}
@ -544,9 +574,9 @@ export class AccessController {
if (
tryingRoleId == null ||
tryingRoleId === "" ||
tryingRoleId === userRoleId ||
tryingRoleId === BUILTIN_IDS.BUILDER ||
userRoleId === BUILTIN_IDS.BUILDER
roleIDsAreEqual(tryingRoleId, BUILTIN_IDS.BUILDER) ||
roleIDsAreEqual(userRoleId!, tryingRoleId) ||
roleIDsAreEqual(userRoleId!, BUILTIN_IDS.BUILDER)
) {
return true
}
@ -557,7 +587,7 @@ export class AccessController {
}
return (
roleIds?.find(roleId => compareRoleIds(roleId, tryingRoleId)) !==
roleIds?.find(roleId => roleIDsAreEqual(roleId, tryingRoleId)) !==
undefined
)
}

View File

@ -1,6 +1,7 @@
import cloneDeep from "lodash/cloneDeep"
import * as permissions from "../permissions"
import { BUILTIN_ROLE_IDS } from "../roles"
import { BuiltinPermissionID } from "@budibase/types"
describe("levelToNumber", () => {
it("should return 0 for EXECUTE", () => {
@ -77,7 +78,7 @@ describe("doesHaveBasePermission", () => {
const rolesHierarchy = [
{
roleId: BUILTIN_ROLE_IDS.ADMIN,
permissionId: permissions.BuiltinPermissionID.ADMIN,
permissionId: BuiltinPermissionID.ADMIN,
},
]
expect(
@ -91,7 +92,7 @@ describe("doesHaveBasePermission", () => {
const rolesHierarchy = [
{
roleId: BUILTIN_ROLE_IDS.PUBLIC,
permissionId: permissions.BuiltinPermissionID.PUBLIC,
permissionId: BuiltinPermissionID.PUBLIC,
},
]
expect(
@ -129,7 +130,7 @@ describe("getBuiltinPermissions", () => {
describe("getBuiltinPermissionByID", () => {
it("returns correct permission object for valid ID", () => {
const expectedPermission = {
_id: permissions.BuiltinPermissionID.PUBLIC,
_id: BuiltinPermissionID.PUBLIC,
name: "Public",
permissions: [
new permissions.Permission(

View File

@ -13,6 +13,7 @@ import SqlTableQueryBuilder from "./sqlTable"
import {
Aggregation,
AnySearchFilter,
ArrayFilter,
ArrayOperator,
BasicOperator,
BBReferenceFieldMetadata,
@ -98,6 +99,23 @@ function isSqs(table: Table): boolean {
)
}
function escapeQuotes(value: string, quoteChar = '"'): string {
return value.replace(new RegExp(quoteChar, "g"), `${quoteChar}${quoteChar}`)
}
function wrap(value: string, quoteChar = '"'): string {
return `${quoteChar}${escapeQuotes(value, quoteChar)}${quoteChar}`
}
function stringifyArray(value: any[], quoteStyle = '"'): string {
for (let i in value) {
if (typeof value[i] === "string") {
value[i] = wrap(value[i], quoteStyle)
}
}
return `[${value.join(",")}]`
}
const allowEmptyRelationships: Record<SearchFilterKey, boolean> = {
[BasicOperator.EQUAL]: false,
[BasicOperator.NOT_EQUAL]: true,
@ -152,30 +170,24 @@ class InternalBuilder {
return this.query.meta.table
}
get knexClient(): Knex.Client {
return this.knex.client as Knex.Client
}
getFieldSchema(key: string): FieldSchema | undefined {
const { column } = this.splitter.run(key)
return this.table.schema[column]
}
private quoteChars(): [string, string] {
switch (this.client) {
case SqlClient.ORACLE:
case SqlClient.POSTGRES:
return ['"', '"']
case SqlClient.MS_SQL:
return ["[", "]"]
case SqlClient.MARIADB:
case SqlClient.MY_SQL:
case SqlClient.SQL_LITE:
return ["`", "`"]
}
const wrapped = this.knexClient.wrapIdentifier("foo", {})
return [wrapped[0], wrapped[wrapped.length - 1]]
}
// Takes a string like foo and returns a quoted string like [foo] for SQL Server
// and "foo" for Postgres.
// Takes a string like foo and returns a quoted string like [foo] for SQL
// Server and "foo" for Postgres.
private quote(str: string): string {
const [start, end] = this.quoteChars()
return `${start}${str}${end}`
return this.knexClient.wrapIdentifier(str, {})
}
private isQuoted(key: string): boolean {
@ -193,6 +205,52 @@ class InternalBuilder {
return key.map(part => this.quote(part)).join(".")
}
private quotedValue(value: string): string {
const formatter = this.knexClient.formatter(this.knexClient.queryBuilder())
return formatter.wrap(value, false)
}
private castIntToString(identifier: string | Knex.Raw): Knex.Raw {
switch (this.client) {
case SqlClient.ORACLE: {
return this.knex.raw("to_char(??)", [identifier])
}
case SqlClient.POSTGRES: {
return this.knex.raw("??::TEXT", [identifier])
}
case SqlClient.MY_SQL:
case SqlClient.MARIADB: {
return this.knex.raw("CAST(?? AS CHAR)", [identifier])
}
case SqlClient.SQL_LITE: {
// Technically sqlite can actually represent numbers larger than a 64bit
// int as a string, but it does it using scientific notation (e.g.
// "1e+20") which is not what we want. Given that the external SQL
// databases are limited to supporting only 64bit ints, we settle for
// that here.
return this.knex.raw("printf('%d', ??)", [identifier])
}
case SqlClient.MS_SQL: {
return this.knex.raw("CONVERT(NVARCHAR, ??)", [identifier])
}
}
}
// Unfortuantely we cannot rely on knex's identifier escaping because it trims
// the identifier string before escaping it, which breaks cases for us where
// columns that start or end with a space aren't referenced correctly anymore.
//
// So whenever you're using an identifier binding in knex, e.g. knex.raw("??
// as ?", ["foo", "bar"]), you need to make sure you call this:
//
// knex.raw("?? as ?", [this.quotedIdentifier("foo"), "bar"])
//
// Issue we filed against knex about this:
// https://github.com/knex/knex/issues/6143
private rawQuotedIdentifier(key: string): Knex.Raw {
return this.knex.raw(this.quotedIdentifier(key))
}
// Turns an identifier like a.b.c or `a`.`b`.`c` into ["a", "b", "c"]
private splitIdentifier(key: string): string[] {
const [start, end] = this.quoteChars()
@ -236,7 +294,7 @@ class InternalBuilder {
const alias = this.getTableName(endpoint.entityId)
const schema = meta.table.schema
if (!this.isFullSelectStatementRequired()) {
return [this.knex.raw(`${this.quote(alias)}.*`)]
return [this.knex.raw("??", [`${alias}.*`])]
}
// get just the fields for this table
return resource.fields
@ -258,30 +316,39 @@ class InternalBuilder {
const columnSchema = schema[column]
if (this.SPECIAL_SELECT_CASES.POSTGRES_MONEY(columnSchema)) {
return this.knex.raw(
`${this.quotedIdentifier(
[table, column].join(".")
)}::money::numeric as ${this.quote(field)}`
)
return this.knex.raw(`??::money::numeric as ??`, [
this.rawQuotedIdentifier([table, column].join(".")),
this.knex.raw(this.quote(field)),
])
}
if (this.SPECIAL_SELECT_CASES.MSSQL_DATES(columnSchema)) {
// Time gets returned as timestamp from mssql, not matching the expected
// HH:mm format
return this.knex.raw(`CONVERT(varchar, ${field}, 108) as "${field}"`)
// TODO: figure out how to express this safely without string
// interpolation.
return this.knex.raw(`CONVERT(varchar, ??, 108) as ??`, [
this.rawQuotedIdentifier(field),
this.knex.raw(this.quote(field)),
])
}
const quoted = table
? `${this.quote(table)}.${this.quote(column)}`
: this.quote(field)
return this.knex.raw(quoted)
if (table) {
return this.rawQuotedIdentifier(`${table}.${column}`)
} else {
return this.rawQuotedIdentifier(field)
}
})
}
// OracleDB can't use character-large-objects (CLOBs) in WHERE clauses,
// so when we use them we need to wrap them in to_char(). This function
// converts a field name to the appropriate identifier.
private convertClobs(field: string, opts?: { forSelect?: boolean }): string {
private convertClobs(
field: string,
opts?: { forSelect?: boolean }
): Knex.Raw {
if (this.client !== SqlClient.ORACLE) {
throw new Error(
"you've called convertClobs on a DB that's not Oracle, this is a mistake"
@ -290,7 +357,7 @@ class InternalBuilder {
const parts = this.splitIdentifier(field)
const col = parts.pop()!
const schema = this.table.schema[col]
let identifier = this.quotedIdentifier(field)
let identifier = this.rawQuotedIdentifier(field)
if (
schema.type === FieldType.STRING ||
@ -301,9 +368,12 @@ class InternalBuilder {
schema.type === FieldType.BARCODEQR
) {
if (opts?.forSelect) {
identifier = `to_char(${identifier}) as ${this.quotedIdentifier(col)}`
identifier = this.knex.raw("to_char(??) as ??", [
identifier,
this.rawQuotedIdentifier(col),
])
} else {
identifier = `to_char(${identifier})`
identifier = this.knex.raw("to_char(??)", [identifier])
}
}
return identifier
@ -427,7 +497,6 @@ class InternalBuilder {
filterKey: string,
whereCb: (filterKey: string, query: Knex.QueryBuilder) => Knex.QueryBuilder
): Knex.QueryBuilder {
const mainKnex = this.knex
const { relationships, endpoint, tableAliases: aliases } = this.query
const tableName = endpoint.entityId
const fromAlias = aliases?.[tableName] || tableName
@ -449,8 +518,8 @@ class InternalBuilder {
relationship.to &&
relationship.tableName
) {
const joinTable = mainKnex
.select(mainKnex.raw(1))
const joinTable = this.knex
.select(this.knex.raw(1))
.from({ [toAlias]: relatedTableName })
let subQuery = joinTable.clone()
const manyToMany = validateManyToMany(relationship)
@ -459,7 +528,7 @@ class InternalBuilder {
if (!matchesTableName) {
updatedKey = filterKey.replace(
new RegExp(`^${relationship.column}.`),
`${aliases![relationship.tableName]}.`
`${aliases?.[relationship.tableName] || relationship.tableName}.`
)
} else {
updatedKey = filterKey
@ -485,9 +554,7 @@ class InternalBuilder {
.where(
`${throughAlias}.${manyToMany.from}`,
"=",
mainKnex.raw(
this.quotedIdentifier(`${fromAlias}.${manyToMany.fromPrimary}`)
)
this.rawQuotedIdentifier(`${fromAlias}.${manyToMany.fromPrimary}`)
)
// in SQS the same junction table is used for different many-to-many relationships between the
// two same tables, this is needed to avoid rows ending up in all columns
@ -516,7 +583,7 @@ class InternalBuilder {
subQuery = subQuery.where(
toKey,
"=",
mainKnex.raw(this.quotedIdentifier(foreignKey))
this.rawQuotedIdentifier(foreignKey)
)
query = query.where(q => {
@ -546,7 +613,7 @@ class InternalBuilder {
filters = this.parseFilters({ ...filters })
const aliases = this.query.tableAliases
// if all or specified in filters, then everything is an or
const allOr = filters.allOr
const shouldOr = filters.allOr
const isSqlite = this.client === SqlClient.SQL_LITE
const tableName = isSqlite ? this.table._id! : this.table.name
@ -610,7 +677,7 @@ class InternalBuilder {
value
)
} else if (shouldProcessRelationship) {
if (allOr) {
if (shouldOr) {
query = query.or
}
query = builder.addRelationshipForFilter(
@ -626,85 +693,102 @@ class InternalBuilder {
}
const like = (q: Knex.QueryBuilder, key: string, value: any) => {
const fuzzyOr = filters?.fuzzyOr
const fnc = fuzzyOr || allOr ? "orWhere" : "where"
// postgres supports ilike, nothing else does
if (this.client === SqlClient.POSTGRES) {
return q[fnc](key, "ilike", `%${value}%`)
} else {
const rawFnc = `${fnc}Raw`
// @ts-ignore
return q[rawFnc](`LOWER(${this.quotedIdentifier(key)}) LIKE ?`, [
if (filters?.fuzzyOr || shouldOr) {
q = q.or
}
if (
this.client === SqlClient.ORACLE ||
this.client === SqlClient.SQL_LITE
) {
return q.whereRaw(`LOWER(??) LIKE ?`, [
this.rawQuotedIdentifier(key),
`%${value.toLowerCase()}%`,
])
}
return q.whereILike(
// @ts-expect-error knex types are wrong, raw is fine here
this.rawQuotedIdentifier(key),
this.knex.raw("?", [`%${value}%`])
)
}
const contains = (mode: AnySearchFilter, any: boolean = false) => {
const rawFnc = allOr ? "orWhereRaw" : "whereRaw"
const not = mode === filters?.notContains ? "NOT " : ""
function stringifyArray(value: Array<any>, quoteStyle = '"'): string {
for (let i in value) {
if (typeof value[i] === "string") {
value[i] = `${quoteStyle}${value[i]}${quoteStyle}`
}
const contains = (mode: ArrayFilter, any = false) => {
function addModifiers<T extends {}, Q>(q: Knex.QueryBuilder<T, Q>) {
if (shouldOr || mode === filters?.containsAny) {
q = q.or
}
return `[${value.join(",")}]`
if (mode === filters?.notContains) {
q = q.not
}
return q
}
if (this.client === SqlClient.POSTGRES) {
iterate(mode, ArrayOperator.CONTAINS, (q, key, value) => {
const wrap = any ? "" : "'"
const op = any ? "\\?| array" : "@>"
const fieldNames = key.split(/\./g)
const table = fieldNames[0]
const col = fieldNames[1]
return q[rawFnc](
`${not}COALESCE("${table}"."${col}"::jsonb ${op} ${wrap}${stringifyArray(
value,
any ? "'" : '"'
)}${wrap}, FALSE)`
)
q = addModifiers(q)
if (any) {
return q.whereRaw(`COALESCE(??::jsonb \\?| array??, FALSE)`, [
this.rawQuotedIdentifier(key),
this.knex.raw(stringifyArray(value, "'")),
])
} else {
return q.whereRaw(`COALESCE(??::jsonb @> '??', FALSE)`, [
this.rawQuotedIdentifier(key),
this.knex.raw(stringifyArray(value)),
])
}
})
} else if (
this.client === SqlClient.MY_SQL ||
this.client === SqlClient.MARIADB
) {
const jsonFnc = any ? "JSON_OVERLAPS" : "JSON_CONTAINS"
iterate(mode, ArrayOperator.CONTAINS, (q, key, value) => {
return q[rawFnc](
`${not}COALESCE(${jsonFnc}(${key}, '${stringifyArray(
value
)}'), FALSE)`
)
return addModifiers(q).whereRaw(`COALESCE(?(??, ?), FALSE)`, [
this.knex.raw(any ? "JSON_OVERLAPS" : "JSON_CONTAINS"),
this.rawQuotedIdentifier(key),
this.knex.raw(wrap(stringifyArray(value))),
])
})
} else {
const andOr = mode === filters?.containsAny ? " OR " : " AND "
iterate(mode, ArrayOperator.CONTAINS, (q, key, value) => {
let statement = ""
const identifier = this.quotedIdentifier(key)
for (let i in value) {
if (typeof value[i] === "string") {
value[i] = `%"${value[i].toLowerCase()}"%`
} else {
value[i] = `%${value[i]}%`
}
statement += `${
statement ? andOr : ""
}COALESCE(LOWER(${identifier}), '') LIKE ?`
}
if (statement === "") {
if (value.length === 0) {
return q
}
if (not) {
return q[rawFnc](
`(NOT (${statement}) OR ${identifier} IS NULL)`,
value
)
} else {
return q[rawFnc](statement, value)
}
q = q.where(subQuery => {
if (mode === filters?.notContains) {
subQuery = subQuery.not
}
subQuery = subQuery.where(subSubQuery => {
for (const elem of value) {
if (mode === filters?.containsAny) {
subSubQuery = subSubQuery.or
} else {
subSubQuery = subSubQuery.and
}
const lower =
typeof elem === "string" ? `"${elem.toLowerCase()}"` : elem
subSubQuery = subSubQuery.whereLike(
// @ts-expect-error knex types are wrong, raw is fine here
this.knex.raw(`COALESCE(LOWER(??), '')`, [
this.rawQuotedIdentifier(key),
]),
`%${lower}%`
)
}
})
if (mode === filters?.notContains) {
subQuery = subQuery.or.whereNull(
// @ts-expect-error knex types are wrong, raw is fine here
this.rawQuotedIdentifier(key)
)
}
return subQuery
})
return q
})
}
}
@ -730,45 +814,46 @@ class InternalBuilder {
}
if (filters.oneOf) {
const fnc = allOr ? "orWhereIn" : "whereIn"
iterate(
filters.oneOf,
ArrayOperator.ONE_OF,
(q, key: string, array) => {
if (this.client === SqlClient.ORACLE) {
key = this.convertClobs(key)
array = Array.isArray(array) ? array : [array]
const binding = new Array(array.length).fill("?").join(",")
return q.whereRaw(`${key} IN (${binding})`, array)
} else {
return q[fnc](key, Array.isArray(array) ? array : [array])
if (shouldOr) {
q = q.or
}
if (this.client === SqlClient.ORACLE) {
// @ts-ignore
key = this.convertClobs(key)
}
return q.whereIn(key, Array.isArray(array) ? array : [array])
},
(q, key: string[], array) => {
if (this.client === SqlClient.ORACLE) {
const keyStr = `(${key.map(k => this.convertClobs(k)).join(",")})`
const binding = `(${array
.map((a: any) => `(${new Array(a.length).fill("?").join(",")})`)
.join(",")})`
return q.whereRaw(`${keyStr} IN ${binding}`, array.flat())
} else {
return q[fnc](key, Array.isArray(array) ? array : [array])
if (shouldOr) {
q = q.or
}
if (this.client === SqlClient.ORACLE) {
// @ts-ignore
key = key.map(k => this.convertClobs(k))
}
return q.whereIn(key, Array.isArray(array) ? array : [array])
}
)
}
if (filters.string) {
iterate(filters.string, BasicOperator.STRING, (q, key, value) => {
const fnc = allOr ? "orWhere" : "where"
// postgres supports ilike, nothing else does
if (this.client === SqlClient.POSTGRES) {
return q[fnc](key, "ilike", `${value}%`)
} else {
const rawFnc = `${fnc}Raw`
// @ts-ignore
return q[rawFnc](`LOWER(${this.quotedIdentifier(key)}) LIKE ?`, [
if (shouldOr) {
q = q.or
}
if (
this.client === SqlClient.ORACLE ||
this.client === SqlClient.SQL_LITE
) {
return q.whereRaw(`LOWER(??) LIKE ?`, [
this.rawQuotedIdentifier(key),
`${value.toLowerCase()}%`,
])
} else {
return q.whereILike(key, `${value}%`)
}
})
}
@ -795,67 +880,59 @@ class InternalBuilder {
const schema = this.getFieldSchema(key)
let rawKey: string | Knex.Raw = key
let high = value.high
let low = value.low
if (this.client === SqlClient.ORACLE) {
// @ts-ignore
key = this.knex.raw(this.convertClobs(key))
rawKey = this.convertClobs(key)
} else if (
this.client === SqlClient.SQL_LITE &&
schema?.type === FieldType.BIGINT
) {
rawKey = this.knex.raw("CAST(?? AS INTEGER)", [
this.rawQuotedIdentifier(key),
])
high = this.knex.raw("CAST(? AS INTEGER)", [value.high])
low = this.knex.raw("CAST(? AS INTEGER)", [value.low])
}
if (shouldOr) {
q = q.or
}
if (lowValid && highValid) {
if (
schema?.type === FieldType.BIGINT &&
this.client === SqlClient.SQL_LITE
) {
return q.whereRaw(
`CAST(${key} AS INTEGER) BETWEEN CAST(? AS INTEGER) AND CAST(? AS INTEGER)`,
[value.low, value.high]
)
} else {
const fnc = allOr ? "orWhereBetween" : "whereBetween"
return q[fnc](key, [value.low, value.high])
}
// @ts-expect-error knex types are wrong, raw is fine here
return q.whereBetween(rawKey, [low, high])
} else if (lowValid) {
if (
schema?.type === FieldType.BIGINT &&
this.client === SqlClient.SQL_LITE
) {
return q.whereRaw(`CAST(${key} AS INTEGER) >= CAST(? AS INTEGER)`, [
value.low,
])
} else {
const fnc = allOr ? "orWhere" : "where"
return q[fnc](key, ">=", value.low)
}
// @ts-expect-error knex types are wrong, raw is fine here
return q.where(rawKey, ">=", low)
} else if (highValid) {
if (
schema?.type === FieldType.BIGINT &&
this.client === SqlClient.SQL_LITE
) {
return q.whereRaw(`CAST(${key} AS INTEGER) <= CAST(? AS INTEGER)`, [
value.high,
])
} else {
const fnc = allOr ? "orWhere" : "where"
return q[fnc](key, "<=", value.high)
}
// @ts-expect-error knex types are wrong, raw is fine here
return q.where(rawKey, "<=", high)
}
return q
})
}
if (filters.equal) {
iterate(filters.equal, BasicOperator.EQUAL, (q, key, value) => {
const fnc = allOr ? "orWhereRaw" : "whereRaw"
if (shouldOr) {
q = q.or
}
if (this.client === SqlClient.MS_SQL) {
return q[fnc](
`CASE WHEN ${this.quotedIdentifier(key)} = ? THEN 1 ELSE 0 END = 1`,
[value]
)
} else if (this.client === SqlClient.ORACLE) {
const identifier = this.convertClobs(key)
return q[fnc](`(${identifier} IS NOT NULL AND ${identifier} = ?)`, [
return q.whereRaw(`CASE WHEN ?? = ? THEN 1 ELSE 0 END = 1`, [
this.rawQuotedIdentifier(key),
value,
])
} else if (this.client === SqlClient.ORACLE) {
const identifier = this.convertClobs(key)
return q.where(subq =>
// @ts-expect-error knex types are wrong, raw is fine here
subq.whereNotNull(identifier).andWhere(identifier, value)
)
} else {
return q[fnc](`COALESCE(${this.quotedIdentifier(key)} = ?, FALSE)`, [
return q.whereRaw(`COALESCE(?? = ?, FALSE)`, [
this.rawQuotedIdentifier(key),
value,
])
}
@ -863,20 +940,30 @@ class InternalBuilder {
}
if (filters.notEqual) {
iterate(filters.notEqual, BasicOperator.NOT_EQUAL, (q, key, value) => {
const fnc = allOr ? "orWhereRaw" : "whereRaw"
if (shouldOr) {
q = q.or
}
if (this.client === SqlClient.MS_SQL) {
return q[fnc](
`CASE WHEN ${this.quotedIdentifier(key)} = ? THEN 1 ELSE 0 END = 0`,
[value]
)
return q.whereRaw(`CASE WHEN ?? = ? THEN 1 ELSE 0 END = 0`, [
this.rawQuotedIdentifier(key),
value,
])
} else if (this.client === SqlClient.ORACLE) {
const identifier = this.convertClobs(key)
return q[fnc](
`(${identifier} IS NOT NULL AND ${identifier} != ?) OR ${identifier} IS NULL`,
[value]
return (
q
.where(subq =>
subq.not
// @ts-expect-error knex types are wrong, raw is fine here
.whereNull(identifier)
.and.where(identifier, "!=", value)
)
// @ts-expect-error knex types are wrong, raw is fine here
.or.whereNull(identifier)
)
} else {
return q[fnc](`COALESCE(${this.quotedIdentifier(key)} != ?, TRUE)`, [
return q.whereRaw(`COALESCE(?? != ?, TRUE)`, [
this.rawQuotedIdentifier(key),
value,
])
}
@ -884,14 +971,18 @@ class InternalBuilder {
}
if (filters.empty) {
iterate(filters.empty, BasicOperator.EMPTY, (q, key) => {
const fnc = allOr ? "orWhereNull" : "whereNull"
return q[fnc](key)
if (shouldOr) {
q = q.or
}
return q.whereNull(key)
})
}
if (filters.notEmpty) {
iterate(filters.notEmpty, BasicOperator.NOT_EMPTY, (q, key) => {
const fnc = allOr ? "orWhereNotNull" : "whereNotNull"
return q[fnc](key)
if (shouldOr) {
q = q.or
}
return q.whereNotNull(key)
})
}
if (filters.contains) {
@ -976,9 +1067,7 @@ class InternalBuilder {
const selectFields = qualifiedFields.map(field =>
this.convertClobs(field, { forSelect: true })
)
query = query
.groupByRaw(groupByFields.join(", "))
.select(this.knex.raw(selectFields.join(", ")))
query = query.groupBy(groupByFields).select(selectFields)
} else {
query = query.groupBy(qualifiedFields).select(qualifiedFields)
}
@ -990,11 +1079,10 @@ class InternalBuilder {
if (this.client === SqlClient.ORACLE) {
const field = this.convertClobs(`${tableName}.${aggregation.field}`)
query = query.select(
this.knex.raw(
`COUNT(DISTINCT ${field}) as ${this.quotedIdentifier(
aggregation.name
)}`
)
this.knex.raw(`COUNT(DISTINCT ??) as ??`, [
field,
aggregation.name,
])
)
} else {
query = query.countDistinct(
@ -1002,24 +1090,36 @@ class InternalBuilder {
)
}
} else {
query = query.count(`* as ${aggregation.name}`)
if (this.client === SqlClient.ORACLE) {
const field = this.convertClobs(`${tableName}.${aggregation.field}`)
query = query.select(
this.knex.raw(`COUNT(??) as ??`, [field, aggregation.name])
)
} else {
query = query.count(`${aggregation.field} as ${aggregation.name}`)
}
}
} else {
const field = `${tableName}.${aggregation.field} as ${aggregation.name}`
switch (op) {
case CalculationType.SUM:
query = query.sum(field)
break
case CalculationType.AVG:
query = query.avg(field)
break
case CalculationType.MIN:
query = query.min(field)
break
case CalculationType.MAX:
query = query.max(field)
break
const fieldSchema = this.getFieldSchema(aggregation.field)
if (!fieldSchema) {
// This should not happen in practice.
throw new Error(
`field schema missing for aggregation target: ${aggregation.field}`
)
}
let aggregate = this.knex.raw("??(??)", [
this.knex.raw(op),
this.rawQuotedIdentifier(`${tableName}.${aggregation.field}`),
])
if (fieldSchema.type === FieldType.BIGINT) {
aggregate = this.castIntToString(aggregate)
}
query = query.select(
this.knex.raw("?? as ??", [aggregate, aggregation.name])
)
}
}
return query
@ -1059,9 +1159,11 @@ class InternalBuilder {
} else {
let composite = `${aliased}.${key}`
if (this.client === SqlClient.ORACLE) {
query = query.orderByRaw(
`${this.convertClobs(composite)} ${direction} nulls ${nulls}`
)
query = query.orderByRaw(`?? ?? nulls ??`, [
this.convertClobs(composite),
this.knex.raw(direction),
this.knex.raw(nulls as string),
])
} else {
query = query.orderBy(composite, direction, nulls)
}
@ -1091,17 +1193,22 @@ class InternalBuilder {
private buildJsonField(field: string): string {
const parts = field.split(".")
let tableField: string, unaliased: string
let unaliased: string
let tableField: string
if (parts.length > 1) {
const alias = parts.shift()!
unaliased = parts.join(".")
tableField = `${this.quote(alias)}.${this.quote(unaliased)}`
tableField = `${alias}.${unaliased}`
} else {
unaliased = parts.join(".")
tableField = this.quote(unaliased)
tableField = unaliased
}
const separator = this.client === SqlClient.ORACLE ? " VALUE " : ","
return `'${unaliased}'${separator}${tableField}`
return this.knex
.raw(`?${separator}??`, [unaliased, this.rawQuotedIdentifier(tableField)])
.toString()
}
maxFunctionParameters() {
@ -1197,13 +1304,13 @@ class InternalBuilder {
subQuery = subQuery.where(
correlatedTo,
"=",
knex.raw(this.quotedIdentifier(correlatedFrom))
this.rawQuotedIdentifier(correlatedFrom)
)
const standardWrap = (select: string): Knex.QueryBuilder => {
const standardWrap = (select: Knex.Raw): Knex.QueryBuilder => {
subQuery = subQuery.select(`${toAlias}.*`).limit(getRelationshipLimit())
// @ts-ignore - the from alias syntax isn't in Knex typing
return knex.select(knex.raw(select)).from({
return knex.select(select).from({
[toAlias]: subQuery,
})
}
@ -1213,12 +1320,12 @@ class InternalBuilder {
// need to check the junction table document is to the right column, this is just for SQS
subQuery = this.addJoinFieldCheck(subQuery, relationship)
wrapperQuery = standardWrap(
`json_group_array(json_object(${fieldList}))`
this.knex.raw(`json_group_array(json_object(${fieldList}))`)
)
break
case SqlClient.POSTGRES:
wrapperQuery = standardWrap(
`json_agg(json_build_object(${fieldList}))`
this.knex.raw(`json_agg(json_build_object(${fieldList}))`)
)
break
case SqlClient.MARIADB:
@ -1232,21 +1339,25 @@ class InternalBuilder {
case SqlClient.MY_SQL:
case SqlClient.ORACLE:
wrapperQuery = standardWrap(
`json_arrayagg(json_object(${fieldList}))`
this.knex.raw(`json_arrayagg(json_object(${fieldList}))`)
)
break
case SqlClient.MS_SQL:
case SqlClient.MS_SQL: {
const comparatorQuery = knex
.select(`${fromAlias}.*`)
// @ts-ignore - from alias syntax not TS supported
.from({
[fromAlias]: subQuery
.select(`${toAlias}.*`)
.limit(getRelationshipLimit()),
})
wrapperQuery = knex.raw(
`(SELECT ${this.quote(toAlias)} = (${knex
.select(`${fromAlias}.*`)
// @ts-ignore - from alias syntax not TS supported
.from({
[fromAlias]: subQuery
.select(`${toAlias}.*`)
.limit(getRelationshipLimit()),
})} FOR JSON PATH))`
`(SELECT ?? = (${comparatorQuery} FOR JSON PATH))`,
[this.rawQuotedIdentifier(toAlias)]
)
break
}
default:
throw new Error(`JSON relationships not implement for ${sqlClient}`)
}
@ -1351,7 +1462,8 @@ class InternalBuilder {
schema.constraints?.presence === true ||
schema.type === FieldType.FORMULA ||
schema.type === FieldType.AUTO ||
schema.type === FieldType.LINK
schema.type === FieldType.LINK ||
schema.type === FieldType.AI
) {
continue
}
@ -1473,7 +1585,7 @@ class InternalBuilder {
query = this.addFilters(query, filters, { relationship: true })
// handle relationships with a CTE for all others
if (relationships?.length) {
if (relationships?.length && aggregations.length === 0) {
const mainTable =
this.query.tableAliases?.[this.query.endpoint.entityId] ||
this.query.endpoint.entityId
@ -1488,10 +1600,8 @@ class InternalBuilder {
// add JSON aggregations attached to the CTE
return this.addJsonRelationships(cte, tableName, relationships)
}
// no relationships found - return query
else {
return query
}
return query
}
update(opts: QueryOptions): Knex.QueryBuilder {

View File

@ -1,29 +1,6 @@
import { getDB } from "../db/db"
import { getGlobalDBName } from "../context"
import { TenantInfo } from "@budibase/types"
export function getTenantDB(tenantId: string) {
return getDB(getGlobalDBName(tenantId))
}
export async function saveTenantInfo(tenantInfo: TenantInfo) {
const db = getTenantDB(tenantInfo.tenantId)
// save the tenant info to db
return db.put({
_id: "tenant_info",
...tenantInfo,
})
}
export async function getTenantInfo(
tenantId: string
): Promise<TenantInfo | undefined> {
try {
const db = getTenantDB(tenantId)
const tenantInfo = (await db.get("tenant_info")) as TenantInfo
delete tenantInfo.owner.password
return tenantInfo
} catch {
return undefined
}
}

View File

@ -16,14 +16,15 @@ import {
isSSOUser,
SaveUserOpts,
User,
UserStatus,
UserGroup,
UserIdentifier,
UserStatus,
PlatformUserBySsoId,
PlatformUserById,
AnyDocument,
} from "@budibase/types"
import {
getAccountHolderFromUserIds,
getAccountHolderFromUsers,
isAdmin,
isCreator,
validateUniqueUser,
@ -412,7 +413,9 @@ export class UserDB {
)
}
static async bulkDelete(userIds: string[]): Promise<BulkUserDeleted> {
static async bulkDelete(
users: Array<UserIdentifier>
): Promise<BulkUserDeleted> {
const db = getGlobalDB()
const response: BulkUserDeleted = {
@ -421,13 +424,13 @@ export class UserDB {
}
// remove the account holder from the delete request if present
const account = await getAccountHolderFromUserIds(userIds)
if (account) {
userIds = userIds.filter(u => u !== account.budibaseUserId)
const accountHolder = await getAccountHolderFromUsers(users)
if (accountHolder) {
users = users.filter(u => u.userId !== accountHolder.userId)
// mark user as unsuccessful
response.unsuccessful.push({
_id: account.budibaseUserId,
email: account.email,
_id: accountHolder.userId,
email: accountHolder.email,
reason: "Account holder cannot be deleted",
})
}
@ -435,7 +438,7 @@ export class UserDB {
// Get users and delete
const allDocsResponse = await db.allDocs<User>({
include_docs: true,
keys: userIds,
keys: users.map(u => u.userId),
})
const usersToDelete = allDocsResponse.rows.map(user => {
return user.doc!

View File

@ -70,6 +70,17 @@ export async function getAllUserIds() {
return response.rows.map(row => row.id)
}
export async function getAllUsers(): Promise<User[]> {
const db = getGlobalDB()
const startKey = `${DocumentType.USER}${SEPARATOR}`
const response = await db.allDocs({
startkey: startKey,
endkey: `${startKey}${UNICODE_MAX}`,
include_docs: true,
})
return response.rows.map(row => row.doc) as User[]
}
export async function bulkUpdateGlobalUsers(users: User[]) {
const db = getGlobalDB()
return (await db.bulkDocs(users)) as BulkDocsResponse

View File

@ -1,11 +1,9 @@
import { CloudAccount, ContextUser, User, UserGroup } from "@budibase/types"
import { ContextUser, User, UserGroup, UserIdentifier } from "@budibase/types"
import * as accountSdk from "../accounts"
import env from "../environment"
import { getFirstPlatformUser } from "./lookup"
import { getExistingAccounts, getFirstPlatformUser } from "./lookup"
import { EmailUnavailableError } from "../errors"
import { getTenantId } from "../context"
import { sdk } from "@budibase/shared-core"
import { getAccountByTenantId } from "../accounts"
import { BUILTIN_ROLE_IDS } from "../security/roles"
import * as context from "../context"
@ -67,21 +65,17 @@ export async function validateUniqueUser(email: string, tenantId: string) {
}
/**
* For the given user id's, return the account holder if it is in the ids.
* For a list of users, return the account holder if there is an email match.
*/
export async function getAccountHolderFromUserIds(
userIds: string[]
): Promise<CloudAccount | undefined> {
export async function getAccountHolderFromUsers(
users: Array<UserIdentifier>
): Promise<UserIdentifier | undefined> {
if (!env.SELF_HOSTED && !env.DISABLE_ACCOUNT_PORTAL) {
const tenantId = getTenantId()
const account = await getAccountByTenantId(tenantId)
if (!account) {
throw new Error(`Account not found for tenantId=${tenantId}`)
}
const budibaseUserId = account.budibaseUserId
if (userIds.includes(budibaseUserId)) {
return account
}
const accountMetadata = await getExistingAccounts(
users.map(user => user.email)
)
return users.find(user =>
accountMetadata.map(metadata => metadata.email).includes(user.email)
)
}
}

View File

@ -102,6 +102,14 @@ export const useAppBuilders = () => {
return useFeature(Feature.APP_BUILDERS)
}
export const useBudibaseAI = () => {
return useFeature(Feature.BUDIBASE_AI)
}
export const useAICustomConfigs = () => {
return useFeature(Feature.AI_CUSTOM_CONFIGS)
}
// QUOTAS
export const setAutomationLogsQuota = (value: number) => {

View File

@ -1,15 +1,11 @@
<script>
import "@spectrum-css/actionbutton/dist/index-vars.css"
import { createEventDispatcher } from "svelte"
import Tooltip from "../Tooltip/Tooltip.svelte"
import { fade } from "svelte/transition"
const dispatch = createEventDispatcher()
import { hexToRGBA } from "../helpers"
export let quiet = false
export let emphasized = false
export let selected = false
export let longPressable = false
export let disabled = false
export let icon = ""
export let size = "M"
@ -17,82 +13,64 @@
export let fullWidth = false
export let noPadding = false
export let tooltip = ""
export let accentColor = null
let showTooltip = false
function longPress(element) {
if (!longPressable) return
let timer
$: accentStyle = getAccentStyle(accentColor)
const listener = () => {
timer = setTimeout(() => {
dispatch("longpress")
}, 700)
}
element.addEventListener("pointerdown", listener)
return {
destroy() {
clearTimeout(timer)
element.removeEventListener("pointerdown", longPress)
},
const getAccentStyle = color => {
if (!color) {
return ""
}
let style = ""
style += `--accent-bg-color:${hexToRGBA(color, 0.15)};`
style += `--accent-border-color:${hexToRGBA(color, 0.35)};`
return style
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span
class="btn-wrap"
<button
class="spectrum-ActionButton spectrum-ActionButton--size{size}"
class:spectrum-ActionButton--quiet={quiet}
class:is-selected={selected}
class:noPadding
class:fullWidth
class:active
class:disabled
class:accent={accentColor != null}
on:click|preventDefault
on:mouseover={() => (showTooltip = true)}
on:mouseleave={() => (showTooltip = false)}
on:focus={() => (showTooltip = true)}
{disabled}
style={accentStyle}
>
<button
use:longPress
class:spectrum-ActionButton--quiet={quiet}
class:spectrum-ActionButton--emphasized={emphasized}
class:is-selected={selected}
class:noPadding
class:fullWidth
class="spectrum-ActionButton spectrum-ActionButton--size{size}"
class:active
class:disabled
{disabled}
on:longPress
on:click|preventDefault
>
{#if longPressable}
<svg
class="spectrum-Icon spectrum-UIIcon-CornerTriangle100 spectrum-ActionButton-hold"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-CornerTriangle100" />
</svg>
{/if}
{#if icon}
<svg
class="spectrum-Icon spectrum-Icon--sizeS"
focusable="false"
aria-hidden="true"
aria-label={icon}
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
{/if}
{#if $$slots}
<span class="spectrum-ActionButton-label"><slot /></span>
{/if}
{#if tooltip && showTooltip}
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}>
<Tooltip textWrapping direction="bottom" text={tooltip} />
</div>
{/if}
</button>
</span>
{#if icon}
<svg
class="spectrum-Icon spectrum-Icon--sizeS"
focusable="false"
aria-hidden="true"
aria-label={icon}
>
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
{/if}
{#if $$slots}
<span class="spectrum-ActionButton-label"><slot /></span>
{/if}
{#if tooltip && showTooltip}
<div class="tooltip" in:fade={{ duration: 130, delay: 250 }}>
<Tooltip textWrapping direction="bottom" text={tooltip} />
</div>
{/if}
</button>
<style>
button {
transition: filter 130ms ease-out, background 130ms ease-out,
border 130ms ease-out, color 130ms ease-out;
}
.fullWidth {
width: 100%;
}
@ -104,9 +82,7 @@
margin-left: 0;
transition: color ease-out 130ms;
}
.is-selected:not(.spectrum-ActionButton--emphasized):not(
.spectrum-ActionButton--quiet
) {
.is-selected:not(.spectrum-ActionButton--quiet) {
background: var(--spectrum-global-color-gray-300);
border-color: var(--spectrum-global-color-gray-500);
}
@ -115,12 +91,13 @@
}
.spectrum-ActionButton--quiet.is-selected {
color: var(--spectrum-global-color-gray-900);
background: var(--spectrum-global-color-gray-300);
}
.noPadding {
padding: 0;
min-width: 0;
}
.is-selected:not(.emphasized) .spectrum-Icon {
.is-selected .spectrum-Icon {
color: var(--spectrum-global-color-gray-900);
}
.is-selected.disabled .spectrum-Icon {
@ -137,4 +114,12 @@
text-align: center;
z-index: 1;
}
.accent.is-selected,
.accent:active {
border: 1px solid var(--accent-border-color);
background: var(--accent-bg-color);
}
.accent:hover {
filter: brightness(1.2);
}
</style>

View File

@ -1,14 +1,20 @@
<script>
import { setContext } from "svelte"
import { setContext, getContext } from "svelte"
import Popover from "../Popover/Popover.svelte"
import Menu from "../Menu/Menu.svelte"
export let disabled = false
export let align = "left"
export let portalTarget
export let openOnHover = false
export let animate
export let offset
const actionMenuContext = getContext("actionMenu")
let anchor
let dropdown
let timeout
// This is needed because display: contents is considered "invisible".
// It should only ever be an action button, so should be fine.
@ -16,11 +22,19 @@
anchor = node.firstChild
}
export const show = () => {
cancelHide()
dropdown.show()
}
export const hide = () => {
dropdown.hide()
}
export const show = () => {
dropdown.show()
// Hides this menu and all parent menus
const hideAll = () => {
hide()
actionMenuContext?.hide()
}
const openMenu = event => {
@ -30,12 +44,25 @@
}
}
setContext("actionMenu", { show, hide })
const queueHide = () => {
timeout = setTimeout(hide, 10)
}
const cancelHide = () => {
clearTimeout(timeout)
}
setContext("actionMenu", { show, hide, hideAll })
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div use:getAnchor on:click={openMenu}>
<div
use:getAnchor
on:click={openOnHover ? null : openMenu}
on:mouseenter={openOnHover ? show : null}
on:mouseleave={openOnHover ? queueHide : null}
>
<slot name="control" />
</div>
<Popover
@ -43,9 +70,13 @@
{anchor}
{align}
{portalTarget}
{animate}
{offset}
resizable={false}
on:open
on:close
on:mouseenter={openOnHover ? cancelHide : null}
on:mouseleave={openOnHover ? queueHide : null}
>
<Menu>
<slot />

View File

@ -151,9 +151,9 @@ export default function positionDropdown(element, opts) {
// Determine X strategy
if (align === "right") {
applyXStrategy(Strategies.EndToEnd)
} else if (align === "right-outside") {
} else if (align === "right-outside" || align === "right-context-menu") {
applyXStrategy(Strategies.StartToEnd)
} else if (align === "left-outside") {
} else if (align === "left-outside" || align === "left-context-menu") {
applyXStrategy(Strategies.EndToStart)
} else if (align === "center") {
applyXStrategy(Strategies.MidPoint)
@ -164,6 +164,12 @@ export default function positionDropdown(element, opts) {
// Determine Y strategy
if (align === "right-outside" || align === "left-outside") {
applyYStrategy(Strategies.MidPoint)
} else if (
align === "right-context-menu" ||
align === "left-context-menu"
) {
applyYStrategy(Strategies.StartToStart)
styles.top -= 5 // Manual adjustment for action menu padding
} else {
applyYStrategy(Strategies.StartToEnd)
}
@ -240,7 +246,7 @@ export default function positionDropdown(element, opts) {
}
// Apply initial styles which don't need to change
element.style.position = "absolute"
element.style.position = "fixed"
element.style.zIndex = "9999"
// Set up a scroll listener

View File

@ -17,6 +17,8 @@
export let tooltip = undefined
export let newStyles = true
export let id
export let ref
export let reverse = false
const dispatch = createEventDispatcher()
</script>
@ -25,6 +27,7 @@
<button
{id}
{type}
bind:this={ref}
class:spectrum-Button--cta={cta}
class:spectrum-Button--primary={primary}
class:spectrum-Button--secondary={secondary}
@ -41,6 +44,9 @@
}
}}
>
{#if $$slots && reverse}
<span class="spectrum-Button-label"><slot /></span>
{/if}
{#if icon}
<svg
class="spectrum-Icon spectrum-Icon--size{size.toUpperCase()}"
@ -51,7 +57,7 @@
<use xlink:href="#spectrum-icon-18-{icon}" />
</svg>
{/if}
{#if $$slots}
{#if $$slots && !reverse}
<span class="spectrum-Button-label"><slot /></span>
{/if}
</button>
@ -91,4 +97,11 @@
.spectrum-Button--secondary.new-styles.is-disabled {
color: var(--spectrum-global-color-gray-500);
}
.spectrum-Button .spectrum-Button-label + .spectrum-Icon {
margin-left: var(--spectrum-button-primary-icon-gap);
margin-right: calc(
-1 * (var(--spectrum-button-primary-textonly-padding-left-adjusted) -
var(--spectrum-button-primary-padding-left-adjusted))
);
}
</style>

View File

@ -0,0 +1,57 @@
<script>
import Button from "../Button/Button.svelte"
import Popover from "../Popover/Popover.svelte"
import Menu from "../Menu/Menu.svelte"
import MenuItem from "../Menu/Item.svelte"
export let buttons
export let text = "Action"
export let size = "M"
export let align = "left"
export let offset
export let animate
export let quiet = false
let anchor
let popover
const handleClick = async button => {
popover.hide()
await button.onClick?.()
}
</script>
<Button
bind:ref={anchor}
{size}
icon="ChevronDown"
{quiet}
primary={quiet}
cta={!quiet}
newStyles={!quiet}
on:click={() => popover?.show()}
on:click
reverse
>
{text || "Action"}
</Button>
<Popover
bind:this={popover}
{align}
{anchor}
{offset}
{animate}
resizable={false}
on:close
on:open
on:mouseenter
on:mouseleave
>
<Menu>
{#each buttons as button}
<MenuItem on:click={() => handleClick(button)} disabled={button.disabled}>
{button.text || "Button"}
</MenuItem>
{/each}
</Menu>
</Popover>

View File

@ -19,6 +19,7 @@
{disabled}
on:change={onChange}
on:click
on:click|stopPropagation
{id}
type="checkbox"
class="spectrum-Switch-input"

View File

@ -1,6 +1,6 @@
<script>
import "@spectrum-css/textfield/dist/index-vars.css"
import { createEventDispatcher, onMount } from "svelte"
import { createEventDispatcher, onMount, tick } from "svelte"
export let value = null
export let placeholder = null
@ -68,10 +68,13 @@
return type === "number" ? "decimal" : "text"
}
onMount(() => {
onMount(async () => {
if (disabled) return
focus = autofocus
if (focus) field.focus()
if (focus) {
await tick()
field.focus()
}
})
</script>

View File

@ -60,10 +60,11 @@
.newStyles {
color: var(--spectrum-global-color-gray-700);
}
svg {
transition: color var(--spectrum-global-animation-duration-100, 130ms);
}
svg.hoverable {
pointer-events: all;
transition: color var(--spectrum-global-animation-duration-100, 130ms);
}
svg.hoverable:hover {
color: var(--hover-color) !important;

View File

@ -8,6 +8,7 @@
export let onConfirm = undefined
export let buttonText = ""
export let cta = false
$: icon = selectIcon(type)
// if newlines used, convert them to different elements
$: split = message.split("\n")

View File

@ -1,55 +1,68 @@
<script>
import Body from "../Typography/Body.svelte"
import IconAvatar from "../Icon/IconAvatar.svelte"
import Label from "../Label/Label.svelte"
import Avatar from "../Avatar/Avatar.svelte"
import Icon from "../Icon/Icon.svelte"
import StatusLight from "../StatusLight/StatusLight.svelte"
export let icon = null
export let iconBackground = null
export let iconColor = null
export let avatar = false
export let title = null
export let subtitle = null
export let url = null
export let hoverable = false
$: initials = avatar ? title?.[0] : null
export let showArrow = false
export let selected = false
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="list-item" class:hoverable on:click>
<div class="left">
{#if icon}
<IconAvatar {icon} color={iconColor} background={iconBackground} />
<a
href={url}
class="list-item"
class:hoverable={hoverable || url != null}
class:large={!!subtitle}
on:click
class:selected
>
<div class="list-item__left">
{#if icon === "StatusLight"}
<StatusLight square size="L" color={iconColor} />
{:else if icon}
<div class="list-item__icon">
<Icon name={icon} color={iconColor} size={subtitle ? "XL" : "M"} />
</div>
{/if}
{#if avatar}
<Avatar {initials} />
{/if}
{#if title}
<Body>{title}</Body>
{/if}
{#if subtitle}
<Label>{subtitle}</Label>
<div class="list-item__text">
{#if title}
<div class="list-item__title">
{title}
</div>
{/if}
{#if subtitle}
<div class="list-item__subtitle">
{subtitle}
</div>
{/if}
</div>
</div>
<div class="list-item__right">
<slot name="right" />
{#if showArrow}
<Icon name="ChevronRight" />
{/if}
</div>
{#if $$slots.default}
<div class="right">
<slot />
</div>
{/if}
</div>
</a>
<style>
.list-item {
padding: 0 16px;
height: 56px;
background: var(--spectrum-global-color-gray-50);
padding: var(--spacing-m) var(--spacing-l);
background: var(--spectrum-global-color-gray-75);
display: flex;
flex-direction: row;
justify-content: space-between;
border: 1px solid var(--spectrum-global-color-gray-300);
transition: background 130ms ease-out;
transition: background 130ms ease-out, border-color 130ms ease-out;
gap: var(--spacing-m);
color: var(--spectrum-global-color-gray-800);
cursor: pointer;
position: relative;
box-sizing: border-box;
}
.list-item:not(:first-child) {
border-top: none;
@ -64,32 +77,87 @@
}
.hoverable:hover {
cursor: pointer;
background: var(--spectrum-global-color-gray-75);
}
.left,
.right {
.hoverable:not(.selected):hover {
background: var(--spectrum-global-color-gray-200);
border-color: var(--spectrum-global-color-gray-400);
}
.selected {
background: var(--spectrum-global-color-blue-100);
}
/* Selection is only meant for standalone list items (non stacked) so we just set a fixed border radius */
.list-item.selected {
background-color: var(--spectrum-global-color-blue-100);
border-color: var(--spectrum-global-color-blue-100);
}
.list-item.selected:after {
content: "";
position: absolute;
height: 100%;
width: 100%;
border: 1px solid var(--spectrum-global-color-blue-400);
pointer-events: none;
top: 0;
left: 0;
border-radius: 4px;
box-sizing: border-box;
z-index: 1;
opacity: 0.5;
}
/* Large icons */
.list-item.large .list-item__icon {
background-color: var(--spectrum-global-color-gray-200);
padding: 4px;
border-radius: 4px;
border: 1px solid var(--spectrum-global-color-gray-300);
transition: background-color 130ms ease-out, border-color 130ms ease-out,
color 130ms ease-out;
}
.list-item.large.hoverable:not(.selected):hover .list-item__icon {
background-color: var(--spectrum-global-color-gray-300);
}
.list-item.large.selected .list-item__icon {
background-color: var(--spectrum-global-color-blue-400);
color: white;
border-color: var(--spectrum-global-color-blue-100);
}
/* Internal layout */
.list-item__left,
.list-item__right {
display: flex;
flex-direction: row;
align-items: center;
gap: var(--spacing-xl);
gap: var(--spacing-m);
}
.left {
.list-item.large .list-item__left,
.list-item.large .list-item__right {
gap: var(--spacing-m);
}
.list-item__left {
width: 0;
flex: 1 1 auto;
}
.right {
.list-item__right {
flex: 0 0 auto;
color: var(--spectrum-global-color-gray-600);
}
.list-item :global(.spectrum-Icon),
.list-item :global(.spectrum-Avatar) {
flex: 0 0 auto;
/* Text */
.list-item__text {
flex: 1 1 auto;
width: 0;
}
.list-item :global(.spectrum-Body) {
color: var(--spectrum-global-color-gray-900);
}
.list-item :global(.spectrum-Body) {
.list-item__title,
.list-item__subtitle {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.list-item__subtitle {
color: var(--spectrum-global-color-gray-700);
font-size: 12px;
}
</style>

View File

@ -27,7 +27,7 @@
const onClick = () => {
if (actionMenu && !noClose) {
actionMenu.hide()
actionMenu.hideAll()
}
dispatch("click")
}
@ -35,7 +35,7 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<li
on:click|preventDefault={disabled ? null : onClick}
on:click={disabled ? null : onClick}
class="spectrum-Menu-item"
class:is-disabled={disabled}
role="menuitem"
@ -47,8 +47,9 @@
</div>
{/if}
<span class="spectrum-Menu-itemLabel"><slot /></span>
{#if keys?.length}
{#if keys?.length || $$slots.right}
<div class="keys">
<slot name="right" />
{#each keys as key}
<div class="key">
{#if key.startsWith("!")}

View File

@ -30,7 +30,9 @@
export let custom = false
const { hide, cancel } = getContext(Context.Modal)
let loading = false
$: confirmDisabled = disabled || loading
async function secondary(e) {
@ -90,7 +92,7 @@
<!-- TODO: Remove content-grid class once Layout components are in bbui -->
<section class="spectrum-Dialog-content content-grid">
<slot />
<slot {loading} />
</section>
{#if showCancelButton || showConfirmButton || $$slots.footer}
<div
@ -145,6 +147,9 @@
.spectrum-Dialog--extraLarge {
width: 1000px;
}
.spectrum-Dialog--medium {
width: 540px;
}
.content-grid {
display: grid;

View File

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

View File

@ -1,7 +1,7 @@
<script>
import "@spectrum-css/popover/dist/index-vars.css"
import Portal from "svelte-portal"
import { createEventDispatcher, getContext } from "svelte"
import { createEventDispatcher, getContext, onDestroy } from "svelte"
import positionDropdown from "../Actions/position_dropdown"
import clickOutside from "../Actions/click_outside"
import { fly } from "svelte/transition"
@ -28,7 +28,24 @@
export let resizable = true
export let wrap = false
const animationDuration = 260
let timeout
let blockPointerEvents = false
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
$: {
// Disable pointer events for the initial part of the animation, because we
// fly from top to bottom and initially can be positioned under the cursor,
// causing a flashing hover state in the content
if (open && animate) {
blockPointerEvents = true
clearTimeout(timeout)
timeout = setTimeout(() => {
blockPointerEvents = false
}, animationDuration / 2)
}
}
export const show = () => {
dispatch("open")
@ -77,6 +94,10 @@
hide()
}
}
onDestroy(() => {
clearTimeout(timeout)
})
</script>
{#if open}
@ -104,9 +125,13 @@
class="spectrum-Popover is-open"
class:customZindex
class:hidden={!showPopover}
class:blockPointerEvents
role="presentation"
style="height: {customHeight}; --customZindex: {customZindex};"
transition:fly|local={{ y: -20, duration: animate ? 260 : 0 }}
transition:fly|local={{
y: -20,
duration: animate ? animationDuration : 0,
}}
on:mouseenter
on:mouseleave
>
@ -121,6 +146,12 @@
border-color: var(--spectrum-global-color-gray-300);
overflow: auto;
transition: opacity 260ms ease-out;
filter: none;
-webkit-filter: none;
box-shadow: 0 1px 4px var(--drop-shadow);
}
.blockPointerEvents {
pointer-events: none;
}
.hidden {
opacity: 0;

View File

@ -228,3 +228,13 @@ export const getDateDisplayValue = (
return value.format(`${localeDateFormat} HH:mm`)
}
}
export const hexToRGBA = (color, opacity) => {
if (color.includes("#")) {
color = color.replace("#", "")
}
const r = parseInt(color.substring(0, 2), 16)
const g = parseInt(color.substring(2, 4), 16)
const b = parseInt(color.substring(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${opacity})`
}

View File

@ -21,6 +21,7 @@ export { default as EnvDropdown } from "./Form/EnvDropdown.svelte"
export { default as Multiselect } from "./Form/Multiselect.svelte"
export { default as Search } from "./Form/Search.svelte"
export { default as RichTextField } from "./Form/RichTextField.svelte"
export { default as FieldLabel } from "./Form/FieldLabel.svelte"
export { default as Slider } from "./Form/Slider.svelte"
export { default as File } from "./Form/File.svelte"
@ -39,6 +40,7 @@ export { default as ActionGroup } from "./ActionGroup/ActionGroup.svelte"
export { default as ActionMenu } from "./ActionMenu/ActionMenu.svelte"
export { default as Button } from "./Button/Button.svelte"
export { default as ButtonGroup } from "./ButtonGroup/ButtonGroup.svelte"
export { default as CollapsedButtonGroup } from "./ButtonGroup/CollapsedButtonGroup.svelte"
export { default as ClearButton } from "./ClearButton/ClearButton.svelte"
export { default as Icon } from "./Icon/Icon.svelte"
export { default as IconAvatar } from "./Icon/IconAvatar.svelte"

View File

@ -59,12 +59,14 @@
"@codemirror/state": "^6.2.0",
"@codemirror/theme-one-dark": "^6.1.2",
"@codemirror/view": "^6.11.2",
"@dagrejs/dagre": "1.1.4",
"@fontsource/source-sans-pro": "^5.0.3",
"@fortawesome/fontawesome-svg-core": "^6.4.2",
"@fortawesome/free-brands-svg-icons": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@spectrum-css/page": "^3.0.1",
"@spectrum-css/vars": "^3.0.1",
"@xyflow/svelte": "^0.1.18",
"@zerodevx/svelte-json-view": "^1.0.7",
"codemirror": "^5.65.16",
"cron-parser": "^4.9.0",

View File

@ -12,13 +12,17 @@
import { Icon, notifications, Modal, Toggle } from "@budibase/bbui"
import { ActionStepID } from "constants/backend/automations"
import UndoRedoControl from "components/common/UndoRedoControl.svelte"
import { sdk } from "@budibase/shared-core"
export let automation
let testDataModal
let confirmDeleteDialog
let scrolling = false
$: blocks = getBlocks(automation).filter(x => x.stepId !== ActionStepID.LOOP)
$: isRowAction = sdk.automations.isRowAction(automation)
const getBlocks = automation => {
let blocks = []
if (automation.definition.trigger) {
@ -74,17 +78,19 @@
Test details
</div>
</div>
<div class="setting-spacing">
<Toggle
text={automation.disabled ? "Paused" : "Activated"}
on:change={automationStore.actions.toggleDisabled(
automation._id,
automation.disabled
)}
disabled={!$selectedAutomation?.definition?.trigger}
value={!automation.disabled}
/>
</div>
{#if !isRowAction}
<div class="setting-spacing">
<Toggle
text={automation.disabled ? "Paused" : "Activated"}
on:change={automationStore.actions.toggleDisabled(
automation._id,
automation.disabled
)}
disabled={!$selectedAutomation?.definition?.trigger}
value={!automation.disabled}
/>
</div>
{/if}
</div>
</div>
<div class="canvas" on:scroll={handleScroll}>

View File

@ -3,7 +3,7 @@
automationStore,
selectedAutomation,
permissions,
selectedAutomationDisplayData,
tables,
} from "stores/builder"
import {
Icon,
@ -17,6 +17,7 @@
AbsTooltip,
InlineAlert,
} from "@budibase/bbui"
import { sdk } from "@budibase/shared-core"
import AutomationBlockSetup from "../../SetupPanel/AutomationBlockSetup.svelte"
import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte"
import ActionModal from "./ActionModal.svelte"
@ -51,7 +52,12 @@
$: isAppAction && setPermissions(role)
$: isAppAction && getPermissions(automationId)
$: triggerInfo = $selectedAutomationDisplayData?.triggerInfo
$: triggerInfo = sdk.automations.isRowAction($selectedAutomation) && {
title: "Automation trigger",
tableName: $tables.list.find(
x => x._id === $selectedAutomation.definition.trigger.inputs?.tableId
)?.name,
}
async function setPermissions(role) {
if (!role || !automationId) {
@ -187,10 +193,10 @@
{block}
{webhookModal}
/>
{#if isTrigger && triggerInfo}
{#if triggerInfo}
<InlineAlert
header={triggerInfo.type}
message={`This trigger is tied to the row action ${triggerInfo.rowAction.name} on your ${triggerInfo.table.name} table`}
header={triggerInfo.title}
message={`This trigger is tied to your "${triggerInfo.tableName}" table`}
/>
{/if}
{#if lastStep}

View File

@ -17,11 +17,14 @@
let confirmDeleteDialog
let updateAutomationDialog
$: isRowAction = sdk.automations.isRowAction(automation)
async function deleteAutomation() {
try {
await automationStore.actions.delete(automation)
notifications.success("Automation deleted successfully")
} catch (error) {
console.error(error)
notifications.error("Error deleting automation")
}
}
@ -36,42 +39,7 @@
}
const getContextMenuItems = () => {
const isRowAction = sdk.automations.isRowAction(automation)
const result = []
if (!isRowAction) {
result.push(
...[
{
icon: "Delete",
name: "Delete",
keyBind: null,
visible: true,
disabled: false,
callback: confirmDeleteDialog.show,
},
{
icon: "Edit",
name: "Edit",
keyBind: null,
visible: true,
disabled: !automation.definition.trigger,
callback: updateAutomationDialog.show,
},
{
icon: "Duplicate",
name: "Duplicate",
keyBind: null,
visible: true,
disabled:
!automation.definition.trigger ||
automation.definition.trigger?.name === "Webhook",
callback: duplicateAutomation,
},
]
)
}
result.push({
const pause = {
icon: automation.disabled ? "CheckmarkCircle" : "Cancel",
name: automation.disabled ? "Activate" : "Pause",
keyBind: null,
@ -83,8 +51,50 @@
automation.disabled
)
},
})
return result
}
const del = {
icon: "Delete",
name: "Delete",
keyBind: null,
visible: true,
disabled: false,
callback: confirmDeleteDialog.show,
}
if (!isRowAction) {
return [
{
icon: "Edit",
name: "Edit",
keyBind: null,
visible: true,
disabled: !automation.definition.trigger,
callback: updateAutomationDialog.show,
},
{
icon: "Duplicate",
name: "Duplicate",
keyBind: null,
visible: true,
disabled:
!automation.definition.trigger ||
automation.definition.trigger?.name === "Webhook",
callback: duplicateAutomation,
},
pause,
del,
]
} else {
return [
{
icon: "Edit",
name: "Edit",
keyBind: null,
visible: true,
callback: updateAutomationDialog.show,
},
del,
]
}
}
const openContextMenu = e => {
@ -99,17 +109,17 @@
<NavItem
on:contextmenu={openContextMenu}
{icon}
iconColor={"var(--spectrum-global-color-gray-900)"}
text={automation.displayName}
iconColor={automation.disabled
? "var(--spectrum-global-color-gray-600)"
: "var(--spectrum-global-color-gray-900)"}
text={automation.name}
selected={automation._id === $selectedAutomation?._id}
hovering={automation._id === $contextMenuStore.id}
on:click={() => automationStore.actions.select(automation._id)}
selectedBy={$userSelectedResourceMap[automation._id]}
disabled={automation.disabled}
>
<div class="icon">
<Icon on:click={openContextMenu} size="S" hoverable name="MoreSmallList" />
</div>
<Icon on:click={openContextMenu} size="S" hoverable name="MoreSmallList" />
</NavItem>
<ConfirmDialog
@ -122,13 +132,5 @@
<i>{automation.name}?</i>
This action cannot be undone.
</ConfirmDialog>
<UpdateAutomationModal {automation} bind:this={updateAutomationDialog} />
<style>
div.icon {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
</style>
<UpdateAutomationModal {automation} bind:this={updateAutomationDialog} />

View File

@ -3,13 +3,21 @@
import { Modal, notifications, Layout } from "@budibase/bbui"
import NavHeader from "components/common/NavHeader.svelte"
import { onMount } from "svelte"
import { automationStore } from "stores/builder"
import { automationStore, tables } from "stores/builder"
import AutomationNavItem from "./AutomationNavItem.svelte"
import { TriggerStepID } from "constants/backend/automations"
export let modal
export let webhookModal
let searchString
const dsTriggers = [
TriggerStepID.ROW_SAVED,
TriggerStepID.ROW_UPDATED,
TriggerStepID.ROW_DELETED,
TriggerStepID.ROW_ACTION,
]
$: filteredAutomations = $automationStore.automations
.filter(automation => {
return (
@ -17,31 +25,53 @@
automation.name.toLowerCase().includes(searchString.toLowerCase())
)
})
.map(automation => ({
...automation,
displayName:
$automationStore.automationDisplayData[automation._id]?.displayName ||
automation.name,
}))
.sort((a, b) => {
const lowerA = a.displayName.toLowerCase()
const lowerB = b.displayName.toLowerCase()
const lowerA = a.name.toLowerCase()
const lowerB = b.name.toLowerCase()
return lowerA > lowerB ? 1 : -1
})
$: groupedAutomations = filteredAutomations.reduce((acc, auto) => {
const catName = auto.definition?.trigger?.event || "No Trigger"
acc[catName] ??= {
icon: auto.definition?.trigger?.icon || "AlertCircle",
name: (auto.definition?.trigger?.name || "No Trigger").toUpperCase(),
entries: [],
}
acc[catName].entries.push(auto)
return acc
}, {})
$: groupedAutomations = groupAutomations(filteredAutomations)
$: showNoResults = searchString && !filteredAutomations.length
const groupAutomations = automations => {
let groups = {}
for (let auto of automations) {
let category = null
let dataTrigger = false
// Group by datasource if possible
if (dsTriggers.includes(auto.definition?.trigger?.stepId)) {
if (auto.definition.trigger.inputs?.tableId) {
const tableId = auto.definition.trigger.inputs?.tableId
category = $tables.list.find(x => x._id === tableId)?.name
}
}
// Otherwise group by trigger
if (!category) {
category = auto.definition?.trigger?.name || "No Trigger"
} else {
dataTrigger = true
}
groups[category] ??= {
icon: auto.definition?.trigger?.icon || "AlertCircle",
name: category.toUpperCase(),
entries: [],
dataTrigger,
}
groups[category].entries.push(auto)
}
return Object.values(groups).sort((a, b) => {
if (a.dataTrigger === b.dataTrigger) {
return a.name < b.name ? -1 : 1
}
return a.dataTrigger ? -1 : 1
})
}
onMount(async () => {
try {
await automationStore.actions.fetch()
@ -88,16 +118,22 @@
<style>
.nav-group {
padding-top: var(--spacing-l);
padding-top: 24px;
}
.nav-group:first-child {
padding-top: var(--spacing-s);
}
.nav-group-header {
color: var(--spectrum-global-color-gray-600);
padding: 0px calc(var(--spacing-l) + 4px);
padding-bottom: var(--spacing-l);
padding-bottom: var(--spacing-m);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
font-weight: 600;
}
.side-bar {
flex: 0 0 260px;
display: flex;

View File

@ -1,4 +1,5 @@
<script>
import { goto } from "@roxi/routify"
import { automationStore } from "stores/builder"
import {
notifications,
@ -32,11 +33,12 @@
triggerVal.stepId,
triggerVal
)
await automationStore.actions.create(name, trigger)
const automation = await automationStore.actions.create(name, trigger)
if (triggerVal.stepId === TriggerStepID.WEBHOOK) {
webhookModal.show()
}
notifications.success(`Automation ${name} created`)
$goto(`../automation/${automation._id}`)
} catch (error) {
notifications.error("Error creating automation")
}

View File

@ -62,6 +62,7 @@
} from "@budibase/types"
import { FIELDS } from "constants/backend"
import PropField from "./PropField.svelte"
import { utils } from "@budibase/shared-core"
export let block
export let testData
@ -96,8 +97,14 @@
$: memoEnvVariables.set($environment.variables)
$: memoBlock.set(block)
$: filters = lookForFilters(schemaProperties) || []
$: tempFilters = filters
$: filters = lookForFilters(schemaProperties)
$: filterCount =
filters?.groups?.reduce((acc, group) => {
acc = acc += group?.filters?.length || 0
return acc
}, 0) || 0
$: tempFilters = cloneDeep(filters)
$: stepId = $memoBlock.stepId
$: automationBindings = getAvailableBindings(
@ -791,14 +798,15 @@
break
}
}
return filters || []
return Array.isArray(filters)
? utils.processSearchFilters(filters)
: filters
}
function saveFilters(key) {
const filters = QueryUtils.buildQuery(tempFilters)
const query = QueryUtils.buildQuery(tempFilters)
onChange({
[key]: filters,
[key]: query,
[`${key}-def`]: tempFilters, // need to store the builder definition in the automation
})
@ -1027,18 +1035,24 @@
</div>
</div>
{:else if value.customType === AutomationCustomIOType.FILTERS || value.customType === AutomationCustomIOType.TRIGGER_FILTER}
<ActionButton fullWidth on:click={drawer.show}
>{filters.length > 0
? "Update Filter"
: "No Filter set"}</ActionButton
<ActionButton fullWidth on:click={drawer.show}>
{filterCount > 0 ? "Update Filter" : "No Filter set"}
</ActionButton>
<Drawer
bind:this={drawer}
title="Filtering"
forceModal
on:drawerShow={() => {
tempFilters = filters
}}
>
<Drawer bind:this={drawer} title="Filtering">
<Button cta slot="buttons" on:click={() => saveFilters(key)}>
Save
</Button>
<DrawerContent slot="body">
<FilterBuilder
{filters}
filters={tempFilters}
{bindings}
{schemaFields}
datasource={{ type: "table", tableId }}

View File

@ -9,7 +9,7 @@
} from "@budibase/bbui"
import { onMount, createEventDispatcher } from "svelte"
import { flags } from "stores/builder"
import { licensing } from "stores/portal"
import { featureFlags } from "stores/portal"
import { API } from "api"
import MagicWand from "../../../../assets/MagicWand.svelte"
@ -26,8 +26,7 @@
let aiCronPrompt = ""
let loadingAICronExpression = false
$: aiEnabled =
$licensing.customAIConfigsEnabled || $licensing.budibaseAIEnabled
$: aiEnabled = $featureFlags.AI_CUSTOM_CONFIGS || $featureFlags.BUDIBASE_AI
$: {
if (cronExpression) {
try {

View File

@ -233,6 +233,14 @@
)
dispatch("change", result)
}
/**
* Converts arrays into strings. The CodeEditor expects a string or encoded JS
* @param{object} fieldValue
*/
const drawerValue = fieldValue => {
return Array.isArray(fieldValue) ? fieldValue.join(",") : fieldValue
}
</script>
{#each schemaFields || [] as [field, schema]}
@ -257,7 +265,7 @@
panel={AutomationBindingPanel}
type={schema.type}
{schema}
value={editableRow[field]}
value={drawerValue(editableRow[field])}
on:change={e =>
onChange({
row: {

View File

@ -1,44 +0,0 @@
<script>
import { API } from "api"
import Table from "./Table.svelte"
import { tables } from "stores/builder"
import { notifications } from "@budibase/bbui"
export let tableId
export let rowId
export let fieldName
let row
let title
$: data = row?.[fieldName] ?? []
$: linkedTableId = data?.length ? data[0].tableId : null
$: linkedTable = $tables.list.find(table => table._id === linkedTableId)
$: schema = linkedTable?.schema
$: table = $tables.list.find(table => table._id === tableId)
$: fetchData(tableId, rowId)
$: {
let rowLabel = row?.[table?.primaryDisplay]
if (rowLabel) {
title = `${rowLabel} - ${fieldName}`
} else {
title = fieldName
}
}
async function fetchData(tableId, rowId) {
try {
row = await API.fetchRelationshipData({
tableId,
rowId,
})
} catch (error) {
row = null
notifications.error("Error fetching relationship data")
}
}
</script>
{#if row && row._id === rowId}
<Table {title} {schema} {data} />
{/if}

View File

@ -1,120 +0,0 @@
<script>
import { datasources, tables, integrations, appStore } from "stores/builder"
import { themeStore, admin } from "stores/portal"
import EditRolesButton from "./buttons/EditRolesButton.svelte"
import { TableNames } from "constants"
import { Grid } from "@budibase/frontend-core"
import { API } from "api"
import GridCreateAutomationButton from "./buttons/grid/GridCreateAutomationButton.svelte"
import GridAddColumnModal from "components/backend/DataTable/modals/grid/GridCreateColumnModal.svelte"
import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte"
import GridEditUserModal from "components/backend/DataTable/modals/grid/GridEditUserModal.svelte"
import GridCreateViewButton from "components/backend/DataTable/buttons/grid/GridCreateViewButton.svelte"
import GridImportButton from "components/backend/DataTable/buttons/grid/GridImportButton.svelte"
import GridExportButton from "components/backend/DataTable/buttons/grid/GridExportButton.svelte"
import GridFilterButton from "components/backend/DataTable/buttons/grid/GridFilterButton.svelte"
import GridManageAccessButton from "components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte"
import GridRelationshipButton from "components/backend/DataTable/buttons/grid/GridRelationshipButton.svelte"
import GridEditColumnModal from "components/backend/DataTable/modals/grid/GridEditColumnModal.svelte"
import GridUsersTableButton from "components/backend/DataTable/modals/grid/GridUsersTableButton.svelte"
import { DB_TYPE_EXTERNAL } from "constants/backend"
const userSchemaOverrides = {
firstName: { displayName: "First name", disabled: true },
lastName: { displayName: "Last name", disabled: true },
email: { displayName: "Email", disabled: true },
roleId: { displayName: "Role", disabled: true },
status: { displayName: "Status", disabled: true },
}
$: id = $tables.selected?._id
$: isUsersTable = id === TableNames.USERS
$: isInternal = $tables.selected?.sourceType !== DB_TYPE_EXTERNAL
$: gridDatasource = {
type: "table",
tableId: id,
}
$: tableDatasource = $datasources.list.find(datasource => {
return datasource._id === $tables.selected?.sourceId
})
$: relationshipsEnabled = relationshipSupport(tableDatasource)
$: currentTheme = $themeStore?.theme
$: darkMode = !currentTheme.includes("light")
const relationshipSupport = datasource => {
const integration = $integrations[datasource?.source]
return !isInternal && integration?.relationships !== false
}
const handleGridTableUpdate = async e => {
tables.replaceTable(id, e.detail)
// We need to refresh datasources when an external table changes.
if (e.detail?.sourceType === DB_TYPE_EXTERNAL) {
await datasources.fetch()
}
}
</script>
<div class="wrapper">
<Grid
{API}
{darkMode}
datasource={gridDatasource}
canAddRows={!isUsersTable}
canDeleteRows={!isUsersTable}
canEditRows={!isUsersTable || !$appStore.features.disableUserMetadata}
canEditColumns={!isUsersTable || !$appStore.features.disableUserMetadata}
schemaOverrides={isUsersTable ? userSchemaOverrides : null}
showAvatars={false}
on:updatedatasource={handleGridTableUpdate}
isCloud={$admin.cloud}
>
<svelte:fragment slot="filter">
{#if isUsersTable && $appStore.features.disableUserMetadata}
<GridUsersTableButton />
{/if}
<GridFilterButton />
</svelte:fragment>
<svelte:fragment slot="controls">
{#if !isUsersTable}
<GridCreateViewButton />
{/if}
<GridManageAccessButton />
{#if !isUsersTable}
<GridCreateAutomationButton />
{/if}
{#if relationshipsEnabled}
<GridRelationshipButton />
{/if}
{#if isUsersTable}
<EditRolesButton />
{:else}
<GridImportButton />
{/if}
<GridExportButton />
{#if isUsersTable}
<GridEditUserModal />
{:else}
<GridCreateEditRowModal />
{/if}
</svelte:fragment>
<svelte:fragment slot="edit-column">
<GridEditColumnModal />
</svelte:fragment>
<svelte:fragment slot="add-column">
<GridAddColumnModal />
</svelte:fragment>
</Grid>
</div>
<style>
.wrapper {
flex: 1 1 auto;
margin: -28px -40px -40px -40px;
display: flex;
flex-direction: column;
background: var(--background);
}
</style>

View File

@ -1,80 +0,0 @@
<script>
import { API } from "api"
import { tables } from "stores/builder"
import Table from "./Table.svelte"
import CalculateButton from "./buttons/CalculateButton.svelte"
import GroupByButton from "./buttons/GroupByButton.svelte"
import ViewFilterButton from "./buttons/ViewFilterButton.svelte"
import ExportButton from "./buttons/ExportButton.svelte"
import ManageAccessButton from "./buttons/ManageAccessButton.svelte"
import HideAutocolumnButton from "./buttons/HideAutocolumnButton.svelte"
import { notifications } from "@budibase/bbui"
import { ROW_EXPORT_FORMATS } from "constants/backend"
export let view = {}
let hideAutocolumns = true
let data = []
let loading = false
$: name = view.name
$: schema = view.schema
$: calculation = view.calculation
$: supportedFormats = Object.values(ROW_EXPORT_FORMATS).filter(key => {
if (calculation && key === ROW_EXPORT_FORMATS.JSON_WITH_SCHEMA) {
return false
}
return true
})
// Fetch rows for specified view
$: fetchViewData(name, view.field, view.groupBy, view.calculation)
async function fetchViewData(name, field, groupBy, calculation) {
loading = true
const _tables = $tables.list
const allTableViews = _tables.map(table => table.views)
const thisView = allTableViews.filter(
views => views != null && views[name] != null
)[0]
// Don't fetch view data if the view no longer exists
if (!thisView) {
loading = false
return
}
try {
data = await API.fetchViewData({
name,
calculation,
field,
groupBy,
})
} catch (error) {
notifications.error("Error fetching view data")
}
loading = false
}
</script>
<Table
title={decodeURI(name)}
{schema}
tableId={view.tableId}
{data}
{loading}
rowCount={10}
allowEditing={false}
bind:hideAutocolumns
>
<ViewFilterButton {view} />
<CalculateButton {view} />
{#if view.calculation}
<GroupByButton {view} />
{/if}
<ManageAccessButton resourceId={decodeURI(name)} />
<HideAutocolumnButton bind:hideAutocolumns />
<ExportButton view={view.name} formats={supportedFormats} />
</Table>

View File

@ -1,58 +0,0 @@
<script>
import { viewsV2 } from "stores/builder"
import { admin, themeStore } from "stores/portal"
import { Grid } from "@budibase/frontend-core"
import { API } from "api"
import GridCreateEditRowModal from "components/backend/DataTable/modals/grid/GridCreateEditRowModal.svelte"
import GridFilterButton from "components/backend/DataTable/buttons/grid/GridFilterButton.svelte"
import GridManageAccessButton from "components/backend/DataTable/buttons/grid/GridManageAccessButton.svelte"
import { isEnabled } from "helpers/featureFlags"
import { FeatureFlag } from "@budibase/types"
$: id = $viewsV2.selected?.id
$: datasource = {
type: "viewV2",
id,
tableId: $viewsV2.selected?.tableId,
}
$: currentTheme = $themeStore?.theme
$: darkMode = !currentTheme.includes("light")
const handleGridViewUpdate = async e => {
viewsV2.replaceView(id, e.detail)
}
</script>
<div class="wrapper">
<Grid
{API}
{datasource}
{darkMode}
allowAddRows
allowDeleteRows
showAvatars={false}
on:updatedatasource={handleGridViewUpdate}
isCloud={$admin.cloud}
canSetRelationshipSchemas={isEnabled(FeatureFlag.ENRICHED_RELATIONSHIPS)}
>
<svelte:fragment slot="filter">
<GridFilterButton />
</svelte:fragment>
<svelte:fragment slot="controls">
<GridCreateEditRowModal />
<GridManageAccessButton />
</svelte:fragment>
</Grid>
</div>
<style>
.wrapper {
flex: 1 1 auto;
margin: -28px -40px -40px -40px;
display: flex;
flex-direction: column;
background: var(--background);
overflow: hidden;
}
</style>

View File

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

View File

@ -1,20 +1,144 @@
<script>
import { ActionButton, Modal } from "@budibase/bbui"
import ExportModal from "../modals/ExportModal.svelte"
import {
ActionButton,
Select,
notifications,
Body,
Button,
} from "@budibase/bbui"
import download from "downloadjs"
import { API } from "api"
import { ROW_EXPORT_FORMATS } from "constants/backend"
import DetailPopover from "components/common/DetailPopover.svelte"
export let view
export let filters
export let sorting
export let disabled = false
export let selectedRows
export let formats
let modal
const FORMATS = [
{
name: "CSV",
key: ROW_EXPORT_FORMATS.CSV,
},
{
name: "JSON",
key: ROW_EXPORT_FORMATS.JSON,
},
{
name: "JSON with Schema",
key: ROW_EXPORT_FORMATS.JSON_WITH_SCHEMA,
},
]
let popover
let exportFormat
let loading = false
$: options = FORMATS.filter(format => {
if (formats && !formats.includes(format.key)) {
return false
}
return true
})
$: if (options && !exportFormat) {
exportFormat = Array.isArray(options) ? options[0]?.key : []
}
const openPopover = () => {
loading = false
popover.show()
}
function downloadWithBlob(data, filename) {
download(new Blob([data], { type: "text/plain" }), filename)
}
const exportAllData = async () => {
return await API.exportView({
viewName: view,
format: exportFormat,
})
}
const exportFilteredData = async () => {
let payload = {
tableId: view,
format: exportFormat,
search: {
paginate: false,
},
}
if (selectedRows?.length) {
payload.rows = selectedRows.map(row => row._id)
}
if (sorting) {
payload.search.sort = sorting.sortColumn
payload.search.sortOrder = sorting.sortOrder
}
return await API.exportRows(payload)
}
const exportData = async () => {
try {
loading = true
let data
if (selectedRows?.length || sorting) {
data = await exportFilteredData()
} else {
data = await exportAllData()
}
notifications.success("Export successful")
downloadWithBlob(data, `export.${exportFormat}`)
popover.hide()
} catch (error) {
console.error(error)
notifications.error("Error exporting data")
} finally {
loading = false
}
}
</script>
<ActionButton {disabled} icon="DataDownload" quiet on:click={modal.show}>
Export
</ActionButton>
<Modal bind:this={modal}>
<ExportModal {view} {filters} {sorting} {selectedRows} {formats} />
</Modal>
<DetailPopover title="Export data" bind:this={popover}>
<svelte:fragment slot="anchor" let:open>
<ActionButton
icon="DataDownload"
quiet
on:click={openPopover}
{disabled}
selected={open}
>
Export
</ActionButton>
</svelte:fragment>
{#if selectedRows?.length}
<Body size="S">
<span data-testid="exporting-n-rows">
<strong>{selectedRows?.length}</strong>
{`row${selectedRows?.length > 1 ? "s" : ""} will be exported.`}
</span>
</Body>
{:else}
<Body size="S">
<span data-testid="export-all-rows">
Exporting <strong>all</strong> rows.
</span>
</Body>
{/if}
<span data-testid="format-select">
<Select
label="Format"
bind:value={exportFormat}
{options}
placeholder={null}
getOptionLabel={x => x.name}
getOptionValue={x => x.key}
/>
</span>
<div>
<Button cta disabled={loading} on:click={exportData}>Export</Button>
</div>
</DetailPopover>

View File

@ -1,17 +1,81 @@
<script>
import { ActionButton, Modal } from "@budibase/bbui"
import ImportModal from "../modals/ImportModal.svelte"
import { ActionButton, Button, Body, notifications } from "@budibase/bbui"
import DetailPopover from "components/common/DetailPopover.svelte"
import ExistingTableDataImport from "components/backend/TableNavigator/ExistingTableDataImport.svelte"
import { createEventDispatcher } from "svelte"
import { API } from "api"
export let tableId
export let tableType
export let disabled
let modal
const dispatch = createEventDispatcher()
let popover
let rows = []
let allValid = false
let displayColumn = null
let identifierFields = []
let loading = false
const openPopover = () => {
rows = []
allValid = false
displayColumn = null
identifierFields = []
loading = false
popover.show()
}
const importData = async () => {
try {
loading = true
await API.importTableData({
tableId,
rows,
identifierFields,
})
notifications.success("Rows successfully imported")
popover.hide()
} catch (error) {
console.error(error)
notifications.error("Unable to import data")
} finally {
loading = false
}
// Always refresh rows just to be sure
dispatch("importrows")
}
</script>
<ActionButton icon="DataUpload" quiet on:click={modal.show} {disabled}>
Import
</ActionButton>
<Modal bind:this={modal}>
<ImportModal {tableId} {tableType} on:importrows />
</Modal>
<DetailPopover title="Import data" bind:this={popover}>
<svelte:fragment slot="anchor" let:open>
<ActionButton
icon="DataUpload"
quiet
on:click={openPopover}
{disabled}
selected={open}
>
Import
</ActionButton>
</svelte:fragment>
<Body size="S">
Import rows to an existing table from a CSV or JSON file. Only columns from
the file which exist in the table will be imported.
</Body>
<ExistingTableDataImport
{tableId}
{tableType}
bind:rows
bind:allValid
bind:displayColumn
bind:identifierFields
/>
<div>
<Button cta disabled={loading || !allValid} on:click={importData}>
Import
</Button>
</div>
</DetailPopover>

View File

@ -1,23 +1,200 @@
<script>
import { ActionButton, Modal } from "@budibase/bbui"
import { permissions } from "stores/builder"
import ManageAccessModal from "../modals/ManageAccessModal.svelte"
import {
ActionButton,
Input,
Select,
Label,
List,
ListItem,
notifications,
} from "@budibase/bbui"
import { permissions as permissionsStore, roles } from "stores/builder"
import DetailPopover from "components/common/DetailPopover.svelte"
import { PermissionSource } from "@budibase/types"
import { capitalise } from "helpers"
import InfoDisplay from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
import { Roles } from "constants/backend"
export let resourceId
export let disabled = false
let modal
let resourcePermissions
const inheritedRoleId = "inherited"
const builtins = [Roles.ADMIN, Roles.POWER, Roles.BASIC, Roles.PUBLIC]
async function openModal() {
resourcePermissions = await permissions.forResourceDetailed(resourceId)
modal.show()
let permissions
let showPopover = true
let dependantsInfoMessage
$: fetchPermissions(resourceId)
$: loadDependantInfo(resourceId)
$: roleMismatch = checkRoleMismatch(permissions)
$: selectedRole = roleMismatch ? null : permissions?.[0]?.value
$: readableRole = selectedRole
? $roles.find(x => x._id === selectedRole)?.uiMetadata.displayName
: null
$: buttonLabel = readableRole ? `Access: ${readableRole}` : "Access"
$: highlight = roleMismatch || selectedRole === Roles.PUBLIC
$: builtInRoles = builtins
.map(roleId => $roles.find(x => x._id === roleId))
.filter(r => !!r)
$: customRoles = $roles
.filter(x => !builtins.includes(x._id))
.slice()
.toSorted((a, b) => {
const aName = a.uiMetadata.displayName || a.name
const bName = b.uiMetadata.displayName || b.name
return aName < bName ? -1 : 1
})
const fetchPermissions = async id => {
const res = await permissionsStore.forResourceDetailed(id)
permissions = Object.entries(res?.permissions || {}).map(([perm, info]) => {
let enriched = {
permission: perm,
value:
info.permissionType === PermissionSource.INHERITED
? inheritedRoleId
: info.role,
options: [...$roles],
}
if (info.inheritablePermission) {
enriched.options.unshift({
_id: inheritedRoleId,
name: `Inherit (${
$roles.find(x => x._id === info.inheritablePermission).name
})`,
})
}
return enriched
})
}
const checkRoleMismatch = permissions => {
if (!permissions || permissions.length < 2) {
return false
}
return (
permissions[0].value !== permissions[1].value ||
permissions[0].value === inheritedRoleId
)
}
const loadDependantInfo = async resourceId => {
const dependantsInfo = await permissionsStore.getDependantsInfo(resourceId)
const resourceByType = dependantsInfo?.resourceByType
if (resourceByType) {
const total = Object.values(resourceByType).reduce((p, c) => p + c, 0)
let resourceDisplay =
Object.keys(resourceByType).length === 1 && resourceByType.view
? "view"
: "resource"
if (total === 1) {
dependantsInfoMessage = `1 ${resourceDisplay} is inheriting this access`
} else if (total > 1) {
dependantsInfoMessage = `${total} ${resourceDisplay}s are inheriting this access`
} else {
dependantsInfoMessage = null
}
} else {
dependantsInfoMessage = null
}
}
const changePermission = async role => {
if (role === selectedRole) {
return
}
try {
await permissionsStore.save({
level: "read",
role,
resource: resourceId,
})
await permissionsStore.save({
level: "write",
role,
resource: resourceId,
})
await fetchPermissions(resourceId)
notifications.success("Updated permissions")
} catch (error) {
console.error(error)
notifications.error("Error updating permissions")
}
}
</script>
<ActionButton icon="LockClosed" quiet on:click={openModal} {disabled}>
Access
</ActionButton>
<Modal bind:this={modal}>
<ManageAccessModal {resourceId} permissions={resourcePermissions} />
</Modal>
<DetailPopover title="Select access role" {showPopover}>
<svelte:fragment slot="anchor" let:open>
<ActionButton
icon="LockClosed"
selected={open || highlight}
quiet
accentColor={highlight ? "#ff0000" : null}
>
{buttonLabel}
</ActionButton>
</svelte:fragment>
{#if roleMismatch}
<div class="row">
<Label extraSmall grey>Level</Label>
<Label extraSmall grey>Role</Label>
{#each permissions as permission}
<Input value={capitalise(permission.permission)} disabled />
<Select
placeholder={false}
value={permission.value}
on:change={e => changePermission(e.detail)}
disabled
options={permission.options}
getOptionLabel={x => x.name}
getOptionValue={x => x._id}
/>
{/each}
</div>
<InfoDisplay
error
icon="Alert"
body="Your previous configuration is shown above.<br/> Please choose a single role for read and write access."
/>
{/if}
<List>
{#each builtInRoles as role}
<ListItem
title={role.uiMetadata.displayName}
subtitle={role.uiMetadata.description}
hoverable
selected={selectedRole === role._id}
icon="StatusLight"
iconColor={role.uiMetadata.color}
on:click={() => changePermission(role._id)}
/>
{/each}
{#each customRoles as role}
<ListItem
title={role.uiMetadata.displayName}
subtitle={role.uiMetadata.description}
hoverable
selected={selectedRole === role._id}
icon="StatusLight"
iconColor={role.uiMetadata.color}
on:click={() => changePermission(role._id)}
/>
{/each}
</List>
{#if dependantsInfoMessage}
<InfoDisplay info body={dependantsInfoMessage} />
{/if}
</DetailPopover>
<style>
.row {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: var(--spacing-s);
}
</style>

View File

@ -1,11 +1,12 @@
<script>
import { createEventDispatcher } from "svelte"
import { ActionButton, Drawer, DrawerContent, Button } from "@budibase/bbui"
import { ActionButton, Button } from "@budibase/bbui"
import FilterBuilder from "components/design/settings/controls/FilterEditor/FilterBuilder.svelte"
import { getUserBindings } from "dataBinding"
import { makePropSafe } from "@budibase/string-templates"
import { search } from "@budibase/frontend-core"
import { tables } from "stores/builder"
import DetailPopover from "components/common/DetailPopover.svelte"
export let schema
export let filters
@ -14,17 +15,18 @@
const dispatch = createEventDispatcher()
let drawer
let popover
$: tempValue = filters || []
$: localFilters = filters
$: schemaFields = search.getFields(
$tables.list,
Object.values(schema || {}),
{ allowLinks: true }
)
$: text = getText(filters)
$: selected = tempValue.filter(x => !x.onEmptyFilter)?.length > 0
$: filterCount =
localFilters?.groups?.reduce((acc, group) => {
return (acc += group.filters.filter(filter => filter.field).length)
}, 0) || 0
$: bindings = [
{
type: "context",
@ -38,40 +40,44 @@
},
...getUserBindings(),
]
const getText = filters => {
const count = filters?.filter(filter => filter.field)?.length
return count ? `Filter (${count})` : "Filter"
const openPopover = () => {
localFilters = filters
popover.show()
}
</script>
<ActionButton icon="Filter" quiet {disabled} on:click={drawer.show} {selected}>
{text}
</ActionButton>
<DetailPopover bind:this={popover} title="Configure filters" width={800}>
<svelte:fragment slot="anchor" let:open>
<ActionButton
icon="Filter"
quiet
{disabled}
on:click={openPopover}
selected={open || filterCount > 0}
accentColor="#004EA6"
>
{filterCount ? `Filter: ${filterCount}` : "Filter"}
</ActionButton>
</svelte:fragment>
<Drawer
bind:this={drawer}
title="Filtering"
on:drawerHide
on:drawerShow
forceModal
>
<Button
cta
slot="buttons"
on:click={() => {
dispatch("change", tempValue)
drawer.hide()
}}
>
Save
</Button>
<DrawerContent slot="body">
<FilterBuilder
{filters}
{schemaFields}
datasource={{ type: "table", tableId }}
on:change={e => (tempValue = e.detail)}
{bindings}
/>
</DrawerContent>
</Drawer>
<FilterBuilder
filters={localFilters}
{schemaFields}
datasource={{ type: "table", tableId }}
on:change={e => (localFilters = e.detail)}
{bindings}
/>
<div>
<Button
cta
slot="buttons"
on:click={() => {
dispatch("change", localFilters)
popover.hide()
}}
>
Save
</Button>
</div>
</DetailPopover>

View File

@ -1,20 +1,19 @@
<script>
import { getContext } from "svelte"
import { Icon, notifications, ActionButton, Popover } from "@budibase/bbui"
import { getColumnIcon } from "../lib/utils"
import ToggleActionButtonGroup from "./ToggleActionButtonGroup.svelte"
import ToggleActionButtonGroup from "components/common/ToggleActionButtonGroup.svelte"
import { helpers } from "@budibase/shared-core"
import { SchemaUtils } from "@budibase/frontend-core"
import { Icon, notifications, ActionButton, Popover } from "@budibase/bbui"
import { FieldType } from "@budibase/types"
import { FieldPermissions } from "../../../constants"
import { FieldPermissions } from "./GridColumnsSettingButton.svelte"
export let permissions = [FieldPermissions.WRITABLE, FieldPermissions.HIDDEN]
export let disabledPermissions = []
export let columns
export let fromRelationshipField
export let canSetRelationshipSchemas
const { datasource, dispatch, config } = getContext("grid")
$: canSetRelationshipSchemas = $config.canSetRelationshipSchemas
const { datasource, dispatch } = getContext("grid")
let relationshipPanelAnchor
let relationshipFieldName
@ -153,9 +152,6 @@
await datasource.actions.saveSchemaMutations()
} catch (e) {
notifications.error(e.message)
} finally {
await datasource.actions.resetSchemaMutations()
await datasource.actions.refreshDefinition()
}
dispatch(visible ? "show-column" : "hide-column")
}
@ -177,7 +173,7 @@
<div class="columns">
{#each displayColumns as column}
<div class="column">
<Icon size="S" name={getColumnIcon(column)} />
<Icon size="S" name={SchemaUtils.getColumnIcon(column)} />
<div class="column-label" title={column.label}>
{column.label}
</div>
@ -198,6 +194,7 @@
size="S"
icon="ChevronRight"
quiet
selected={relationshipFieldName === column.name}
/>
</div>
{/if}
@ -213,16 +210,18 @@
anchor={relationshipPanelAnchor}
align="left"
>
{#if relationshipPanelColumns.length}
<div class="relationship-header">
{relationshipFieldName} columns
</div>
{/if}
<svelte:self
columns={relationshipPanelColumns}
permissions={[FieldPermissions.READONLY, FieldPermissions.HIDDEN]}
fromRelationshipField={relationshipField}
/>
<div class="nested">
{#if relationshipPanelColumns.length}
<div class="relationship-header">
{relationshipFieldName} columns
</div>
{/if}
<svelte:self
columns={relationshipPanelColumns}
permissions={[FieldPermissions.READONLY, FieldPermissions.HIDDEN]}
fromRelationshipField={relationshipField}
/>
</div>
</Popover>
{/if}
@ -233,11 +232,13 @@
}
.content {
padding: 12px 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.nested {
padding: 12px;
}
.columns {
display: grid;
align-items: center;
@ -265,6 +266,6 @@
}
.relationship-header {
color: var(--spectrum-global-color-gray-600);
padding: 12px 12px 0 12px;
margin-bottom: 12px;
}
</style>

View File

@ -0,0 +1,75 @@
<script>
import { ActionButton, List, ListItem, Button } from "@budibase/bbui"
import DetailPopover from "components/common/DetailPopover.svelte"
import { TriggerStepID } from "constants/backend/automations"
import { automationStore, appStore } from "stores/builder"
import { createEventDispatcher, getContext } from "svelte"
const dispatch = createEventDispatcher()
const { datasource } = getContext("grid")
const triggerTypes = [
TriggerStepID.ROW_SAVED,
TriggerStepID.ROW_UPDATED,
TriggerStepID.ROW_DELETED,
]
let popover
$: ds = $datasource
$: resourceId = ds?.type === "table" ? ds.tableId : ds?.id
$: connectedAutomations = findConnectedAutomations(
$automationStore.automations,
resourceId
)
$: automationCount = connectedAutomations.length
const findConnectedAutomations = (automations, resourceId) => {
return automations.filter(automation => {
if (!triggerTypes.includes(automation.definition?.trigger?.stepId)) {
return false
}
return automation.definition?.trigger?.inputs?.tableId === resourceId
})
}
const generateAutomation = () => {
popover?.hide()
dispatch("generate")
}
</script>
<DetailPopover title="Automations" bind:this={popover}>
<svelte:fragment slot="anchor" let:open>
<ActionButton
icon="JourneyVoyager"
selected={open || automationCount}
quiet
accentColor="#5610AD"
>
Automations{automationCount ? `: ${automationCount}` : ""}
</ActionButton>
</svelte:fragment>
{#if !connectedAutomations.length}
There aren't any automations connected to this data.
{:else}
The following automations are connected to this data.
<List>
{#each connectedAutomations as automation}
<ListItem
icon={automation.disabled ? "PauseCircle" : "PlayCircle"}
iconColor={automation.disabled
? "var(--spectrum-global-color-gray-600)"
: "var(--spectrum-global-color-green-600)"}
title={automation.name}
url={`/builder/app/${$appStore.appId}/automation/${automation._id}`}
showArrow
/>
{/each}
</List>
{/if}
<div>
<Button secondary icon="JourneyVoyager" on:click={generateAutomation}>
Generate automation
</Button>
</div>
</DetailPopover>

View File

@ -0,0 +1,54 @@
<script context="module">
export const FieldPermissions = {
WRITABLE: "writable",
READONLY: "readonly",
HIDDEN: "hidden",
}
</script>
<script>
import { getContext } from "svelte"
import { ActionButton } from "@budibase/bbui"
import ColumnsSettingContent from "./ColumnsSettingContent.svelte"
import { isEnabled } from "helpers/featureFlags"
import { FeatureFlag } from "@budibase/types"
import DetailPopover from "components/common/DetailPopover.svelte"
const { tableColumns, datasource } = getContext("grid")
let popover
$: anyRestricted = $tableColumns.filter(
col => !col.visible || col.readonly
).length
$: text = anyRestricted ? `Columns: ${anyRestricted} restricted` : "Columns"
$: permissions =
$datasource.type === "viewV2"
? [
FieldPermissions.WRITABLE,
FieldPermissions.READONLY,
FieldPermissions.HIDDEN,
]
: [FieldPermissions.WRITABLE, FieldPermissions.HIDDEN]
</script>
<DetailPopover bind:this={popover} title="Column settings">
<svelte:fragment slot="anchor" let:open>
<ActionButton
icon="ColumnSettings"
quiet
size="M"
on:click={popover?.open}
selected={open || anyRestricted}
disabled={!$tableColumns.length}
accentColor="#674D00"
>
{text}
</ActionButton>
</svelte:fragment>
<ColumnsSettingContent
columns={$tableColumns}
canSetRelationshipSchemas={isEnabled(FeatureFlag.ENRICHED_RELATIONSHIPS)}
{permissions}
/>
</DetailPopover>

View File

@ -1,101 +0,0 @@
<script>
import {
ActionButton,
Popover,
Menu,
MenuItem,
notifications,
} from "@budibase/bbui"
import { getContext } from "svelte"
import { automationStore, tables, builderStore } from "stores/builder"
import { TriggerStepID } from "constants/backend/automations"
import { goto } from "@roxi/routify"
const { datasource } = getContext("grid")
$: triggers = $automationStore.blockDefinitions.CREATABLE_TRIGGER
$: table = $tables.list.find(table => table._id === $datasource.tableId)
async function createAutomation(type) {
const triggerType = triggers[type]
if (!triggerType) {
console.error("Invalid trigger type", type)
notifications.error("Invalid automation trigger type")
return
}
if (!table) {
notifications.error("Invalid table, cannot create automation")
return
}
const automationName = `${table.name} : Row ${
type === TriggerStepID.ROW_SAVED ? "created" : "updated"
}`
const triggerBlock = automationStore.actions.constructBlock(
"TRIGGER",
triggerType.stepId,
triggerType
)
triggerBlock.inputs = { tableId: $datasource.tableId }
try {
const response = await automationStore.actions.create(
automationName,
triggerBlock
)
builderStore.setPreviousTopNavPath(
"/builder/app/:application/data",
window.location.pathname
)
$goto(`/builder/app/${response.appId}/automation/${response.id}`)
notifications.success(`Automation created`)
} catch (e) {
console.error("Error creating automation", e)
notifications.error("Error creating automation")
}
}
let anchor
let open
</script>
<div bind:this={anchor}>
<ActionButton
icon="MagicWand"
quiet
size="M"
on:click={() => (open = !open)}
selected={open}
>
Generate
</ActionButton>
</div>
<Popover bind:open {anchor} align="left">
<Menu>
<MenuItem
icon="ShareAndroid"
on:click={() => {
open = false
createAutomation(TriggerStepID.ROW_SAVED)
}}
>
Automation: when row is created
</MenuItem>
<MenuItem
icon="ShareAndroid"
on:click={() => {
open = false
createAutomation(TriggerStepID.ROW_UPDATED)
}}
>
Automation: when row is updated
</MenuItem>
</Menu>
</Popover>
<style>
</style>

View File

@ -1,29 +0,0 @@
<script>
import { getContext } from "svelte"
import { Modal, ActionButton, TooltipType, TempTooltip } from "@budibase/bbui"
import GridCreateViewModal from "../../modals/grid/GridCreateViewModal.svelte"
const { filter } = getContext("grid")
let modal
let firstFilterUsage = false
$: {
if ($filter?.length && !firstFilterUsage) {
firstFilterUsage = true
}
}
</script>
<TempTooltip
text="Create a view to save your filters"
type={TooltipType.Info}
condition={firstFilterUsage}
>
<ActionButton icon="CollectionAdd" quiet on:click={modal.show}>
Create view
</ActionButton>
</TempTooltip>
<Modal bind:this={modal}>
<GridCreateViewModal />
</Modal>

View File

@ -9,21 +9,13 @@
$: selectedRowArray = Object.keys($selectedRows).map(id => ({ _id: id }))
</script>
<span data-ignore-click-outside="true">
<ExportButton
{disabled}
view={$datasource.tableId}
filters={$filter}
sorting={{
sortColumn: $sort.column,
sortOrder: $sort.order,
}}
selectedRows={selectedRowArray}
/>
</span>
<style>
span {
display: contents;
}
</style>
<ExportButton
{disabled}
view={$datasource.tableId}
filters={$filter}
sorting={{
sortColumn: $sort.column,
sortOrder: $sort.order,
}}
selectedRows={selectedRowArray}
/>

View File

@ -0,0 +1,191 @@
<script>
import { ActionButton, ListItem, notifications } from "@budibase/bbui"
import { getContext } from "svelte"
import {
automationStore,
tables,
builderStore,
viewsV2,
} from "stores/builder"
import { TriggerStepID } from "constants/backend/automations"
import { goto } from "@roxi/routify"
import DetailPopover from "components/common/DetailPopover.svelte"
import MagicWand from "./magic-wand.svg"
import { AutoScreenTypes } from "constants"
import CreateScreenModal from "pages/builder/app/[application]/design/_components/NewScreen/CreateScreenModal.svelte"
import { getSequentialName } from "helpers/duplicate"
const { datasource } = getContext("grid")
let popover
let createScreenModal
$: triggers = $automationStore.blockDefinitions.CREATABLE_TRIGGER
$: table = $tables.list.find(table => table._id === $datasource.tableId)
export const show = () => popover?.show()
export const hide = () => popover?.hide()
async function createAutomation(type) {
const triggerType = triggers[type]
if (!triggerType) {
console.error("Invalid trigger type", type)
notifications.error("Invalid automation trigger type")
return
}
if (!table) {
notifications.error("Invalid table, cannot create automation")
return
}
const suffixMap = {
[TriggerStepID.ROW_SAVED]: "created",
[TriggerStepID.ROW_UPDATED]: "updated",
[TriggerStepID.ROW_DELETED]: "deleted",
}
const namePrefix = `Row ${suffixMap[type]} `
const automationName = getSequentialName(
$automationStore.automations,
namePrefix,
{
getName: x => x.name,
}
)
const triggerBlock = automationStore.actions.constructBlock(
"TRIGGER",
triggerType.stepId,
triggerType
)
triggerBlock.inputs = { tableId: $datasource.tableId }
try {
const response = await automationStore.actions.create(
automationName,
triggerBlock
)
builderStore.setPreviousTopNavPath(
"/builder/app/:application/data",
window.location.pathname
)
$goto(`/builder/app/${response.appId}/automation/${response._id}`)
notifications.success(`Automation created successfully`)
} catch (e) {
console.error(e)
notifications.error("Error creating automation")
}
}
const startScreenWizard = autoScreenType => {
popover.hide()
let preSelected
if ($datasource.type === "table") {
preSelected = $tables.list.find(x => x._id === $datasource.tableId)
} else {
preSelected = $viewsV2.list.find(x => x.id === $datasource.id)
}
createScreenModal.show(autoScreenType, preSelected)
}
</script>
<DetailPopover title="Generate" bind:this={popover}>
<svelte:fragment slot="anchor" let:open>
<ActionButton quiet selected={open}>
<div class="center">
<img height={16} alt="magic wand" src={MagicWand} />
Generate
</div>
</ActionButton>
</svelte:fragment>
{#if $datasource.type === "table"}
Generate a new app screen or automation from this data.
{:else}
Generate a new app screen from this data.
{/if}
<div class="generate-section">
<div class="generate-section__title">App screens</div>
<div class="generate-section__options">
<div>
<ListItem
title="Table"
icon="TableEdit"
hoverable
on:click={() => startScreenWizard(AutoScreenTypes.TABLE)}
iconColor="var(--spectrum-global-color-gray-600)"
/>
</div>
<div>
<ListItem
title="Form"
icon="Form"
hoverable
on:click={() => startScreenWizard(AutoScreenTypes.FORM)}
iconColor="var(--spectrum-global-color-gray-600)"
/>
</div>
</div>
</div>
{#if $datasource.type === "table"}
<div class="generate-section">
<div class="generate-section__title">Automation triggers (When a...)</div>
<div class="generate-section__options">
<div>
<ListItem
title="Row is created"
icon="TableRowAddBottom"
hoverable
on:click={() => createAutomation(TriggerStepID.ROW_SAVED)}
iconColor="var(--spectrum-global-color-gray-600)"
/>
</div>
<div>
<ListItem
title="Row is updated"
icon="Refresh"
hoverable
on:click={() => createAutomation(TriggerStepID.ROW_UPDATED)}
iconColor="var(--spectrum-global-color-gray-600)"
/>
</div>
<div>
<ListItem
title="Row is deleted"
icon="TableRowRemoveCenter"
hoverable
on:click={() => createAutomation(TriggerStepID.ROW_DELETED)}
iconColor="var(--spectrum-global-color-gray-600)"
/>
</div>
</div>
</div>
{/if}
</DetailPopover>
<CreateScreenModal bind:this={createScreenModal} />
<style>
.center {
display: flex;
align-items: center;
gap: 8px;
}
.generate-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.generate-section__title {
color: var(--spectrum-global-color-gray-600);
}
.generate-section__options {
display: grid;
grid-template-columns: 1fr 1fr;
grid-column-gap: 16px;
grid-row-gap: 8px;
}
</style>

View File

@ -4,14 +4,8 @@
const { datasource } = getContext("grid")
$: resourceId = getResourceID($datasource)
const getResourceID = datasource => {
if (!datasource) {
return null
}
return datasource.type === "table" ? datasource.tableId : datasource.id
}
$: ds = $datasource
$: resourceId = ds?.type === "table" ? ds.tableId : ds?.id
</script>
<ManageAccessButton {resourceId} />

View File

@ -0,0 +1,146 @@
<script>
import {
ActionButton,
List,
ListItem,
Button,
Toggle,
notifications,
Modal,
ModalContent,
Input,
} from "@budibase/bbui"
import DetailPopover from "components/common/DetailPopover.svelte"
import { getContext } from "svelte"
import { appStore, rowActions } from "stores/builder"
import { goto, url } from "@roxi/routify"
import { derived } from "svelte/store"
const { datasource } = getContext("grid")
let popover
let createModal
let newName
$: ds = $datasource
$: tableId = ds?.tableId
$: viewId = ds?.id
$: isView = ds?.type === "viewV2"
$: tableRowActions = $rowActions[tableId] || []
$: viewRowActions = $rowActions[viewId] || []
$: actionCount = isView ? viewRowActions.length : tableRowActions.length
$: newNameInvalid = newName && tableRowActions.some(x => x.name === newName)
const rowActionUrl = derived([url, appStore], ([$url, $appStore]) => {
return ({ automationId }) => {
return $url(`/builder/app/${$appStore.appId}/automation/${automationId}`)
}
})
const toggleAction = async (action, enabled) => {
if (enabled) {
await rowActions.enableView(tableId, viewId, action.id)
} else {
await rowActions.disableView(tableId, viewId, action.id)
}
}
const showCreateModal = () => {
newName = null
popover.hide()
createModal.show()
}
const createRowAction = async () => {
try {
const newRowAction = await rowActions.createRowAction(
tableId,
viewId,
newName
)
notifications.success("Row action created successfully")
$goto($rowActionUrl(newRowAction))
} catch (error) {
console.error(error)
notifications.error("Error creating row action")
}
}
</script>
<DetailPopover title="Row actions" bind:this={popover}>
<svelte:fragment slot="anchor" let:open>
<ActionButton
icon="Engagement"
selected={open || actionCount}
quiet
accentColor="#A24400"
>
Row actions{actionCount ? `: ${actionCount}` : ""}
</ActionButton>
</svelte:fragment>
A row action is a user-triggered automation for a chosen row.
{#if isView && rowActions.length}
<br />
Use the toggle to enable/disable row actions for this view.
<br />
{/if}
{#if !tableRowActions.length}
<br />
You haven't created any row actions.
{:else}
<List>
{#each tableRowActions as action}
<ListItem title={action.name} url={$rowActionUrl(action)} showArrow>
<svelte:fragment slot="right">
{#if isView}
<span>
<Toggle
value={action.allowedSources?.includes(viewId)}
on:change={e => toggleAction(action, e.detail)}
/>
</span>
{/if}
</svelte:fragment>
</ListItem>
{/each}
</List>
{/if}
<div>
<Button secondary icon="Engagement" on:click={showCreateModal}>
Create row action
</Button>
</div>
</DetailPopover>
<Modal bind:this={createModal}>
<ModalContent
size="S"
title="Create row action"
confirmText="Create"
showCancelButton={false}
showDivider={false}
showCloseIcon={false}
disabled={!newName || newNameInvalid}
onConfirm={createRowAction}
let:loading
>
<Input
label="Name"
bind:value={newName}
error={newNameInvalid && !loading
? "A row action with this name already exists"
: null}
/>
</ModalContent>
</Modal>
<style>
span :global(.spectrum-Switch) {
min-height: 0;
margin-right: 0;
}
span :global(.spectrum-Switch-switch) {
margin-bottom: 0;
margin-top: 2px;
}
</style>

View File

@ -0,0 +1,59 @@
<script>
import { ActionButton, List, ListItem, Button } from "@budibase/bbui"
import DetailPopover from "components/common/DetailPopover.svelte"
import { screenStore, appStore } from "stores/builder"
import { getContext, createEventDispatcher } from "svelte"
const { datasource } = getContext("grid")
const dispatch = createEventDispatcher()
let popover
$: ds = $datasource
$: resourceId = ds?.type === "table" ? ds.tableId : ds?.id
$: connectedScreens = findConnectedScreens($screenStore.screens, resourceId)
$: screenCount = connectedScreens.length
const findConnectedScreens = (screens, resourceId) => {
return screens.filter(screen => {
return JSON.stringify(screen).includes(`"${resourceId}"`)
})
}
const generateScreen = () => {
popover?.hide()
dispatch("generate")
}
</script>
<DetailPopover title="Screens" bind:this={popover}>
<svelte:fragment slot="anchor" let:open>
<ActionButton
icon="WebPage"
selected={open || screenCount}
quiet
accentColor="#364800"
>
Screens{screenCount ? `: ${screenCount}` : ""}
</ActionButton>
</svelte:fragment>
{#if !connectedScreens.length}
There aren't any screens connected to this data.
{:else}
The following screens are connected to this data.
<List>
{#each connectedScreens as screen}
<ListItem
title={screen.routing.route}
url={`/builder/app/${$appStore.appId}/design/${screen._id}`}
showArrow
/>
{/each}
</List>
{/if}
<div>
<Button secondary icon="WebPage" on:click={generateScreen}>
Generate app screen
</Button>
</div>
</DetailPopover>

View File

@ -0,0 +1,127 @@
<script>
import { getContext } from "svelte"
import { ActionButton, Label } from "@budibase/bbui"
import DetailPopover from "components/common/DetailPopover.svelte"
const {
Constants,
columns,
rowHeight,
definition,
fixedRowHeight,
datasource,
} = getContext("grid")
// Some constants for column width options
const smallColSize = 120
const mediumColSize = Constants.DefaultColumnWidth
const largeColSize = Constants.DefaultColumnWidth * 1.5
// Row height sizes
const rowSizeOptions = [
{
label: "Small",
size: Constants.SmallRowHeight,
},
{
label: "Medium",
size: Constants.MediumRowHeight,
},
{
label: "Large",
size: Constants.LargeRowHeight,
},
]
let popover
// Column width sizes
$: allSmall = $columns.every(col => col.width === smallColSize)
$: allMedium = $columns.every(col => col.width === mediumColSize)
$: allLarge = $columns.every(col => col.width === largeColSize)
$: custom = !allSmall && !allMedium && !allLarge
$: columnSizeOptions = [
{
label: "Small",
size: smallColSize,
selected: allSmall,
},
{
label: "Medium",
size: mediumColSize,
selected: allMedium,
},
{
label: "Large",
size: largeColSize,
selected: allLarge,
},
]
const changeRowHeight = height => {
datasource.actions.saveDefinition({
...$definition,
rowHeight: height,
})
}
</script>
<DetailPopover bind:this={popover} title="Column and row size" width={300}>
<svelte:fragment slot="anchor" let:open>
<ActionButton
icon="MoveUpDown"
quiet
size="M"
on:click={popover?.open}
selected={open}
disabled={!$columns.length}
>
Size
</ActionButton>
</svelte:fragment>
<div class="size">
<Label>Row height</Label>
<div class="options">
{#each rowSizeOptions as option}
<ActionButton
disabled={$fixedRowHeight}
quiet
selected={$rowHeight === option.size}
on:click={() => changeRowHeight(option.size)}
>
{option.label}
</ActionButton>
{/each}
</div>
</div>
<div class="size">
<Label>Column width</Label>
<div class="options">
{#each columnSizeOptions as option}
<ActionButton
quiet
on:click={() => columns.actions.changeAllColumnWidths(option.size)}
selected={option.selected}
>
{option.label}
</ActionButton>
{/each}
{#if custom}
<ActionButton selected={custom} quiet>Custom</ActionButton>
{/if}
</div>
</div>
</DetailPopover>
<style>
.size {
display: flex;
flex-direction: column;
gap: 8px;
}
.options {
display: flex;
align-items: center;
gap: 8px;
}
</style>

View File

@ -0,0 +1,79 @@
<script>
import { getContext } from "svelte"
import { ActionButton, Select } from "@budibase/bbui"
import { canBeSortColumn } from "@budibase/frontend-core"
import DetailPopover from "components/common/DetailPopover.svelte"
const { sort, columns } = getContext("grid")
let popover
$: columnOptions = $columns
.filter(col => canBeSortColumn(col.schema))
.map(col => ({
label: col.label || col.name,
value: col.name,
}))
$: orderOptions = getOrderOptions($sort.column, columnOptions)
const getOrderOptions = (column, columnOptions) => {
const type = columnOptions.find(col => col.value === column)?.type
return [
{
label: type === "number" ? "Low-high" : "A-Z",
value: "ascending",
},
{
label: type === "number" ? "High-low" : "Z-A",
value: "descending",
},
]
}
const updateSortColumn = e => {
sort.update(state => ({
column: e.detail,
order: e.detail ? state.order : "ascending",
}))
}
const updateSortOrder = e => {
sort.update(state => ({
...state,
order: e.detail,
}))
}
</script>
<DetailPopover bind:this={popover} title="Sorting" width={300}>
<svelte:fragment slot="anchor" let:open>
<ActionButton
icon="SortOrderDown"
quiet
size="M"
on:click={popover?.open}
selected={open}
disabled={!columnOptions.length}
>
Sort
</ActionButton>
</svelte:fragment>
<Select
placeholder="Default"
value={$sort.column}
options={columnOptions}
autoWidth
on:change={updateSortColumn}
label="Column"
/>
{#if $sort.column}
<Select
placeholder={null}
value={$sort.order || "ascending"}
options={orderOptions}
autoWidth
on:change={updateSortOrder}
label="Order"
/>
{/if}
</DetailPopover>

View File

@ -0,0 +1,267 @@
<script>
import {
ActionButton,
Select,
Icon,
Multiselect,
Button,
} from "@budibase/bbui"
import { CalculationType, canGroupBy, isNumeric } from "@budibase/types"
import InfoDisplay from "pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/InfoDisplay.svelte"
import { getContext } from "svelte"
import DetailPopover from "components/common/DetailPopover.svelte"
const { definition, datasource, rows } = getContext("grid")
const calculationTypeOptions = [
{
label: "Average",
value: CalculationType.AVG,
},
{
label: "Sum",
value: CalculationType.SUM,
},
{
label: "Minimum",
value: CalculationType.MIN,
},
{
label: "Maximum",
value: CalculationType.MAX,
},
{
label: "Count",
value: CalculationType.COUNT,
},
]
let popover
let calculations = []
let groupBy = []
let schema = {}
let loading = false
$: schema = $definition?.schema || {}
$: count = extractCalculations($definition?.schema || {}).length
$: groupByOptions = getGroupByOptions(schema)
const openPopover = () => {
calculations = extractCalculations(schema)
groupBy = calculations.length ? extractGroupBy(schema) : []
popover?.show()
}
const extractCalculations = schema => {
if (!schema) {
return []
}
return Object.keys(schema)
.filter(field => {
return schema[field].calculationType != null
})
.map(field => ({
type: schema[field].calculationType,
field: schema[field].field,
}))
}
const extractGroupBy = schema => {
if (!schema) {
return []
}
return Object.keys(schema).filter(field => {
return schema[field].calculationType == null && schema[field].visible
})
}
// Gets the available types for a given calculation
const getTypeOptions = (self, calculations) => {
return calculationTypeOptions.filter(option => {
return !calculations.some(
calc =>
calc !== self &&
calc.field === self.field &&
calc.type === option.value
)
})
}
// Gets the available fields for a given calculation
const getFieldOptions = (self, calculations, schema) => {
return Object.entries(schema)
.filter(([field, fieldSchema]) => {
// Don't allow other calculation columns
if (fieldSchema.calculationType) {
return false
}
// Only allow numeric columns for most calculation types
if (
self.type !== CalculationType.COUNT &&
!isNumeric(fieldSchema.type)
) {
return false
}
// Don't allow duplicates
return !calculations.some(calc => {
return (
calc !== self && calc.type === self.type && calc.field === field
)
})
})
.map(([field]) => field)
}
// Gets the available fields to group by
const getGroupByOptions = schema => {
return Object.entries(schema)
.filter(([_, fieldSchema]) => {
// Don't allow grouping by calculations
if (fieldSchema.calculationType) {
return false
}
// Don't allow complex types
return canGroupBy(fieldSchema.type)
})
.map(([field]) => field)
}
const addCalc = () => {
calculations = [...calculations, { type: CalculationType.AVG }]
}
const deleteCalc = idx => {
calculations = calculations.toSpliced(idx, 1)
// Remove any grouping if clearing the last calculation
if (!calculations.length) {
groupBy = []
}
}
const save = async () => {
let newSchema = {}
loading = true
// Add calculations
for (let calc of calculations) {
if (!calc.type || !calc.field) {
continue
}
const typeOption = calculationTypeOptions.find(x => x.value === calc.type)
const name = `${typeOption.label} ${calc.field}`
newSchema[name] = {
calculationType: calc.type,
field: calc.field,
visible: true,
}
}
// Add other fields
for (let field of Object.keys(schema)) {
if (schema[field].calculationType) {
continue
}
newSchema[field] = {
...schema[field],
visible: groupBy.includes(field),
}
}
// Ensure primary display is valid
let primaryDisplay = $definition.primaryDisplay
if (!primaryDisplay || !newSchema[primaryDisplay]?.visible) {
primaryDisplay = groupBy[0]
}
// Save changes
try {
await datasource.actions.saveDefinition({
...$definition,
primaryDisplay,
schema: newSchema,
})
await rows.actions.refreshData()
} finally {
loading = false
popover.hide()
}
}
</script>
<DetailPopover bind:this={popover} title="Configure calculations" width={480}>
<svelte:fragment slot="anchor" let:open>
<ActionButton icon="WebPage" quiet on:click={openPopover} selected={open}>
Configure calculations{count ? `: ${count}` : ""}
</ActionButton>
</svelte:fragment>
{#if calculations.length}
<div class="calculations">
{#each calculations as calc, idx}
<span>{idx === 0 ? "Calculate" : "and"} the</span>
<Select
options={getTypeOptions(calc, calculations)}
bind:value={calc.type}
placeholder={false}
/>
<span>of</span>
<Select
options={getFieldOptions(calc, calculations, schema)}
bind:value={calc.field}
placeholder="Column"
/>
<Icon
hoverable
name="Delete"
size="S"
on:click={() => deleteCalc(idx)}
color="var(--spectrum-global-color-gray-700)"
/>
{/each}
<span>Group by</span>
<div class="group-by">
<Multiselect
options={groupByOptions}
bind:value={groupBy}
placeholder="None"
/>
</div>
</div>
{/if}
<div class="buttons">
<ActionButton
quiet
icon="Add"
on:click={addCalc}
disabled={calculations.length >= 5}
>
Add calculation
</ActionButton>
</div>
<InfoDisplay
icon="Help"
quiet
body="Most calculations only work with numeric columns and a maximum of 5 calculations can be added at once."
/>
<div>
<Button cta on:click={save} disabled={loading}>Save</Button>
</div>
</DetailPopover>
<style>
.calculations {
display: grid;
grid-template-columns: auto 1fr auto 1fr auto;
align-items: center;
column-gap: var(--spacing-m);
row-gap: var(--spacing-m);
}
.buttons {
display: flex;
flex-direction: row;
}
.group-by {
grid-column: 2 / 5;
}
</style>

View File

@ -0,0 +1,6 @@
<svg width="13" height="12" viewBox="0 0 13 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.4179 4.13222C9.4179 3.73121 9.26166 3.35428 8.97913 3.07175C8.41342 2.50538 7.4239 2.50408 6.85753 3.07175L5.64342 4.28586C5.6291 4.30018 5.61543 4.3158 5.60305 4.33143C5.58678 4.3438 5.5718 4.35747 5.55683 4.37244L0.491426 9.43785C0.208245 9.72103 0.052002 10.098 0.052002 10.4983C0.052002 10.8987 0.208245 11.2756 0.491426 11.5588C0.774607 11.842 1.15153 11.9982 1.5519 11.9982C1.95227 11.9982 2.32919 11.842 2.61238 11.5588L8.97848 5.1927C9.26166 4.90952 9.4179 4.53259 9.4179 4.13222ZM1.90539 10.8518C1.7166 11.0406 1.3872 11.0406 1.1984 10.8518C1.10401 10.7574 1.05193 10.6318 1.05193 10.4983C1.05193 10.3649 1.104 10.2392 1.1984 10.1448L5.99821 5.34503L6.70845 6.04875L1.90539 10.8518ZM8.2715 4.48571L7.41544 5.34178L6.7052 4.63805L7.56452 3.77873C7.7533 3.58995 8.08271 3.58929 8.2715 3.77939C8.36589 3.87313 8.41798 3.99877 8.41798 4.13223C8.41798 4.26569 8.3659 4.39132 8.2715 4.48571Z" fill="#C8C8C8"/>
<path d="M11.8552 6.55146L11.0144 6.21913L10.879 5.32449C10.8356 5.03919 10.3737 4.98776 10.2686 5.255L9.93606 6.09642L9.04143 6.23085C8.89951 6.25216 8.78884 6.36658 8.77257 6.50947C8.75629 6.65253 8.83783 6.78826 8.97193 6.84148L9.81335 7.17464L9.94794 8.06862C9.9691 8.21053 10.0835 8.32121 10.2266 8.33748C10.3695 8.35375 10.5052 8.27221 10.5586 8.13811L10.8914 7.29751L11.7855 7.1621C11.9283 7.1403 12.0381 7.02637 12.0544 6.88348C12.0707 6.74058 11.9887 6.60403 11.8552 6.55146Z" fill="#F9634C"/>
<path d="M8.94215 1.76145L9.78356 2.0946L9.91815 2.9885C9.93931 3.13049 10.0539 3.24117 10.1968 3.25744C10.3398 3.27371 10.4756 3.19218 10.5288 3.05807L10.8618 2.21739L11.7559 2.08207C11.8985 2.06034 12.0085 1.94633 12.0248 1.80344C12.0411 1.66054 11.959 1.524 11.8254 1.47143L10.9847 1.13909L10.8494 0.244456C10.806 -0.0409246 10.3439 -0.0922745 10.2388 0.174881L9.90643 1.0163L9.0118 1.15089C8.86972 1.17213 8.75905 1.28654 8.74278 1.42952C8.72651 1.57249 8.80804 1.70823 8.94215 1.76145Z" fill="#8488FD"/>
<path d="M3.2379 2.46066L3.92063 2.73091L4.02984 3.45637C4.04709 3.57151 4.14002 3.66135 4.25606 3.67453C4.37194 3.6878 4.48212 3.62163 4.52541 3.51276L4.79557 2.83059L5.52094 2.72074C5.63682 2.70316 5.72601 2.61072 5.73936 2.49468C5.75254 2.37864 5.68597 2.26797 5.57758 2.22533L4.89533 1.95565L4.78548 1.22963C4.75016 0.998038 4.37535 0.956375 4.29007 1.17315L4.0204 1.85597L3.29437 1.96517C3.17915 1.98235 3.08931 2.07527 3.07613 2.19131C3.06294 2.30727 3.12902 2.41737 3.2379 2.46066Z" fill="#F7D804"/>
</svg>

After

(image error) Size: 2.5 KiB

View File

@ -8,6 +8,7 @@ const MAX_DEPTH = 1
const TYPES_TO_SKIP = [
FieldType.FORMULA,
FieldType.AI,
FieldType.LONGFORM,
FieldType.SIGNATURE_SINGLE,
FieldType.ATTACHMENTS,

View File

@ -4,6 +4,7 @@
Button,
Label,
Select,
Multiselect,
Toggle,
Icon,
DatePicker,
@ -25,6 +26,7 @@
import { createEventDispatcher, getContext, onMount } from "svelte"
import { cloneDeep } from "lodash/fp"
import { tables, datasources } from "stores/builder"
import { featureFlags } from "stores/portal"
import { TableNames, UNEDITABLE_USER_FIELDS } from "constants"
import {
FIELDS,
@ -34,6 +36,7 @@
} from "constants/backend"
import { getAutoColumnInformation, buildAutoColumn } from "helpers/utils"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import AIFieldConfiguration from "components/common/AIFieldConfiguration.svelte"
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import { getBindings } from "components/backend/DataTable/formula"
import JSONSchemaModal from "./JSONSchemaModal.svelte"
@ -49,18 +52,13 @@
import { isEnabled } from "helpers/featureFlags"
import { getUserBindings } from "dataBinding"
const AUTO_TYPE = FieldType.AUTO
const FORMULA_TYPE = FieldType.FORMULA
const LINK_TYPE = FieldType.LINK
const STRING_TYPE = FieldType.STRING
const NUMBER_TYPE = FieldType.NUMBER
const JSON_TYPE = FieldType.JSON
const DATE_TYPE = FieldType.DATETIME
export let field
const dispatch = createEventDispatcher()
const { dispatch: gridDispatch, rows } = getContext("grid")
export let field
const SafeID = `${makePropSafe("user")}.${makePropSafe("_id")}`
const SingleUserDefault = `{{ ${SafeID} }}`
const MultiUserDefault = `{{ js "${btoa(`return [$("${SafeID}")]`)}" }}`
let mounted = false
let originalName
@ -103,13 +101,14 @@
let optionsValid = true
$: rowGoldenSample = RowUtils.generateGoldenSample($rows)
$: aiEnabled = $featureFlags.BUDIBASE_AI || $featureFlags.AI_CUSTOM_CONFIGS
$: if (primaryDisplay) {
editableColumn.constraints.presence = { allowEmpty: false }
}
$: {
// 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) {
if (editableColumn.type === FieldType.LINK) {
relationshipTableIdPrimary = table._id
if (relationshipPart1 === PrettyRelationshipDefinitions.ONE) {
relationshipOpts2 = relationshipOpts2.filter(
@ -137,15 +136,16 @@
}
$: initialiseField(field, savingColumn)
$: checkConstraints(editableColumn)
$: required = hasDefault
? false
: !!editableColumn?.constraints?.presence || primaryDisplay
$: required =
primaryDisplay ||
editableColumn?.constraints?.presence === true ||
editableColumn?.constraints?.presence?.allowEmpty === false
$: uneditable =
$tables.selected?._id === TableNames.USERS &&
UNEDITABLE_USER_FIELDS.includes(editableColumn.name)
$: invalid =
!editableColumn?.name ||
(editableColumn?.type === LINK_TYPE && !editableColumn?.tableId) ||
(editableColumn?.type === FieldType.LINK && !editableColumn?.tableId) ||
Object.keys(errors).length !== 0 ||
!optionsValid
$: errors = checkErrors(editableColumn)
@ -168,12 +168,12 @@
// used to select what different options can be displayed for column type
$: canBeDisplay =
canBeDisplayColumn(editableColumn) && !editableColumn.autocolumn
$: canHaveDefault =
isEnabled("DEFAULT_VALUES") && canHaveDefaultColumn(editableColumn.type)
$: defaultValuesEnabled = isEnabled("DEFAULT_VALUES")
$: canHaveDefault = !required && canHaveDefaultColumn(editableColumn.type)
$: canBeRequired =
editableColumn?.type !== LINK_TYPE &&
editableColumn?.type !== FieldType.LINK &&
!uneditable &&
editableColumn?.type !== AUTO_TYPE &&
editableColumn?.type !== FieldType.AUTO &&
!editableColumn.autocolumn
$: hasDefault =
editableColumn?.default != null && editableColumn?.default !== ""
@ -188,7 +188,6 @@
(originalName &&
SWITCHABLE_TYPES[field.type] &&
!editableColumn?.autocolumn)
$: allowedTypes = getAllowedTypes(datasource).map(t => ({
fieldId: makeFieldId(t.type, t.subtype),
...t,
@ -206,6 +205,11 @@
},
...getUserBindings(),
]
$: sanitiseDefaultValue(
editableColumn.type,
editableColumn.constraints?.inclusion || [],
editableColumn.default
)
const fieldDefinitions = Object.values(FIELDS).reduce(
// Storing the fields by complex field id
@ -218,7 +222,7 @@
function makeFieldId(type, subtype, autocolumn) {
// don't make field IDs for auto types
if (type === AUTO_TYPE || autocolumn) {
if (type === FieldType.AUTO || autocolumn) {
return type.toUpperCase()
} else if (
type === FieldType.BB_REFERENCE ||
@ -243,7 +247,7 @@
// 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) {
if (editableColumn.type === FieldType.LINK && editableColumn.tableId) {
relationshipTableIdPrimary = table._id
relationshipTableIdSecondary = editableColumn.tableId
if (editableColumn.relationshipType in relationshipMap) {
@ -284,17 +288,33 @@
delete saveColumn.fieldId
if (saveColumn.type === AUTO_TYPE) {
if (saveColumn.type === FieldType.AUTO) {
saveColumn = buildAutoColumn(
$tables.selected.name,
saveColumn.name,
saveColumn.subtype
)
}
if (saveColumn.type !== LINK_TYPE) {
if (saveColumn.type !== FieldType.LINK) {
delete saveColumn.fieldName
}
// Ensure we don't have a default value if we can't have one
if (!canHaveDefault || !defaultValuesEnabled) {
delete saveColumn.default
}
// Ensure primary display columns are always required and don't have default values
if (primaryDisplay) {
saveColumn.constraints.presence = { allowEmpty: false }
delete saveColumn.default
}
// Ensure the field is not required if we have a default value
if (saveColumn.default) {
saveColumn.constraints.presence = false
}
try {
await tables.saveField({
originalName,
@ -362,9 +382,9 @@
editableColumn.subtype = definition.subtype
// Default relationships many to many
if (editableColumn.type === LINK_TYPE) {
if (editableColumn.type === FieldType.LINK) {
editableColumn.relationshipType = RelationshipType.MANY_TO_MANY
} else if (editableColumn.type === FORMULA_TYPE) {
} else if (editableColumn.type === FieldType.FORMULA) {
editableColumn.formulaType = "dynamic"
}
}
@ -430,6 +450,7 @@
FIELDS.BOOLEAN,
FIELDS.DATETIME,
FIELDS.LINK,
...(aiEnabled ? [FIELDS.AI] : []),
FIELDS.LONGFORM,
FIELDS.USER,
FIELDS.USERS,
@ -483,17 +504,23 @@
fieldToCheck.constraints = {}
}
// some string types may have been built by server, may not always have constraints
if (fieldToCheck.type === STRING_TYPE && !fieldToCheck.constraints.length) {
if (
fieldToCheck.type === FieldType.STRING &&
!fieldToCheck.constraints.length
) {
fieldToCheck.constraints.length = {}
}
// some number types made server-side will be missing constraints
if (
fieldToCheck.type === NUMBER_TYPE &&
fieldToCheck.type === FieldType.NUMBER &&
!fieldToCheck.constraints.numericality
) {
fieldToCheck.constraints.numericality = {}
}
if (fieldToCheck.type === DATE_TYPE && !fieldToCheck.constraints.datetime) {
if (
fieldToCheck.type === FieldType.DATETIME &&
!fieldToCheck.constraints.datetime
) {
fieldToCheck.constraints.datetime = {}
}
}
@ -541,6 +568,20 @@
return newError
}
const sanitiseDefaultValue = (type, options, defaultValue) => {
if (!defaultValue?.length) {
return
}
// Delete default value for options fields if the option is no longer available
if (type === FieldType.OPTIONS && !options.includes(defaultValue)) {
delete editableColumn.default
}
// Filter array default values to only valid options
if (type === FieldType.ARRAY) {
editableColumn.default = defaultValue.filter(x => options.includes(x))
}
}
onMount(() => {
mounted = true
})
@ -554,13 +595,13 @@
on:input={e => {
if (
!uneditable &&
!(linkEditDisabled && editableColumn.type === LINK_TYPE)
!(linkEditDisabled && editableColumn.type === FieldType.LINK)
) {
editableColumn.name = e.target.value
}
}}
disabled={uneditable ||
(linkEditDisabled && editableColumn.type === LINK_TYPE)}
(linkEditDisabled && editableColumn.type === FieldType.LINK)}
error={errors?.name}
/>
{/if}
@ -574,7 +615,7 @@
getOptionValue={field => field.fieldId}
getOptionIcon={field => field.icon}
isOptionEnabled={option => {
if (option.type === AUTO_TYPE) {
if (option.type === FieldType.AUTO) {
return availableAutoColumnKeys?.length > 0
}
return true
@ -617,7 +658,7 @@
bind:optionColors={editableColumn.optionColors}
bind:valid={optionsValid}
/>
{:else if editableColumn.type === DATE_TYPE && !editableColumn.autocolumn}
{:else if editableColumn.type === FieldType.DATETIME && !editableColumn.autocolumn}
<div class="split-label">
<div class="label-length">
<Label size="M">Earliest</Label>
@ -704,7 +745,7 @@
{tableOptions}
{errors}
/>
{:else if editableColumn.type === FORMULA_TYPE}
{:else if editableColumn.type === FieldType.FORMULA}
{#if !externalTable}
<div class="split-label">
<div class="label-length">
@ -747,12 +788,19 @@
/>
</div>
</div>
{:else if editableColumn.type === JSON_TYPE}
<Button primary text on:click={openJsonSchemaEditor}
>Open schema editor</Button
>
{:else if editableColumn.type === FieldType.AI}
<AIFieldConfiguration
aiField={editableColumn}
context={rowGoldenSample}
bindings={getBindings({ table })}
schema={table.schema}
/>
{:else if editableColumn.type === FieldType.JSON}
<Button primary text on:click={openJsonSchemaEditor}>
Open schema editor
</Button>
{/if}
{#if editableColumn.type === AUTO_TYPE || editableColumn.autocolumn}
{#if editableColumn.type === FieldType.AUTO || editableColumn.autocolumn}
<Select
label="Auto column type"
value={editableColumn.subtype}
@ -779,27 +827,51 @@
</div>
{/if}
{#if canHaveDefault}
<div>
<ModalBindableInput
panel={ServerBindingPanel}
title="Default"
label="Default"
{#if defaultValuesEnabled}
{#if editableColumn.type === FieldType.OPTIONS}
<Select
disabled={!canHaveDefault}
options={editableColumn.constraints?.inclusion || []}
label="Default value"
value={editableColumn.default}
on:change={e => {
editableColumn = {
...editableColumn,
default: e.detail,
}
if (e.detail) {
setRequired(false)
}
}}
on:change={e => (editableColumn.default = e.detail)}
placeholder="None"
/>
{:else if editableColumn.type === FieldType.ARRAY}
<Multiselect
disabled={!canHaveDefault}
options={editableColumn.constraints?.inclusion || []}
label="Default value"
value={editableColumn.default}
on:change={e =>
(editableColumn.default = e.detail?.length ? e.detail : undefined)}
placeholder="None"
/>
{:else if editableColumn.subtype === BBReferenceFieldSubType.USER}
{@const defaultValue =
editableColumn.type === FieldType.BB_REFERENCE_SINGLE
? SingleUserDefault
: MultiUserDefault}
<Toggle
disabled={!canHaveDefault}
text="Default to current user"
value={editableColumn.default === defaultValue}
on:change={e =>
(editableColumn.default = e.detail ? defaultValue : undefined)}
/>
{:else}
<ModalBindableInput
disabled={!canHaveDefault}
panel={ServerBindingPanel}
title="Default value"
label="Default value"
placeholder="None"
value={editableColumn.default}
on:change={e => (editableColumn.default = e.detail)}
bindings={defaultValueBindings}
allowJS
/>
</div>
{/if}
{/if}
</Layout>

View File

@ -7,6 +7,7 @@
import { FIELDS } from "constants/backend"
const FORMULA_TYPE = FIELDS.FORMULA.type
const AI_TYPE = FIELDS.AI.type
export let row = {}
@ -60,7 +61,7 @@
}}
>
{#each tableSchema as [key, meta]}
{#if !meta.autocolumn && meta.type !== FORMULA_TYPE}
{#if !meta.autocolumn && meta.type !== FORMULA_TYPE && meta.type !== AI_TYPE}
<div>
<RowFieldControl error={errors[key]} {meta} bind:value={row[key]} />
</div>

View File

@ -125,7 +125,7 @@
label="Role"
bind:value={row.roleId}
options={$roles}
getOptionLabel={role => role.name}
getOptionLabel={role => role.uiMetadata.displayName}
getOptionValue={role => role._id}
disabled={!creating}
/>

View File

@ -1,174 +0,0 @@
<script>
import {
keepOpen,
ModalContent,
Select,
Input,
Button,
notifications,
} from "@budibase/bbui"
import { onMount } from "svelte"
import { API } from "api"
import ErrorsBox from "components/common/ErrorsBox.svelte"
import { roles } from "stores/builder"
const BASE_ROLE = { _id: "", inherits: "BASIC", permissionId: "write" }
let basePermissions = []
let selectedRole = BASE_ROLE
let errors = []
let builtInRoles = ["Admin", "Power", "Basic", "Public"]
let validRegex = /^[a-zA-Z0-9_]*$/
// Don't allow editing of public role
$: editableRoles = $roles.filter(role => role._id !== "PUBLIC")
$: selectedRoleId = selectedRole._id
$: otherRoles = editableRoles.filter(role => role._id !== selectedRoleId)
$: isCreating = selectedRoleId == null || selectedRoleId === ""
$: roleNameError = getRoleNameError(selectedRole.name)
$: valid =
selectedRole.name &&
selectedRole.inherits &&
selectedRole.permissionId &&
!builtInRoles.includes(selectedRole.name)
$: shouldDisableRoleInput =
builtInRoles.includes(selectedRole.name) &&
selectedRole.name?.toLowerCase() === selectedRoleId?.toLowerCase()
const fetchBasePermissions = async () => {
try {
basePermissions = await API.getBasePermissions()
} catch (error) {
notifications.error("Error fetching base permission options")
basePermissions = []
}
}
// Changes the selected role
const changeRole = event => {
const id = event?.detail
const role = $roles.find(role => role._id === id)
if (role) {
selectedRole = {
...role,
inherits: role.inherits ?? "",
permissionId: role.permissionId ?? "",
}
} else {
selectedRole = BASE_ROLE
}
errors = []
}
// Saves or creates the selected role
const saveRole = async () => {
errors = []
// Clean up empty strings
const keys = ["_id", "inherits", "permissionId"]
keys.forEach(key => {
if (selectedRole[key] === "") {
delete selectedRole[key]
}
})
// Validation
if (!selectedRole.name || selectedRole.name.trim() === "") {
errors.push({ message: "Please enter a role name" })
}
if (!selectedRole.permissionId) {
errors.push({ message: "Please choose permissions" })
}
if (errors.length) {
return keepOpen
}
// Save/create the role
try {
await roles.save(selectedRole)
notifications.success("Role saved successfully")
} catch (error) {
notifications.error(`Error saving role - ${error.message}`)
return keepOpen
}
}
// Deletes the selected role
const deleteRole = async () => {
try {
await roles.delete(selectedRole)
changeRole()
notifications.success("Role deleted successfully")
} catch (error) {
notifications.error(`Error deleting role - ${error.message}`)
return false
}
}
const getRoleNameError = name => {
const hasUniqueRoleName = !otherRoles
?.map(role => role.name)
?.includes(name)
const invalidRoleName = !validRegex.test(name)
if (!hasUniqueRoleName) {
return "Select a unique role name."
} else if (invalidRoleName) {
return "Please enter a role name consisting of only alphanumeric symbols and underscores"
}
}
onMount(fetchBasePermissions)
</script>
<ModalContent
title="Edit Roles"
confirmText={isCreating ? "Create" : "Save"}
onConfirm={saveRole}
disabled={!valid || roleNameError}
>
{#if errors.length}
<ErrorsBox {errors} />
{/if}
<Select
thin
secondary
label="Role"
value={selectedRoleId}
on:change={changeRole}
options={editableRoles}
placeholder="Create new role"
getOptionValue={role => role._id}
getOptionLabel={role => role.name}
/>
{#if selectedRole}
<Input
label="Name"
bind:value={selectedRole.name}
disabled={!!selectedRoleId}
error={roleNameError}
/>
<Select
label="Inherits Role"
bind:value={selectedRole.inherits}
options={selectedRole._id === "BASIC" ? $roles : otherRoles}
getOptionValue={role => role._id}
getOptionLabel={role => role.name}
disabled={shouldDisableRoleInput}
/>
<Select
label="Base Permissions"
bind:value={selectedRole.permissionId}
options={basePermissions}
getOptionValue={x => x._id}
getOptionLabel={x => x.name}
disabled={shouldDisableRoleInput}
/>
{/if}
<div slot="footer">
{#if !isCreating && !builtInRoles.includes(selectedRole.name)}
<Button warning on:click={deleteRole}>Delete</Button>
{/if}
</div>
</ModalContent>

View File

@ -1,224 +0,0 @@
<script>
import {
Select,
ModalContent,
notifications,
Body,
Table,
} from "@budibase/bbui"
import download from "downloadjs"
import { API } from "api"
import { QueryUtils } from "@budibase/frontend-core"
import { utils } from "@budibase/shared-core"
import { ROW_EXPORT_FORMATS } from "constants/backend"
export let view
export let filters
export let sorting
export let selectedRows = []
export let formats
const FORMATS = [
{
name: "CSV",
key: ROW_EXPORT_FORMATS.CSV,
},
{
name: "JSON",
key: ROW_EXPORT_FORMATS.JSON,
},
{
name: "JSON with Schema",
key: ROW_EXPORT_FORMATS.JSON_WITH_SCHEMA,
},
]
$: appliedFilters = filters?.filter(filter => !filter.onEmptyFilter)
$: options = FORMATS.filter(format => {
if (formats && !formats.includes(format.key)) {
return false
}
return true
})
let exportFormat
let filterLookup
$: if (options && !exportFormat) {
exportFormat = Array.isArray(options) ? options[0]?.key : []
}
$: query = QueryUtils.buildQuery(appliedFilters)
$: exportOpDisplay = buildExportOpDisplay(
sorting,
filterDisplay,
appliedFilters
)
filterLookup = utils.filterValueToLabel()
const filterDisplay = () => {
if (!appliedFilters) {
return []
}
return appliedFilters.map(filter => {
let newFieldName = filter.field + ""
const parts = newFieldName.split(":")
parts.shift()
newFieldName = parts.join(":")
return {
Field: newFieldName,
Operation: filterLookup[filter.operator],
"Field Value": filter.value || "",
}
})
}
const buildExportOpDisplay = (sorting, filterDisplay) => {
let filterDisplayConfig = filterDisplay()
if (sorting?.sortColumn) {
filterDisplayConfig = [
...filterDisplayConfig,
{
Field: sorting.sortColumn,
Operation: "Order By",
"Field Value": sorting.sortOrder,
},
]
}
return filterDisplayConfig
}
const displaySchema = {
Field: {
type: "string",
fieldName: "Field",
},
Operation: {
type: "string",
fieldName: "Operation",
},
"Field Value": {
type: "string",
fieldName: "Value",
},
}
function downloadWithBlob(data, filename) {
download(new Blob([data], { type: "text/plain" }), filename)
}
async function exportView() {
try {
const data = await API.exportView({
viewName: view,
format: exportFormat,
})
downloadWithBlob(
data,
`export.${exportFormat === "csv" ? "csv" : "json"}`
)
} catch (error) {
notifications.error(`Unable to export ${exportFormat.toUpperCase()} data`)
}
}
async function exportRows() {
if (selectedRows?.length) {
const data = await API.exportRows({
tableId: view,
rows: selectedRows.map(row => row._id),
format: exportFormat,
})
downloadWithBlob(data, `export.${exportFormat}`)
} else if (appliedFilters || sorting) {
let response
try {
response = await API.exportRows({
tableId: view,
format: exportFormat,
search: {
query,
sort: sorting?.sortColumn,
sortOrder: sorting?.sortOrder,
paginate: false,
},
})
} catch (e) {
console.error("Failed to export", e)
notifications.error("Export Failed")
}
if (response) {
downloadWithBlob(response, `export.${exportFormat}`)
notifications.success("Export Successful")
}
} else {
await exportView()
}
}
</script>
<ModalContent
title="Export Data"
confirmText="Export"
onConfirm={exportRows}
size={appliedFilters?.length || sorting ? "M" : "S"}
>
{#if selectedRows?.length}
<Body size="S">
<span data-testid="exporting-n-rows">
<strong>{selectedRows?.length}</strong>
{`row${selectedRows?.length > 1 ? "s" : ""} will be exported`}
</span>
</Body>
{:else if appliedFilters?.length || (sorting?.sortOrder && sorting?.sortColumn)}
<Body size="S">
{#if !appliedFilters}
<span data-testid="exporting-rows">
Exporting <strong>all</strong> rows
</span>
{:else}
<span data-testid="filters-applied">Filters applied</span>
{/if}
</Body>
<div class="table-wrap" data-testid="export-config-table">
<Table
schema={displaySchema}
data={exportOpDisplay}
{appliedFilters}
loading={false}
rowCount={appliedFilters?.length + 1}
disableSorting={true}
allowSelectRows={false}
allowEditRows={false}
allowEditColumns={false}
quiet={true}
compact={true}
/>
</div>
{:else}
<Body size="S">
<span data-testid="export-all-rows">
Exporting <strong>all</strong> rows
</span>
</Body>
{/if}
<span data-testid="format-select">
<Select
label="Format"
bind:value={exportFormat}
{options}
placeholder={null}
getOptionLabel={x => x.name}
getOptionValue={x => x.key}
/>
</span>
</ModalContent>
<style>
.table-wrap :global(.wrapper) {
max-width: 400px;
}
</style>

View File

@ -1,241 +0,0 @@
import { it, expect, describe, vi } from "vitest"
import { render, screen } from "@testing-library/svelte"
import "@testing-library/jest-dom"
import ExportModal from "./ExportModal.svelte"
import { utils } from "@budibase/shared-core"
const labelLookup = utils.filterValueToLabel()
const rowText = filter => {
let readableField = filter.field.split(":")[1]
let rowLabel = labelLookup[filter.operator]
let value = Array.isArray(filter.value)
? JSON.stringify(filter.value)
: filter.value
return `${readableField}${rowLabel}${value}`.trim()
}
const defaultFilters = [
{
onEmptyFilter: "all",
},
]
vi.mock("svelte", async () => {
return {
getContext: () => {
return {
hide: vi.fn(),
cancel: vi.fn(),
}
},
createEventDispatcher: vi.fn(),
onDestroy: vi.fn(),
tick: vi.fn(),
}
})
vi.mock("api", async () => {
return {
API: {
exportView: vi.fn(),
exportRows: vi.fn(),
},
}
})
describe("Export Modal", () => {
it("show default messaging with no export config specified", () => {
render(ExportModal, {
props: {},
})
expect(screen.getByTestId("export-all-rows")).toBeVisible()
expect(screen.getByTestId("export-all-rows")).toHaveTextContent(
"Exporting all rows"
)
expect(screen.queryByTestId("export-config-table")).toBe(null)
})
it("indicate that a filter is being applied to the export", () => {
const propsCfg = {
filters: [
{
id: "MOQkMx9p9",
field: "1:Cost",
operator: "rangeHigh",
value: "100",
valueType: "Value",
type: "number",
noValue: false,
},
...defaultFilters,
],
}
render(ExportModal, {
props: propsCfg,
})
expect(screen.getByTestId("filters-applied")).toBeVisible()
expect(screen.getByTestId("filters-applied").textContent).toBe(
"Filters applied"
)
const ele = screen.queryByTestId("export-config-table")
expect(ele).toBeVisible()
const rows = ele.getElementsByClassName("spectrum-Table-row")
expect(rows.length).toBe(1)
let rowTextContent = rowText(propsCfg.filters[0])
//"CostLess than or equal to100"
expect(rows[0].textContent?.trim()).toEqual(rowTextContent)
})
it("Show only selected row messaging if rows are supplied", () => {
const propsCfg = {
filters: [
{
id: "MOQkMx9p9",
field: "1:Cost",
operator: "rangeHigh",
value: "100",
valueType: "Value",
type: "number",
noValue: false,
},
...defaultFilters,
],
sorting: {
sortColumn: "Cost",
sortOrder: "descending",
},
selectedRows: [
{
_id: "ro_ta_bb_expenses_57d5f6fe1b6640d8bb22b15f5eae62cd",
},
{
_id: "ro_ta_bb_expenses_99ce5760a53a430bab4349cd70335a07",
},
],
}
render(ExportModal, {
props: propsCfg,
})
expect(screen.queryByTestId("export-config-table")).toBeNull()
expect(screen.queryByTestId("filters-applied")).toBeNull()
expect(screen.queryByTestId("exporting-n-rows")).toBeVisible()
expect(screen.queryByTestId("exporting-n-rows").textContent).toEqual(
"2 rows will be exported"
)
})
it("Show only the configured sort when no filters are specified", () => {
const propsCfg = {
filters: [...defaultFilters],
sorting: {
sortColumn: "Cost",
sortOrder: "descending",
},
}
render(ExportModal, {
props: propsCfg,
})
expect(screen.queryByTestId("export-config-table")).toBeVisible()
const ele = screen.queryByTestId("export-config-table")
const rows = ele.getElementsByClassName("spectrum-Table-row")
expect(rows.length).toBe(1)
expect(rows[0].textContent?.trim()).toEqual(
`${propsCfg.sorting.sortColumn}Order By${propsCfg.sorting.sortOrder}`
)
})
it("Display all currently configured filters and applied sort", () => {
const propsCfg = {
filters: [
{
id: "MOQkMx9p9",
field: "1:Cost",
operator: "rangeHigh",
value: "100",
valueType: "Value",
type: "number",
noValue: false,
},
{
id: "2ot-aB0gE",
field: "2:Expense Tags",
operator: "contains",
value: ["Equipment", "Services"],
valueType: "Value",
type: "array",
noValue: false,
},
...defaultFilters,
],
sorting: {
sortColumn: "Payment Due",
sortOrder: "ascending",
},
}
render(ExportModal, {
props: propsCfg,
})
const ele = screen.queryByTestId("export-config-table")
expect(ele).toBeVisible()
const rows = ele.getElementsByClassName("spectrum-Table-row")
expect(rows.length).toBe(3)
let rowTextContent1 = rowText(propsCfg.filters[0])
expect(rows[0].textContent?.trim()).toEqual(rowTextContent1)
let rowTextContent2 = rowText(propsCfg.filters[1])
expect(rows[1].textContent?.trim()).toEqual(rowTextContent2)
expect(rows[2].textContent?.trim()).toEqual(
`${propsCfg.sorting.sortColumn}Order By${propsCfg.sorting.sortOrder}`
)
})
it("show only the valid, configured download formats", () => {
const propsCfg = {
formats: ["badger", "json"],
}
render(ExportModal, {
props: propsCfg,
})
let ele = screen.getByTestId("format-select")
expect(ele).toBeVisible()
let formatDisplay = ele.getElementsByTagName("button")[0]
expect(formatDisplay.textContent.trim()).toBe("JSON")
})
it("Load the default format config when no explicit formats are configured", () => {
render(ExportModal, {
props: {},
})
let ele = screen.getByTestId("format-select")
expect(ele).toBeVisible()
let formatDisplay = ele.getElementsByTagName("button")[0]
expect(formatDisplay.textContent.trim()).toBe("CSV")
})
})

View File

@ -1,61 +0,0 @@
<script>
import {
ModalContent,
Label,
notifications,
Body,
Layout,
} from "@budibase/bbui"
import TableDataImport from "../../TableNavigator/ExistingTableDataImport.svelte"
import { API } from "api"
import { createEventDispatcher } from "svelte"
const dispatch = createEventDispatcher()
export let tableId
export let tableType
let rows = []
let allValid = false
let displayColumn = null
let identifierFields = []
async function importData() {
try {
await API.importTableData({
tableId,
rows,
identifierFields,
})
notifications.success("Rows successfully imported")
} catch (error) {
notifications.error("Unable to import data")
}
// Always refresh rows just to be sure
dispatch("importrows")
}
</script>
<ModalContent
title="Import Data"
confirmText="Import"
onConfirm={importData}
disabled={!allValid}
>
<Body size="S">
Import rows to an existing table from a CSV or JSON file. Only columns from
the file which exist in the table will be imported.
</Body>
<Layout gap="XS" noPadding>
<Label grey extraSmall>CSV or JSON file to import</Label>
<TableDataImport
{tableId}
{tableType}
bind:rows
bind:allValid
bind:displayColumn
bind:identifierFields
/>
</Layout>
</ModalContent>

View File

@ -1,155 +0,0 @@
<script>
import { PermissionSource } from "@budibase/types"
import { roles, permissions as permissionsStore } from "stores/builder"
import {
Label,
Input,
Select,
notifications,
Body,
ModalContent,
Tags,
Tag,
Icon,
} from "@budibase/bbui"
import { capitalise } from "helpers"
import { getFormattedPlanName } from "helpers/planTitle"
import { get } from "svelte/store"
export let resourceId
export let permissions
const inheritedRoleId = "inherited"
async function changePermission(level, role) {
try {
if (role === inheritedRoleId) {
await permissionsStore.remove({
level,
role,
resource: resourceId,
})
} else {
await permissionsStore.save({
level,
role,
resource: resourceId,
})
}
// Show updated permissions in UI: REMOVE
permissions = await permissionsStore.forResourceDetailed(resourceId)
notifications.success("Updated permissions")
} catch (error) {
notifications.error("Error updating permissions")
}
}
$: computedPermissions = Object.entries(permissions.permissions).reduce(
(p, [level, roleInfo]) => {
p[level] = {
selectedValue:
roleInfo.permissionType === PermissionSource.INHERITED
? inheritedRoleId
: roleInfo.role,
options: [...get(roles)],
}
if (roleInfo.inheritablePermission) {
p[level].inheritOption = roleInfo.inheritablePermission
p[level].options.unshift({
_id: inheritedRoleId,
name: `Inherit (${
get(roles).find(x => x._id === roleInfo.inheritablePermission).name
})`,
})
}
return p
},
{}
)
$: requiresPlanToModify = permissions.requiresPlanToModify
let dependantsInfoMessage
async function loadDependantInfo() {
const dependantsInfo = await permissionsStore.getDependantsInfo(resourceId)
const resourceByType = dependantsInfo?.resourceByType
if (resourceByType) {
const total = Object.values(resourceByType).reduce((p, c) => p + c, 0)
let resourceDisplay =
Object.keys(resourceByType).length === 1 && resourceByType.view
? "view"
: "resource"
if (total === 1) {
dependantsInfoMessage = `1 ${resourceDisplay} is inheriting this access.`
} else if (total > 1) {
dependantsInfoMessage = `${total} ${resourceDisplay}s are inheriting this access.`
}
}
}
loadDependantInfo()
</script>
<ModalContent showCancelButton={false} showConfirmButton={false}>
<span slot="header">
Manage Access
{#if requiresPlanToModify}
<span class="lock-tag">
<Tags>
<Tag icon="LockClosed"
>{getFormattedPlanName(requiresPlanToModify)}</Tag
>
</Tags>
</span>
{/if}
</span>
<Body size="S">Specify the minimum access level role for this data.</Body>
<div class="row">
<Label extraSmall grey>Level</Label>
<Label extraSmall grey>Role</Label>
{#each Object.keys(computedPermissions) as level}
<Input value={capitalise(level)} disabled />
<Select
disabled={requiresPlanToModify}
placeholder={false}
value={computedPermissions[level].selectedValue}
on:change={e => changePermission(level, e.detail)}
options={computedPermissions[level].options}
getOptionLabel={x => x.name}
getOptionValue={x => x._id}
/>
{/each}
</div>
{#if dependantsInfoMessage}
<div class="inheriting-resources">
<Icon name="Alert" />
<Body size="S">
<i>
{dependantsInfoMessage}
</i>
</Body>
</div>
{/if}
</ModalContent>
<style>
.row {
display: grid;
grid-template-columns: 1fr 1fr;
grid-gap: var(--spacing-s);
}
.lock-tag {
padding-left: var(--spacing-s);
}
.inheriting-resources {
display: flex;
gap: var(--spacing-s);
}
</style>

View File

@ -1,60 +0,0 @@
<script>
import { getContext } from "svelte"
import { Input, notifications, ModalContent } from "@budibase/bbui"
import { goto } from "@roxi/routify"
import { viewsV2 } from "stores/builder"
const { filter, sort, definition } = getContext("grid")
let name
$: views = Object.keys($definition?.views || {}).map(x => x.toLowerCase())
$: nameExists = views.includes(name?.trim().toLowerCase())
const enrichSchema = schema => {
// We need to sure that "visible" is set to true for any fields which have
// not yet been saved with grid metadata attached
const cloned = { ...schema }
Object.entries(cloned).forEach(([field, fieldSchema]) => {
if (fieldSchema.visible == null) {
cloned[field] = { ...cloned[field], visible: true }
}
})
return cloned
}
const saveView = async () => {
name = name?.trim()
try {
const newView = await viewsV2.create({
name,
tableId: $definition._id,
query: $filter,
sort: {
field: $sort.column,
order: $sort.order,
},
schema: enrichSchema($definition.schema),
primaryDisplay: $definition.primaryDisplay,
})
notifications.success(`View ${name} created`)
$goto(`../../view/v2/${newView.id}`)
} catch (error) {
notifications.error("Error creating view")
}
}
</script>
<ModalContent
title="Create view"
confirmText="Create view"
onConfirm={saveView}
disabled={nameExists}
>
<Input
label="View name"
thin
bind:value={name}
error={nameExists ? "A view already exists with that name" : null}
/>
</ModalContent>

View File

@ -39,9 +39,7 @@
const selectTable = tableId => {
tables.select(tableId)
if (!$isActive("./table/:tableId")) {
$goto(`./table/${tableId}`)
}
$goto(`./table/${tableId}`)
}
function openNode(datasource) {
@ -78,6 +76,13 @@
selectedBy={$userSelectedResourceMap[TableNames.USERS]}
/>
{/if}
<NavItem
icon="UserAdmin"
text="Manage roles"
selected={$isActive("./roles")}
on:click={() => $goto("./roles")}
selectedBy={$userSelectedResourceMap.roles}
/>
{#each enrichedDataSources.filter(ds => ds.show) as datasource}
<DatasourceNavItem
{datasource}

View File

@ -0,0 +1,63 @@
<script>
import { BaseEdge } from "@xyflow/svelte"
import { NodeWidth, GridResolution } from "./constants"
import { getContext } from "svelte"
export let sourceX
export let sourceY
const { bounds } = getContext("flow")
$: bracketWidth = GridResolution * 3
$: bracketHeight = $bounds.height / 2 + GridResolution * 2
$: path = getCurlyBracePath(
sourceX + bracketWidth,
sourceY - bracketHeight,
sourceX + bracketWidth,
sourceY + bracketHeight
)
const getCurlyBracePath = (x1, y1, x2, y2) => {
const w = 2 // Thickness
const q = 1 // Intensity
const i = 28 // Inner radius strenth (lower is stronger)
const j = 32 // Outer radius strength (higher is stronger)
// Calculate unit vector
var dx = x1 - x2
var dy = y1 - y2
var len = Math.sqrt(dx * dx + dy * dy)
dx = dx / len
dy = dy / len
// Path control points
const qx1 = x1 + q * w * dy - j
const qy1 = y1 - q * w * dx
const qx2 = x1 - 0.25 * len * dx + (1 - q) * w * dy - i
const qy2 = y1 - 0.25 * len * dy - (1 - q) * w * dx
const tx1 = x1 - 0.5 * len * dx + w * dy - bracketWidth
const ty1 = y1 - 0.5 * len * dy - w * dx
const qx3 = x2 + q * w * dy - j
const qy3 = y2 - q * w * dx
const qx4 = x1 - 0.75 * len * dx + (1 - q) * w * dy - i
const qy4 = y1 - 0.75 * len * dy - (1 - q) * w * dx
return `M ${x1} ${y1} Q ${qx1} ${qy1} ${qx2} ${qy2} T ${tx1} ${ty1} M ${x2} ${y2} Q ${qx3} ${qy3} ${qx4} ${qy4} T ${tx1} ${ty1}`
}
</script>
<BaseEdge
{...$$props}
{path}
style="--width:{NodeWidth}px; --x:{sourceX}px; --y:{sourceY}px;"
/>
<style>
:global(#basic-bracket) {
animation-timing-function: linear(1, 0);
}
:global(#admin-bracket) {
transform: scale(-1, 1) translateX(calc(var(--width) + 8px));
transform-origin: var(--x) var(--y);
}
</style>

View File

@ -0,0 +1,74 @@
<script>
import { Button, ActionButton } from "@budibase/bbui"
import { useSvelteFlow } from "@xyflow/svelte"
import { getContext } from "svelte"
import { ZoomDuration } from "./constants"
const { createRole, layoutAndFit } = getContext("flow")
const flow = useSvelteFlow()
</script>
<div class="control top-right">
<div class="group">
<ActionButton
icon="Add"
quiet
on:click={() => flow.zoomIn({ duration: ZoomDuration })}
/>
<ActionButton
icon="Remove"
quiet
on:click={() => flow.zoomOut({ duration: ZoomDuration })}
/>
</div>
<Button secondary on:click={layoutAndFit}>Auto layout</Button>
</div>
<div class="control bottom-right">
<Button icon="Add" cta on:click={createRole}>Add role</Button>
</div>
<style>
.control {
position: absolute;
z-index: 10;
display: flex;
flex-direction: row;
align-items: center;
gap: 12px;
}
.top-right {
top: 20px;
right: 20px;
}
.bottom-right {
bottom: 20px;
right: 20px;
}
.top-right :global(.spectrum-Button),
.top-right :global(.spectrum-ActionButton),
.top-right :global(.spectrum-Icon) {
color: var(--spectrum-global-color-gray-900) !important;
}
.top-right :global(.spectrum-Button),
.top-right :global(.spectrum-ActionButton) {
background: var(--spectrum-global-color-gray-200) !important;
}
.top-right :global(.spectrum-Button:hover),
.top-right :global(.spectrum-ActionButton:hover) {
background: var(--spectrum-global-color-gray-300) !important;
}
.group {
border-radius: 4px;
display: flex;
flex-direction: row;
}
.group :global(> *:not(:first-child)) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: 2px solid var(--spectrum-global-color-gray-300);
}
.group :global(> *:not(:last-child)) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
</style>

View File

@ -0,0 +1,24 @@
<script>
import { NodeWidth, NodeHeight } from "./constants"
</script>
<div class="node" style={`--width:${NodeWidth}px; --height:${NodeHeight}px;`}>
Add custom roles for more granular control over permissions
</div>
<style>
.node {
border-radius: 4px;
width: var(--width);
height: var(--height);
display: grid;
place-items: center;
font-size: 16px;
text-align: center;
color: var(--spectrum-global-color-gray-800);
text-shadow: 4px 4px 10px var(--background-color),
4px -4px 10px var(--background-color),
-4px 4px 10px var(--background-color),
-4px -4px 10px var(--background-color);
}
</style>

View File

@ -0,0 +1,123 @@
<script>
import { getBezierPath, BaseEdge, EdgeLabelRenderer } from "@xyflow/svelte"
import { Icon, TooltipPosition } from "@budibase/bbui"
import { getContext, onMount } from "svelte"
import { roles } from "stores/builder"
export let sourceX
export let sourceY
export let sourcePosition
export let targetX
export let targetY
export let targetPosition
export let id
export let source
export let target
const { deleteEdge, selectedNodes } = getContext("flow")
let iconHovered = false
let edgeHovered = false
$: hovered = iconHovered || edgeHovered
$: active =
hovered ||
$selectedNodes.includes(source) ||
$selectedNodes.includes(target)
$: edgeClasses = getEdgeClasses(active, iconHovered)
$: [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
})
$: sourceRole = $roles.find(x => x._id === source)
$: targetRole = $roles.find(x => x._id === target)
$: tooltip =
sourceRole && targetRole
? `Stop ${targetRole.uiMetadata.displayName} from inheriting ${sourceRole.uiMetadata.displayName}`
: null
const getEdgeClasses = (active, iconHovered) => {
let classes = ""
if (active) classes += `active `
if (iconHovered) classes += `delete `
return classes
}
const onEdgeMouseOver = () => {
edgeHovered = true
}
const onEdgeMouseOut = () => {
edgeHovered = false
}
onMount(() => {
const edge = document.querySelector(`.svelte-flow__edge[data-id="${id}"]`)
if (edge) {
edge.addEventListener("mouseover", onEdgeMouseOver)
edge.addEventListener("mouseout", onEdgeMouseOut)
}
return () => {
if (edge) {
edge.removeEventListener("mouseover", onEdgeMouseOver)
edge.removeEventListener("mouseout", onEdgeMouseOut)
}
}
})
</script>
<BaseEdge path={edgePath} class={edgeClasses} />
<EdgeLabelRenderer>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<div
style:transform="translate(-50%, -50%) translate({labelX}px,{labelY}px)"
class="edge-label nodrag nopan"
class:active
on:click={() => deleteEdge(id)}
on:mouseover={() => (iconHovered = true)}
on:mouseout={() => (iconHovered = false)}
>
<Icon
name="Delete"
size="S"
{tooltip}
tooltipPosition={TooltipPosition.Top}
/>
</div>
</EdgeLabelRenderer>
<style>
.edge-label {
position: absolute;
padding: 8px;
opacity: 0;
pointer-events: none;
}
.edge-label.active {
opacity: 1;
pointer-events: all;
cursor: pointer;
}
.edge-label:hover :global(.spectrum-Icon) {
color: var(--spectrum-global-color-red-400);
}
.edge-label :global(.spectrum-Icon) {
background: var(--background-color);
color: var(--spectrum-global-color-gray-600);
}
.edge-label :global(svg) {
padding: 4px;
}
:global(.svelte-flow__edge-path.active) {
stroke: var(--spectrum-global-color-blue-400);
}
:global(.svelte-flow__edge-path.active.delete) {
stroke: var(--spectrum-global-color-red-400);
}
</style>

View File

@ -0,0 +1,8 @@
<script>
import { SvelteFlowProvider } from "@xyflow/svelte"
import RoleFlow from "./RoleFlow.svelte"
</script>
<SvelteFlowProvider>
<RoleFlow />
</SvelteFlowProvider>

View File

@ -0,0 +1,234 @@
<script>
import { Heading, Helpers, notifications } from "@budibase/bbui"
import { writable, derived } from "svelte/store"
import {
SvelteFlow,
Background,
BackgroundVariant,
useSvelteFlow,
} from "@xyflow/svelte"
import "@xyflow/svelte/dist/style.css"
import RoleNode from "./RoleNode.svelte"
import EmptyStateNode from "./EmptyStateNode.svelte"
import RoleEdge from "./RoleEdge.svelte"
import BracketEdge from "./BracketEdge.svelte"
import {
autoLayout,
getAdminPosition,
getBasicPosition,
rolesToLayout,
nodeToRole,
getBounds,
} from "./utils"
import { setContext, tick } from "svelte"
import Controls from "./Controls.svelte"
import { GridResolution, MaxAutoZoom, ZoomDuration } from "./constants"
import { roles } from "stores/builder"
import { Roles } from "constants/backend"
import { getSequentialName } from "helpers/duplicate"
import { derivedMemo } from "@budibase/frontend-core"
const flow = useSvelteFlow()
const edges = writable([])
const nodes = writable([])
const dragging = writable(false)
// Derive the list of selected nodes
const selectedNodes = derived(nodes, $nodes => {
return $nodes.filter(node => node.selected).map(node => node.id)
})
// Derive the bounds of all custom role nodes
const bounds = derivedMemo(nodes, getBounds)
$: handleExternalRoleChanges($roles)
$: updateBuiltins($bounds)
// Updates nodes and edges based on external changes to roles
const handleExternalRoleChanges = roles => {
const currentNodes = $nodes
const newLayout = autoLayout(rolesToLayout(roles))
edges.set(newLayout.edges)
// For nodes we want to persist some metadata if possible
nodes.set(
newLayout.nodes.map(node => {
const currentNode = currentNodes.find(x => x.id === node.id)
if (!currentNode) {
return node
}
return {
...node,
position: currentNode.position || node.position,
selected: currentNode.selected || node.selected,
}
})
)
}
// Positions the basic and admin role at either edge of the flow
const updateBuiltins = bounds => {
flow.updateNode(Roles.BASIC, {
position: getBasicPosition(bounds),
})
flow.updateNode(Roles.ADMIN, {
position: getAdminPosition(bounds),
})
}
// Automatically lays out all roles and edges and zooms to fit them
const layoutAndFit = () => {
const layout = autoLayout({ nodes: $nodes, edges: $edges })
nodes.set(layout.nodes)
edges.set(layout.edges)
flow.fitView({ maxZoom: MaxAutoZoom, duration: ZoomDuration })
}
const createRole = async () => {
const roleId = Helpers.uuid()
await roles.save({
name: roleId,
uiMetadata: {
displayName: getSequentialName($roles, "New role ", {
getName: role => role.uiMetadata.displayName,
}),
color: "var(--spectrum-global-color-gray-700)",
description: "Custom role",
},
inherits: [Roles.BASIC],
})
await tick()
layoutAndFit()
// Select the new node
nodes.update($nodes => {
return $nodes.map(node => ({
...node,
selected: node.id === roleId,
}))
})
}
const updateRole = async (roleId, metadata) => {
const node = $nodes.find(node => node.id === roleId)
if (!node) {
return
}
// Update metadata immediately, before saving
if (metadata) {
flow.updateNodeData(roleId, metadata)
}
try {
await roles.save(nodeToRole({ node, edges: $edges }))
layoutAndFit()
} catch (error) {
notifications.error(error?.message || error || "Failed to update role")
handleExternalRoleChanges($roles)
}
}
const deleteRole = async roleId => {
nodes.set($nodes.filter(node => node.id !== roleId))
layoutAndFit()
const role = $roles.find(role => role._id === roleId)
if (role) {
roles.delete(role)
}
}
const deleteEdge = async edgeId => {
const edge = $edges.find(edge => edge.id === edgeId)
edges.set($edges.filter(edge => edge.id !== edgeId))
await updateRole(edge.target)
}
const onConnect = async connection => {
await updateRole(connection.target)
}
setContext("flow", {
nodes,
edges,
dragging,
selectedNodes,
bounds,
createRole,
updateRole,
deleteRole,
deleteEdge,
layoutAndFit,
})
</script>
<div class="title">
<div class="heading" />
</div>
<div class="flow">
<SvelteFlow
fitView
{nodes}
{edges}
snapGrid={[GridResolution, GridResolution]}
nodeTypes={{ role: RoleNode, empty: EmptyStateNode }}
edgeTypes={{ role: RoleEdge, bracket: BracketEdge }}
proOptions={{ hideAttribution: true }}
fitViewOptions={{ maxZoom: MaxAutoZoom }}
defaultEdgeOptions={{ type: "role", animated: true, selectable: false }}
onconnectstart={() => dragging.set(true)}
onconnectend={() => dragging.set(false)}
onconnect={onConnect}
deleteKey={null}
>
<Background variant={BackgroundVariant.Dots} />
<Controls />
<div class="title">
<Heading size="S">Manage roles</Heading>
</div>
<div class="footer">Roles inherit permissions from each other</div>
</SvelteFlow>
</div>
<style>
.flow {
margin: -28px -40px -40px -40px;
flex: 1 1 auto;
overflow: hidden;
position: relative;
--background-color: var(--spectrum-global-color-gray-50);
--border-color: var(--spectrum-global-color-gray-300);
--edge-color: var(--spectrum-global-color-gray-500);
--handle-color: var(--spectrum-global-color-gray-600);
--selected-color: var(--spectrum-global-color-blue-400);
}
.title {
position: absolute;
top: 20px;
left: 20px;
z-index: 10;
}
.footer {
position: absolute;
left: 20px;
bottom: 20px;
color: var(--spectrum-global-color-gray-600);
z-index: 10;
}
/* Customise svelte-flow theme */
.flow :global(.svelte-flow) {
/* Panel */
--xy-background-color: var(--background-color);
/* Controls */
--xy-controls-button-border-color: var(--border-color);
/* Handles */
--xy-handle-background-color: var(--handle-color);
--xy-handle-border-color: var(--handle-color);
/* Edges */
--xy-edge-stroke: var(--edge-color);
--xy-edge-stroke-selected: var(--edge-color);
--xy-edge-stroke-width: 2px;
}
</style>

View File

@ -0,0 +1,231 @@
<script>
import { Handle, Position } from "@xyflow/svelte"
import {
Icon,
Input,
ColorPicker,
Modal,
ModalContent,
FieldLabel,
} from "@budibase/bbui"
import { NodeWidth, NodeHeight } from "./constants"
import { getContext } from "svelte"
import { roles } from "stores/builder"
import ConfirmDialog from "components/common/ConfirmDialog.svelte"
export let data
export let id
export let selected
export let isConnectable
const { dragging, updateRole, deleteRole } = getContext("flow")
let anchor
let modal
let tempDisplayName
let tempDescription
let tempColor
let deleteModal
$: nameError = validateName(tempDisplayName, $roles)
$: descriptionError = validateDescription(tempDescription)
$: invalid = nameError || descriptionError
const validateName = (name, roles) => {
if (!name?.length) {
return "Please enter a name"
}
if (roles.some(x => x.uiMetadata.displayName === name && x._id !== id)) {
return "That name is already used by another role"
}
return null
}
const validateDescription = description => {
if (!description?.length) {
return "Please enter a name"
}
return null
}
const openPopover = e => {
e.stopPropagation()
tempDisplayName = data.displayName
tempDescription = data.description
tempColor = data.color
modal.show()
}
const saveChanges = () => {
updateRole(id, {
displayName: tempDisplayName,
description: tempDescription,
color: tempColor,
})
}
</script>
<div
class="node"
class:dragging={$dragging}
class:selected
class:interactive={data.interactive}
class:custom={data.custom}
class:selectable={isConnectable}
style={`--color:${data.color}; --width:${NodeWidth}px; --height:${NodeHeight}px;`}
bind:this={anchor}
>
<div class="color" />
<div class="content">
<div class="text">
<div class="name">
{data.displayName}
</div>
{#if data.description}
<div class="description" title={data.description}>
{data.description}
</div>
{/if}
</div>
{#if data.custom}
<div class="buttons">
<Icon size="S" name="Edit" hoverable on:click={openPopover} />
<Icon size="S" name="Delete" hoverable on:click={deleteModal?.show} />
</div>
{/if}
</div>
<Handle
type="target"
position={Position.Left}
isConnectable={isConnectable && $dragging && data.custom}
/>
<Handle type="source" position={Position.Right} {isConnectable} />
</div>
<ConfirmDialog
bind:this={deleteModal}
title={`Delete ${data.displayName}`}
body="Are you sure you want to delete this role? This can't be undone."
okText="Delete"
onOk={async () => await deleteRole(id)}
/>
<Modal bind:this={modal}>
<ModalContent
title={`Edit ${data.displayName}`}
confirmText="Save"
onConfirm={saveChanges}
disabled={invalid}
>
<Input
label="Name"
value={tempDisplayName}
error={nameError}
on:change={e => (tempDisplayName = e.detail)}
/>
<Input
label="Description"
value={tempDescription}
error={descriptionError}
on:change={e => (tempDescription = e.detail)}
/>
<div>
<FieldLabel label="Color" />
<ColorPicker value={tempColor} on:change={e => (tempColor = e.detail)} />
</div>
</ModalContent>
</Modal>
<style>
/* Node styles */
.node {
position: relative;
background: var(--spectrum-global-color-gray-100);
border-radius: 4px;
width: var(--width);
height: var(--height);
display: flex;
flex-direction: row;
box-sizing: border-box;
transition: background 130ms ease-out;
}
.node.selectable:hover {
cursor: pointer;
background: var(--spectrum-global-color-gray-200);
}
.node.selectable.selected {
background: var(--spectrum-global-color-blue-100);
cursor: grab;
}
.color {
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
height: 100%;
width: 10px;
flex: 0 0 10px;
background: var(--color);
}
/* Main container */
.content {
flex: 1 1 auto;
display: flex;
flex-direction: row;
border: 1px solid var(--border-color);
border-left-width: 0;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
padding: 12px;
gap: 6px;
}
.node.selected .content {
border-color: var(--spectrum-global-color-blue-100);
}
/* Text */
.text {
width: 0;
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: center;
align-items: stretch;
}
.name,
.description {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.description {
color: var(--spectrum-global-color-gray-600);
font-size: 12px;
}
/* Icons */
.buttons {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
}
.buttons :global(.spectrum-Icon) {
color: var(--spectrum-global-color-gray-600);
}
/* Handles */
.node :global(.svelte-flow__handle) {
width: 6px;
height: 6px;
border-width: 2px;
}
.node :global(.svelte-flow__handle.target) {
background: var(--background-color);
}
.node:not(.dragging) :global(.svelte-flow__handle.target),
.node:not(.interactive) :global(.svelte-flow__handle),
.node:not(.custom) :global(.svelte-flow__handle.target) {
visibility: hidden;
pointer-events: none;
}
</style>

View File

@ -0,0 +1,9 @@
export const ZoomDuration = 300
export const MaxAutoZoom = 1.2
export const GridResolution = 20
export const NodeHeight = GridResolution * 3
export const NodeWidth = GridResolution * 12
export const NodeHSpacing = GridResolution * 6
export const NodeVSpacing = GridResolution * 2
export const MinHeight = GridResolution * 10
export const EmptyStateID = "empty"

View File

@ -0,0 +1,245 @@
import dagre from "@dagrejs/dagre"
import {
NodeWidth,
NodeHeight,
GridResolution,
NodeHSpacing,
NodeVSpacing,
MinHeight,
EmptyStateID,
} from "./constants"
import { getNodesBounds, Position } from "@xyflow/svelte"
import { Roles } from "constants/backend"
import { roles } from "stores/builder"
import { get } from "svelte/store"
// Calculates the bounds of all custom nodes
export const getBounds = nodes => {
const interactiveNodes = nodes.filter(node => node.data.interactive)
// Empty state bounds which line up with bounds after adding first node
if (!interactiveNodes.length) {
return {
x: 0,
y: -3.5 * GridResolution,
width: 12 * GridResolution,
height: 10 * GridResolution,
}
}
let bounds = getNodesBounds(interactiveNodes)
// Enforce a min size
if (bounds.height < MinHeight) {
const diff = MinHeight - bounds.height
bounds.height = MinHeight
bounds.y -= diff / 2
}
return bounds
}
// Gets the position of the basic role
export const getBasicPosition = bounds => ({
x: bounds.x - NodeHSpacing - NodeWidth,
y: bounds.y + bounds.height / 2 - NodeHeight / 2,
})
// Gets the position of the admin role
export const getAdminPosition = bounds => ({
x: bounds.x + bounds.width + NodeHSpacing,
y: bounds.y + bounds.height / 2 - NodeHeight / 2,
})
// Filters out invalid nodes and edges
const preProcessLayout = ({ nodes, edges }) => {
const ignoredIds = [Roles.PUBLIC, Roles.BASIC, Roles.ADMIN, EmptyStateID]
const targetlessIds = [Roles.POWER]
return {
nodes: nodes.filter(node => {
// Filter out ignored IDs
if (ignoredIds.includes(node.id)) {
return false
}
return true
}),
edges: edges.filter(edge => {
// Filter out edges from ignored IDs
if (
ignoredIds.includes(edge.source) ||
ignoredIds.includes(edge.target)
) {
return false
}
// Filter out edges which have the same source and target
if (edge.source === edge.target) {
return false
}
// Filter out edges which target targetless roles
if (targetlessIds.includes(edge.target)) {
return false
}
return true
}),
}
}
// Updates positions of nodes and edges into a nice graph structure
export const dagreLayout = ({ nodes, edges }) => {
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
dagreGraph.setGraph({
rankdir: "LR",
ranksep: NodeHSpacing,
nodesep: NodeVSpacing,
})
nodes.forEach(node => {
dagreGraph.setNode(node.id, { width: NodeWidth, height: NodeHeight })
})
edges.forEach(edge => {
dagreGraph.setEdge(edge.source, edge.target)
})
dagre.layout(dagreGraph)
nodes.forEach(node => {
const pos = dagreGraph.node(node.id)
node.targetPosition = Position.Left
node.sourcePosition = Position.Right
node.position = {
x: Math.round((pos.x - NodeWidth / 2) / GridResolution) * GridResolution,
y: Math.round((pos.y - NodeHeight / 2) / GridResolution) * GridResolution,
}
})
return { nodes, edges }
}
const postProcessLayout = ({ nodes, edges }) => {
// Add basic and admin nodes at each edge
const bounds = getBounds(nodes)
const $roles = get(roles)
nodes.push({
...roleToNode($roles.find(role => role._id === Roles.BASIC)),
position: getBasicPosition(bounds),
})
nodes.push({
...roleToNode($roles.find(role => role._id === Roles.ADMIN)),
position: getAdminPosition(bounds),
})
// Add custom edges for basic and admin brackets
edges.push({
id: "basic-bracket",
source: Roles.BASIC,
target: Roles.ADMIN,
type: "bracket",
})
edges.push({
id: "admin-bracket",
source: Roles.ADMIN,
target: Roles.BASIC,
type: "bracket",
})
// Add empty state node if required
if (!nodes.some(node => node.data.interactive)) {
nodes.push({
id: EmptyStateID,
type: "empty",
position: {
x: bounds.x + bounds.width / 2 - NodeWidth / 2,
y: bounds.y + bounds.height / 2 - NodeHeight / 2,
},
data: {},
measured: {
width: NodeWidth,
height: NodeHeight,
},
deletable: false,
draggable: false,
connectable: false,
selectable: false,
})
}
return { nodes, edges }
}
// Automatically lays out the graph, sanitising and enriching the structure
export const autoLayout = ({ nodes, edges }) => {
return postProcessLayout(dagreLayout(preProcessLayout({ nodes, edges })))
}
// Converts a role doc into a node structure
export const roleToNode = role => {
const custom = ![
Roles.PUBLIC,
Roles.BASIC,
Roles.POWER,
Roles.ADMIN,
Roles.BUILDER,
].includes(role._id)
const interactive = custom || role._id === Roles.POWER
return {
id: role._id,
sourcePosition: Position.Right,
targetPosition: Position.Left,
type: "role",
position: { x: 0, y: 0 },
data: {
...role.uiMetadata,
custom,
interactive,
},
measured: {
width: NodeWidth,
height: NodeHeight,
},
deletable: custom,
draggable: interactive,
connectable: interactive,
selectable: interactive,
}
}
// Converts a node structure back into a role doc
export const nodeToRole = ({ node, edges }) => ({
...get(roles).find(role => role._id === node.id),
inherits: edges
.filter(x => x.target === node.id)
.map(x => x.source)
.concat(Roles.BASIC),
uiMetadata: {
displayName: node.data.displayName,
color: node.data.color,
description: node.data.description,
},
})
// Builds a default layout from an array of roles
export const rolesToLayout = roles => {
let nodes = []
let edges = []
// Add all nodes and edges
for (let role of roles) {
// Add node for this role
nodes.push(roleToNode(role))
// Add edges for this role
let inherits = []
if (role.inherits) {
inherits = Array.isArray(role.inherits) ? role.inherits : [role.inherits]
}
for (let sourceRole of inherits) {
if (!roles.some(x => x._id === sourceRole)) {
continue
}
edges.push({
id: `${sourceRole}-${role._id}`,
source: sourceRole,
target: role._id,
})
}
}
return {
nodes,
edges,
}
}

View File

@ -4,7 +4,7 @@
BBReferenceFieldSubType,
SourceName,
} from "@budibase/types"
import { Select, Toggle, Multiselect } from "@budibase/bbui"
import { Select, Toggle, Multiselect, Label, Layout } from "@budibase/bbui"
import { DB_TYPE_INTERNAL } from "constants/backend"
import { API } from "api"
import { parseFile } from "./utils"
@ -140,84 +140,91 @@
}
</script>
<div class="dropzone">
<input
disabled={!schema || loading}
id="file-upload"
accept="text/csv,application/json"
type="file"
on:change={handleFile}
/>
<label for="file-upload" class:uploaded={rows.length > 0}>
{#if loading}
loading...
{:else if error}
error: {error}
{:else if fileName}
{fileName}
{:else}
Upload
{/if}
</label>
</div>
{#if fileName && Object.keys(validation).length === 0}
<p>No valid fields, try another file</p>
{:else if rows.length > 0 && !error}
<div class="schema-fields">
{#each Object.keys(validation) as name}
<div class="field">
<span>{name}</span>
<Select
value={`${schema[name]?.type}${schema[name]?.subtype || ""}`}
options={typeOptions}
placeholder={null}
getOptionLabel={option => option.label}
getOptionValue={option => option.value}
disabled
/>
<span
class={loading || validation[name]
? "fieldStatusSuccess"
: "fieldStatusFailure"}
>
{validation[name] ? "Success" : "Failure"}
</span>
</div>
{/each}
</div>
<br />
<!-- SQL Server doesn't yet support overwriting rows by existing keys -->
{#if datasource?.source !== SourceName.SQL_SERVER}
<Toggle
bind:value={updateExistingRows}
on:change={() => (identifierFields = [])}
thin
text="Update existing rows"
/>
{/if}
{#if updateExistingRows}
{#if tableType === DB_TYPE_INTERNAL}
<Multiselect
label="Identifier field(s)"
options={Object.keys(validation)}
bind:value={identifierFields}
<Layout gap="S" noPadding>
<Layout noPadding gap="XS">
<Label grey extraSmall>CSV or JSON file to import</Label>
<div class="dropzone">
<input
disabled={!schema || loading}
id="file-upload"
accept="text/csv,application/json"
type="file"
on:change={handleFile}
/>
{:else}
<p>Rows will be updated based on the table's primary key.</p>
<label for="file-upload" class:uploaded={rows.length > 0}>
{#if loading}
loading...
{:else if error}
error: {error}
{:else if fileName}
{fileName}
{:else}
Upload
{/if}
</label>
</div>
</Layout>
{#if fileName && Object.keys(validation).length === 0}
<div>No valid fields - please try another file.</div>
{:else if fileName && rows.length > 0 && !error}
<div>
{#each Object.keys(validation) as name}
<div class="field">
<span>{name}</span>
<Select
value={`${schema[name]?.type}${schema[name]?.subtype || ""}`}
options={typeOptions}
placeholder={null}
getOptionLabel={option => option.label}
getOptionValue={option => option.value}
disabled
/>
<span
class={loading || validation[name]
? "fieldStatusSuccess"
: "fieldStatusFailure"}
>
{validation[name] ? "Success" : "Failure"}
</span>
</div>
{/each}
</div>
<!-- SQL Server doesn't yet support overwriting rows by existing keys -->
{#if datasource?.source !== SourceName.SQL_SERVER}
<Toggle
bind:value={updateExistingRows}
on:change={() => (identifierFields = [])}
thin
text="Update existing rows"
/>
{/if}
{#if updateExistingRows}
{#if tableType === DB_TYPE_INTERNAL}
<Multiselect
label="Identifier field(s)"
options={Object.keys(validation)}
bind:value={identifierFields}
/>
{:else}
<div>Rows will be updated based on the table's primary key.</div>
{/if}
{/if}
{#if invalidColumns.length > 0}
<Layout noPadding gap="XS">
<div>
The following columns are present in the data you wish to import, but
do not match the schema of this table and will be ignored:
</div>
<div>
{#each invalidColumns as column}
- {column}<br />
{/each}
</div>
</Layout>
{/if}
{/if}
{#if invalidColumns.length > 0}
<p class="spectrum-FieldLabel spectrum-FieldLabel--sizeM">
The following columns are present in the data you wish to import, but do
not match the schema of this table and will be ignored.
</p>
<ul class="ignoredList">
{#each invalidColumns as column}
<li>{column}</li>
{/each}
</ul>
{/if}
{/if}
</Layout>
<style>
.dropzone {
@ -228,11 +235,9 @@
border-radius: 10px;
transition: all 0.3s;
}
input {
display: none;
}
label {
font-family: var(--font-sans);
cursor: pointer;
@ -240,7 +245,6 @@
box-sizing: border-box;
overflow: hidden;
border-radius: var(--border-radius-s);
color: var(--ink);
padding: var(--spacing-m) var(--spacing-l);
transition: all 0.2s ease 0s;
display: inline-flex;
@ -254,20 +258,14 @@
align-items: center;
justify-content: center;
width: 100%;
background-color: var(--grey-2);
font-size: var(--font-size-xs);
background-color: var(--spectrum-global-color-gray-300);
font-size: var(--font-size-s);
line-height: normal;
border: var(--border-transparent);
}
.uploaded {
color: var(--blue);
color: var(--spectrum-global-color-blue-600);
}
.schema-fields {
margin-top: var(--spacing-xl);
}
.field {
display: grid;
grid-template-columns: 2fr 2fr 1fr auto;
@ -276,23 +274,14 @@
grid-gap: var(--spacing-m);
font-size: var(--spectrum-global-dimension-font-size-75);
}
.fieldStatusSuccess {
color: var(--green);
justify-self: center;
font-weight: 600;
}
.fieldStatusFailure {
color: var(--red);
justify-self: center;
font-weight: 600;
}
.ignoredList {
margin: 0;
padding: 0;
list-style: none;
font-size: var(--spectrum-global-dimension-font-size-75);
}
</style>

View File

@ -1,5 +1,5 @@
<script>
import { Select, Icon } from "@budibase/bbui"
import { Select, Icon, Layout, Label } from "@budibase/bbui"
import { FIELDS } from "constants/backend"
import { utils } from "@budibase/shared-core"
import { canBeDisplayColumn } from "@budibase/frontend-core"
@ -184,70 +184,76 @@
}
</script>
<div class="dropzone">
<input
bind:this={fileInput}
disabled={loading}
id="file-upload"
accept="text/csv,application/json"
type="file"
on:change={handleFile}
/>
<label for="file-upload" class:uploaded={rawRows.length > 0}>
{#if error}
Error: {error}
{:else if fileName}
{fileName}
{:else}
Upload
{/if}
</label>
</div>
{#if rawRows.length > 0 && !error}
<div class="schema-fields">
{#each Object.entries(schema) as [name, column]}
<div class="field">
<span>{column.name}</span>
<Select
bind:value={selectedColumnTypes[column.name]}
on:change={e => handleChange(name, e)}
options={Object.values(typeOptions)}
placeholder={null}
getOptionLabel={option => option.label}
getOptionValue={option => option.value}
/>
<span
class={validation[column.name]
? "fieldStatusSuccess"
: "fieldStatusFailure"}
>
{#if validation[column.name]}
Success
{:else}
Failure
{#if errors[column.name]}
<Icon name="Help" tooltip={errors[column.name]} />
<Layout noPadding gap="S">
<Layout gap="XS" noPadding>
<Label grey extraSmall>
Create a Table from a CSV or JSON file (Optional)
</Label>
<div class="dropzone">
<input
bind:this={fileInput}
disabled={loading}
id="file-upload"
accept="text/csv,application/json"
type="file"
on:change={handleFile}
/>
<label for="file-upload" class:uploaded={rawRows.length > 0}>
{#if error}
Error: {error}
{:else if fileName}
{fileName}
{:else}
Upload
{/if}
</label>
</div>
</Layout>
{#if rawRows.length > 0 && !error}
<div>
{#each Object.entries(schema) as [name, column]}
<div class="field">
<span>{column.name}</span>
<Select
bind:value={selectedColumnTypes[column.name]}
on:change={e => handleChange(name, e)}
options={Object.values(typeOptions)}
placeholder={null}
getOptionLabel={option => option.label}
getOptionValue={option => option.value}
/>
<span
class={validation[column.name]
? "fieldStatusSuccess"
: "fieldStatusFailure"}
>
{#if validation[column.name]}
Success
{:else}
Failure
{#if errors[column.name]}
<Icon name="Help" tooltip={errors[column.name]} />
{/if}
{/if}
{/if}
</span>
<Icon
size="S"
name="Close"
hoverable
on:click={() => deleteColumn(column.name)}
/>
</div>
{/each}
</div>
<div class="display-column">
</span>
<Icon
size="S"
name="Close"
hoverable
on:click={() => deleteColumn(column.name)}
/>
</div>
{/each}
</div>
<Select
label="Display Column"
bind:value={displayColumn}
options={displayColumnOptions}
sort
/>
</div>
{/if}
{/if}
</Layout>
<style>
.dropzone {
@ -269,7 +275,6 @@
box-sizing: border-box;
overflow: hidden;
border-radius: var(--border-radius-s);
color: var(--ink);
padding: var(--spacing-m) var(--spacing-l);
transition: all 0.2s ease 0s;
display: inline-flex;
@ -283,20 +288,14 @@
align-items: center;
justify-content: center;
width: 100%;
background-color: var(--grey-2);
font-size: var(--font-size-xs);
background-color: var(--spectrum-global-color-gray-300);
font-size: var(--font-size-s);
line-height: normal;
border: var(--border-transparent);
}
.uploaded {
color: var(--blue);
color: var(--spectrum-global-color-blue-600);
}
.schema-fields {
margin-top: var(--spacing-xl);
}
.field {
display: grid;
grid-template-columns: 2fr 2fr 1fr auto;
@ -322,8 +321,4 @@
.fieldStatusFailure :global(.spectrum-Icon) {
width: 12px;
}
.display-column {
margin-top: var(--spacing-xl);
}
</style>

View File

@ -104,7 +104,7 @@
</InlineAlert>
</div>
{/if}
<p class="fourthWarning">Please enter the app name below to confirm.</p>
<p class="fourthWarning">Please enter the table name below to confirm.</p>
<Input bind:value={deleteTableName} placeholder={table.name} />
</div>
</ConfirmDialog>

View File

@ -20,14 +20,6 @@
const getContextMenuItems = () => {
return [
{
icon: "Delete",
name: "Delete",
keyBind: null,
visible: true,
disabled: false,
callback: deleteConfirmationModal.show,
},
{
icon: "Edit",
name: "Edit",
@ -36,6 +28,14 @@
disabled: false,
callback: editModal.show,
},
{
icon: "Delete",
name: "Delete",
keyBind: null,
visible: true,
disabled: false,
callback: deleteConfirmationModal.show,
},
]
}

View File

@ -1,33 +1,15 @@
<script>
import { goto } from "@roxi/routify"
import TableNavItem from "./TableNavItem/TableNavItem.svelte"
import ViewNavItem from "./ViewNavItem/ViewNavItem.svelte"
import { alphabetical } from "./utils"
export let tables
export let selectTable
$: sortedTables = tables.sort(alphabetical)
const alphabetical = (a, b) => {
return a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
}
</script>
<div class="hierarchy-items-container">
{#each sortedTables as table, idx}
<TableNavItem {table} {idx} on:click={() => selectTable(table._id)} />
{#each [...Object.entries(table.views || {})].sort() as [name, view], idx (idx)}
<ViewNavItem
{view}
{name}
on:click={() => {
if (view.version === 2) {
$goto(`./view/v2/${encodeURIComponent(view.id)}`)
} else {
$goto(`./view/v1/${encodeURIComponent(name)}`)
}
}}
/>
{/each}
{/each}
</div>

View File

@ -1,71 +0,0 @@
<script>
import {
contextMenuStore,
views,
viewsV2,
userSelectedResourceMap,
} from "stores/builder"
import NavItem from "components/common/NavItem.svelte"
import { isActive } from "@roxi/routify"
import { Icon } from "@budibase/bbui"
import EditViewModal from "./EditViewModal.svelte"
import DeleteConfirmationModal from "./DeleteConfirmationModal.svelte"
export let view
export let name
let editModal
let deleteConfirmationModal
const getContextMenuItems = () => {
return [
{
icon: "Delete",
name: "Delete",
keyBind: null,
visible: true,
disabled: false,
callback: deleteConfirmationModal.show,
},
{
icon: "Edit",
name: "Edit",
keyBind: null,
visible: true,
disabled: false,
callback: editModal.show,
},
]
}
const openContextMenu = e => {
e.preventDefault()
e.stopPropagation()
const items = getContextMenuItems()
contextMenuStore.open(view.id, items, { x: e.clientX, y: e.clientY })
}
const isViewActive = (view, isActive, views, viewsV2) => {
return (
(isActive("./view/v1") && views.selected?.name === view.name) ||
(isActive("./view/v2") && viewsV2.selected?.id === view.id)
)
}
</script>
<NavItem
on:contextmenu={openContextMenu}
indentLevel={2}
icon="Remove"
text={name}
selected={isViewActive(view, $isActive, $views, $viewsV2)}
hovering={view.id === $contextMenuStore.id}
on:click
selectedBy={$userSelectedResourceMap[name] ||
$userSelectedResourceMap[view.id]}
>
<Icon on:click={openContextMenu} s hoverable name="MoreSmallList" />
</NavItem>
<EditViewModal {view} bind:this={editModal} />
<DeleteConfirmationModal {view} bind:this={deleteConfirmationModal} />

View File

@ -1,13 +1,7 @@
<script>
import { goto, url } from "@roxi/routify"
import { tables, datasources } from "stores/builder"
import {
notifications,
Input,
Label,
ModalContent,
Layout,
} from "@budibase/bbui"
import { notifications, Input, ModalContent } from "@budibase/bbui"
import TableDataImport from "../TableDataImport.svelte"
import {
BUDIBASE_INTERNAL_DB_ID,
@ -92,6 +86,7 @@
disabled={error ||
!name ||
(rows.length && (!allValid || displayColumn == null))}
size="M"
>
<Input
thin
@ -100,18 +95,11 @@
bind:value={name}
{error}
/>
<div>
<Layout gap="XS" noPadding>
<Label grey extraSmall
>Create a Table from a CSV or JSON file (Optional)</Label
>
<TableDataImport
{promptUpload}
bind:rows
bind:schema
bind:allValid
bind:displayColumn
/>
</Layout>
</div>
<TableDataImport
{promptUpload}
bind:rows
bind:schema
bind:allValid
bind:displayColumn
/>
</ModalContent>

View File

@ -66,3 +66,7 @@ export const parseFile = e => {
reader.readAsText(file)
})
}
export const alphabetical = (a, b) => {
return a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
}

View File

@ -22,7 +22,7 @@
} from "stores/builder"
import { themeStore } from "stores/portal"
import { getContext } from "svelte"
import { Constants } from "@budibase/frontend-core"
import { ThemeOptions } from "@budibase/shared-core"
const modalContext = getContext(Context.Modal)
const commands = [
@ -141,13 +141,13 @@
icon: "ShareAndroid",
action: () => $goto(`./automation/${automation._id}`),
})) ?? []),
...Constants.Themes.map(theme => ({
...ThemeOptions.map(themeMeta => ({
type: "Change Builder Theme",
name: theme.name,
name: themeMeta.name,
icon: "ColorPalette",
action: () =>
themeStore.update(state => {
state.theme = theme.class
state.theme = themeMeta.id
return state
}),
})),

View File

@ -0,0 +1,59 @@
<script>
import { Helpers, Multiselect, Select } from "@budibase/bbui"
import ServerBindingPanel from "components/common/bindings/ServerBindingPanel.svelte"
import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte"
import {
AIOperations,
OperationFields,
OperationFieldTypes,
} from "@budibase/shared-core"
const AIFieldConfigOptions = Object.keys(AIOperations).map(key => ({
label: AIOperations[key].label,
value: AIOperations[key].value,
}))
export let bindings
export let context
export let schema
export let aiField = {}
$: OperationField = OperationFields[aiField.operation]
$: schemaWithoutRelations = Object.keys(schema).filter(
key => schema[key].type !== "link"
)
</script>
<Select
label={"Operation"}
options={AIFieldConfigOptions}
bind:value={aiField.operation}
/>
{#if aiField.operation}
{#each Object.keys(OperationField) as key}
{#if OperationField[key] === OperationFieldTypes.BINDABLE_TEXT}
<ModalBindableInput
label={Helpers.capitalise(key)}
panel={ServerBindingPanel}
title="Prompt"
on:change={e => (aiField[key] = e.detail)}
value={aiField[key]}
{bindings}
allowJS
{context}
/>
{:else if OperationField[key] === OperationFieldTypes.MULTI_COLUMN}
<Multiselect
bind:value={aiField[key]}
label={Helpers.capitalise(key)}
options={schemaWithoutRelations}
/>
{:else if OperationField[key] === OperationFieldTypes.COLUMN}
<Select
bind:value={aiField[key]}
label={Helpers.capitalise(key)}
options={schemaWithoutRelations}
/>
{/if}
{/each}
{/if}

View File

@ -0,0 +1,77 @@
<script>
import { Popover, Icon } from "@budibase/bbui"
export let title
export let align = "left"
export let showPopover
export let width
let popover
let anchor
let open
export const show = () => popover?.show()
export const hide = () => popover?.hide()
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="anchor" bind:this={anchor} on:click={show}>
<slot name="anchor" {open} />
</div>
<Popover
bind:this={popover}
bind:open
minWidth={width || 400}
maxWidth={width || 400}
{anchor}
{align}
{showPopover}
on:open
on:close
customZindex={100}
>
<div class="detail-popover">
<div class="detail-popover__header">
<div class="detail-popover__title">
{title}
</div>
<Icon
name="Close"
hoverable
color="var(--spectrum-global-color-gray-600)"
hoverColor="var(--spectum-global-color-gray-900)"
on:click={hide}
/>
</div>
<div class="detail-popover__body">
<slot />
</div>
</div>
</Popover>
<style>
.detail-popover {
background-color: var(--spectrum-alias-background-color-primary);
}
.detail-popover__header {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--spectrum-global-color-gray-300);
padding: var(--spacing-l) var(--spacing-xl);
}
.detail-popover__title {
font-size: 16px;
font-weight: 600;
}
.detail-popover__body {
padding: var(--spacing-xl) var(--spacing-xl);
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--spacing-xl);
}
</style>

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