Merge from master
This commit is contained in:
parent
96ec39bbbf
commit
7235fd9d5c
.github/workflows
.gitignorehosting/single
lerna.jsonpackages
account-portal
backend-core
bbui/src
ActionButton
ActionMenu
Actions
Button
ButtonGroup
Form/Core
Icon
InlineAlert
List
Menu
Modal
Notification
Popover
helpers.jsindex.jsbuilder
package.json
src/components
automation
AutomationBuilder/FlowChart
AutomationPanel
SetupPanel
backend
DataTable
RelationshipDataTable.svelteTableDataTable.svelteViewDataTable.svelteViewV2DataTable.svelte
buttons
EditRolesButton.svelteExportButton.svelteImportButton.svelteManageAccessButton.svelteTableFilterButton.svelte
formula.jsgrid
ColumnsSettingContent.svelteGridAutomationsButton.svelteGridColumnsSettingButton.svelteGridCreateAutomationButton.svelteGridCreateViewButton.svelteGridExportButton.svelteGridGenerateButton.svelteGridManageAccessButton.svelteGridRowActionsButton.svelteGridScreensButton.svelteGridSizeButton.svelteGridSortButton.svelteGridUsersTableButton.svelteGridViewCalculationButton.sveltemagic-wand.svg
modals
DatasourceNavigator
RoleEditor
BracketEdge.svelteControls.svelteEmptyStateNode.svelteRoleEdge.svelteRoleEditor.svelteRoleFlow.svelteRoleNode.svelteconstants.jsutils.js
TableNavigator
commandPalette
common
|
@ -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
|
||||
|
|
|
@ -3,7 +3,7 @@ name: Deploy QA
|
|||
on:
|
||||
push:
|
||||
branches:
|
||||
- v3-ui
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -19,6 +19,7 @@
|
|||
{disabled}
|
||||
on:change={onChange}
|
||||
on:click
|
||||
on:click|stopPropagation
|
||||
{id}
|
||||
type="checkbox"
|
||||
class="spectrum-Switch-input"
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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("!")}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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})`
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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 |
|
@ -8,6 +8,7 @@ const MAX_DEPTH = 1
|
|||
|
||||
const TYPES_TO_SKIP = [
|
||||
FieldType.FORMULA,
|
||||
FieldType.AI,
|
||||
FieldType.LONGFORM,
|
||||
FieldType.SIGNATURE_SINGLE,
|
||||
FieldType.ATTACHMENTS,
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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")
|
||||
})
|
||||
})
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,8 @@
|
|||
<script>
|
||||
import { SvelteFlowProvider } from "@xyflow/svelte"
|
||||
import RoleFlow from "./RoleFlow.svelte"
|
||||
</script>
|
||||
|
||||
<SvelteFlowProvider>
|
||||
<RoleFlow />
|
||||
</SvelteFlowProvider>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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"
|
|
@ -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,
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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} />
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}),
|
||||
})),
|
||||
|
|
|
@ -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}
|
|
@ -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
Loading…
Reference in New Issue