commit
cb7bef818f
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,6 +215,22 @@ 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.
|
||||
*/
|
||||
|
@ -220,8 +238,8 @@ export function builtinRoleToNumber(id: string) {
|
|||
const builtins = getBuiltinRoles()
|
||||
const MAX = Object.values(builtins).length + 1
|
||||
if (
|
||||
compareRoleIds(id, BUILTIN_IDS.ADMIN) ||
|
||||
compareRoleIds(id, BUILTIN_IDS.BUILDER)
|
||||
roleIDsAreEqual(id, BUILTIN_IDS.ADMIN) ||
|
||||
roleIDsAreEqual(id, BUILTIN_IDS.BUILDER)
|
||||
) {
|
||||
return MAX
|
||||
}
|
||||
|
@ -260,7 +278,7 @@ export async function roleToNumber(id: string) {
|
|||
const highestBuiltin: number | undefined = role.inherits
|
||||
.map(roleId => {
|
||||
const foundRole = hierarchy.find(role =>
|
||||
compareRoleIds(role._id!, roleId)
|
||||
roleIDsAreEqual(role._id!, roleId)
|
||||
)
|
||||
if (foundRole) {
|
||||
return findNumber(foundRole) + 1
|
||||
|
@ -295,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)
|
||||
}
|
||||
|
@ -328,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)
|
||||
|
@ -385,7 +403,7 @@ async function getAllUserRoles(
|
|||
): Promise<RoleDoc[]> {
|
||||
const allRoles = await getAllRoles()
|
||||
// admins have access to all roles
|
||||
if (compareRoleIds(userRoleId, BUILTIN_IDS.ADMIN)) {
|
||||
if (roleIDsAreEqual(userRoleId, BUILTIN_IDS.ADMIN)) {
|
||||
return allRoles
|
||||
}
|
||||
|
||||
|
@ -497,7 +515,7 @@ export async function getAllRoles(appId?: string): Promise<RoleDoc[]> {
|
|||
for (let builtinRoleId of externalBuiltinRoles) {
|
||||
const builtinRole = builtinRoles[builtinRoleId]
|
||||
const dbBuiltin = roles.filter(dbRole =>
|
||||
compareRoleIds(dbRole._id!, builtinRoleId)
|
||||
roleIDsAreEqual(dbRole._id!, builtinRoleId)
|
||||
)[0]
|
||||
if (dbBuiltin == null) {
|
||||
roles.push(builtinRole || builtinRoles.BASIC)
|
||||
|
@ -537,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
|
||||
}
|
||||
|
||||
|
@ -553,9 +574,9 @@ export class AccessController {
|
|||
if (
|
||||
tryingRoleId == null ||
|
||||
tryingRoleId === "" ||
|
||||
compareRoleIds(tryingRoleId, BUILTIN_IDS.BUILDER) ||
|
||||
compareRoleIds(userRoleId!, tryingRoleId) ||
|
||||
compareRoleIds(userRoleId!, BUILTIN_IDS.BUILDER)
|
||||
roleIDsAreEqual(tryingRoleId, BUILTIN_IDS.BUILDER) ||
|
||||
roleIDsAreEqual(userRoleId!, tryingRoleId) ||
|
||||
roleIDsAreEqual(userRoleId!, BUILTIN_IDS.BUILDER)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
@ -566,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(
|
||||
|
|
|
@ -179,12 +179,6 @@ class InternalBuilder {
|
|||
return this.table.schema[column]
|
||||
}
|
||||
|
||||
private supportsILike(): boolean {
|
||||
return !(
|
||||
this.client === SqlClient.ORACLE || this.client === SqlClient.SQL_LITE
|
||||
)
|
||||
}
|
||||
|
||||
private quoteChars(): [string, string] {
|
||||
const wrapped = this.knexClient.wrapIdentifier("foo", {})
|
||||
return [wrapped[0], wrapped[wrapped.length - 1]]
|
||||
|
@ -216,8 +210,30 @@ class InternalBuilder {
|
|||
return formatter.wrap(value, false)
|
||||
}
|
||||
|
||||
private rawQuotedValue(value: string): Knex.Raw {
|
||||
return this.knex.raw(this.quotedValue(value))
|
||||
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
|
||||
|
@ -512,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
|
||||
|
@ -1074,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
|
||||
|
@ -1434,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
|
||||
}
|
||||
|
@ -1556,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
|
||||
|
@ -1571,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 {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 Width: | Height: | 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>
|
|
@ -1,12 +1,14 @@
|
|||
<script>
|
||||
import { RoleUtils } from "@budibase/frontend-core"
|
||||
import { StatusLight } from "@budibase/bbui"
|
||||
import { roles } from "stores/builder"
|
||||
|
||||
export let id
|
||||
export let size = "M"
|
||||
export let disabled = false
|
||||
|
||||
$: color = RoleUtils.getRoleColour(id)
|
||||
$: color =
|
||||
$roles.find(x => x._id === id)?.color ||
|
||||
"var(--spectrum-global-color-static-magenta-400)"
|
||||
</script>
|
||||
|
||||
<StatusLight square {disabled} {size} {color} />
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
import { roles } from "stores/builder"
|
||||
import { licensing } from "stores/portal"
|
||||
|
||||
import { Constants, RoleUtils } from "@budibase/frontend-core"
|
||||
import { Constants } from "@budibase/frontend-core"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { capitalise } from "helpers"
|
||||
|
||||
|
@ -49,7 +49,8 @@
|
|||
let options = roles
|
||||
.filter(role => allowedRoles.includes(role._id))
|
||||
.map(role => ({
|
||||
name: enrichLabel(role.name),
|
||||
color: role.uiMetadata.color,
|
||||
name: enrichLabel(role.uiMetadata.displayName),
|
||||
_id: role._id,
|
||||
}))
|
||||
if (allowedRoles.includes(Constants.Roles.CREATOR)) {
|
||||
|
@ -64,7 +65,8 @@
|
|||
|
||||
// Allow all core roles
|
||||
let options = roles.map(role => ({
|
||||
name: enrichLabel(role.name),
|
||||
color: role.uiMetadata.color,
|
||||
name: enrichLabel(role.uiMetadata.displayName),
|
||||
_id: role._id,
|
||||
}))
|
||||
|
||||
|
@ -100,7 +102,7 @@
|
|||
if (role._id === Constants.Roles.CREATOR || role._id === RemoveID) {
|
||||
return null
|
||||
}
|
||||
return RoleUtils.getRoleColour(role._id)
|
||||
return role.color || "var(--spectrum-global-color-static-magenta-400)"
|
||||
}
|
||||
|
||||
const getIcon = role => {
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
export let options
|
||||
</script>
|
||||
|
||||
<div class="permissionPicker">
|
||||
<div>
|
||||
{#each options as option}
|
||||
<AbsTooltip text={option.tooltip} type={TooltipType.Info}>
|
||||
<ActionButton
|
||||
|
@ -26,15 +26,14 @@
|
|||
</div>
|
||||
|
||||
<style>
|
||||
.permissionPicker {
|
||||
div {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.permissionPicker :global(.spectrum-Icon) {
|
||||
div :global(.spectrum-Icon) {
|
||||
width: 14px;
|
||||
}
|
||||
.permissionPicker :global(.spectrum-ActionButton) {
|
||||
div :global(.spectrum-ActionButton) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
|
@ -25,6 +25,7 @@
|
|||
appStore,
|
||||
deploymentStore,
|
||||
sortedScreens,
|
||||
appPublished,
|
||||
} from "stores/builder"
|
||||
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
|
||||
import { TOUR_STEP_KEYS } from "components/portal/onboarding/tours.js"
|
||||
|
@ -45,11 +46,6 @@
|
|||
|
||||
$: filteredApps = $appsStore.apps.filter(app => app.devId === application)
|
||||
$: selectedApp = filteredApps?.length ? filteredApps[0] : null
|
||||
$: latestDeployments = $deploymentStore
|
||||
.filter(deployment => deployment.status === "SUCCESS")
|
||||
.sort((a, b) => a.updatedAt > b.updatedAt)
|
||||
$: isPublished =
|
||||
selectedApp?.status === "published" && latestDeployments?.length > 0
|
||||
$: updateAvailable =
|
||||
$appStore.upgradableVersion &&
|
||||
$appStore.version &&
|
||||
|
@ -117,7 +113,7 @@
|
|||
}
|
||||
|
||||
const confirmUnpublishApp = async () => {
|
||||
if (!application || !isPublished) {
|
||||
if (!application || !$appPublished) {
|
||||
//confirm the app has loaded.
|
||||
return
|
||||
}
|
||||
|
@ -204,7 +200,7 @@
|
|||
>
|
||||
<div bind:this={appActionPopoverAnchor}>
|
||||
<div class="app-action">
|
||||
<Icon name={isPublished ? "GlobeCheck" : "GlobeStrike"} />
|
||||
<Icon name={$appPublished ? "GlobeCheck" : "GlobeStrike"} />
|
||||
<TourWrap stepKeys={[TOUR_STEP_KEYS.BUILDER_APP_PUBLISH]}>
|
||||
<span class="publish-open" id="builder-app-publish-button">
|
||||
Publish
|
||||
|
@ -219,7 +215,7 @@
|
|||
<Popover
|
||||
bind:this={appActionPopover}
|
||||
align="right"
|
||||
disabled={!isPublished}
|
||||
disabled={!$appPublished}
|
||||
anchor={appActionPopoverAnchor}
|
||||
offset={35}
|
||||
on:close={() => {
|
||||
|
@ -236,13 +232,13 @@
|
|||
class="app-link"
|
||||
on:click={() => {
|
||||
appActionPopover.hide()
|
||||
if (isPublished) {
|
||||
if ($appPublished) {
|
||||
viewApp()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{$appStore.url}
|
||||
{#if isPublished}
|
||||
{#if $appPublished}
|
||||
<Icon size="S" name="LinkOut" />
|
||||
{/if}
|
||||
</span>
|
||||
|
@ -250,7 +246,7 @@
|
|||
|
||||
<Body size="S">
|
||||
<span class="publish-popover-status">
|
||||
{#if isPublished}
|
||||
{#if $appPublished}
|
||||
<span class="status-text">
|
||||
{lastDeployed}
|
||||
</span>
|
||||
|
@ -279,7 +275,7 @@
|
|||
</span>
|
||||
</Body>
|
||||
<div class="action-buttons">
|
||||
{#if isPublished}
|
||||
{#if $appPublished}
|
||||
<ActionButton
|
||||
quiet
|
||||
icon="Code"
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
<script>
|
||||
import { Select, Label, Checkbox } from "@budibase/bbui"
|
||||
import { tables, viewsV2, rowActions } from "stores/builder"
|
||||
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
|
||||
|
||||
export let parameters
|
||||
export let bindings = []
|
||||
|
||||
$: tableOptions = $tables.list.map(table => ({
|
||||
label: table.name,
|
||||
resourceId: table._id,
|
||||
}))
|
||||
$: viewOptions = $viewsV2.list.map(view => ({
|
||||
label: view.name,
|
||||
tableId: view.tableId,
|
||||
resourceId: view.id,
|
||||
}))
|
||||
$: datasourceOptions = [...(tableOptions || []), ...(viewOptions || [])]
|
||||
$: resourceId = parameters.resourceId
|
||||
$: rowActions.refreshRowActions(resourceId)
|
||||
$: enabledRowActions = $rowActions[resourceId] || []
|
||||
$: rowActionOptions = enabledRowActions.map(action => ({
|
||||
label: action.name,
|
||||
value: action.id,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
<div class="params">
|
||||
<Label>Table or view</Label>
|
||||
<Select
|
||||
bind:value={parameters.resourceId}
|
||||
options={datasourceOptions}
|
||||
getOptionLabel={x => x.label}
|
||||
getOptionValue={x => x.resourceId}
|
||||
/>
|
||||
|
||||
<Label small>Row ID</Label>
|
||||
<DrawerBindableInput
|
||||
{bindings}
|
||||
title="Row ID"
|
||||
value={parameters.rowId}
|
||||
on:change={value => (parameters.rowId = value.detail)}
|
||||
/>
|
||||
|
||||
<Label small>Row action</Label>
|
||||
<Select bind:value={parameters.rowActionId} options={rowActionOptions} />
|
||||
|
||||
<br />
|
||||
<Checkbox text="Require confirmation" bind:value={parameters.confirm} />
|
||||
|
||||
{#if parameters.confirm}
|
||||
<Label small>Title</Label>
|
||||
<DrawerBindableInput
|
||||
placeholder="Prompt User"
|
||||
value={parameters.customTitleText}
|
||||
on:change={e => (parameters.customTitleText = e.detail)}
|
||||
{bindings}
|
||||
/>
|
||||
<Label small>Text</Label>
|
||||
<DrawerBindableInput
|
||||
placeholder="Are you sure you want to continue?"
|
||||
value={parameters.confirmText}
|
||||
on:change={e => (parameters.confirmText = e.detail)}
|
||||
{bindings}
|
||||
/>
|
||||
<Label small>Confirm Text</Label>
|
||||
<DrawerBindableInput
|
||||
placeholder="Confirm"
|
||||
value={parameters.confirmButtonText}
|
||||
on:change={e => (parameters.confirmButtonText = e.detail)}
|
||||
{bindings}
|
||||
/>
|
||||
<Label small>Cancel Text</Label>
|
||||
<DrawerBindableInput
|
||||
placeholder="Cancel"
|
||||
value={parameters.cancelButtonText}
|
||||
on:change={e => (parameters.cancelButtonText = e.detail)}
|
||||
{bindings}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.root {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: stretch;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.params {
|
||||
display: grid;
|
||||
column-gap: var(--spacing-l);
|
||||
row-gap: var(--spacing-s);
|
||||
grid-template-columns: 60px 1fr;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
|
@ -33,7 +33,7 @@
|
|||
|
||||
const getSchemaFields = resourceId => {
|
||||
const { schema } = getSchemaForDatasourcePlus(resourceId)
|
||||
return Object.values(schema || {})
|
||||
return Object.values(schema || {}).filter(field => !field.readonly)
|
||||
}
|
||||
|
||||
const onFieldsChanged = e => {
|
||||
|
|
|
@ -25,3 +25,4 @@ export { default as OpenModal } from "./OpenModal.svelte"
|
|||
export { default as CloseModal } from "./CloseModal.svelte"
|
||||
export { default as ClearRowSelection } from "./ClearRowSelection.svelte"
|
||||
export { default as DownloadFile } from "./DownloadFile.svelte"
|
||||
export { default as RowAction } from "./RowAction.svelte"
|
||||
|
|
|
@ -178,6 +178,11 @@
|
|||
"name": "Download File",
|
||||
"type": "data",
|
||||
"component": "DownloadFile"
|
||||
},
|
||||
{
|
||||
"name": "Row Action",
|
||||
"type": "data",
|
||||
"component": "RowAction"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
@ -2,10 +2,11 @@
|
|||
import DraggableList from "../DraggableList/DraggableList.svelte"
|
||||
import ButtonSetting from "./ButtonSetting.svelte"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { Helpers } from "@budibase/bbui"
|
||||
import { Helpers, Menu, MenuItem, Popover } from "@budibase/bbui"
|
||||
import { componentStore } from "stores/builder"
|
||||
import { getEventContextBindings } from "dataBinding"
|
||||
import { cloneDeep, isEqual } from "lodash/fp"
|
||||
import { getRowActionButtonTemplates } from "templates/rowActions"
|
||||
|
||||
export let componentInstance
|
||||
export let componentBindings
|
||||
|
@ -17,13 +18,14 @@
|
|||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let focusItem
|
||||
let cachedValue
|
||||
let rowActionTemplates = []
|
||||
let anchor
|
||||
let popover
|
||||
|
||||
$: if (!isEqual(value, cachedValue)) {
|
||||
cachedValue = cloneDeep(value)
|
||||
}
|
||||
|
||||
$: buttonList = sanitizeValue(cachedValue) || []
|
||||
$: buttonCount = buttonList.length
|
||||
$: eventContextBindings = getEventContextBindings({
|
||||
|
@ -73,17 +75,32 @@
|
|||
_instanceName: Helpers.uuid(),
|
||||
text: cfg.text,
|
||||
type: cfg.type || "primary",
|
||||
},
|
||||
{}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const addButton = () => {
|
||||
const addCustomButton = () => {
|
||||
const newButton = buildPseudoInstance({
|
||||
text: `Button ${buttonCount + 1}`,
|
||||
})
|
||||
dispatch("change", [...buttonList, newButton])
|
||||
focusItem = newButton._id
|
||||
popover.hide()
|
||||
}
|
||||
|
||||
const addRowActionTemplate = template => {
|
||||
dispatch("change", [...buttonList, template])
|
||||
popover.hide()
|
||||
}
|
||||
|
||||
const addButton = async () => {
|
||||
rowActionTemplates = await getRowActionButtonTemplates({
|
||||
component: componentInstance,
|
||||
})
|
||||
if (rowActionTemplates.length) {
|
||||
popover.show()
|
||||
} else {
|
||||
addCustomButton()
|
||||
}
|
||||
}
|
||||
|
||||
const removeButton = id => {
|
||||
|
@ -105,12 +122,11 @@
|
|||
listItemKey={"_id"}
|
||||
listType={ButtonSetting}
|
||||
listTypeProps={itemProps}
|
||||
focus={focusItem}
|
||||
draggable={buttonCount > 1}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
bind:this={anchor}
|
||||
class="list-footer"
|
||||
class:disabled={!canAddButtons}
|
||||
on:click={addButton}
|
||||
|
@ -120,6 +136,17 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Popover bind:this={popover} {anchor} useAnchorWidth resizable={false}>
|
||||
<Menu>
|
||||
<MenuItem on:click={addCustomButton}>Custom button</MenuItem>
|
||||
{#each rowActionTemplates as template}
|
||||
<MenuItem on:click={() => addRowActionTemplate(template)}>
|
||||
{template.text}
|
||||
</MenuItem>
|
||||
{/each}
|
||||
</Menu>
|
||||
</Popover>
|
||||
|
||||
<style>
|
||||
.button-configuration :global(.spectrum-ActionButton) {
|
||||
width: 100%;
|
||||
|
|
|
@ -80,6 +80,9 @@
|
|||
if (columnType === FieldType.FORMULA) {
|
||||
return "https://docs.budibase.com/docs/formula"
|
||||
}
|
||||
if (columnType === FieldType.AI) {
|
||||
return "https://docs.budibase.com/docs/ai"
|
||||
}
|
||||
if (columnType === FieldType.OPTIONS) {
|
||||
return "https://docs.budibase.com/docs/options"
|
||||
}
|
||||
|
|
|
@ -1,87 +1,32 @@
|
|||
<script>
|
||||
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
|
||||
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
|
||||
|
||||
import { dataFilters } from "@budibase/shared-core"
|
||||
import { FilterBuilder } from "@budibase/frontend-core"
|
||||
import { CoreFilterBuilder } from "@budibase/frontend-core"
|
||||
import { tables } from "stores/builder"
|
||||
|
||||
import { createEventDispatcher, onMount } from "svelte"
|
||||
import {
|
||||
runtimeToReadableBinding,
|
||||
readableToRuntimeBinding,
|
||||
} from "dataBinding"
|
||||
|
||||
export let schemaFields
|
||||
export let filters = []
|
||||
export let filters
|
||||
export let bindings = []
|
||||
export let panel = ClientBindingPanel
|
||||
export let allowBindings = true
|
||||
export let datasource
|
||||
export let showFilterEmptyDropdown
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let rawFilters
|
||||
|
||||
$: parseFilters(rawFilters)
|
||||
$: dispatch("change", enrichFilters(rawFilters))
|
||||
|
||||
// Remove field key prefixes and determine which behaviours to use
|
||||
const parseFilters = filters => {
|
||||
rawFilters = (filters || []).map(filter => {
|
||||
const { field } = filter
|
||||
let newFilter = { ...filter }
|
||||
delete newFilter.allOr
|
||||
newFilter.field = dataFilters.removeKeyNumbering(field)
|
||||
return newFilter
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
parseFilters(filters)
|
||||
rawFilters.forEach(filter => {
|
||||
filter.type =
|
||||
schemaFields.find(field => field.name === filter.field)?.type ||
|
||||
filter.type
|
||||
})
|
||||
})
|
||||
|
||||
// Add field key prefixes and a special metadata filter object to indicate
|
||||
// how to handle filter behaviour
|
||||
const enrichFilters = rawFilters => {
|
||||
let count = 1
|
||||
return rawFilters
|
||||
.filter(filter => filter.field)
|
||||
.map(filter => ({
|
||||
...filter,
|
||||
field: `${count++}:${filter.field}`,
|
||||
}))
|
||||
.concat(...rawFilters.filter(filter => !filter.field))
|
||||
}
|
||||
</script>
|
||||
|
||||
<FilterBuilder
|
||||
bind:filters={rawFilters}
|
||||
<CoreFilterBuilder
|
||||
toReadable={runtimeToReadableBinding}
|
||||
toRuntime={readableToRuntimeBinding}
|
||||
behaviourFilters={true}
|
||||
tables={$tables.list}
|
||||
{filters}
|
||||
{panel}
|
||||
{schemaFields}
|
||||
{datasource}
|
||||
{allowBindings}
|
||||
{showFilterEmptyDropdown}
|
||||
>
|
||||
<div slot="filtering-hero-content" />
|
||||
|
||||
<DrawerBindableInput
|
||||
let:filter
|
||||
slot="binding"
|
||||
disabled={filter.noValue}
|
||||
title={filter.field}
|
||||
value={filter.value}
|
||||
placeholder="Value"
|
||||
{panel}
|
||||
{bindings}
|
||||
on:change={event => {
|
||||
const indexToUpdate = rawFilters.findIndex(f => f.id === filter.id)
|
||||
rawFilters[indexToUpdate] = {
|
||||
...rawFilters[indexToUpdate],
|
||||
value: event.detail,
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FilterBuilder>
|
||||
{bindings}
|
||||
on:change
|
||||
/>
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
Button,
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
Helpers,
|
||||
} from "@budibase/bbui"
|
||||
import { createEventDispatcher } from "svelte"
|
||||
import { getDatasourceForProvider, getSchemaForDatasource } from "dataBinding"
|
||||
|
@ -21,7 +22,7 @@
|
|||
|
||||
let drawer
|
||||
|
||||
$: tempValue = value
|
||||
$: localFilters = Helpers.cloneDeep(value)
|
||||
$: datasource = getDatasourceForProvider($selectedScreen, componentInstance)
|
||||
$: dsSchema = getSchemaForDatasource($selectedScreen, datasource)?.schema
|
||||
$: schemaFields = search.getFields(
|
||||
|
@ -29,19 +30,24 @@
|
|||
Object.values(schema || dsSchema || {}),
|
||||
{ allowLinks: true }
|
||||
)
|
||||
$: text = getText(value?.filter(filter => filter.field))
|
||||
|
||||
$: text = getText(value?.groups)
|
||||
|
||||
async function saveFilter() {
|
||||
dispatch("change", tempValue)
|
||||
dispatch("change", localFilters)
|
||||
notifications.success("Filters saved")
|
||||
drawer.hide()
|
||||
}
|
||||
|
||||
const getText = filters => {
|
||||
if (!filters?.length) {
|
||||
const getText = (filterGroups = []) => {
|
||||
const allFilters = filterGroups.reduce((acc, group) => {
|
||||
return (acc += group.filters.filter(filter => filter.field).length)
|
||||
}, 0)
|
||||
|
||||
if (allFilters === 0) {
|
||||
return "No filters set"
|
||||
} else {
|
||||
return `${filters.length} filter${filters.length === 1 ? "" : "s"} set`
|
||||
return `${allFilters} filter${allFilters === 1 ? "" : "s"} set`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -49,15 +55,26 @@
|
|||
<div class="filter-editor">
|
||||
<ActionButton on:click={drawer.show}>{text}</ActionButton>
|
||||
</div>
|
||||
<Drawer bind:this={drawer} title="Filtering" on:drawerHide on:drawerShow>
|
||||
<Drawer
|
||||
bind:this={drawer}
|
||||
title="Filtering"
|
||||
on:drawerHide
|
||||
on:drawerShow
|
||||
on:drawerShow={() => {
|
||||
// Reset to the currently available value.
|
||||
localFilters = Helpers.cloneDeep(value)
|
||||
}}
|
||||
>
|
||||
<Button cta slot="buttons" on:click={saveFilter}>Save</Button>
|
||||
<DrawerContent slot="body">
|
||||
<FilterBuilder
|
||||
filters={value}
|
||||
filters={localFilters}
|
||||
{bindings}
|
||||
{schemaFields}
|
||||
{datasource}
|
||||
on:change={e => (tempValue = e.detail)}
|
||||
on:change={e => {
|
||||
localFilters = e.detail
|
||||
}}
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue