1
0
Fork 0
mirror of synced 2024-10-04 03:54:37 +13:00

Merge branch 'develop' into BUDI-7393/allow-view-permissions

This commit is contained in:
Adria Navarro 2023-08-30 15:01:02 +02:00 committed by GitHub
commit 691d7c22cb
32 changed files with 1044 additions and 336 deletions

View file

@ -0,0 +1,19 @@
name: deploy-featurebranch
on:
pull_request:
branches:
- develop
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: passeidireto/trigger-external-workflow-action@main
env:
BRANCH: ${{ github.head_ref }}
with:
repository: budibase/budibase-deploys
event: featurebranch-qa-deploy
github_pat: ${{ secrets.GH_ACCESS_TOKEN }}

View file

@ -1,5 +1,5 @@
{
"version": "2.9.30-alpha.13",
"version": "2.9.33-alpha.4",
"npmClient": "yarn",
"packages": [
"packages/*"

View file

@ -17,6 +17,7 @@ export default function positionDropdown(element, opts) {
maxWidth,
useAnchorWidth,
offset = 5,
customUpdate,
} = opts
if (!anchor) {
return
@ -32,33 +33,42 @@ export default function positionDropdown(element, opts) {
left: null,
top: null,
}
// Determine vertical styles
if (align === "right-outside") {
styles.top = anchorBounds.top
} else if (window.innerHeight - anchorBounds.bottom < (maxHeight || 100)) {
styles.top = anchorBounds.top - elementBounds.height - offset
styles.maxHeight = maxHeight || 240
} else {
styles.top = anchorBounds.bottom + offset
styles.maxHeight =
maxHeight || window.innerHeight - anchorBounds.bottom - 20
}
// Determine horizontal styles
if (!maxWidth && useAnchorWidth) {
styles.maxWidth = anchorBounds.width
}
if (useAnchorWidth) {
styles.minWidth = anchorBounds.width
}
if (align === "right") {
styles.left = anchorBounds.left + anchorBounds.width - elementBounds.width
} else if (align === "right-outside") {
styles.left = anchorBounds.right + offset
} else if (align === "left-outside") {
styles.left = anchorBounds.left - elementBounds.width - offset
if (typeof customUpdate === "function") {
styles = customUpdate(anchorBounds, elementBounds, styles)
} else {
styles.left = anchorBounds.left
// Determine vertical styles
if (align === "right-outside") {
styles.top = anchorBounds.top
} else if (
window.innerHeight - anchorBounds.bottom <
(maxHeight || 100)
) {
styles.top = anchorBounds.top - elementBounds.height - offset
styles.maxHeight = maxHeight || 240
} else {
styles.top = anchorBounds.bottom + offset
styles.maxHeight =
maxHeight || window.innerHeight - anchorBounds.bottom - 20
}
// Determine horizontal styles
if (!maxWidth && useAnchorWidth) {
styles.maxWidth = anchorBounds.width
}
if (useAnchorWidth) {
styles.minWidth = anchorBounds.width
}
if (align === "right") {
styles.left =
anchorBounds.left + anchorBounds.width - elementBounds.width
} else if (align === "right-outside") {
styles.left = anchorBounds.right + offset
} else if (align === "left-outside") {
styles.left = anchorBounds.left - elementBounds.width - offset
} else {
styles.left = anchorBounds.left
}
}
// Apply styles

View file

@ -44,7 +44,9 @@
align-items: stretch;
border-bottom: var(--border-light);
}
.property-group-container:last-child {
border-bottom: 0px;
}
.property-group-name {
cursor: pointer;
display: flex;

View file

@ -4,6 +4,8 @@
import Body from "../Typography/Body.svelte"
import Heading from "../Typography/Heading.svelte"
import { setContext } from "svelte"
import { createEventDispatcher } from "svelte"
import { generate } from "shortid"
export let title
export let fillWidth
@ -11,13 +13,17 @@
export let width = "calc(100% - 626px)"
export let headless = false
const dispatch = createEventDispatcher()
let visible = false
let drawerId = generate()
export function show() {
if (visible) {
return
}
visible = true
dispatch("drawerShow", drawerId)
}
export function hide() {
@ -25,6 +31,7 @@
return
}
visible = false
dispatch("drawerHide", drawerId)
}
setContext("drawer-actions", {

View file

@ -2,8 +2,8 @@
import "@spectrum-css/inputgroup/dist/index-vars.css"
import "@spectrum-css/popover/dist/index-vars.css"
import "@spectrum-css/menu/dist/index-vars.css"
import { fly } from "svelte/transition"
import { createEventDispatcher } from "svelte"
import clickOutside from "../../Actions/click_outside"
export let value = null
export let id = null
@ -80,10 +80,11 @@
</svg>
</button>
{#if open}
<div class="overlay" on:mousedown|self={() => (open = false)} />
<div
transition:fly|local={{ y: -20, duration: 200 }}
class="spectrum-Popover spectrum-Popover--bottom is-open"
use:clickOutside={() => {
open = false
}}
>
<ul class="spectrum-Menu" role="listbox">
{#if options && Array.isArray(options)}
@ -125,14 +126,6 @@
.spectrum-Textfield-input {
width: 0;
}
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 999;
}
.spectrum-Popover {
max-height: 240px;
width: 100%;

View file

@ -23,6 +23,10 @@
export let animate = true
export let customZindex
export let handlePostionUpdate
export let showPopover = true
export let clickOutsideOverride = false
$: target = portalTarget || getContext(Context.PopoverRoot) || ".spectrum"
export const show = () => {
@ -44,6 +48,9 @@
}
const handleOutsideClick = e => {
if (clickOutsideOverride) {
return
}
if (open) {
// Stop propagation if the source is the anchor
let node = e.target
@ -62,6 +69,9 @@
}
function handleEscape(e) {
if (!clickOutsideOverride) {
return
}
if (open && e.key === "Escape") {
hide()
}
@ -79,6 +89,7 @@
maxWidth,
useAnchorWidth,
offset,
customUpdate: handlePostionUpdate,
}}
use:clickOutside={{
callback: dismissible ? handleOutsideClick : () => {},
@ -87,6 +98,7 @@
on:keydown={handleEscape}
class="spectrum-Popover is-open"
class:customZindex
class:hide-popover={open && !showPopover}
role="presentation"
style="height: {customHeight}; --customZindex: {customZindex};"
transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }}
@ -97,6 +109,10 @@
{/if}
<style>
.hide-popover {
display: contents;
}
.spectrum-Popover {
min-width: var(--spectrum-global-dimension-size-2000);
border-color: var(--spectrum-global-color-gray-300);

View file

@ -215,7 +215,7 @@
const nameA = getDisplayName(a)
const nameB = getDisplayName(b)
if (orderA !== orderB) {
return orderA < orderB ? orderA : orderB
return orderA < orderB ? a : b
}
return nameA < nameB ? a : b
})

View file

@ -6,7 +6,7 @@ import {
findComponentPath,
getComponentSettings,
} from "./componentUtils"
import { store } from "builderStore"
import { store, currentAsset } from "builderStore"
import {
queries as queriesStores,
tables as tablesStore,
@ -22,6 +22,7 @@ import { TableNames } from "../constants"
import { JSONUtils } from "@budibase/frontend-core"
import ActionDefinitions from "components/design/settings/controls/ButtonActionEditor/manifest.json"
import { environment, licensing } from "stores/portal"
import { convertOldFieldFormat } from "components/design/settings/controls/FieldConfiguration/utils"
// Regex to match all instances of template strings
const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
@ -328,7 +329,7 @@ const getProviderContextBindings = (asset, dataProviders) => {
if (context.type === "form") {
// Forms do not need table schemas
// Their schemas are built from their component field names
schema = buildFormSchema(component)
schema = buildFormSchema(component, asset)
readablePrefix = "Fields"
} else if (context.type === "static") {
// Static contexts are fully defined by the components
@ -370,6 +371,11 @@ const getProviderContextBindings = (asset, dataProviders) => {
if (runtimeSuffix) {
providerId += `-${runtimeSuffix}`
}
if (!filterCategoryByContext(component, context)) {
return
}
const safeComponentId = makePropSafe(providerId)
// Create bindable properties for each schema field
@ -387,6 +393,12 @@ const getProviderContextBindings = (asset, dataProviders) => {
}
readableBinding += `.${fieldSchema.name || key}`
const bindingCategory = getComponentBindingCategory(
component,
context,
def
)
// Create the binding object
bindings.push({
type: "context",
@ -399,8 +411,8 @@ const getProviderContextBindings = (asset, dataProviders) => {
// Table ID is used by JSON fields to know what table the field is in
tableId: table?._id,
component: component._component,
category: component._instanceName,
icon: def.icon,
category: bindingCategory.category,
icon: bindingCategory.icon,
display: {
name: fieldSchema.name || key,
type: fieldSchema.type,
@ -413,6 +425,40 @@ const getProviderContextBindings = (asset, dataProviders) => {
return bindings
}
// Exclude a data context based on the component settings
const filterCategoryByContext = (component, context) => {
const { _component } = component
if (_component.endsWith("formblock")) {
if (
(component.actionType == "Create" && context.type === "schema") ||
(component.actionType == "View" && context.type === "form")
) {
return false
}
}
return true
}
const getComponentBindingCategory = (component, context, def) => {
let icon = def.icon
let category = component._instanceName
if (component._component.endsWith("formblock")) {
let contextCategorySuffix = {
form: "Fields",
schema: "Row",
}
category = `${component._instanceName} - ${
contextCategorySuffix[context.type]
}`
icon = context.type === "form" ? "Form" : "Data"
}
return {
icon,
category,
}
}
/**
* Gets all bindable properties from the logged in user.
*/
@ -507,6 +553,7 @@ const getSelectedRowsBindings = asset => {
)}.${makePropSafe("selectedRows")}`,
readableBinding: `${block._instanceName}.Selected rows`,
category: "Selected rows",
icon: "ViewRow",
display: { name: block._instanceName },
}))
)
@ -582,24 +629,36 @@ const getRoleBindings = () => {
}
/**
* Gets all bindable properties exposed in an event action flow up until
* the specified action ID, as well as context provided for the action
* setting as a whole by the component.
* Gets all bindable event context properties provided in the component
* setting
*/
export const getEventContextBindings = (
asset,
componentId,
export const getEventContextBindings = ({
settingKey,
actions,
actionId
) => {
componentInstance,
componentId,
componentDefinition,
asset,
}) => {
let bindings = []
const selectedAsset = asset ?? get(currentAsset)
// Check if any context bindings are provided by the component for this
// setting
const component = findComponent(asset.props, componentId)
const def = store.actions.components.getDefinition(component?._component)
const component =
componentInstance ?? findComponent(selectedAsset.props, componentId)
if (!component) {
return bindings
}
const definition =
componentDefinition ??
store.actions.components.getDefinition(component?._component)
const settings = getComponentSettings(component?._component)
const eventSetting = settings.find(setting => setting.key === settingKey)
if (eventSetting?.context?.length) {
eventSetting.context.forEach(contextEntry => {
bindings.push({
@ -608,14 +667,23 @@ export const getEventContextBindings = (
contextEntry.key
)}`,
category: component._instanceName,
icon: def.icon,
icon: definition.icon,
display: {
name: contextEntry.label,
},
})
})
}
return bindings
}
/**
* Gets all bindable properties exposed in an event action flow up until
* the specified action ID, as well as context provided for the action
* setting as a whole by the component.
*/
export const getActionBindings = (actions, actionId) => {
let bindings = []
// Get the steps leading up to this value
const index = actions?.findIndex(action => action.id === actionId)
if (index == null || index === -1) {
@ -642,7 +710,6 @@ export const getEventContextBindings = (
})
}
})
return bindings
}
@ -835,18 +902,36 @@ export const getSchemaForDatasource = (asset, datasource, options) => {
* Builds a form schema given a form component.
* A form schema is a schema of all the fields nested anywhere within a form.
*/
export const buildFormSchema = component => {
export const buildFormSchema = (component, asset) => {
let schema = {}
if (!component) {
return schema
}
// If this is a form block, simply use the fields setting
if (component._component.endsWith("formblock")) {
let schema = {}
component.fields?.forEach(field => {
schema[field] = { type: "string" }
})
const datasource = getDatasourceForProvider(asset, component)
const info = getSchemaForDatasource(component, datasource)
if (!component.fields) {
Object.values(info?.schema)
.filter(
({ autocolumn, name }) =>
!autocolumn && !["_rev", "_id"].includes(name)
)
.forEach(({ name }) => {
schema[name] = { type: info?.schema[name].type }
})
} else {
// Field conversion
const patched = convertOldFieldFormat(component.fields || [])
patched?.forEach(({ field, active }) => {
if (!active) return
schema[field] = { type: info?.schema[field].type }
})
}
return schema
}
@ -862,7 +947,7 @@ export const buildFormSchema = component => {
}
}
component._children?.forEach(child => {
const childSchema = buildFormSchema(child)
const childSchema = buildFormSchema(child, asset)
schema = { ...schema, ...childSchema }
})
return schema

View file

@ -111,6 +111,7 @@ export const getFrontendStore = () => {
}
let clone = cloneDeep(screen)
const result = patchFn(clone)
if (result === false) {
return
}
@ -837,6 +838,7 @@ export const getFrontendStore = () => {
return
}
const patchScreen = screen => {
// findComponent looks in the tree not comp.settings[0]
let component = findComponent(screen.props, componentId)
if (!component) {
return false
@ -1226,7 +1228,12 @@ export const getFrontendStore = () => {
})
},
updateSetting: async (name, value) => {
await store.actions.components.patch(component => {
await store.actions.components.patch(
store.actions.components.updateComponentSetting(name, value)
)
},
updateComponentSetting: (name, value) => {
return component => {
if (!name || !component) {
return false
}
@ -1254,9 +1261,8 @@ export const getFrontendStore = () => {
component[key] = columnNames
})
}
component[name] = value
})
}
},
requestEjectBlock: componentId => {
store.actions.preview.sendEvent("eject-block", componentId)

View file

@ -74,6 +74,8 @@
{/if}
</div>
<Drawer
on:drawerHide
on:drawerShow
{fillWidth}
bind:this={bindingDrawer}
{title}

View file

@ -13,9 +13,9 @@
import { generate } from "shortid"
import {
getEventContextBindings,
getActionBindings,
makeStateBinding,
} from "builderStore/dataBinding"
import { currentAsset, store } from "builderStore"
import { cloneDeep } from "lodash/fp"
const flipDurationMs = 150
@ -26,6 +26,7 @@
export let actions
export let bindings = []
export let nested
export let componentInstance
let actionQuery
let selectedAction = actions?.length ? actions[0] : null
@ -68,15 +69,19 @@
acc[action.type].push(action)
return acc
}, {})
// These are ephemeral bindings which only exist while executing actions
$: eventContexBindings = getEventContextBindings(
$currentAsset,
$store.selectedComponentId,
key,
actions,
selectedAction?.id
$: eventContextBindings = getEventContextBindings({
componentInstance,
settingKey: key,
})
$: actionContextBindings = getActionBindings(actions, selectedAction?.id)
$: allBindings = getAllBindings(
bindings,
[...eventContextBindings, ...actionContextBindings],
actions
)
$: allBindings = getAllBindings(bindings, eventContexBindings, actions)
$: {
// Ensure each action has a unique ID
if (actions) {

View file

@ -13,6 +13,7 @@
export let name
export let bindings
export let nested
export let componentInstance
let drawer
let tmpValue
@ -74,7 +75,7 @@
<ActionButton on:click={openDrawer}>{actionText}</ActionButton>
</div>
<Drawer bind:this={drawer} title={"Actions"}>
<Drawer bind:this={drawer} title={"Actions"} on:drawerHide on:drawerShow>
<svelte:fragment slot="description">
Define what actions to run.
</svelte:fragment>
@ -86,6 +87,7 @@
{bindings}
{key}
{nested}
{componentInstance}
/>
</Drawer>

View file

@ -0,0 +1,154 @@
<script>
import { Icon } from "@budibase/bbui"
import { dndzone } from "svelte-dnd-action"
import { createEventDispatcher } from "svelte"
import { generate } from "shortid"
import { setContext } from "svelte"
import { writable } from "svelte/store"
export let items = []
export let showHandle = true
export let listType
export let listTypeProps = {}
export let listItemKey
export let draggable = true
let store = writable({
selected: null,
actions: {
select: id => {
store.update(state => ({
...state,
selected: id,
}))
},
},
})
setContext("draggable", store)
const dispatch = createEventDispatcher()
const flipDurationMs = 150
let anchors = {}
let draggableItems = []
const buildDragable = items => {
return items.map(item => {
return {
id: listItemKey ? item[listItemKey] : generate(),
item,
}
})
}
$: if (items) {
draggableItems = buildDragable(items)
}
const updateRowOrder = e => {
draggableItems = e.detail.items
}
const serialiseUpdate = () => {
return draggableItems.reduce((acc, ele) => {
acc.push(ele.item)
return acc
}, [])
}
const handleFinalize = e => {
updateRowOrder(e)
dispatch("change", serialiseUpdate())
}
const onItemChanged = e => {
dispatch("itemChange", e.detail)
}
</script>
<ul
class="list-wrap"
use:dndzone={{
items: draggableItems,
flipDurationMs,
dropTargetStyle: { outline: "none" },
dragDisabled: !draggable,
}}
on:finalize={handleFinalize}
on:consider={updateRowOrder}
>
{#each draggableItems as draggable (draggable.id)}
<li
bind:this={anchors[draggable.id]}
class:highlighted={draggable.id === $store.selected}
>
<div class="left-content">
{#if showHandle}
<div class="handle" aria-label="drag-handle">
<Icon name="DragHandle" size="XL" />
</div>
{/if}
</div>
<div class="right-content">
<svelte:component
this={listType}
anchor={anchors[draggable.item._id]}
item={draggable.item}
{...listTypeProps}
on:change={onItemChanged}
/>
</div>
</li>
{/each}
</ul>
<style>
.list-wrap {
list-style-type: none;
margin: 0;
padding: 0;
width: 100%;
border-radius: 4px;
background-color: var(
--spectrum-table-background-color,
var(--spectrum-global-color-gray-50)
);
border: 1px solid
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid));
}
.list-wrap > li {
background-color: var(
--spectrum-table-background-color,
var(--spectrum-global-color-gray-50)
);
transition: background-color ease-in-out 130ms;
display: flex;
align-items: center;
border-bottom: 1px solid
var(--spectrum-table-border-color, var(--spectrum-alias-border-color-mid));
}
.list-wrap > li:hover,
li.highlighted {
background-color: var(
--spectrum-table-row-background-color-hover,
var(--spectrum-alias-highlight-hover)
);
}
.list-wrap > li:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.list-wrap > li:last-child {
border-top-left-radius: var(--spectrum-table-regular-border-radius);
border-top-right-radius: var(--spectrum-table-regular-border-radius);
}
.right-content {
flex: 1;
min-width: 0;
}
.list-wrap li {
padding-left: var(--spacing-s);
padding-right: var(--spacing-s);
}
</style>

View file

@ -0,0 +1,160 @@
<script>
import { Icon, Popover, Layout } from "@budibase/bbui"
import { store } from "builderStore"
import { cloneDeep } from "lodash/fp"
import { createEventDispatcher } from "svelte"
import ComponentSettingsSection from "../../../../../pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Component/ComponentSettingsSection.svelte"
import { getContext } from "svelte"
export let anchor
export let field
export let componentBindings
export let bindings
const draggable = getContext("draggable")
const dispatch = createEventDispatcher()
let popover
let drawers = []
let pseudoComponentInstance
let open = false
$: if (open && $draggable.selected && $draggable.selected != field._id) {
popover.hide()
}
$: if (field) {
pseudoComponentInstance = field
}
$: componentDef = store.actions.components.getDefinition(
pseudoComponentInstance._component
)
$: parsedComponentDef = processComponentDefinitionSettings(componentDef)
const processComponentDefinitionSettings = componentDef => {
if (!componentDef) {
return {}
}
const clone = cloneDeep(componentDef)
const updatedSettings = clone.settings
.filter(setting => setting.key !== "field")
.map(setting => {
return { ...setting, nested: true }
})
clone.settings = updatedSettings
return clone
}
const updateSetting = async (setting, value) => {
const nestedComponentInstance = cloneDeep(pseudoComponentInstance)
const patchFn = store.actions.components.updateComponentSetting(
setting.key,
value
)
patchFn(nestedComponentInstance)
const update = {
...nestedComponentInstance,
active: pseudoComponentInstance.active,
}
dispatch("change", update)
}
</script>
<Icon
name="Settings"
hoverable
size="S"
on:click={() => {
if (!open) {
popover.show()
open = true
}
}}
/>
<Popover
bind:this={popover}
on:open={() => {
drawers = []
$draggable.actions.select(field._id)
}}
on:close={() => {
open = false
if ($draggable.selected == field._id) {
$draggable.actions.select()
}
}}
{anchor}
align="left-outside"
showPopover={drawers.length == 0}
clickOutsideOverride={drawers.length > 0}
maxHeight={600}
handlePostionUpdate={(anchorBounds, eleBounds, cfg) => {
let { left, top } = cfg
let percentageOffset = 30
// left-outside
left = anchorBounds.left - eleBounds.width - 18
// shift up from the anchor, if space allows
let offsetPos = Math.floor(eleBounds.height / 100) * percentageOffset
let defaultTop = anchorBounds.top - offsetPos
if (window.innerHeight - defaultTop < eleBounds.height) {
top = window.innerHeight - eleBounds.height - 5
} else {
top = anchorBounds.top - offsetPos
}
return { ...cfg, left, top }
}}
>
<span class="popover-wrap">
<Layout noPadding noGap>
<div class="type-icon">
<Icon name={parsedComponentDef.icon} />
<span>{field.field}</span>
</div>
<ComponentSettingsSection
componentInstance={pseudoComponentInstance}
componentDefinition={parsedComponentDef}
isScreen={false}
onUpdateSetting={updateSetting}
showSectionTitle={false}
showInstanceName={false}
{bindings}
{componentBindings}
on:drawerShow={e => {
drawers = [...drawers, e.detail]
}}
on:drawerHide={() => {
drawers = drawers.slice(0, -1)
}}
/>
</Layout>
</span>
</Popover>
<style>
.popover-wrap {
background-color: var(--spectrum-alias-background-color-primary);
}
.type-icon {
display: flex;
gap: var(--spacing-m);
margin: var(--spacing-xl);
margin-bottom: 0px;
height: var(--spectrum-alias-item-height-m);
padding: 0px var(--spectrum-alias-item-padding-m);
border-width: var(--spectrum-actionbutton-border-size);
border-radius: var(--spectrum-alias-border-radius-regular);
border: 1px solid
var(
--spectrum-actionbutton-m-border-color,
var(--spectrum-alias-border-color)
);
align-items: center;
}
</style>

View file

@ -1,45 +1,70 @@
<script>
import { Button, ActionButton, Drawer } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import ColumnDrawer from "./ColumnDrawer.svelte"
import { cloneDeep } from "lodash/fp"
import { cloneDeep, isEqual } from "lodash/fp"
import {
getDatasourceForProvider,
getSchemaForDatasource,
getBindableProperties,
getComponentBindableProperties,
} from "builderStore/dataBinding"
import { currentAsset } from "builderStore"
import DraggableList from "../DraggableList.svelte"
import { createEventDispatcher } from "svelte"
import { store, selectedScreen } from "builderStore"
import FieldSetting from "./FieldSetting.svelte"
import { convertOldFieldFormat, getComponentForField } from "./utils"
export let componentInstance
export let value = []
export let value
const dispatch = createEventDispatcher()
let sanitisedFields
let fieldList
let schema
let cachedValue
let drawer
let boundValue
$: bindings = getBindableProperties($selectedScreen, componentInstance._id)
$: actionType = componentInstance.actionType
let componentBindings = []
$: text = getText(value)
$: convertOldColumnFormat(value)
$: datasource = getDatasourceForProvider($currentAsset, componentInstance)
$: schema = getSchema($currentAsset, datasource)
$: options = Object.keys(schema || {})
$: sanitisedValue = getValidColumns(value, options)
$: updateBoundValue(sanitisedValue)
const getText = value => {
if (!value?.length) {
return "All fields"
}
let text = `${value.length} field`
if (value.length !== 1) {
text += "s"
}
return text
$: if (actionType) {
componentBindings = getComponentBindableProperties(
$selectedScreen,
componentInstance._id
)
}
const convertOldColumnFormat = oldColumns => {
if (typeof oldColumns?.[0] === "string") {
value = oldColumns.map(field => ({ name: field, displayName: field }))
$: datasource = getDatasourceForProvider($currentAsset, componentInstance)
$: if (!isEqual(value, cachedValue)) {
cachedValue = value
schema = getSchema($currentAsset, datasource)
}
$: options = Object.keys(schema || {})
$: sanitisedValue = getValidColumns(convertOldFieldFormat(value), options)
$: updateSanitsedFields(sanitisedValue)
$: unconfigured = buildUnconfiguredOptions(schema, sanitisedFields)
// Builds unused ones only
const buildUnconfiguredOptions = (schema, selected) => {
if (!schema) {
return []
}
let schemaClone = cloneDeep(schema)
selected.forEach(val => {
delete schemaClone[val.field]
})
return Object.keys(schemaClone)
.filter(key => !schemaClone[key].autocolumn)
.map(key => {
const col = schemaClone[key]
let toggleOn = !value
return {
field: key,
active: typeof col.active != "boolean" ? toggleOn : col.active,
}
})
}
const getSchema = (asset, datasource) => {
@ -54,50 +79,85 @@
return schema
}
const updateBoundValue = value => {
boundValue = cloneDeep(value)
const updateSanitsedFields = value => {
sanitisedFields = cloneDeep(value)
}
const getValidColumns = (columns, options) => {
if (!Array.isArray(columns) || !columns.length) {
return []
}
// We need to account for legacy configs which would just be an array
// of strings
if (typeof columns[0] === "string") {
columns = columns.map(col => ({
name: col,
displayName: col,
}))
}
return columns.filter(column => {
return options.includes(column.name)
return options.includes(column.field)
})
}
const open = () => {
updateBoundValue(sanitisedValue)
drawer.show()
const buildSudoInstance = instance => {
if (instance._component) {
return instance
}
const type = getComponentForField(instance.field, schema)
instance._component = `@budibase/standard-components/${type}`
const pseudoComponentInstance = store.actions.components.createInstance(
instance._component,
{
_instanceName: instance.field,
field: instance.field,
label: instance.field,
placeholder: instance.field,
},
{}
)
return { ...instance, ...pseudoComponentInstance }
}
const save = () => {
dispatch("change", getValidColumns(boundValue, options))
drawer.hide()
$: if (sanitisedFields) {
fieldList = [...sanitisedFields, ...unconfigured].map(buildSudoInstance)
}
const processItemUpdate = e => {
const updatedField = e.detail
const parentFieldsUpdated = fieldList ? cloneDeep(fieldList) : []
let parentFieldIdx = parentFieldsUpdated.findIndex(pSetting => {
return pSetting.field === updatedField?.field
})
if (parentFieldIdx == -1) {
parentFieldsUpdated.push(updatedField)
} else {
parentFieldsUpdated[parentFieldIdx] = updatedField
}
dispatch("change", getValidColumns(parentFieldsUpdated, options))
}
const listUpdated = e => {
const parsedColumns = getValidColumns(e.detail, options)
dispatch("change", parsedColumns)
}
</script>
<div class="field-configuration">
<ActionButton on:click={open}>{text}</ActionButton>
{#if fieldList?.length}
<DraggableList
on:change={listUpdated}
on:itemChange={processItemUpdate}
items={fieldList}
listItemKey={"_id"}
listType={FieldSetting}
listTypeProps={{
componentBindings,
bindings,
}}
/>
{/if}
</div>
<Drawer bind:this={drawer} title="Form Fields">
<svelte:fragment slot="description">
Configure the fields in your form.
</svelte:fragment>
<Button cta slot="buttons" on:click={save}>Save</Button>
<ColumnDrawer slot="body" bind:columns={boundValue} {options} {schema} />
</Drawer>
<style>
.field-configuration :global(.spectrum-ActionButton) {
width: 100%;

View file

@ -0,0 +1,56 @@
<script>
import EditFieldPopover from "./EditFieldPopover.svelte"
import { Toggle } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { cloneDeep } from "lodash/fp"
export let item
export let componentBindings
export let bindings
export let anchor
const dispatch = createEventDispatcher()
const onToggle = item => {
return e => {
item.active = e.detail
dispatch("change", { ...cloneDeep(item), active: e.detail })
}
}
</script>
<div class="list-item-body">
<div class="list-item-left">
<EditFieldPopover
{anchor}
field={item}
{componentBindings}
{bindings}
on:change
/>
<div class="field-label">{item.label || item.field}</div>
</div>
<div class="list-item-right">
<Toggle on:change={onToggle(item)} text="" value={item.active} thin />
</div>
</div>
<style>
.field-label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.list-item-body,
.list-item-left {
display: flex;
align-items: center;
gap: var(--spacing-m);
min-width: 0;
}
.list-item-right :global(div.spectrum-Switch) {
margin: 0px;
}
.list-item-body {
justify-content: space-between;
}
</style>

View file

@ -0,0 +1,46 @@
export const convertOldFieldFormat = fields => {
if (!fields) {
return []
}
const converted = fields.map(field => {
if (typeof field === "string") {
// existed but was a string
return {
field,
active: true,
}
} else if (typeof field?.active != "boolean") {
// existed but had no state
return {
field: field.name,
active: true,
}
} else {
return field
}
})
return converted
}
export const getComponentForField = (field, schema) => {
if (!field || !schema?.[field]) {
return null
}
const type = schema[field].type
return FieldTypeToComponentMap[type]
}
export const FieldTypeToComponentMap = {
string: "stringfield",
number: "numberfield",
bigint: "bigintfield",
options: "optionsfield",
array: "multifieldselect",
boolean: "booleanfield",
longform: "longformfield",
datetime: "datetimefield",
attachment: "attachmentfield",
link: "relationshipfield",
json: "jsonfield",
barcodeqr: "codescanner",
}

View file

@ -24,11 +24,22 @@
}
</script>
<ActionButton on:click={drawer.show}>Define Options</ActionButton>
<Drawer bind:this={drawer} title="Options">
<div class="options-wrap">
<div />
<div><ActionButton on:click={drawer.show}>Define Options</ActionButton></div>
</div>
<Drawer bind:this={drawer} title="Options" on:drawerHide on:drawerShow>
<svelte:fragment slot="description">
Define the options for this picker.
</svelte:fragment>
<Button cta slot="buttons" on:click={saveOptions}>Save</Button>
<OptionsDrawer bind:options={tempValue} slot="body" />
</Drawer>
<style>
.options-wrap {
gap: 8px;
display: grid;
grid-template-columns: 90px 1fr;
}
</style>

View file

@ -100,6 +100,8 @@
{key}
{type}
{...props}
on:drawerHide
on:drawerShow
/>
</div>
{#if info}

View file

@ -5,9 +5,8 @@
export let value = []
export let bindings = []
export let componentDefinition
export let componentInstance
export let type
const dispatch = createEventDispatcher()
let drawer
@ -31,7 +30,7 @@
<ActionButton on:click={drawer.show}>{text}</ActionButton>
</div>
<Drawer bind:this={drawer} title="Validation Rules">
<Drawer bind:this={drawer} title="Validation Rules" on:drawerHide on:drawerShow>
<svelte:fragment slot="description">
Configure validation rules for this field.
</svelte:fragment>
@ -41,7 +40,7 @@
bind:rules={value}
{type}
{bindings}
{componentDefinition}
fieldName={componentInstance?.field}
/>
</Drawer>

View file

@ -1,36 +1,12 @@
<script>
import { DetailSummary, Icon } from "@budibase/bbui"
import { DetailSummary } from "@budibase/bbui"
import InfoDisplay from "./InfoDisplay.svelte"
export let componentDefinition
</script>
<DetailSummary collapsible={false}>
<div class="info">
<div class="title">
<Icon name="HelpOutline" />
{componentDefinition.name}
</div>
{componentDefinition.info}
</div>
<DetailSummary collapsible={false} noPadding={true}>
<InfoDisplay
title={componentDefinition.name}
body={componentDefinition.info}
/>
</DetailSummary>
<style>
.title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
margin-bottom: var(--spacing-m);
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-s);
color: var(--spectrum-global-color-gray-600);
}
.info {
padding: var(--spacing-m) var(--spacing-l) var(--spacing-l) var(--spacing-l);
background-color: var(--background-alt);
border-radius: var(--border-radius-s);
font-size: 13px;
}
</style>

View file

@ -5,7 +5,7 @@
import DesignSection from "./DesignSection.svelte"
import CustomStylesSection from "./CustomStylesSection.svelte"
import ConditionalUISection from "./ConditionalUISection.svelte"
import ComponentInfoSection from "./ComponentInfoSection.svelte"
import {
getBindableProperties,
getComponentBindableProperties,
@ -55,9 +55,6 @@
</div>
</span>
{#if section == "settings"}
{#if componentDefinition?.info}
<ComponentInfoSection {componentDefinition} />
{/if}
<ComponentSettingsSection
{componentInstance}
{componentDefinition}

View file

@ -6,6 +6,7 @@
import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte"
import EjectBlockButton from "components/design/settings/controls/EjectBlockButton.svelte"
import { getComponentForSetting } from "components/design/settings/componentSettings"
import InfoDisplay from "./InfoDisplay.svelte"
import analytics, { Events } from "analytics"
export let componentDefinition
@ -13,6 +14,9 @@
export let bindings
export let componentBindings
export let isScreen = false
export let onUpdateSetting
export let showSectionTitle = true
export let showInstanceName = true
$: sections = getSections(componentInstance, componentDefinition, isScreen)
@ -47,8 +51,11 @@
const updateSetting = async (setting, value) => {
try {
await store.actions.components.updateSetting(setting.key, value)
if (typeof onUpdateSetting === "function") {
await onUpdateSetting(setting, value)
} else {
await store.actions.components.updateSetting(setting.key, value)
}
// Send event if required
if (setting.sendEvents) {
analytics.captureEvent(Events.COMPONENT_UPDATED, {
@ -97,7 +104,7 @@
}
}
return true
return typeof setting.visible == "boolean" ? setting.visible : true
}
const canRenderControl = (instance, setting, isScreen) => {
@ -116,9 +123,22 @@
{#each sections as section, idx (section.name)}
{#if section.visible}
<DetailSummary name={section.name} collapsible={false}>
<DetailSummary
name={showSectionTitle ? section.name : ""}
collapsible={false}
>
{#if section.info}
<div class="section-info">
<InfoDisplay body={section.info} />
</div>
{:else if idx === 0 && section.name === "General" && componentDefinition.info}
<InfoDisplay
title={componentDefinition.name}
body={componentDefinition.info}
/>
{/if}
<div class="settings">
{#if idx === 0 && !componentInstance._component.endsWith("/layout") && !isScreen}
{#if idx === 0 && !componentInstance._component.endsWith("/layout") && !isScreen && showInstanceName}
<PropertyControl
control={Input}
label="Name"
@ -157,6 +177,8 @@
{componentBindings}
{componentInstance}
{componentDefinition}
on:drawerShow
on:drawerHide
/>
{/if}
{/each}

View file

@ -0,0 +1,66 @@
<script>
import { Icon } from "@budibase/bbui"
export let title
export let body
export let icon = "HelpOutline"
</script>
<div class="info" class:noTitle={!title}>
{#if title}
<div class="title">
<Icon name={icon} />
{title || ""}
</div>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html body}
{:else}
<span class="icon">
<Icon name={icon} />
</span>
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html body}
{/if}
</div>
<style>
.title {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
margin-bottom: var(--spacing-m);
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-s);
}
.title,
.icon {
color: var(--spectrum-global-color-gray-600);
}
.info {
padding: var(--spacing-m) var(--spacing-l) var(--spacing-l) var(--spacing-l);
background-color: var(--background-alt);
border-radius: var(--border-radius-s);
font-size: 13px;
}
.noTitle {
display: flex;
align-items: center;
gap: var(--spacing-m);
}
.info :global(a) {
color: inherit;
transition: color 130ms ease-out;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.info :global(a:hover) {
color: var(--spectrum-global-color-gray-900);
}
.info :global(a) {
text-decoration: underline;
}
</style>

View file

@ -1,4 +1,4 @@
export { CommandWord, InitType, AnalyticsEvent } from "@budibase/types"
export const POSTHOG_TOKEN = "phc_yGOn4i7jWKaCTapdGR6lfA4AvmuEQ2ijn5zAVSFYPlS"
export const POSTHOG_TOKEN = "phc_bIjZL7oh2GEUd2vqvTBH8WvrX0fWTFQMs6H5KQxiUxU"
export const GENERATED_USER_EMAIL = "admin@admin.com"

View file

@ -4737,7 +4737,7 @@
]
},
{
"label": "Fields",
"label": "",
"type": "fieldConfiguration",
"key": "sidePanelFields",
"nested": true,
@ -4747,17 +4747,7 @@
}
},
{
"label": "Show delete",
"type": "boolean",
"key": "sidePanelShowDelete",
"nested": true,
"dependsOn": {
"setting": "clickBehaviour",
"value": "details"
}
},
{
"label": "Save label",
"label": "Save button",
"type": "text",
"key": "sidePanelSaveLabel",
"defaultValue": "Save",
@ -4768,7 +4758,7 @@
}
},
{
"label": "Delete label",
"label": "Delete button",
"type": "text",
"key": "sidePanelDeleteLabel",
"defaultValue": "Delete",
@ -5284,17 +5274,6 @@
"label": "Table",
"key": "dataSource"
},
{
"type": "text",
"label": "Row ID",
"key": "rowId",
"nested": true,
"dependsOn": {
"setting": "actionType",
"value": "Create",
"invert": true
}
},
{
"type": "text",
"label": "Title",
@ -5302,116 +5281,55 @@
"nested": true
},
{
"type": "select",
"label": "Size",
"key": "size",
"options": [
{
"label": "Medium",
"value": "spectrum--medium"
},
{
"label": "Large",
"value": "spectrum--large"
}
],
"defaultValue": "spectrum--medium"
},
{
"type": "text",
"label": "Empty text",
"key": "noRowsMessage",
"defaultValue": "We couldn't find a row to display",
"section": true,
"dependsOn": {
"setting": "actionType",
"value": "Create",
"invert": true
}
},
{
"section": true,
"name": "Fields",
},
"name": "Row details",
"info": "<a href='https://docs.budibase.com/docs/form-block' target='_blank'>How to pass a row ID using bindings</a>",
"settings": [
{
"type": "fieldConfiguration",
"label": "Fields",
"key": "fields",
"selectAllFields": true
"type": "text",
"label": "Row ID",
"key": "rowId",
"nested": true
},
{
"type": "select",
"label": "Field labels",
"key": "labelPosition",
"defaultValue": "left",
"options": [
{
"label": "Left",
"value": "left"
},
{
"label": "Above",
"value": "above"
}
]
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false,
"dependsOn": {
"setting": "actionType",
"value": "View",
"invert": true
}
"type": "text",
"label": "Empty text",
"key": "noRowsMessage",
"defaultValue": "We couldn't find a row to display",
"nested": true
}
]
},
{
"section": true,
"name": "Buttons",
"dependsOn": {
"setting": "actionType",
"value": "View",
"invert": true
},
"settings": [
{
"type": "boolean",
"label": "Show save button",
"key": "showSaveButton",
"defaultValue": true,
"dependsOn": {
"setting": "actionType",
"value": "View",
"invert": true
}
},
{
"type": "text",
"key": "saveButtonLabel",
"label": "Save button label",
"label": "Save button",
"nested": true,
"defaultValue": "Save",
"dependsOn": {
"setting": "showSaveButton",
"value": true
}
},
{
"type": "boolean",
"label": "Allow delete",
"key": "showDeleteButton",
"defaultValue": false,
"dependsOn": {
"setting": "actionType",
"value": "Update"
}
"defaultValue": "Save"
},
{
"type": "text",
"key": "deleteButtonLabel",
"label": "Delete button label",
"label": "Delete button",
"nested": true,
"defaultValue": "Delete",
"dependsOn": {
"setting": "showDeleteButton",
"value": true
"setting": "actionType",
"value": "Update"
}
},
{
@ -5429,7 +5347,67 @@
"type": "boolean",
"label": "Hide notifications",
"key": "notificationOverride",
"defaultValue": false
"defaultValue": false,
"dependsOn": {
"setting": "actionType",
"value": "View",
"invert": true
}
}
]
},
{
"section": true,
"name": "Fields",
"settings": [
{
"type": "select",
"label": "Align labels",
"key": "labelPosition",
"defaultValue": "left",
"options": [
{
"label": "Left",
"value": "left"
},
{
"label": "Above",
"value": "above"
}
]
},
{
"type": "select",
"label": "Size",
"key": "size",
"options": [
{
"label": "Medium",
"value": "spectrum--medium"
},
{
"label": "Large",
"value": "spectrum--large"
}
],
"defaultValue": "spectrum--medium"
},
{
"type": "fieldConfiguration",
"key": "fields",
"nested": true,
"selectAllFields": true
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false,
"dependsOn": {
"setting": "actionType",
"value": "View",
"invert": true
}
}
]
}

View file

@ -45,6 +45,9 @@
let enrichedSearchColumns
let schemaLoaded = false
// Accommodate old config to ensure delete button does not reappear
$: deleteLabel = sidePanelShowDelete === false ? "" : sidePanelDeleteLabel
$: fetchSchema(dataSource)
$: enrichSearchColumns(searchColumns, schema).then(
val => (enrichedSearchColumns = val)
@ -245,10 +248,8 @@
bind:id={detailsFormBlockId}
props={{
dataSource,
showSaveButton: true,
showDeleteButton: sidePanelShowDelete,
saveButtonLabel: sidePanelSaveLabel,
deleteButtonLabel: sidePanelDeleteLabel,
saveButtonLabel: sidePanelSaveLabel || "Save", //always show
deleteButtonLabel: deleteLabel, //respect config
actionType: "Update",
rowId: `{{ ${safe("state")}.${safe(stateKey)} }}`,
fields: sidePanelFields || normalFields,

View file

@ -12,42 +12,59 @@
export let fields
export let labelPosition
export let title
export let showDeleteButton
export let showSaveButton
export let saveButtonLabel
export let deleteButtonLabel
export let showSaveButton
export let showDeleteButton
export let rowId
export let actionUrl
export let noRowsMessage
export let notificationOverride
// Accommodate old config to ensure delete button does not reappear
$: deleteLabel = showDeleteButton === false ? "" : deleteButtonLabel?.trim()
$: saveLabel = showSaveButton === false ? "" : saveButtonLabel?.trim()
const { fetchDatasourceSchema } = getContext("sdk")
const convertOldFieldFormat = fields => {
if (typeof fields?.[0] === "string") {
return fields.map(field => ({ name: field, displayName: field }))
if (!fields) {
return []
}
return fields
return fields.map(field => {
if (typeof field === "string") {
// existed but was a string
return {
name: field,
active: true,
}
} else {
// existed but had no state
return {
...field,
active: typeof field?.active != "boolean" ? true : field?.active,
}
}
})
}
const getDefaultFields = (fields, schema) => {
if (schema && (!fields || fields.length === 0)) {
const defaultFields = []
Object.values(schema).forEach(field => {
if (field.autocolumn) return
defaultFields.push({
name: field.name,
displayName: field.name,
})
})
return defaultFields
if (!schema) {
return []
}
let defaultFields = []
return fields
if (!fields || fields.length === 0) {
Object.values(schema)
.filter(field => !field.autocolumn)
.forEach(field => {
defaultFields.push({
name: field.name,
active: true,
})
})
}
return [...fields, ...defaultFields].filter(field => field.active)
}
let schema
@ -56,7 +73,6 @@
$: formattedFields = convertOldFieldFormat(fields)
$: fieldsOrDefault = getDefaultFields(formattedFields, schema)
$: fetchSchema(dataSource)
$: dataProvider = `{{ literal ${safe(providerId)} }}`
$: filter = [
@ -82,15 +98,12 @@
fields: fieldsOrDefault,
labelPosition,
title,
saveButtonLabel,
deleteButtonLabel,
showSaveButton,
showDeleteButton,
saveButtonLabel: saveLabel,
deleteButtonLabel: deleteLabel,
schema,
repeaterId,
notificationOverride,
}
const fetchSchema = async () => {
schema = (await fetchDatasourceSchema(dataSource)) || {}
}

View file

@ -13,8 +13,6 @@
export let title
export let saveButtonLabel
export let deleteButtonLabel
export let showSaveButton
export let showDeleteButton
export let schema
export let repeaterId
export let notificationOverride
@ -100,18 +98,33 @@
},
]
$: renderDeleteButton = showDeleteButton && actionType === "Update"
$: renderSaveButton = showSaveButton && actionType !== "View"
$: renderDeleteButton = deleteButtonLabel && actionType === "Update"
$: renderSaveButton = saveButtonLabel && actionType !== "View"
$: renderButtons = renderDeleteButton || renderSaveButton
$: renderHeader = renderButtons || title
const getComponentForField = field => {
if (!field || !schema?.[field]) {
const fieldSchemaName = field.field || field.name
if (!fieldSchemaName || !schema?.[fieldSchemaName]) {
return null
}
const type = schema[field].type
const type = schema[fieldSchemaName].type
return FieldTypeToComponentMap[type]
}
const getPropsForField = field => {
let fieldProps = field._component
? {
...field,
}
: {
field: field.name,
label: field.name,
placeholder: field.name,
_instanceName: field.name,
}
return fieldProps
}
</script>
{#if fields?.length}
@ -175,7 +188,7 @@
<BlockComponent
type="button"
props={{
text: deleteButtonLabel || "Delete",
text: deleteButtonLabel,
onClick: onDelete,
quiet: true,
type: "secondary",
@ -187,7 +200,7 @@
<BlockComponent
type="button"
props={{
text: saveButtonLabel || "Save",
text: saveButtonLabel,
onClick: onSave,
type: "cta",
}}
@ -200,15 +213,10 @@
{/if}
<BlockComponent type="fieldgroup" props={{ labelPosition }} order={1}>
{#each fields as field, idx}
{#if getComponentForField(field.name)}
{#if getComponentForField(field) && field.active}
<BlockComponent
type={getComponentForField(field.name)}
props={{
validation: field.validation,
field: field.name,
label: field.displayName,
placeholder: field.displayName,
}}
type={getComponentForField(field)}
props={getPropsForField(field)}
order={idx}
/>
{/if}

@ -1 +1 @@
Subproject commit b7815e099bbd5e1410185c464dbd54f7287e732f
Subproject commit 140386c7ad7e3d50bd361fc702e49b288c1747c2

View file

@ -54,24 +54,36 @@ if [ -d "../account-portal" ]; then
yarn bootstrap
cd packages/server
echo "Linking backend-core to account-portal"
echo "Linking backend-core to account-portal (server)"
yarn link "@budibase/backend-core"
echo "Linking string-templates to account-portal"
echo "Linking string-templates to account-portal (server)"
yarn link "@budibase/string-templates"
echo "Linking types to account-portal"
echo "Linking types to account-portal (server)"
yarn link "@budibase/types"
echo "Linking shared-core to account-portal (server)"
yarn link "@budibase/shared-core"
if [ $pro_loaded_locally = true ]; then
echo "Linking pro to account-portal"
echo "Linking pro to account-portal (server)"
yarn link "@budibase/pro"
fi
cd ../ui
echo "Linking bbui to account-portal"
echo "Linking bbui to account-portal (ui)"
yarn link "@budibase/bbui"
echo "Linking frontend-core to account-portal"
echo "Linking shared-core to account-portal (ui)"
yarn link "@budibase/shared-core"
echo "Linking string-templates to account-portal (ui)"
yarn link "@budibase/string-templates"
echo "Linking types to account-portal (ui)"
yarn link "@budibase/types"
echo "Linking frontend-core to account-portal (ui)"
yarn link "@budibase/frontend-core"
fi