Merge pull request #14556 from Budibase/v3-ui

V3 UI
This commit is contained in:
Martin McKeaveney 2024-11-03 15:50:04 +00:00 committed by GitHub
commit cb7bef818f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
337 changed files with 11619 additions and 6179 deletions

View File

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

3
.gitignore vendored
View File

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

View File

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

View File

@ -223,6 +223,8 @@ const environment = {
BB_ADMIN_USER_EMAIL: process.env.BB_ADMIN_USER_EMAIL,
BB_ADMIN_USER_PASSWORD: process.env.BB_ADMIN_USER_PASSWORD,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
MIN_VERSION_WITHOUT_POWER_ROLE:
process.env.MIN_VERSION_WITHOUT_POWER_ROLE || "3.0.0",
}
export function setEnv(newEnvVars: Partial<typeof environment>): () => void {

View File

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

View File

@ -1,5 +1,4 @@
import semver from "semver"
import { BuiltinPermissionID, PermissionLevel } from "./permissions"
import {
prefixRoleID,
getRoleParams,
@ -14,10 +13,13 @@ import {
RoleUIMetadata,
Database,
App,
BuiltinPermissionID,
PermissionLevel,
} from "@budibase/types"
import cloneDeep from "lodash/fp/cloneDeep"
import { RoleColor, helpers } from "@budibase/shared-core"
import { uniqBy } from "lodash"
import { default as env } from "../environment"
export const BUILTIN_ROLE_IDS = {
ADMIN: "ADMIN",
@ -50,7 +52,7 @@ export class Role implements RoleDoc {
_id: string
_rev?: string
name: string
permissionId: string
permissionId: BuiltinPermissionID
inherits?: string | string[]
version?: string
permissions: Record<string, PermissionLevel[]> = {}
@ -59,7 +61,7 @@ export class Role implements RoleDoc {
constructor(
id: string,
name: string,
permissionId: string,
permissionId: BuiltinPermissionID,
uiMetadata?: RoleUIMetadata
) {
this._id = id
@ -213,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
)
}

View File

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

View File

@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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} />

View File

@ -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 => {

View File

@ -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;
}

View File

@ -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"

View File

@ -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>

View File

@ -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 => {

View File

@ -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"

View File

@ -178,6 +178,11 @@
"name": "Download File",
"type": "data",
"component": "DownloadFile"
},
{
"name": "Row Action",
"type": "data",
"component": "RowAction"
}
]
}

View File

@ -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%;

View File

@ -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"
}

View File

@ -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
/>

View File

@ -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