Merge branch 'develop' of github.com:Budibase/budibase into merge-master-develop

This commit is contained in:
mike12345567 2023-09-05 12:29:27 +01:00
commit 83875cd2d6
198 changed files with 5147 additions and 2244 deletions

View File

@ -19,4 +19,4 @@
"loadEnvFiles": false "loadEnvFiles": false
} }
} }
} }

View File

@ -11,7 +11,11 @@ export function getDB(dbName?: string, opts?: any): Database {
// we have to use a callback for this so that we can close // we have to use a callback for this so that we can close
// the DB when we're done, without this manual requests would // the DB when we're done, without this manual requests would
// need to close the database when done with it to avoid memory leaks // need to close the database when done with it to avoid memory leaks
export async function doWithDB(dbName: string, cb: any, opts = {}) { export async function doWithDB<T>(
dbName: string,
cb: (db: Database) => Promise<T>,
opts = {}
) {
const db = getDB(dbName, opts) const db = getDB(dbName, opts)
// need this to be async so that we can correctly close DB after all // need this to be async so that we can correctly close DB after all
// async operations have been completed // async operations have been completed

View File

@ -87,6 +87,7 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.QUERY, PermissionLevel.WRITE), new Permission(PermissionType.QUERY, PermissionLevel.WRITE),
new Permission(PermissionType.TABLE, PermissionLevel.WRITE), new Permission(PermissionType.TABLE, PermissionLevel.WRITE),
new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE), new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE),
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
], ],
}, },
POWER: { POWER: {
@ -97,6 +98,7 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.USER, PermissionLevel.READ), new Permission(PermissionType.USER, PermissionLevel.READ),
new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE), new Permission(PermissionType.AUTOMATION, PermissionLevel.EXECUTE),
new Permission(PermissionType.WEBHOOK, PermissionLevel.READ), new Permission(PermissionType.WEBHOOK, PermissionLevel.READ),
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
], ],
}, },
ADMIN: { ADMIN: {
@ -108,6 +110,7 @@ export const BUILTIN_PERMISSIONS = {
new Permission(PermissionType.AUTOMATION, PermissionLevel.ADMIN), new Permission(PermissionType.AUTOMATION, PermissionLevel.ADMIN),
new Permission(PermissionType.WEBHOOK, PermissionLevel.READ), new Permission(PermissionType.WEBHOOK, PermissionLevel.READ),
new Permission(PermissionType.QUERY, PermissionLevel.ADMIN), new Permission(PermissionType.QUERY, PermissionLevel.ADMIN),
new Permission(PermissionType.LEGACY_VIEW, PermissionLevel.READ),
], ],
}, },
} }

View File

@ -253,7 +253,7 @@ export function checkForRoleResourceArray(
* Given an app ID this will retrieve all of the roles that are currently within that app. * Given an app ID this will retrieve all of the roles that are currently within that app.
* @return {Promise<object[]>} An array of the role objects that were found. * @return {Promise<object[]>} An array of the role objects that were found.
*/ */
export async function getAllRoles(appId?: string) { export async function getAllRoles(appId?: string): Promise<RoleDoc[]> {
if (appId) { if (appId) {
return doWithDB(appId, internal) return doWithDB(appId, internal)
} else { } else {
@ -312,37 +312,6 @@ export async function getAllRoles(appId?: string) {
} }
} }
/**
* This retrieves the required role for a resource
* @param permLevel The level of request
* @param resourceId The resource being requested
* @param subResourceId The sub resource being requested
* @return {Promise<{permissions}|Object>} returns the permissions required to access.
*/
export async function getRequiredResourceRole(
permLevel: string,
{ resourceId, subResourceId }: { resourceId?: string; subResourceId?: string }
) {
const roles = await getAllRoles()
let main = [],
sub = []
for (let role of roles) {
// no permissions, ignore it
if (!role.permissions) {
continue
}
const mainRes = resourceId ? role.permissions[resourceId] : undefined
const subRes = subResourceId ? role.permissions[subResourceId] : undefined
if (mainRes && mainRes.indexOf(permLevel) !== -1) {
main.push(role._id)
} else if (subRes && subRes.indexOf(permLevel) !== -1) {
sub.push(role._id)
}
}
// for now just return the IDs
return main.concat(sub)
}
export class AccessController { export class AccessController {
userHierarchies: { [key: string]: string[] } userHierarchies: { [key: string]: string[] }
constructor() { constructor() {

View File

@ -17,6 +17,7 @@ export default function positionDropdown(element, opts) {
maxWidth, maxWidth,
useAnchorWidth, useAnchorWidth,
offset = 5, offset = 5,
customUpdate,
} = opts } = opts
if (!anchor) { if (!anchor) {
return return
@ -32,33 +33,42 @@ export default function positionDropdown(element, opts) {
left: null, left: null,
top: 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 (typeof customUpdate === "function") {
if (!maxWidth && useAnchorWidth) { styles = customUpdate(anchorBounds, elementBounds, styles)
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 { } 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 // Apply styles

View File

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

View File

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

View File

@ -2,8 +2,9 @@
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import FancyField from "./FancyField.svelte" import FancyField from "./FancyField.svelte"
import Icon from "../Icon/Icon.svelte" import Icon from "../Icon/Icon.svelte"
import Popover from "../Popover/Popover.svelte"
import FancyFieldLabel from "./FancyFieldLabel.svelte" import FancyFieldLabel from "./FancyFieldLabel.svelte"
import StatusLight from "../StatusLight/StatusLight.svelte"
import Picker from "../Form/Core/Picker.svelte"
export let label export let label
export let value export let value
@ -11,18 +12,30 @@
export let error = null export let error = null
export let validate = null export let validate = null
export let options = [] export let options = []
export let isOptionEnabled = () => true
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
export let getOptionSubtitle = option => extractProperty(option, "subtitle")
export let getOptionColour = () => null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let open = false let open = false
let popover
let wrapper let wrapper
$: placeholder = !value $: placeholder = !value
$: selectedLabel = getSelectedLabel(value) $: selectedLabel = getSelectedLabel(value)
$: fieldColour = getFieldAttribute(getOptionColour, value, options)
const getFieldAttribute = (getAttribute, value, options) => {
// Wait for options to load if there is a value but no options
if (!options?.length) {
return ""
}
const index = options.findIndex(
(option, idx) => getOptionValue(option, idx) === value
)
return index !== -1 ? getAttribute(options[index], index) : null
}
const extractProperty = (value, property) => { const extractProperty = (value, property) => {
if (value && typeof value === "object") { if (value && typeof value === "object") {
return value[property] return value[property]
@ -64,46 +77,45 @@
<FancyFieldLabel {placeholder}>{label}</FancyFieldLabel> <FancyFieldLabel {placeholder}>{label}</FancyFieldLabel>
{/if} {/if}
{#if fieldColour}
<span class="align">
<StatusLight square color={fieldColour} />
</span>
{/if}
<div class="value" class:placeholder> <div class="value" class:placeholder>
{selectedLabel || ""} {selectedLabel || ""}
</div> </div>
<div class="arrow"> <div class="align arrow-alignment">
<Icon name="ChevronDown" /> <Icon name="ChevronDown" />
</div> </div>
</FancyField> </FancyField>
<Popover <div id="picker-wrapper">
anchor={wrapper} <Picker
align="left" customAnchor={wrapper}
portalTarget={document.documentElement} onlyPopover={true}
bind:this={popover} bind:open
{open} {error}
on:close={() => (open = false)} {disabled}
useAnchorWidth={true} {options}
maxWidth={null} {getOptionLabel}
> {getOptionValue}
<div class="popover-content"> {getOptionSubtitle}
{#if options.length} {getOptionColour}
{#each options as option, idx} {isOptionEnabled}
<div isPlaceholder={value == null || value === ""}
class="popover-option" placeholderOption={placeholder === false ? null : placeholder}
tabindex="0" onSelectOption={onChange}
on:click={() => onChange(getOptionValue(option, idx))} isOptionSelected={option => option === value}
> />
<span class="option-text"> </div>
{getOptionLabel(option, idx)}
</span>
{#if value === getOptionValue(option, idx)}
<Icon name="Checkmark" />
{/if}
</div>
{/each}
{/if}
</div>
</Popover>
<style> <style>
#picker-wrapper :global(.spectrum-Picker) {
display: none;
}
.value { .value {
display: block; display: block;
flex: 1 1 auto; flex: 1 1 auto;
@ -118,30 +130,23 @@
width: 0; width: 0;
transform: translateY(9px); transform: translateY(9px);
} }
.align {
display: block;
font-size: 15px;
line-height: 17px;
color: var(--spectrum-global-color-gray-900);
transition: transform 130ms ease-out, opacity 130ms ease-out;
transform: translateY(9px);
}
.arrow-alignment {
transform: translateY(-2px);
}
.value.placeholder { .value.placeholder {
transform: translateY(0); transform: translateY(0);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
margin-top: 0; margin-top: 0;
} }
.popover-content {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
padding: 7px 0;
}
.popover-option {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 7px 16px;
transition: background 130ms ease-out;
font-size: 15px;
}
.popover-option:hover {
background: var(--spectrum-global-color-gray-200);
cursor: pointer;
}
</style> </style>

View File

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

View File

@ -8,6 +8,8 @@
import Icon from "../../Icon/Icon.svelte" import Icon from "../../Icon/Icon.svelte"
import StatusLight from "../../StatusLight/StatusLight.svelte" import StatusLight from "../../StatusLight/StatusLight.svelte"
import Popover from "../../Popover/Popover.svelte" import Popover from "../../Popover/Popover.svelte"
import Tags from "../../Tags/Tags.svelte"
import Tag from "../../Tags/Tag.svelte"
export let id = null export let id = null
export let disabled = false export let disabled = false
@ -26,6 +28,7 @@
export let getOptionIcon = () => null export let getOptionIcon = () => null
export let useOptionIconImage = false export let useOptionIconImage = false
export let getOptionColour = () => null export let getOptionColour = () => null
export let getOptionSubtitle = () => null
export let open = false export let open = false
export let readonly = false export let readonly = false
export let quiet = false export let quiet = false
@ -37,7 +40,7 @@
export let customPopoverHeight export let customPopoverHeight
export let align = "left" export let align = "left"
export let footer = null export let footer = null
export let customAnchor = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let searchTerm = null let searchTerm = null
@ -99,7 +102,7 @@
bind:this={button} bind:this={button}
> >
{#if fieldIcon} {#if fieldIcon}
{#if !useOptionIconImage} {#if !useOptionIconImage}x
<span class="option-extra icon"> <span class="option-extra icon">
<Icon size="S" name={fieldIcon} /> <Icon size="S" name={fieldIcon} />
</span> </span>
@ -139,9 +142,8 @@
<use xlink:href="#spectrum-css-icon-Chevron100" /> <use xlink:href="#spectrum-css-icon-Chevron100" />
</svg> </svg>
</button> </button>
<Popover <Popover
anchor={button} anchor={customAnchor ? customAnchor : button}
align={align || "left"} align={align || "left"}
bind:this={popover} bind:this={popover}
{open} {open}
@ -215,8 +217,21 @@
</span> </span>
{/if} {/if}
<span class="spectrum-Menu-itemLabel"> <span class="spectrum-Menu-itemLabel">
{#if getOptionSubtitle(option, idx)}
<span class="subtitle-text"
>{getOptionSubtitle(option, idx)}</span
>
{/if}
{getOptionLabel(option, idx)} {getOptionLabel(option, idx)}
</span> </span>
{#if option.tag}
<span class="option-tag">
<Tags>
<Tag icon="LockClosed">{option.tag}</Tag>
</Tags>
</span>
{/if}
<svg <svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
focusable="false" focusable="false"
@ -242,6 +257,17 @@
width: 100%; width: 100%;
box-shadow: none; box-shadow: none;
} }
.subtitle-text {
font-size: 12px;
line-height: 15px;
font-weight: 500;
top: 10px;
color: var(--spectrum-global-color-gray-600);
display: block;
margin-bottom: var(--spacing-s);
}
.spectrum-Picker-label.auto-width { .spectrum-Picker-label.auto-width {
margin-right: var(--spacing-xs); margin-right: var(--spacing-xs);
} }
@ -321,4 +347,12 @@
.option-extra.icon.field-icon { .option-extra.icon.field-icon {
display: flex; display: flex;
} }
.option-tag {
margin: 0 var(--spacing-m) 0 var(--spacing-m);
}
.option-tag :global(.spectrum-Tags-item > .spectrum-Icon) {
margin-top: 2px;
}
</style> </style>

View File

@ -21,7 +21,7 @@
export let sort = false export let sort = false
export let align export let align
export let footer = null export let footer = null
export let tag = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let open = false let open = false
@ -83,6 +83,7 @@
{isOptionEnabled} {isOptionEnabled}
{autocomplete} {autocomplete}
{sort} {sort}
{tag}
isPlaceholder={value == null || value === ""} isPlaceholder={value == null || value === ""}
placeholderOption={placeholder === false ? null : placeholder} placeholderOption={placeholder === false ? null : placeholder}
isOptionSelected={option => option === value} isOptionSelected={option => option === value}

View File

@ -25,7 +25,7 @@
export let customPopoverHeight export let customPopoverHeight
export let align export let align
export let footer = null export let footer = null
export let tag = null
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const onChange = e => { const onChange = e => {
value = e.detail value = e.detail
@ -61,6 +61,7 @@
{isOptionEnabled} {isOptionEnabled}
{autocomplete} {autocomplete}
{customPopoverHeight} {customPopoverHeight}
{tag}
on:change={onChange} on:change={onChange}
on:click on:click
/> />

View File

@ -9,6 +9,7 @@
export let fixed = false export let fixed = false
export let inline = false export let inline = false
export let disableCancel = false export let disableCancel = false
export let autoFocus = true
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let visible = fixed || inline let visible = fixed || inline
@ -53,6 +54,9 @@
} }
async function focusModal(node) { async function focusModal(node) {
if (!autoFocus) {
return
}
await tick() await tick()
// Try to focus first input // Try to focus first input

View File

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

View File

@ -57,10 +57,8 @@
function calculateIndicatorLength() { function calculateIndicatorLength() {
if (!vertical) { if (!vertical) {
width = $tab.info?.width + "px" width = $tab.info?.width + "px"
height = $tab.info?.height
} else { } else {
height = $tab.info?.height + 4 + "px" height = $tab.info?.height + 4 + "px"
width = $tab.info?.width
} }
} }

View File

@ -4,7 +4,7 @@
export let text = null export let text = null
export let condition = true export let condition = true
export let duration = 3000 export let duration = 5000
export let position export let position
export let type export let type

View File

@ -6,7 +6,7 @@ import {
findComponentPath, findComponentPath,
getComponentSettings, getComponentSettings,
} from "./componentUtils" } from "./componentUtils"
import { store } from "builderStore" import { store, currentAsset } from "builderStore"
import { import {
queries as queriesStores, queries as queriesStores,
tables as tablesStore, tables as tablesStore,
@ -22,6 +22,7 @@ import { TableNames } from "../constants"
import { JSONUtils } from "@budibase/frontend-core" import { JSONUtils } from "@budibase/frontend-core"
import ActionDefinitions from "components/design/settings/controls/ButtonActionEditor/manifest.json" import ActionDefinitions from "components/design/settings/controls/ButtonActionEditor/manifest.json"
import { environment, licensing } from "stores/portal" import { environment, licensing } from "stores/portal"
import { convertOldFieldFormat } from "components/design/settings/controls/FieldConfiguration/utils"
// Regex to match all instances of template strings // Regex to match all instances of template strings
const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g
@ -328,7 +329,7 @@ const getProviderContextBindings = (asset, dataProviders) => {
if (context.type === "form") { if (context.type === "form") {
// Forms do not need table schemas // Forms do not need table schemas
// Their schemas are built from their component field names // Their schemas are built from their component field names
schema = buildFormSchema(component) schema = buildFormSchema(component, asset)
readablePrefix = "Fields" readablePrefix = "Fields"
} else if (context.type === "static") { } else if (context.type === "static") {
// Static contexts are fully defined by the components // Static contexts are fully defined by the components
@ -350,12 +351,19 @@ const getProviderContextBindings = (asset, dataProviders) => {
schema = info.schema schema = info.schema
table = info.table table = info.table
// For JSON arrays, use the array name as the readable prefix. // Determine what to prefix bindings with
// Otherwise use the table name
if (datasource.type === "jsonarray") { if (datasource.type === "jsonarray") {
// For JSON arrays, use the array name as the readable prefix
const split = datasource.label.split(".") const split = datasource.label.split(".")
readablePrefix = split[split.length - 1] readablePrefix = split[split.length - 1]
} else if (datasource.type === "viewV2") {
// For views, use the view name
const view = Object.values(table?.views || {}).find(
view => view.id === datasource.id
)
readablePrefix = view?.name
} else { } else {
// Otherwise use the table name
readablePrefix = info.table?.name readablePrefix = info.table?.name
} }
} }
@ -370,6 +378,11 @@ const getProviderContextBindings = (asset, dataProviders) => {
if (runtimeSuffix) { if (runtimeSuffix) {
providerId += `-${runtimeSuffix}` providerId += `-${runtimeSuffix}`
} }
if (!filterCategoryByContext(component, context)) {
return
}
const safeComponentId = makePropSafe(providerId) const safeComponentId = makePropSafe(providerId)
// Create bindable properties for each schema field // Create bindable properties for each schema field
@ -387,6 +400,12 @@ const getProviderContextBindings = (asset, dataProviders) => {
} }
readableBinding += `.${fieldSchema.name || key}` readableBinding += `.${fieldSchema.name || key}`
const bindingCategory = getComponentBindingCategory(
component,
context,
def
)
// Create the binding object // Create the binding object
bindings.push({ bindings.push({
type: "context", type: "context",
@ -399,8 +418,8 @@ const getProviderContextBindings = (asset, dataProviders) => {
// Table ID is used by JSON fields to know what table the field is in // Table ID is used by JSON fields to know what table the field is in
tableId: table?._id, tableId: table?._id,
component: component._component, component: component._component,
category: component._instanceName, category: bindingCategory.category,
icon: def.icon, icon: bindingCategory.icon,
display: { display: {
name: fieldSchema.name || key, name: fieldSchema.name || key,
type: fieldSchema.type, type: fieldSchema.type,
@ -413,12 +432,46 @@ const getProviderContextBindings = (asset, dataProviders) => {
return bindings 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. * Gets all bindable properties from the logged in user.
*/ */
export const getUserBindings = () => { export const getUserBindings = () => {
let bindings = [] let bindings = []
const { schema } = getSchemaForTable(TableNames.USERS) const { schema } = getSchemaForDatasourcePlus(TableNames.USERS)
const keys = Object.keys(schema).sort() const keys = Object.keys(schema).sort()
const safeUser = makePropSafe("user") const safeUser = makePropSafe("user")
@ -507,6 +560,7 @@ const getSelectedRowsBindings = asset => {
)}.${makePropSafe("selectedRows")}`, )}.${makePropSafe("selectedRows")}`,
readableBinding: `${block._instanceName}.Selected rows`, readableBinding: `${block._instanceName}.Selected rows`,
category: "Selected rows", category: "Selected rows",
icon: "ViewRow",
display: { name: block._instanceName }, display: { name: block._instanceName },
})) }))
) )
@ -582,24 +636,36 @@ const getRoleBindings = () => {
} }
/** /**
* Gets all bindable properties exposed in an event action flow up until * Gets all bindable event context properties provided in the component
* the specified action ID, as well as context provided for the action * setting
* setting as a whole by the component.
*/ */
export const getEventContextBindings = ( export const getEventContextBindings = ({
asset,
componentId,
settingKey, settingKey,
actions, componentInstance,
actionId componentId,
) => { componentDefinition,
asset,
}) => {
let bindings = [] let bindings = []
const selectedAsset = asset ?? get(currentAsset)
// Check if any context bindings are provided by the component for this // Check if any context bindings are provided by the component for this
// setting // setting
const component = findComponent(asset.props, componentId) const component =
const def = store.actions.components.getDefinition(component?._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 settings = getComponentSettings(component?._component)
const eventSetting = settings.find(setting => setting.key === settingKey) const eventSetting = settings.find(setting => setting.key === settingKey)
if (eventSetting?.context?.length) { if (eventSetting?.context?.length) {
eventSetting.context.forEach(contextEntry => { eventSetting.context.forEach(contextEntry => {
bindings.push({ bindings.push({
@ -608,14 +674,23 @@ export const getEventContextBindings = (
contextEntry.key contextEntry.key
)}`, )}`,
category: component._instanceName, category: component._instanceName,
icon: def.icon, icon: definition.icon,
display: { display: {
name: contextEntry.label, 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 // Get the steps leading up to this value
const index = actions?.findIndex(action => action.id === actionId) const index = actions?.findIndex(action => action.id === actionId)
if (index == null || index === -1) { if (index == null || index === -1) {
@ -642,22 +717,29 @@ export const getEventContextBindings = (
}) })
} }
}) })
return bindings return bindings
} }
/** /**
* Gets the schema for a certain table ID. * Gets the schema for a certain datasource plus.
* The options which can be passed in are: * The options which can be passed in are:
* formSchema: whether the schema is for a form * formSchema: whether the schema is for a form
* searchableSchema: whether to generate a searchable schema, which may have * searchableSchema: whether to generate a searchable schema, which may have
* fewer fields than a readable schema * fewer fields than a readable schema
* @param tableId the table ID to get the schema for * @param resourceId the DS+ resource ID
* @param options options for generating the schema * @param options options for generating the schema
* @return {{schema: Object, table: Object}} * @return {{schema: Object, table: Object}}
*/ */
export const getSchemaForTable = (tableId, options) => { export const getSchemaForDatasourcePlus = (resourceId, options) => {
return getSchemaForDatasource(null, { type: "table", tableId }, options) const isViewV2 = resourceId?.includes("view_")
const datasource = isViewV2
? {
type: "viewV2",
id: resourceId,
tableId: resourceId.split("_").slice(1, 3).join("_"),
}
: { type: "table", tableId: resourceId }
return getSchemaForDatasource(null, datasource, options)
} }
/** /**
@ -734,9 +816,21 @@ export const getSchemaForDatasource = (asset, datasource, options) => {
// Determine the schema from the backing entity if not already determined // Determine the schema from the backing entity if not already determined
if (table && !schema) { if (table && !schema) {
if (type === "view") { if (type === "view") {
// For views, the schema is pulled from the `views` property of the // Old views
// table
schema = cloneDeep(table.views?.[datasource.name]?.schema) schema = cloneDeep(table.views?.[datasource.name]?.schema)
} else if (type === "viewV2") {
// New views which are DS+
const view = Object.values(table.views || {}).find(
view => view.id === datasource.id
)
schema = cloneDeep(view?.schema)
// Strip hidden fields
Object.keys(schema || {}).forEach(field => {
if (!schema[field].visible) {
delete schema[field]
}
})
} else if ( } else if (
type === "query" && type === "query" &&
(options.formSchema || options.searchableSchema) (options.formSchema || options.searchableSchema)
@ -782,12 +876,12 @@ export const getSchemaForDatasource = (asset, datasource, options) => {
// Determine if we should add ID and rev to the schema // Determine if we should add ID and rev to the schema
const isInternal = table && !table.sql const isInternal = table && !table.sql
const isTable = ["table", "link"].includes(datasource.type) const isDSPlus = ["table", "link", "viewV2"].includes(datasource.type)
// ID is part of the readable schema for all tables // ID is part of the readable schema for all tables
// Rev is part of the readable schema for internal tables only // Rev is part of the readable schema for internal tables only
let addId = isTable let addId = isDSPlus
let addRev = isTable && isInternal let addRev = isDSPlus && isInternal
// Don't add ID or rev for form schemas // Don't add ID or rev for form schemas
if (options.formSchema) { if (options.formSchema) {
@ -797,7 +891,7 @@ export const getSchemaForDatasource = (asset, datasource, options) => {
// ID is only searchable for internal tables // ID is only searchable for internal tables
else if (options.searchableSchema) { else if (options.searchableSchema) {
addId = isTable && isInternal addId = isDSPlus && isInternal
} }
// Add schema properties if required // Add schema properties if required
@ -835,18 +929,36 @@ export const getSchemaForDatasource = (asset, datasource, options) => {
* Builds a form schema given a form component. * Builds a form schema given a form component.
* A form schema is a schema of all the fields nested anywhere within a form. * 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 = {} let schema = {}
if (!component) { if (!component) {
return schema return schema
} }
// If this is a form block, simply use the fields setting
if (component._component.endsWith("formblock")) { if (component._component.endsWith("formblock")) {
let schema = {} 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 return schema
} }
@ -862,7 +974,7 @@ export const buildFormSchema = component => {
} }
} }
component._children?.forEach(child => { component._children?.forEach(child => {
const childSchema = buildFormSchema(child) const childSchema = buildFormSchema(child, asset)
schema = { ...schema, ...childSchema } schema = { ...schema, ...childSchema }
}) })
return schema return schema

View File

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

View File

@ -1,10 +1,10 @@
import rowListScreen from "./rowListScreen" import rowListScreen from "./rowListScreen"
import createFromScratchScreen from "./createFromScratchScreen" import createFromScratchScreen from "./createFromScratchScreen"
const allTemplates = tables => [...rowListScreen(tables)] const allTemplates = datasources => [...rowListScreen(datasources)]
// Allows us to apply common behaviour to all create() functions // Allows us to apply common behaviour to all create() functions
const createTemplateOverride = (frontendState, template) => () => { const createTemplateOverride = template => () => {
const screen = template.create() const screen = template.create()
screen.name = screen.props._id screen.name = screen.props._id
screen.routing.route = screen.routing.route.toLowerCase() screen.routing.route = screen.routing.route.toLowerCase()
@ -12,14 +12,13 @@ const createTemplateOverride = (frontendState, template) => () => {
return screen return screen
} }
export default (frontendState, tables) => { export default datasources => {
const enrichTemplate = template => ({ const enrichTemplate = template => ({
...template, ...template,
create: createTemplateOverride(frontendState, template), create: createTemplateOverride(template),
}) })
const fromScratch = enrichTemplate(createFromScratchScreen) const fromScratch = enrichTemplate(createFromScratchScreen)
const tableTemplates = allTemplates(tables).map(enrichTemplate) const tableTemplates = allTemplates(datasources).map(enrichTemplate)
return [ return [
fromScratch, fromScratch,
...tableTemplates.sort((templateA, templateB) => { ...tableTemplates.sort((templateA, templateB) => {

View File

@ -2,31 +2,29 @@ import sanitizeUrl from "./utils/sanitizeUrl"
import { Screen } from "./utils/Screen" import { Screen } from "./utils/Screen"
import { Component } from "./utils/Component" import { Component } from "./utils/Component"
export default function (tables) { export default function (datasources) {
return tables.map(table => { if (!Array.isArray(datasources)) {
return []
}
return datasources.map(datasource => {
return { return {
name: `${table.name} - List`, name: `${datasource.name} - List`,
create: () => createScreen(table), create: () => createScreen(datasource),
id: ROW_LIST_TEMPLATE, id: ROW_LIST_TEMPLATE,
table: table._id, resourceId: datasource.resourceId,
} }
}) })
} }
export const ROW_LIST_TEMPLATE = "ROW_LIST_TEMPLATE" export const ROW_LIST_TEMPLATE = "ROW_LIST_TEMPLATE"
export const rowListUrl = table => sanitizeUrl(`/${table.name}`) export const rowListUrl = datasource => sanitizeUrl(`/${datasource.name}`)
const generateTableBlock = table => { const generateTableBlock = datasource => {
const tableBlock = new Component("@budibase/standard-components/tableblock") const tableBlock = new Component("@budibase/standard-components/tableblock")
tableBlock tableBlock
.customProps({ .customProps({
title: table.name, title: datasource.name,
dataSource: { dataSource: datasource,
label: table.name,
name: table._id,
tableId: table._id,
type: "table",
},
sortOrder: "Ascending", sortOrder: "Ascending",
size: "spectrum--medium", size: "spectrum--medium",
paginate: true, paginate: true,
@ -36,14 +34,14 @@ const generateTableBlock = table => {
titleButtonText: "Create row", titleButtonText: "Create row",
titleButtonClickBehaviour: "new", titleButtonClickBehaviour: "new",
}) })
.instanceName(`${table.name} - Table block`) .instanceName(`${datasource.name} - Table block`)
return tableBlock return tableBlock
} }
const createScreen = table => { const createScreen = datasource => {
return new Screen() return new Screen()
.route(rowListUrl(table)) .route(rowListUrl(datasource))
.instanceName(`${table.name} - List`) .instanceName(`${datasource.name} - List`)
.addChild(generateTableBlock(table)) .addChild(generateTableBlock(datasource))
.json() .json()
} }

View File

@ -73,7 +73,7 @@
if (!perms["execute"]) { if (!perms["execute"]) {
role = "BASIC" role = "BASIC"
} else { } else {
role = perms["execute"] role = perms["execute"].role
} }
} }

View File

@ -39,7 +39,7 @@
import FilterDrawer from "components/design/settings/controls/FilterEditor/FilterDrawer.svelte" import FilterDrawer from "components/design/settings/controls/FilterEditor/FilterDrawer.svelte"
import { LuceneUtils } from "@budibase/frontend-core" import { LuceneUtils } from "@budibase/frontend-core"
import { import {
getSchemaForTable, getSchemaForDatasourcePlus,
getEnvironmentBindings, getEnvironmentBindings,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
@ -67,7 +67,9 @@
$: table = tableId $: table = tableId
? $tables.list.find(table => table._id === inputData.tableId) ? $tables.list.find(table => table._id === inputData.tableId)
: { schema: {} } : { schema: {} }
$: schema = getSchemaForTable(tableId, { searchableSchema: true }).schema $: schema = getSchemaForDatasourcePlus(tableId, {
searchableSchema: true,
}).schema
$: schemaFields = Object.values(schema || {}) $: schemaFields = Object.values(schema || {})
$: queryLimit = tableId?.includes("datasource") ? "∞" : "1000" $: queryLimit = tableId?.includes("datasource") ? "∞" : "1000"
$: isTrigger = block?.type === "TRIGGER" $: isTrigger = block?.type === "TRIGGER"
@ -158,7 +160,7 @@
// instead fetch the schema in the backend at runtime. // instead fetch the schema in the backend at runtime.
let schema let schema
if (e.detail?.tableId) { if (e.detail?.tableId) {
schema = getSchemaForTable(e.detail.tableId, { schema = getSchemaForDatasourcePlus(e.detail.tableId, {
searchableSchema: true, searchableSchema: true,
}).schema }).schema
} }

View File

@ -26,12 +26,14 @@
$: id = $tables.selected?._id $: id = $tables.selected?._id
$: isUsersTable = id === TableNames.USERS $: isUsersTable = id === TableNames.USERS
$: isInternal = $tables.selected?.type !== "external" $: isInternal = $tables.selected?.type !== "external"
$: gridDatasource = {
$: datasource = $datasources.list.find(datasource => { type: "table",
tableId: id,
}
$: tableDatasource = $datasources.list.find(datasource => {
return datasource._id === $tables.selected?.sourceId return datasource._id === $tables.selected?.sourceId
}) })
$: relationshipsEnabled = relationshipSupport(tableDatasource)
$: relationshipsEnabled = relationshipSupport(datasource)
const relationshipSupport = datasource => { const relationshipSupport = datasource => {
const integration = $integrations[datasource?.source] const integration = $integrations[datasource?.source]
@ -54,12 +56,12 @@
<div class="wrapper"> <div class="wrapper">
<Grid <Grid
{API} {API}
tableId={id} datasource={gridDatasource}
allowAddRows={!isUsersTable} canAddRows={!isUsersTable}
allowDeleteRows={!isUsersTable} canDeleteRows={!isUsersTable}
schemaOverrides={isUsersTable ? userSchemaOverrides : null} schemaOverrides={isUsersTable ? userSchemaOverrides : null}
showAvatars={false} showAvatars={false}
on:updatetable={handleGridTableUpdate} on:updatedatasource={handleGridTableUpdate}
> >
<svelte:fragment slot="filter"> <svelte:fragment slot="filter">
<GridFilterButton /> <GridFilterButton />
@ -72,9 +74,7 @@
</svelte:fragment> </svelte:fragment>
<svelte:fragment slot="controls"> <svelte:fragment slot="controls">
{#if isInternal} <GridCreateViewButton />
<GridCreateViewButton />
{/if}
<GridManageAccessButton /> <GridManageAccessButton />
{#if relationshipsEnabled} {#if relationshipsEnabled}
<GridRelationshipButton /> <GridRelationshipButton />

View File

@ -0,0 +1,49 @@
<script>
import { viewsV2 } from "stores/backend"
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"
$: id = $viewsV2.selected?.id
$: datasource = {
type: "viewV2",
id,
tableId: $viewsV2.selected?.tableId,
}
const handleGridViewUpdate = async e => {
viewsV2.replaceView(id, e.detail)
}
</script>
<div class="wrapper">
<Grid
{API}
{datasource}
allowAddRows
allowDeleteRows
showAvatars={false}
on:updatedatasource={handleGridViewUpdate}
>
<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

@ -9,19 +9,15 @@
let modal let modal
let resourcePermissions let resourcePermissions
async function openDropdown() { async function openModal() {
resourcePermissions = await permissions.forResource(resourceId) resourcePermissions = await permissions.forResourceDetailed(resourceId)
modal.show() modal.show()
} }
</script> </script>
<ActionButton icon="LockClosed" quiet on:click={openDropdown} {disabled}> <ActionButton icon="LockClosed" quiet on:click={openModal} {disabled}>
Access Access
</ActionButton> </ActionButton>
<Modal bind:this={modal}> <Modal bind:this={modal}>
<ManageAccessModal <ManageAccessModal {resourceId} permissions={resourcePermissions} />
{resourceId}
levels={$permissions}
permissions={resourcePermissions}
/>
</Modal> </Modal>

View File

@ -15,6 +15,7 @@
$: tempValue = filters || [] $: tempValue = filters || []
$: schemaFields = Object.values(schema || {}) $: schemaFields = Object.values(schema || {})
$: text = getText(filters) $: text = getText(filters)
$: selected = tempValue.filter(x => !x.onEmptyFilter)?.length > 0
const getText = filters => { const getText = filters => {
const count = filters?.filter(filter => filter.field)?.length const count = filters?.filter(filter => filter.field)?.length
@ -22,13 +23,7 @@
} }
</script> </script>
<ActionButton <ActionButton icon="Filter" quiet {disabled} on:click={modal.show} {selected}>
icon="Filter"
quiet
{disabled}
on:click={modal.show}
selected={tempValue?.length > 0}
>
{text} {text}
</ActionButton> </ActionButton>
<Modal bind:this={modal}> <Modal bind:this={modal}>

View File

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

View File

@ -2,7 +2,7 @@
import ExportButton from "../ExportButton.svelte" import ExportButton from "../ExportButton.svelte"
import { getContext } from "svelte" import { getContext } from "svelte"
const { rows, columns, tableId, sort, selectedRows, filter } = const { rows, columns, datasource, sort, selectedRows, filter } =
getContext("grid") getContext("grid")
$: disabled = !$rows.length || !$columns.length $: disabled = !$rows.length || !$columns.length
@ -12,7 +12,7 @@
<span data-ignore-click-outside="true"> <span data-ignore-click-outside="true">
<ExportButton <ExportButton
{disabled} {disabled}
view={$tableId} view={$datasource.tableId}
filters={$filter} filters={$filter}
sorting={{ sorting={{
sortColumn: $sort.column, sortColumn: $sort.column,

View File

@ -2,22 +2,19 @@
import TableFilterButton from "../TableFilterButton.svelte" import TableFilterButton from "../TableFilterButton.svelte"
import { getContext } from "svelte" import { getContext } from "svelte"
const { columns, tableId, filter, table } = getContext("grid") const { columns, datasource, filter, definition } = getContext("grid")
// Wipe filter whenever table ID changes to avoid using stale filters
$: $tableId, filter.set([])
const onFilter = e => { const onFilter = e => {
filter.set(e.detail || []) filter.set(e.detail || [])
} }
</script> </script>
{#key $tableId} {#key $datasource}
<TableFilterButton <TableFilterButton
schema={$table?.schema} schema={$definition?.schema}
filters={$filter} filters={$filter}
on:change={onFilter} on:change={onFilter}
disabled={!$columns.length} disabled={!$columns.length}
tableId={$tableId} tableId={$datasource.tableId}
/> />
{/key} {/key}

View File

@ -4,12 +4,12 @@
export let disabled = false export let disabled = false
const { rows, tableId, table } = getContext("grid") const { rows, datasource, definition } = getContext("grid")
</script> </script>
<ImportButton <ImportButton
{disabled} {disabled}
tableId={$tableId} tableId={$datasource?.tableId}
tableType={$table?.type} tableType={$definition?.type}
on:importrows={rows.actions.refreshData} on:importrows={rows.actions.refreshData}
/> />

View File

@ -2,7 +2,16 @@
import ManageAccessButton from "../ManageAccessButton.svelte" import ManageAccessButton from "../ManageAccessButton.svelte"
import { getContext } from "svelte" import { getContext } from "svelte"
const { tableId } = getContext("grid") const { datasource } = getContext("grid")
$: resourceId = getResourceID($datasource)
const getResourceID = datasource => {
if (!datasource) {
return null
}
return datasource.type === "table" ? datasource.tableId : datasource.id
}
</script> </script>
<ManageAccessButton resourceId={$tableId} /> <ManageAccessButton {resourceId} />

View File

@ -2,12 +2,12 @@
import ExistingRelationshipButton from "../ExistingRelationshipButton.svelte" import ExistingRelationshipButton from "../ExistingRelationshipButton.svelte"
import { getContext } from "svelte" import { getContext } from "svelte"
const { table, rows } = getContext("grid") const { definition, rows } = getContext("grid")
</script> </script>
{#if $table} {#if $definition}
<ExistingRelationshipButton <ExistingRelationshipButton
table={$table} table={$definition}
on:updatecolumns={() => rows.actions.refreshData()} on:updatecolumns={() => rows.actions.refreshData()}
/> />
{/if} {/if}

View File

@ -1,38 +0,0 @@
<script>
import { Input, notifications, ModalContent } from "@budibase/bbui"
import { goto } from "@roxi/routify"
import { views as viewsStore } from "stores/backend"
import { tables } from "stores/backend"
let name
let field
$: views = $tables.list.flatMap(table => Object.keys(table.views || {}))
const saveView = async () => {
name = name?.trim()
if (views.includes(name)) {
notifications.error(`View exists with name ${name}`)
return
}
try {
await viewsStore.save({
name,
tableId: $tables.selected._id,
field,
})
notifications.success(`View ${name} created`)
$goto(`../../view/${encodeURIComponent(name)}`)
} catch (error) {
notifications.error("Error creating view")
}
}
</script>
<ModalContent
title="Create View"
confirmText="Create View"
onConfirm={saveView}
>
<Input label="View Name" thin bind:value={name} />
</ModalContent>

View File

@ -1,4 +1,5 @@
<script> <script>
import { PermissionSource } from "@budibase/types"
import { roles, permissions as permissionsStore } from "stores/backend" import { roles, permissions as permissionsStore } from "stores/backend"
import { import {
Label, Label,
@ -7,45 +8,130 @@
notifications, notifications,
Body, Body,
ModalContent, ModalContent,
Tags,
Tag,
Icon,
} from "@budibase/bbui" } from "@budibase/bbui"
import { capitalise } from "helpers" import { capitalise } from "helpers"
import { get } from "svelte/store"
export let resourceId export let resourceId
export let permissions export let permissions
const inheritedRoleId = "inherited"
async function changePermission(level, role) { async function changePermission(level, role) {
try { try {
await permissionsStore.save({ if (role === inheritedRoleId) {
level, await permissionsStore.remove({
role, level,
resource: resourceId, role,
}) resource: resourceId,
})
} else {
await permissionsStore.save({
level,
role,
resource: resourceId,
})
}
// Show updated permissions in UI: REMOVE // Show updated permissions in UI: REMOVE
permissions = await permissionsStore.forResource(resourceId) permissions = await permissionsStore.forResourceDetailed(resourceId)
notifications.success("Updated permissions") notifications.success("Updated permissions")
} catch (error) { } catch (error) {
notifications.error("Error updating permissions") 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> </script>
<ModalContent title="Manage Access" showCancelButton={false} confirmText="Done"> <ModalContent showCancelButton={false} confirmText="Done">
<span slot="header">
Manage Access
{#if requiresPlanToModify}
<span class="lock-tag">
<Tags>
<Tag icon="LockClosed">{capitalise(requiresPlanToModify)}</Tag>
</Tags>
</span>
{/if}
</span>
<Body size="S">Specify the minimum access level role for this data.</Body> <Body size="S">Specify the minimum access level role for this data.</Body>
<div class="row"> <div class="row">
<Label extraSmall grey>Level</Label> <Label extraSmall grey>Level</Label>
<Label extraSmall grey>Role</Label> <Label extraSmall grey>Role</Label>
{#each Object.keys(permissions) as level} {#each Object.keys(computedPermissions) as level}
<Input value={capitalise(level)} disabled /> <Input value={capitalise(level)} disabled />
<Select <Select
value={permissions[level]} disabled={requiresPlanToModify}
placeholder={false}
value={computedPermissions[level].selectedValue}
on:change={e => changePermission(level, e.detail)} on:change={e => changePermission(level, e.detail)}
options={$roles} options={computedPermissions[level].options}
getOptionLabel={x => x.name} getOptionLabel={x => x.name}
getOptionValue={x => x._id} getOptionValue={x => x._id}
/> />
{/each} {/each}
</div> </div>
{#if dependantsInfoMessage}
<div class="inheriting-resources">
<Icon name="Alert" />
<Body size="S">
<i>
{dependantsInfoMessage}
</i>
</Body>
</div>
{/if}
</ModalContent> </ModalContent>
<style> <style>
@ -54,4 +140,13 @@
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-gap: var(--spacing-s); grid-gap: var(--spacing-s);
} }
.lock-tag {
padding-left: var(--spacing-s);
}
.inheriting-resources {
display: flex;
gap: var(--spacing-s);
}
</style> </style>

View File

@ -2,7 +2,7 @@
import { getContext } from "svelte" import { getContext } from "svelte"
import CreateEditColumn from "components/backend/DataTable/modals/CreateEditColumn.svelte" import CreateEditColumn from "components/backend/DataTable/modals/CreateEditColumn.svelte"
const { rows } = getContext("grid") const { datasource } = getContext("grid")
</script> </script>
<CreateEditColumn on:updatecolumns={rows.actions.refreshTableDefinition} /> <CreateEditColumn on:updatecolumns={datasource.actions.refreshDefinition} />

View File

@ -0,0 +1,60 @@
<script>
import { getContext } from "svelte"
import { Input, notifications, ModalContent } from "@budibase/bbui"
import { goto } from "@roxi/routify"
import { viewsV2 } from "stores/backend"
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

@ -1,7 +1,14 @@
<script> <script>
import { goto, isActive, params } from "@roxi/routify" import { goto, isActive, params } from "@roxi/routify"
import { BUDIBASE_INTERNAL_DB_ID } from "constants/backend" import { BUDIBASE_INTERNAL_DB_ID } from "constants/backend"
import { database, datasources, queries, tables, views } from "stores/backend" import {
database,
datasources,
queries,
tables,
views,
viewsV2,
} from "stores/backend"
import EditDatasourcePopover from "./popovers/EditDatasourcePopover.svelte" import EditDatasourcePopover from "./popovers/EditDatasourcePopover.svelte"
import EditQueryPopover from "./popovers/EditQueryPopover.svelte" import EditQueryPopover from "./popovers/EditQueryPopover.svelte"
import NavItem from "components/common/NavItem.svelte" import NavItem from "components/common/NavItem.svelte"
@ -24,6 +31,7 @@
$tables, $tables,
$queries, $queries,
$views, $views,
$viewsV2,
openDataSources openDataSources
) )
$: openDataSource = enrichedDataSources.find(x => x.open) $: openDataSource = enrichedDataSources.find(x => x.open)
@ -41,6 +49,7 @@
tables, tables,
queries, queries,
views, views,
viewsV2,
openDataSources openDataSources
) => { ) => {
if (!datasources?.list?.length) { if (!datasources?.list?.length) {
@ -57,7 +66,8 @@
isActive, isActive,
tables, tables,
queries, queries,
views views,
viewsV2
) )
const onlySource = datasources.list.length === 1 const onlySource = datasources.list.length === 1
return { return {
@ -106,7 +116,8 @@
isActive, isActive,
tables, tables,
queries, queries,
views views,
viewsV2
) => { ) => {
// Check for being on a datasource page // Check for being on a datasource page
if (params.datasourceId === datasource._id) { if (params.datasourceId === datasource._id) {
@ -152,10 +163,16 @@
// Check for a matching view // Check for a matching view
const selectedView = views.selected?.name const selectedView = views.selected?.name
const table = options.find(table => { const viewTable = options.find(table => {
return table.views?.[selectedView] != null return table.views?.[selectedView] != null
}) })
return table != null if (viewTable) {
return true
}
// Check for a matching viewV2
const viewV2Table = options.find(x => x._id === viewsV2.selected?.tableId)
return viewV2Table != null
} }
</script> </script>

View File

@ -1,5 +1,5 @@
<script> <script>
import { tables, views, database } from "stores/backend" import { tables, views, viewsV2, database } from "stores/backend"
import { TableNames } from "constants" import { TableNames } from "constants"
import EditTablePopover from "./popovers/EditTablePopover.svelte" import EditTablePopover from "./popovers/EditTablePopover.svelte"
import EditViewPopover from "./popovers/EditViewPopover.svelte" import EditViewPopover from "./popovers/EditViewPopover.svelte"
@ -7,9 +7,6 @@
import { goto, isActive } from "@roxi/routify" import { goto, isActive } from "@roxi/routify"
import { userSelectedResourceMap } from "builderStore" import { userSelectedResourceMap } from "builderStore"
const alphabetical = (a, b) =>
a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
export let sourceId export let sourceId
export let selectTable export let selectTable
@ -18,6 +15,17 @@
table => table.sourceId === sourceId && table._id !== TableNames.USERS table => table.sourceId === sourceId && table._id !== TableNames.USERS
) )
.sort(alphabetical) .sort(alphabetical)
const alphabetical = (a, b) => {
return a.name?.toLowerCase() > b.name?.toLowerCase() ? 1 : -1
}
const isViewActive = (view, isActive, views, viewsV2) => {
return (
(isActive("./view/v1") && views.selected?.name === view.name) ||
(isActive("./view/v2") && viewsV2.selected?.id === view.id)
)
}
</script> </script>
{#if $database?._id} {#if $database?._id}
@ -37,18 +45,23 @@
<EditTablePopover {table} /> <EditTablePopover {table} />
{/if} {/if}
</NavItem> </NavItem>
{#each [...Object.keys(table.views || {})].sort() as viewName, idx (idx)} {#each [...Object.entries(table.views || {})].sort() as [name, view], idx (idx)}
<NavItem <NavItem
indentLevel={2} indentLevel={2}
icon="Remove" icon="Remove"
text={viewName} text={name}
selected={$isActive("./view") && $views.selected?.name === viewName} selected={isViewActive(view, $isActive, $views, $viewsV2)}
on:click={() => $goto(`./view/${encodeURIComponent(viewName)}`)} on:click={() => {
selectedBy={$userSelectedResourceMap[viewName]} if (view.version === 2) {
$goto(`./view/v2/${view.id}`)
} else {
$goto(`./view/v1/${encodeURIComponent(name)}`)
}
}}
selectedBy={$userSelectedResourceMap[name] ||
$userSelectedResourceMap[view.id]}
> >
<EditViewPopover <EditViewPopover {view} />
view={{ name: viewName, ...table.views[viewName] }}
/>
</NavItem> </NavItem>
{/each} {/each}
{/each} {/each}

View File

@ -35,7 +35,7 @@
screen => screen.autoTableId === table._id screen => screen.autoTableId === table._id
) )
willBeDeleted = ["All table data"].concat( willBeDeleted = ["All table data"].concat(
templateScreens.map(screen => `Screen ${screen.props._instanceName}`) templateScreens.map(screen => `Screen ${screen.routing?.route || ""}`)
) )
confirmDeleteDialog.show() confirmDeleteDialog.show()
} }
@ -44,7 +44,10 @@
const isSelected = $params.tableId === table._id const isSelected = $params.tableId === table._id
try { try {
await tables.delete(table) await tables.delete(table)
await store.actions.screens.delete(templateScreens) // Screens need deleted one at a time because of undo/redo
for (let screen of templateScreens) {
await store.actions.screens.delete(screen)
}
if (table.type === "external") { if (table.type === "external") {
await datasources.fetch() await datasources.fetch()
} }

View File

@ -1,6 +1,5 @@
<script> <script>
import { goto, params } from "@roxi/routify" import { views, viewsV2 } from "stores/backend"
import { views } from "stores/backend"
import { cloneDeep } from "lodash/fp" import { cloneDeep } from "lodash/fp"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { import {
@ -24,23 +23,29 @@
const updatedView = cloneDeep(view) const updatedView = cloneDeep(view)
updatedView.name = updatedName updatedView.name = updatedName
await views.save({ if (view.version === 2) {
originalName, await viewsV2.save({
...updatedView, originalName,
}) ...updatedView,
})
} else {
await views.save({
originalName,
...updatedView,
})
}
notifications.success("View renamed successfully") notifications.success("View renamed successfully")
} }
async function deleteView() { async function deleteView() {
try { try {
const isSelected = if (view.version === 2) {
decodeURIComponent($params.viewName) === $views.selectedViewName await viewsV2.delete(view)
const id = view.tableId } else {
await views.delete(view) await views.delete(view)
notifications.success("View deleted")
if (isSelected) {
$goto(`./table/${id}`)
} }
notifications.success("View deleted")
} catch (error) { } catch (error) {
notifications.error("Error deleting view") notifications.error("Error deleting view")
} }

View File

@ -109,7 +109,13 @@
type: "View", type: "View",
name: view.name, name: view.name,
icon: "Remove", icon: "Remove",
action: () => $goto(`./data/view/${view.name}`), action: () => {
if (view.version === 2) {
$goto(`./data/view/v2/${view.id}`)
} else {
$goto(`./data/view/${view.name}`)
}
},
})) ?? []), })) ?? []),
...($queries?.list?.map(query => ({ ...($queries?.list?.map(query => ({
type: "Query", type: "Query",

View File

@ -1,8 +1,11 @@
<script> <script>
import { Select } from "@budibase/bbui" import { Select, FancySelect } from "@budibase/bbui"
import { roles } from "stores/backend" import { roles } from "stores/backend"
import { licensing } from "stores/portal"
import { Constants, RoleUtils } from "@budibase/frontend-core" import { Constants, RoleUtils } from "@budibase/frontend-core"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
import { capitalise } from "helpers"
export let value export let value
export let error export let error
@ -15,17 +18,43 @@
export let align export let align
export let footer = null export let footer = null
export let allowedRoles = null export let allowedRoles = null
export let allowCreator = false
export let fancySelect = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
const RemoveID = "remove" const RemoveID = "remove"
$: options = getOptions($roles, allowPublic, allowRemove, allowedRoles) $: options = getOptions(
$roles,
const getOptions = (roles, allowPublic, allowRemove, allowedRoles) => { allowPublic,
allowRemove,
allowedRoles,
allowCreator
)
const getOptions = (
roles,
allowPublic,
allowRemove,
allowedRoles,
allowCreator
) => {
if (allowedRoles?.length) { if (allowedRoles?.length) {
return roles.filter(role => allowedRoles.includes(role._id)) return roles.filter(role => allowedRoles.includes(role._id))
} }
let newRoles = [...roles] let newRoles = [...roles]
if (allowCreator) {
newRoles = [
{
_id: Constants.Roles.CREATOR,
name: "Creator",
tag:
!$licensing.perAppBuildersEnabled &&
capitalise(Constants.PlanType.BUSINESS),
},
...newRoles,
]
}
if (allowRemove) { if (allowRemove) {
newRoles = [ newRoles = [
...newRoles, ...newRoles,
@ -64,19 +93,45 @@
} }
</script> </script>
<Select {#if fancySelect}
{autoWidth} <FancySelect
{quiet} {autoWidth}
{disabled} {quiet}
{align} {disabled}
{footer} {align}
bind:value {footer}
on:change={onChange} bind:value
{options} on:change={onChange}
getOptionLabel={role => role.name} {options}
getOptionValue={role => role._id} label="Access on this app"
getOptionColour={getColor} getOptionLabel={role => role.name}
getOptionIcon={getIcon} getOptionValue={role => role._id}
{placeholder} getOptionColour={getColor}
{error} getOptionIcon={getIcon}
/> isOptionEnabled={option =>
option._id !== Constants.Roles.CREATOR ||
$licensing.perAppBuildersEnabled}
{placeholder}
{error}
/>
{:else}
<Select
{autoWidth}
{quiet}
{disabled}
{align}
{footer}
bind:value
on:change={onChange}
{options}
getOptionLabel={role => role.name}
getOptionValue={role => role._id}
getOptionColour={getColor}
getOptionIcon={getIcon}
isOptionEnabled={option =>
option._id !== Constants.Roles.CREATOR ||
$licensing.perAppBuildersEnabled}
{placeholder}
{error}
/>
{/if}

View File

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

View File

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

View File

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

View File

@ -1,12 +1,20 @@
<script> <script>
import { Select, Label, Checkbox, Input, Body } from "@budibase/bbui" import { Select, Label, Checkbox, Input, Body } from "@budibase/bbui"
import { tables } from "stores/backend" import { tables, viewsV2 } from "stores/backend"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let parameters export let parameters
export let bindings = [] export let bindings = []
$: tableOptions = $tables.list || [] $: tableOptions = $tables.list.map(table => ({
label: table.name,
resourceId: table._id,
}))
$: viewOptions = $viewsV2.list.map(view => ({
label: view.name,
resourceId: view.id,
}))
$: options = [...(tableOptions || []), ...(viewOptions || [])]
</script> </script>
<div class="root"> <div class="root">
@ -15,9 +23,9 @@
<Label>Table</Label> <Label>Table</Label>
<Select <Select
bind:value={parameters.tableId} bind:value={parameters.tableId}
options={tableOptions} {options}
getOptionLabel={table => table.name} getOptionLabel={x => x.label}
getOptionValue={table => table._id} getOptionValue={x => x.resourceId}
/> />
<Label small>Row IDs</Label> <Label small>Row IDs</Label>

View File

@ -1,10 +1,10 @@
<script> <script>
import { Select, Label, Body, Checkbox, Input } from "@budibase/bbui" import { Select, Label, Body, Checkbox, Input } from "@budibase/bbui"
import { store, currentAsset } from "builderStore" import { store, currentAsset } from "builderStore"
import { tables } from "stores/backend" import { tables, viewsV2 } from "stores/backend"
import { import {
getContextProviderComponents, getContextProviderComponents,
getSchemaForTable, getSchemaForDatasourcePlus,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import SaveFields from "./SaveFields.svelte" import SaveFields from "./SaveFields.svelte"
@ -23,7 +23,15 @@
) )
$: providerOptions = getProviderOptions(formComponents, schemaComponents) $: providerOptions = getProviderOptions(formComponents, schemaComponents)
$: schemaFields = getSchemaFields($currentAsset, parameters?.tableId) $: schemaFields = getSchemaFields($currentAsset, parameters?.tableId)
$: tableOptions = $tables.list || [] $: tableOptions = $tables.list.map(table => ({
label: table.name,
resourceId: table._id,
}))
$: viewOptions = $viewsV2.list.map(view => ({
label: view.name,
resourceId: view.id,
}))
$: options = [...(tableOptions || []), ...(viewOptions || [])]
// Gets a context definition of a certain type from a component definition // Gets a context definition of a certain type from a component definition
const extractComponentContext = (component, contextType) => { const extractComponentContext = (component, contextType) => {
@ -60,7 +68,7 @@
} }
const getSchemaFields = (asset, tableId) => { const getSchemaFields = (asset, tableId) => {
const { schema } = getSchemaForTable(tableId) const { schema } = getSchemaForDatasourcePlus(tableId)
delete schema._id delete schema._id
delete schema._rev delete schema._rev
return Object.values(schema || {}) return Object.values(schema || {})
@ -89,9 +97,9 @@
<Label small>Duplicate to Table</Label> <Label small>Duplicate to Table</Label>
<Select <Select
bind:value={parameters.tableId} bind:value={parameters.tableId}
options={tableOptions} {options}
getOptionLabel={option => option.name} getOptionLabel={option => option.label}
getOptionValue={option => option._id} getOptionValue={option => option.resourceId}
/> />
<Label small /> <Label small />

View File

@ -1,21 +1,29 @@
<script> <script>
import { Select, Label } from "@budibase/bbui" import { Select, Label } from "@budibase/bbui"
import { tables } from "stores/backend" import { tables, viewsV2 } from "stores/backend"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte" import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
export let parameters export let parameters
export let bindings = [] export let bindings = []
$: tableOptions = $tables.list || [] $: tableOptions = $tables.list.map(table => ({
label: table.name,
resourceId: table._id,
}))
$: viewOptions = $viewsV2.list.map(view => ({
label: view.name,
resourceId: view.id,
}))
$: options = [...(tableOptions || []), ...(viewOptions || [])]
</script> </script>
<div class="root"> <div class="root">
<Label>Table</Label> <Label>Table</Label>
<Select <Select
bind:value={parameters.tableId} bind:value={parameters.tableId}
options={tableOptions} {options}
getOptionLabel={table => table.name} getOptionLabel={table => table.label}
getOptionValue={table => table._id} getOptionValue={table => table.resourceId}
/> />
<Label small>Row ID</Label> <Label small>Row ID</Label>

View File

@ -1,10 +1,10 @@
<script> <script>
import { Select, Label, Body, Checkbox, Input } from "@budibase/bbui" import { Select, Label, Body, Checkbox, Input } from "@budibase/bbui"
import { store, currentAsset } from "builderStore" import { store, currentAsset } from "builderStore"
import { tables } from "stores/backend" import { tables, viewsV2 } from "stores/backend"
import { import {
getContextProviderComponents, getContextProviderComponents,
getSchemaForTable, getSchemaForDatasourcePlus,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import SaveFields from "./SaveFields.svelte" import SaveFields from "./SaveFields.svelte"
@ -24,8 +24,16 @@
"schema" "schema"
) )
$: providerOptions = getProviderOptions(formComponents, schemaComponents) $: providerOptions = getProviderOptions(formComponents, schemaComponents)
$: schemaFields = getSchemaFields($currentAsset, parameters?.tableId) $: schemaFields = getSchemaFields(parameters?.tableId)
$: tableOptions = $tables.list || [] $: tableOptions = $tables.list.map(table => ({
label: table.name,
resourceId: table._id,
}))
$: viewOptions = $viewsV2.list.map(view => ({
label: view.name,
resourceId: view.id,
}))
$: options = [...(tableOptions || []), ...(viewOptions || [])]
// Gets a context definition of a certain type from a component definition // Gets a context definition of a certain type from a component definition
const extractComponentContext = (component, contextType) => { const extractComponentContext = (component, contextType) => {
@ -61,8 +69,8 @@
}) })
} }
const getSchemaFields = (asset, tableId) => { const getSchemaFields = resourceId => {
const { schema } = getSchemaForTable(tableId) const { schema } = getSchemaForDatasourcePlus(resourceId)
return Object.values(schema || {}) return Object.values(schema || {})
} }
@ -89,9 +97,9 @@
<Label small>Table</Label> <Label small>Table</Label>
<Select <Select
bind:value={parameters.tableId} bind:value={parameters.tableId}
options={tableOptions} {options}
getOptionLabel={option => option.name} getOptionLabel={option => option.label}
getOptionValue={option => option._id} getOptionValue={option => option.resourceId}
/> />
<Label small /> <Label small />

View File

@ -15,6 +15,8 @@
import { import {
tables as tablesStore, tables as tablesStore,
queries as queriesStore, queries as queriesStore,
viewsV2 as viewsV2Store,
views as viewsStore,
} from "stores/backend" } from "stores/backend"
import { datasources, integrations } from "stores/backend" import { datasources, integrations } from "stores/backend"
import BindingBuilder from "components/integration/QueryBindingBuilder.svelte" import BindingBuilder from "components/integration/QueryBindingBuilder.svelte"
@ -39,15 +41,17 @@
tableId: m._id, tableId: m._id,
type: "table", type: "table",
})) }))
$: views = $tablesStore.list.reduce((acc, cur) => { $: viewsV1 = $viewsStore.list.map(view => ({
let viewsArr = Object.entries(cur.views || {}).map(([key, value]) => ({ ...view,
label: key, label: view.name,
name: key, type: "view",
...value, }))
type: "view", $: viewsV2 = $viewsV2Store.list.map(view => ({
})) ...view,
return [...acc, ...viewsArr] label: view.name,
}, []) type: "viewV2",
}))
$: views = [...(viewsV1 || []), ...(viewsV2 || [])]
$: queries = $queriesStore.list $: queries = $queriesStore.list
.filter(q => showAllQueries || q.queryVerb === "read" || q.readable) .filter(q => showAllQueries || q.queryVerb === "read" || q.readable)
.map(query => ({ .map(query => ({

View File

@ -0,0 +1,156 @@
<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 buildDraggable = items => {
return items
.map(item => {
return {
id: listItemKey ? item[listItemKey] : generate(),
item,
}
})
.filter(item => item.id)
}
$: if (items) {
draggableItems = buildDraggable(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> <script>
import { Button, ActionButton, Drawer } from "@budibase/bbui" import { cloneDeep, isEqual } from "lodash/fp"
import { createEventDispatcher } from "svelte"
import ColumnDrawer from "./ColumnDrawer.svelte"
import { cloneDeep } from "lodash/fp"
import { import {
getDatasourceForProvider, getDatasourceForProvider,
getSchemaForDatasource, getSchemaForDatasource,
getBindableProperties,
getComponentBindableProperties,
} from "builderStore/dataBinding" } from "builderStore/dataBinding"
import { currentAsset } from "builderStore" 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 componentInstance
export let value = [] export let value
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let sanitisedFields
let fieldList
let schema
let cachedValue
let drawer $: bindings = getBindableProperties($selectedScreen, componentInstance._id)
let boundValue $: actionType = componentInstance.actionType
let componentBindings = []
$: text = getText(value) $: if (actionType) {
$: convertOldColumnFormat(value) componentBindings = getComponentBindableProperties(
$: datasource = getDatasourceForProvider($currentAsset, componentInstance) $selectedScreen,
$: schema = getSchema($currentAsset, datasource) componentInstance._id
$: 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
} }
const convertOldColumnFormat = oldColumns => { $: datasource = getDatasourceForProvider($currentAsset, componentInstance)
if (typeof oldColumns?.[0] === "string") {
value = oldColumns.map(field => ({ name: field, displayName: field })) $: 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) => { const getSchema = (asset, datasource) => {
@ -54,50 +79,90 @@
return schema return schema
} }
const updateBoundValue = value => { const updateSanitsedFields = value => {
boundValue = cloneDeep(value) sanitisedFields = cloneDeep(value)
} }
const getValidColumns = (columns, options) => { const getValidColumns = (columns, options) => {
if (!Array.isArray(columns) || !columns.length) { if (!Array.isArray(columns) || !columns.length) {
return [] 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 columns.filter(column => {
return options.includes(column.name) return options.includes(column.field)
}) })
} }
const open = () => { const buildSudoInstance = instance => {
updateBoundValue(sanitisedValue) if (instance._component) {
drawer.show() return instance
}
const type = getComponentForField(instance.field, schema)
if (!type) {
return null
}
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 = () => { $: if (sanitisedFields) {
dispatch("change", getValidColumns(boundValue, options)) fieldList = [...sanitisedFields, ...unconfigured]
drawer.hide() .map(buildSudoInstance)
.filter(x => x != null)
}
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> </script>
<div class="field-configuration"> <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> </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> <style>
.field-configuration :global(.spectrum-ActionButton) { .field-configuration :global(.spectrum-ActionButton) {
width: 100%; 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> </script>
<ActionButton on:click={drawer.show}>Define Options</ActionButton> <div class="options-wrap">
<Drawer bind:this={drawer} title="Options"> <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"> <svelte:fragment slot="description">
Define the options for this picker. Define the options for this picker.
</svelte:fragment> </svelte:fragment>
<Button cta slot="buttons" on:click={saveOptions}>Save</Button> <Button cta slot="buttons" on:click={saveOptions}>Save</Button>
<OptionsDrawer bind:options={tempValue} slot="body" /> <OptionsDrawer bind:options={tempValue} slot="body" />
</Drawer> </Drawer>
<style>
.options-wrap {
gap: 8px;
display: grid;
grid-template-columns: 90px 1fr;
}
</style>

View File

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

View File

@ -1,28 +1,47 @@
<script> <script>
import { Select } from "@budibase/bbui" import { Select } from "@budibase/bbui"
import { createEventDispatcher } from "svelte" import { createEventDispatcher, onMount } from "svelte"
import { tables as tablesStore } from "stores/backend" import { tables as tablesStore, viewsV2 } from "stores/backend"
export let value export let value
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: tables = $tablesStore.list.map(m => ({ $: tables = $tablesStore.list.map(table => ({
label: m.name, ...table,
tableId: m._id,
type: "table", type: "table",
label: table.name,
resourceId: table._id,
})) }))
$: views = $viewsV2.list.map(view => ({
...view,
type: "viewV2",
label: view.name,
resourceId: view.id,
}))
$: options = [...(tables || []), ...(views || [])]
const onChange = e => { const onChange = e => {
const dataSource = tables?.find(x => x.tableId === e.detail) dispatch(
dispatch("change", dataSource) "change",
options.find(x => x.resourceId === e.detail)
)
} }
onMount(() => {
// Migrate old values before "resourceId" existed
if (value && !value.resourceId) {
const view = views.find(x => x.resourceId === value.id)
const table = tables.find(x => x.resourceId === value._id)
dispatch("change", view || table)
}
})
</script> </script>
<Select <Select
on:change={onChange} on:change={onChange}
value={value?.tableId} value={value?.resourceId}
options={tables} {options}
getOptionValue={x => x.tableId} getOptionValue={x => x.resourceId}
getOptionLabel={x => x.label} getOptionLabel={x => x.label}
/> />

View File

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

View File

@ -40,7 +40,7 @@
return return
} }
try { try {
roleId = (await permissions.forResource(queryToFetch._id))["read"] roleId = (await permissions.forResource(queryToFetch._id))["read"].role
} catch (err) { } catch (err) {
roleId = Constants.Roles.BASIC roleId = Constants.Roles.BASIC
} }

View File

@ -26,6 +26,9 @@ export const capitalise = s => {
export const lowercase = s => s.substring(0, 1).toLowerCase() + s.substring(1) export const lowercase = s => s.substring(0, 1).toLowerCase() + s.substring(1)
export const lowercaseExceptFirst = s =>
s.charAt(0) + s.substring(1).toLowerCase()
export const get_name = s => (!s ? "" : last(s.split("/"))) export const get_name = s => (!s ? "" : last(s.split("/")))
export const get_capitalised_name = name => pipe(name, [get_name, capitalise]) export const get_capitalised_name = name => pipe(name, [get_name, capitalise])

View File

@ -1,18 +1,27 @@
<script> <script>
import { import {
Icon, Icon,
Divider,
Heading, Heading,
Layout, Layout,
Input, Input,
clickOutside, clickOutside,
notifications, notifications,
ActionButton,
CopyInput, CopyInput,
Modal, Modal,
FancyForm,
FancyInput,
Button,
FancySelect,
} from "@budibase/bbui" } from "@budibase/bbui"
import { store } from "builderStore" import { store } from "builderStore"
import { groups, licensing, apps, users, auth, admin } from "stores/portal" import { groups, licensing, apps, users, auth, admin } from "stores/portal"
import { fetchData, Constants, Utils } from "@budibase/frontend-core" import {
fetchData,
Constants,
Utils,
RoleUtils,
} from "@budibase/frontend-core"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { API } from "api" import { API } from "api"
import GroupIcon from "../../../portal/users/groups/_components/GroupIcon.svelte" import GroupIcon from "../../../portal/users/groups/_components/GroupIcon.svelte"
@ -26,10 +35,15 @@
let loaded = false let loaded = false
let inviting = false let inviting = false
let searchFocus = false let searchFocus = false
let invitingFlow = false
// Initially filter entities without app access // Initially filter entities without app access
// Show all when false // Show all when false
let filterByAppAccess = true let filterByAppAccess = false
let email
let error
let form
let creationRoleType = Constants.BudibaseRoles.AppUser
let creationAccessType = Constants.Roles.BASIC
let appInvites = [] let appInvites = []
let filteredInvites = [] let filteredInvites = []
@ -40,8 +54,7 @@
let userLimitReachedModal let userLimitReachedModal
let inviteFailureResponse = "" let inviteFailureResponse = ""
$: validEmail = emailValidator(email) === true
$: queryIsEmail = emailValidator(query) === true
$: prodAppId = apps.getProdAppID($store.appId) $: prodAppId = apps.getProdAppID($store.appId)
$: promptInvite = showInvite( $: promptInvite = showInvite(
filteredInvites, filteredInvites,
@ -50,7 +63,6 @@
query query
) )
$: isOwner = $auth.accountPortalAccess && $admin.cloud $: isOwner = $auth.accountPortalAccess && $admin.cloud
const showInvite = (invites, users, groups, query) => { const showInvite = (invites, users, groups, query) => {
return !invites?.length && !users?.length && !groups?.length && query return !invites?.length && !users?.length && !groups?.length && query
} }
@ -66,9 +78,9 @@
if (!filterByAppAccess && !query) { if (!filterByAppAccess && !query) {
filteredInvites = filteredInvites =
appInvites.length > 100 ? appInvites.slice(0, 100) : [...appInvites] appInvites.length > 100 ? appInvites.slice(0, 100) : [...appInvites]
filteredInvites.sort(sortInviteRoles)
return return
} }
filteredInvites = appInvites.filter(invite => { filteredInvites = appInvites.filter(invite => {
const inviteInfo = invite.info?.apps const inviteInfo = invite.info?.apps
if (!query && inviteInfo && prodAppId) { if (!query && inviteInfo && prodAppId) {
@ -76,8 +88,8 @@
} }
return invite.email.includes(query) return invite.email.includes(query)
}) })
filteredInvites.sort(sortInviteRoles)
} }
$: filterByAppAccess, prodAppId, filterInvites(query) $: filterByAppAccess, prodAppId, filterInvites(query)
$: if (searchFocus === true) { $: if (searchFocus === true) {
filterByAppAccess = false filterByAppAccess = false
@ -107,24 +119,66 @@
}) })
await usersFetch.refresh() await usersFetch.refresh()
filteredUsers = $usersFetch.rows.map(user => { filteredUsers = $usersFetch.rows
const isAdminOrBuilder = sdk.users.isAdminOrBuilder(user, prodAppId) .filter(user => !user?.admin?.global) // filter out global admins
let role = undefined .map(user => {
if (isAdminOrBuilder) { const isAdminOrGlobalBuilder = sdk.users.isAdminOrGlobalBuilder(
role = Constants.Roles.ADMIN user,
} else { prodAppId
const appRole = Object.keys(user.roles).find(x => x === prodAppId) )
if (appRole) { const isAppBuilder = sdk.users.hasAppBuilderPermissions(user, prodAppId)
role = user.roles[appRole] let role
if (isAdminOrGlobalBuilder) {
role = Constants.Roles.ADMIN
} else if (isAppBuilder) {
role = Constants.Roles.CREATOR
} else {
const appRole = user.roles[prodAppId]
if (appRole) {
role = appRole
}
} }
}
return { return {
...user, ...user,
role, role,
isAdminOrBuilder, isAdminOrGlobalBuilder,
} isAppBuilder,
}) }
})
.sort(sortRoles)
}
const sortInviteRoles = (a, b) => {
const aEmpty =
!a.info?.appBuilders?.length && Object.keys(a.info.apps).length === 0
const bEmpty =
!b.info?.appBuilders?.length && Object.keys(b.info.apps).length === 0
if (aEmpty && !bEmpty) return 1
if (!aEmpty && bEmpty) return -1
}
const sortRoles = (a, b) => {
const roleA = a.role
const roleB = b.role
const priorityA = RoleUtils.getRolePriority(roleA)
const priorityB = RoleUtils.getRolePriority(roleB)
if (roleA === undefined && roleB !== undefined) {
return 1
} else if (roleA !== undefined && roleB === undefined) {
return -1
}
if (priorityA < priorityB) {
return 1
} else if (priorityA > priorityB) {
return -1
}
return 0
} }
const debouncedUpdateFetch = Utils.debounce(searchUsers, 250) const debouncedUpdateFetch = Utils.debounce(searchUsers, 250)
@ -160,6 +214,12 @@
if (user.role === role) { if (user.role === role) {
return return
} }
if (user.isAppBuilder) {
await removeAppBuilder(user._id, prodAppId)
}
if (role === Constants.Roles.CREATOR) {
await removeAppBuilder(user._id, prodAppId)
}
await updateAppUser(user, role) await updateAppUser(user, role)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@ -189,6 +249,9 @@
return return
} }
try { try {
if (group?.builder?.apps.includes(prodAppId)) {
await removeGroupAppBuilder(group._id)
}
await updateAppGroup(group, role) await updateAppGroup(group, role)
} catch { } catch {
notifications.error("Group update failed") notifications.error("Group update failed")
@ -225,14 +288,17 @@
return nameMatch return nameMatch
}) })
.map(enrichGroupRole) .map(enrichGroupRole)
.sort(sortRoles)
} }
const enrichGroupRole = group => { const enrichGroupRole = group => {
return { return {
...group, ...group,
role: group.roles?.[ role: group?.builder?.apps.includes(prodAppId)
groups.actions.getGroupAppIds(group).find(x => x === prodAppId) ? Constants.Roles.CREATOR
], : group.roles?.[
groups.actions.getGroupAppIds(group).find(x => x === prodAppId)
],
} }
} }
@ -245,8 +311,7 @@
$: filteredGroups = searchGroups(enrichedGroups, query) $: filteredGroups = searchGroups(enrichedGroups, query)
$: groupUsers = buildGroupUsers(filteredGroups, filteredUsers) $: groupUsers = buildGroupUsers(filteredGroups, filteredUsers)
$: allUsers = [...filteredUsers, ...groupUsers] $: allUsers = [...filteredUsers, ...groupUsers]
/*
/*
Create pseudo users from the "users" attribute on app groups. Create pseudo users from the "users" attribute on app groups.
These users will appear muted in the UI and show the ROLE These users will appear muted in the UI and show the ROLE
inherited from their parent group. The users allow assigning of user inherited from their parent group. The users allow assigning of user
@ -291,21 +356,28 @@
} }
async function inviteUser() { async function inviteUser() {
if (!queryIsEmail) { if (!validEmail) {
notifications.error("Email is not valid") notifications.error("Email is not valid")
return return
} }
const newUserEmail = query + "" const newUserEmail = email + ""
inviting = true inviting = true
const payload = [ const payload = [
{ {
email: newUserEmail, email: newUserEmail,
builder: false, builder: !!creationRoleType === Constants.BudibaseRoles.Admin,
admin: false, admin: !!creationRoleType === Constants.BudibaseRoles.Admin,
apps: { [prodAppId]: Constants.Roles.BASIC },
}, },
] ]
if (creationAccessType === Constants.Roles.CREATOR) {
payload[0].appBuilders = [prodAppId]
} else {
payload[0].apps = {
[prodAppId]: creationAccessType,
}
}
let userInviteResponse let userInviteResponse
try { try {
userInviteResponse = await users.onboard(payload) userInviteResponse = await users.onboard(payload)
@ -317,16 +389,23 @@
return userInviteResponse return userInviteResponse
} }
const openInviteFlow = () => {
$licensing.userLimitReached
? userLimitReachedModal.show()
: (invitingFlow = true)
}
const onInviteUser = async () => { const onInviteUser = async () => {
form.validate()
userOnboardResponse = await inviteUser() userOnboardResponse = await inviteUser()
const originalQuery = query + "" const originalQuery = email + ""
query = null email = null
const newUser = userOnboardResponse?.successful.find( const newUser = userOnboardResponse?.successful.find(
user => user.email === originalQuery user => user.email === originalQuery
) )
if (newUser) { if (newUser) {
query = originalQuery email = originalQuery
notifications.success( notifications.success(
userOnboardResponse.created userOnboardResponse.created
? "User created successfully" ? "User created successfully"
@ -344,16 +423,27 @@
notifications.error(inviteFailureResponse) notifications.error(inviteFailureResponse)
} }
userOnboardResponse = null userOnboardResponse = null
invitingFlow = false
// trigger reload of the users
query = ""
} }
const onUpdateUserInvite = async (invite, role) => { const onUpdateUserInvite = async (invite, role) => {
await users.updateInvite({ let updateBody = {
code: invite.code, code: invite.code,
apps: { apps: {
...invite.apps, ...invite.apps,
[prodAppId]: role, [prodAppId]: role,
}, },
}) }
if (role === Constants.Roles.CREATOR) {
updateBody.appBuilders = [...(updateBody.appBuilders ?? []), prodAppId]
delete updateBody?.apps?.[prodAppId]
} else if (role !== Constants.Roles.CREATOR && invite?.appBuilders) {
invite.appBuilders = []
}
await users.updateInvite(updateBody)
await filterInvites(query) await filterInvites(query)
} }
@ -373,6 +463,22 @@
}) })
} }
const addAppBuilder = async userId => {
await users.addAppBuilder(userId, prodAppId)
}
const removeAppBuilder = async userId => {
await users.removeAppBuilder(userId, prodAppId)
}
const addGroupAppBuilder = async groupId => {
await groups.actions.addGroupAppBuilder(groupId, prodAppId)
}
const removeGroupAppBuilder = async groupId => {
await groups.actions.removeGroupAppBuilder(groupId, prodAppId)
}
const initSidePanel = async sidePaneOpen => { const initSidePanel = async sidePaneOpen => {
if (sidePaneOpen === true) { if (sidePaneOpen === true) {
await groups.actions.init() await groups.actions.init()
@ -383,27 +489,17 @@
$: initSidePanel($store.builderSidePanel) $: initSidePanel($store.builderSidePanel)
function handleKeyDown(evt) { function handleKeyDown(evt) {
if (evt.key === "Enter" && queryIsEmail && !inviting) { if (evt.key === "Enter" && validEmail && !inviting) {
onInviteUser() onInviteUser()
} }
} }
const userTitle = user => {
if (sdk.users.isAdmin(user)) {
return "Admin"
} else if (sdk.users.isBuilder(user, prodAppId)) {
return "Developer"
} else {
return "App user"
}
}
const getRoleFooter = user => { const getRoleFooter = user => {
if (user.group) { if (user.group) {
const role = $roles.find(role => role._id === user.role) const role = $roles.find(role => role._id === user.role)
return `This user has been given ${role?.name} access from the ${user.group} group` return `This user has been given ${role?.name} access from the ${user.group} group`
} }
if (user.isAdminOrBuilder) { if (user.isAdminOrGlobalBuilder) {
return "This user's role grants admin access to all apps" return "This user's role grants admin access to all apps"
} }
return null return null
@ -423,227 +519,300 @@
}} }}
> >
<div class="builder-side-panel-header"> <div class="builder-side-panel-header">
<Heading size="S">Users</Heading> <div
<Icon
color="var(--spectrum-global-color-gray-600)"
name="RailRightClose"
hoverable
on:click={() => { on:click={() => {
store.update(state => { invitingFlow = false
state.builderSidePanel = false
return state
})
}}
/>
</div>
<div class="search" class:focused={searchFocus}>
<span class="search-input">
<Input
placeholder={"Add users and groups to your app"}
autocomplete="off"
disabled={inviting}
value={query}
on:input={e => {
query = e.target.value.trim()
}}
on:focus={() => (searchFocus = true)}
on:blur={() => (searchFocus = false)}
/>
</span>
<span
class="search-input-icon"
class:searching={query || !filterByAppAccess}
on:click={() => {
if (!filterByAppAccess) {
filterByAppAccess = true
}
if (!query) {
return
}
query = null
userOnboardResponse = null
filterByAppAccess = true
}} }}
class="header"
> >
<Icon name={!filterByAppAccess || query ? "Close" : "Search"} /> {#if invitingFlow}
</span> <Icon name="BackAndroid" />
{/if}
<Heading size="S">{invitingFlow ? "Invite new user" : "Users"}</Heading>
</div>
<div class="header">
<Button on:click={openInviteFlow} size="S" cta>Invite user</Button>
<Icon
color="var(--spectrum-global-color-gray-600)"
name="RailRightClose"
hoverable
on:click={() => {
store.update(state => {
state.builderSidePanel = false
return state
})
}}
/>
</div>
</div> </div>
{#if !invitingFlow}
<div class="search" class:focused={searchFocus}>
<span class="search-input">
<Input
placeholder={"Add users and groups to your app"}
autocomplete="off"
disabled={inviting}
value={query}
on:input={e => {
query = e.target.value.trim()
}}
on:focus={() => (searchFocus = true)}
on:blur={() => (searchFocus = false)}
/>
</span>
<div class="body"> <span
{#if promptInvite && !userOnboardResponse} class="search-input-icon"
<Layout gap="S" paddingX="XL"> class:searching={query || !filterByAppAccess}
<div class="invite-header"> on:click={() => {
<Heading size="XS">No user found</Heading> if (!query) {
<div class="invite-directions"> return
Add a valid email to invite a new user }
</div> query = null
</div> userOnboardResponse = null
<div class="invite-form"> }}
<span>{query || ""}</span> >
<ActionButton <Icon name={!filterByAppAccess || query ? "Close" : "Search"} />
icon="UserAdd" </span>
disabled={!queryIsEmail || inviting} </div>
on:click={$licensing.userLimitReached
? userLimitReachedModal.show
: onInviteUser}
>
Add user
</ActionButton>
</div>
</Layout>
{/if}
{#if !promptInvite} <div class="body">
<Layout gap="L" noPadding> {#if promptInvite && !userOnboardResponse}
{#if filteredInvites?.length} <Layout gap="S" paddingX="XL">
<Layout noPadding gap="XS"> <div class="invite-header">
<div class="auth-entity-header"> <Heading size="XS">No user found</Heading>
<div class="auth-entity-title">Pending invites</div> <div class="invite-directions">
<div class="auth-entity-access-title">Access</div> Try searching a different email or <span
</div> class="underlined"
{#each filteredInvites as invite} on:click={openInviteFlow}>invite a new user</span
<div class="auth-entity">
<div class="details">
<div class="user-email" title={invite.email}>
{invite.email}
</div>
</div>
<div class="auth-entity-access">
<RoleSelect
placeholder={false}
value={invite.info.apps?.[prodAppId]}
allowRemove={invite.info.apps?.[prodAppId]}
allowPublic={false}
quiet={true}
on:change={e => {
onUpdateUserInvite(invite, e.detail)
}}
on:remove={() => {
onUninviteAppUser(invite)
}}
autoWidth
align="right"
/>
</div>
</div>
{/each}
</Layout>
{/if}
{#if $licensing.groupsEnabled && filteredGroups?.length}
<Layout noPadding gap="XS">
<div class="auth-entity-header">
<div class="auth-entity-title">Groups</div>
<div class="auth-entity-access-title">Access</div>
</div>
{#each filteredGroups as group}
<div
class="auth-entity group"
on:click={() => {
if (selectedGroup != group._id) {
selectedGroup = group._id
} else {
selectedGroup = null
}
}}
on:keydown={() => {}}
> >
<div class="details">
<GroupIcon {group} size="S" />
<div>
{group.name}
</div>
<div class="auth-entity-meta">
{`${group.users?.length} user${
group.users?.length != 1 ? "s" : ""
}`}
</div>
</div>
<div class="auth-entity-access">
<RoleSelect
placeholder={false}
value={group.role}
allowRemove={group.role}
allowPublic={false}
quiet={true}
on:change={e => {
onUpdateGroup(group, e.detail)
}}
on:remove={() => {
onUpdateGroup(group)
}}
autoWidth
align="right"
/>
</div>
</div>
{/each}
</Layout>
{/if}
{#if filteredUsers?.length}
<div class="auth-entity-section">
<div class="auth-entity-header">
<div class="auth-entity-title">Users</div>
<div class="auth-entity-access-title">Access</div>
</div> </div>
{#each allUsers as user}
<div class="auth-entity">
<div class="details">
<div class="user-email" title={user.email}>
{user.email}
</div>
<div class="auth-entity-meta">
{userTitle(user)}
</div>
</div>
<div class="auth-entity-access" class:muted={user.group}>
<RoleSelect
footer={getRoleFooter(user)}
placeholder={false}
value={user.role}
allowRemove={user.role && !user.group}
allowPublic={false}
quiet={true}
on:change={e => {
onUpdateUser(user, e.detail)
}}
on:remove={() => {
onUpdateUser(user)
}}
autoWidth
align="right"
allowedRoles={user.isAdminOrBuilder
? [Constants.Roles.ADMIN]
: null}
/>
</div>
</div>
{/each}
</div> </div>
{/if} </Layout>
</Layout> {/if}
{/if}
{#if userOnboardResponse?.created} {#if !promptInvite}
<Layout gap="S" paddingX="XL"> <Layout gap="L" noPadding>
<div class="invite-header"> {#if filteredInvites?.length}
<Heading size="XS">User added!</Heading> <Layout noPadding gap="XS">
<div class="invite-directions"> <div class="auth-entity-header">
Email invites are not available without SMTP configuration. Here is <div class="auth-entity-title">Pending invites</div>
the password that has been generated for this user. <div class="auth-entity-access-title">Access</div>
</div>
{#each filteredInvites as invite}
<div class="auth-entity">
<div class="details">
<div class="user-email" title={invite.email}>
{invite.email}
</div>
</div>
<div class="auth-entity-access">
<RoleSelect
placeholder={false}
value={invite.info?.appBuilders?.includes(prodAppId)
? Constants.Roles.CREATOR
: invite.info.apps?.[prodAppId]}
allowRemove={invite.info.apps?.[prodAppId]}
allowPublic={false}
allowCreator={true}
quiet={true}
on:change={e => {
onUpdateUserInvite(invite, e.detail)
}}
on:remove={() => {
onUninviteAppUser(invite)
}}
autoWidth
align="right"
/>
</div>
</div>
{/each}
</Layout>
{/if}
{#if $licensing.groupsEnabled && filteredGroups?.length}
<Layout noPadding gap="XS">
<div class="auth-entity-header">
<div class="auth-entity-title">Groups</div>
<div class="auth-entity-access-title">Access</div>
</div>
{#each filteredGroups as group}
<div
class="auth-entity group"
on:click={() => {
if (selectedGroup != group._id) {
selectedGroup = group._id
} else {
selectedGroup = null
}
}}
on:keydown={() => {}}
>
<div class="details">
<GroupIcon {group} size="S" />
<div>
{group.name}
</div>
<div class="auth-entity-meta">
{`${group.users?.length} user${
group.users?.length != 1 ? "s" : ""
}`}
</div>
</div>
<div class="auth-entity-access">
<RoleSelect
placeholder={false}
value={group.role}
allowRemove={group.role}
allowPublic={false}
quiet={true}
allowCreator={true}
on:change={e => {
if (e.detail === Constants.Roles.CREATOR) {
addGroupAppBuilder(group._id)
} else {
onUpdateGroup(group, e.detail)
}
}}
on:remove={() => {
onUpdateGroup(group)
}}
autoWidth
align="right"
/>
</div>
</div>
{/each}
</Layout>
{/if}
{#if filteredUsers?.length}
<div class="auth-entity-section">
<div class="auth-entity-header">
<div class="auth-entity-title">Users</div>
<div class="auth-entity-access-title">Access</div>
</div>
{#each allUsers as user}
<div class="auth-entity">
<div class="details">
<div class="user-email" title={user.email}>
{user.email}
</div>
</div>
<div class="auth-entity-access" class:muted={user.group}>
<RoleSelect
footer={getRoleFooter(user)}
placeholder={false}
value={user.role}
allowRemove={user.role && !user.group}
allowPublic={false}
allowCreator={true}
quiet={true}
on:addcreator={() => {}}
on:change={e => {
if (e.detail === Constants.Roles.CREATOR) {
addAppBuilder(user._id)
} else {
onUpdateUser(user, e.detail)
}
}}
on:remove={() => {
onUpdateUser(user)
}}
autoWidth
align="right"
allowedRoles={user.isAdminOrGlobalBuilder
? [Constants.Roles.ADMIN]
: null}
/>
</div>
</div>
{/each}
</div>
{/if}
</Layout>
{/if}
{#if userOnboardResponse?.created}
<Layout gap="S" paddingX="XL">
<div class="invite-header">
<Heading size="XS">User added!</Heading>
<div class="invite-directions">
Email invites are not available without SMTP configuration. Here
is the password that has been generated for this user.
</div>
</div> </div>
</div> <div>
<div> <CopyInput
<CopyInput value={userOnboardResponse.successful[0]?.password}
value={userOnboardResponse.successful[0]?.password} label="Password"
label="Password" />
/> </div>
</Layout>
{/if}
</div>
{:else}
<Divider />
<div class="body">
<Layout gap="L" noPadding>
<div class="user-invite-form">
<FancyForm bind:this={form}>
<FancyInput
disabled={false}
label="Email"
value={email}
on:change={e => {
email = e.detail
}}
validate={() => {
if (!email) {
return "Please enter an email"
}
return null
}}
{error}
/>
<FancySelect
bind:value={creationRoleType}
options={sdk.users.isAdmin($auth.user)
? Constants.BudibaseRoleOptionsNew
: Constants.BudibaseRoleOptionsNew.filter(
option => option.value !== Constants.BudibaseRoles.Admin
)}
label="Role"
/>
{#if creationRoleType !== Constants.BudibaseRoles.Admin}
<RoleSelect
placeholder={false}
bind:value={creationAccessType}
allowPublic={false}
allowCreator={true}
quiet={true}
autoWidth
align="right"
fancySelect
/>
{/if}
</FancyForm>
{#if creationRoleType === Constants.BudibaseRoles.Admin}
<div class="admin-info">
<Icon name="Info" />
Admins will get full access to all apps and settings
</div>
{/if}
<span class="add-user">
<Button
newStyles
cta
disabled={!email?.length}
on:click={onInviteUser}>Add user</Button
>
</span>
</div> </div>
</Layout> </Layout>
{/if} </div>
</div> {/if}
<Modal bind:this={userLimitReachedModal}> <Modal bind:this={userLimitReachedModal}>
<UpgradeModal {isOwner} /> <UpgradeModal {isOwner} />
</Modal> </Modal>
@ -659,6 +828,27 @@
align-items: center; align-items: center;
} }
.add-user {
padding-top: var(--spacing-xl);
width: 100%;
display: grid;
}
.admin-info {
margin-top: var(--spacing-xl);
padding: var(--spacing-l) var(--spacing-l) var(--spacing-l) var(--spacing-l);
display: flex;
align-items: center;
gap: var(--spacing-xl);
height: 30px;
background-color: var(--background-alt);
}
.underlined {
text-decoration: underline;
cursor: pointer;
}
.search-input { .search-input {
flex: 1; flex: 1;
} }
@ -746,12 +936,6 @@
box-sizing: border-box; box-sizing: border-box;
} }
.invite-form {
display: flex;
align-items: center;
justify-content: space-between;
}
#builder-side-panel-container .search { #builder-side-panel-container .search {
padding-top: var(--spacing-m); padding-top: var(--spacing-m);
padding-bottom: var(--spacing-m); padding-bottom: var(--spacing-m);
@ -798,6 +982,16 @@
flex-direction: column; flex-direction: column;
} }
.header {
display: flex;
align-items: center;
gap: var(--spacing-l);
}
.user-invite-form {
padding: 0 var(--spacing-xl) var(--spacing-xl) var(--spacing-xl);
}
.body { .body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@ -1,5 +1,5 @@
<script> <script>
import TableDataTable from "components/backend/DataTable/DataTable.svelte" import TableDataTable from "components/backend/DataTable/TableDataTable.svelte"
import { tables, database } from "stores/backend" import { tables, database } from "stores/backend"
import { Banner } from "@budibase/bbui" import { Banner } from "@budibase/bbui"

View File

@ -1,14 +1,17 @@
<script> <script>
import { onMount } from "svelte" import { onMount } from "svelte"
import { views } from "stores/backend" import { views, viewsV2 } from "stores/backend"
import { redirect } from "@roxi/routify" import { redirect } from "@roxi/routify"
onMount(async () => { onMount(async () => {
const { list, selected } = $views if ($viewsV2.selected) {
if (selected) { $redirect(`./v2/${$viewsV2.selected.id}`)
$redirect(`./${encodeURIComponent(selected?.name)}`) } else if ($viewsV2.list?.length) {
} else if (list?.length) { $redirect(`./v2/${$viewsV2.list[0].id}`)
$redirect(`./${encodeURIComponent(list[0].name)}`) } else if ($views.selected) {
$redirect(`./${encodeURIComponent($views.selected?.name)}`)
} else if ($views.list?.length) {
$redirect(`./${encodeURIComponent($views.list[0].name)}`)
} else { } else {
$redirect("../") $redirect("../")
} }

View File

@ -5,15 +5,15 @@
import { onDestroy } from "svelte" import { onDestroy } from "svelte"
import { store } from "builderStore" import { store } from "builderStore"
$: viewName = $views.selectedViewName $: name = $views.selectedViewName
$: store.actions.websocket.selectResource(viewName) $: store.actions.websocket.selectResource(name)
const stopSyncing = syncURLToState({ const stopSyncing = syncURLToState({
urlParam: "viewName", urlParam: "viewName",
stateKey: "selectedViewName", stateKey: "selectedViewName",
validate: name => $views.list?.some(view => view.name === name), validate: name => $views.list?.some(view => view.name === name),
update: views.select, update: views.select,
fallbackUrl: "../", fallbackUrl: "../../",
store: views, store: views,
routify, routify,
decode: decodeURIComponent, decode: decodeURIComponent,

View File

@ -0,0 +1,5 @@
<script>
import { redirect } from "@roxi/routify"
$redirect("../")
</script>

View File

@ -0,0 +1,25 @@
<script>
import { viewsV2 } from "stores/backend"
import { syncURLToState } from "helpers/urlStateSync"
import * as routify from "@roxi/routify"
import { onDestroy } from "svelte"
import { store } from "builderStore"
$: id = $viewsV2.selectedViewId
$: store.actions.websocket.selectResource(id)
const stopSyncing = syncURLToState({
urlParam: "viewId",
stateKey: "selectedViewId",
validate: id => $viewsV2.list?.some(view => view.id === id),
update: viewsV2.select,
fallbackUrl: "../../",
store: viewsV2,
routify,
decode: decodeURIComponent,
})
onDestroy(stopSyncing)
</script>
<slot />

View File

@ -0,0 +1,5 @@
<script>
import ViewV2DataTable from "components/backend/DataTable/ViewV2DataTable.svelte"
</script>
<ViewV2DataTable />

View File

@ -0,0 +1,5 @@
<script>
import { redirect } from "@roxi/routify"
$redirect("../")
</script>

View File

@ -1,36 +1,12 @@
<script> <script>
import { DetailSummary, Icon } from "@budibase/bbui" import { DetailSummary } from "@budibase/bbui"
import InfoDisplay from "./InfoDisplay.svelte"
export let componentDefinition export let componentDefinition
</script> </script>
<DetailSummary collapsible={false}> <DetailSummary collapsible={false} noPadding={true}>
<div class="info"> <InfoDisplay
<div class="title"> title={componentDefinition.name}
<Icon name="HelpOutline" /> body={componentDefinition.info}
{componentDefinition.name} />
</div>
{componentDefinition.info}
</div>
</DetailSummary> </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 DesignSection from "./DesignSection.svelte"
import CustomStylesSection from "./CustomStylesSection.svelte" import CustomStylesSection from "./CustomStylesSection.svelte"
import ConditionalUISection from "./ConditionalUISection.svelte" import ConditionalUISection from "./ConditionalUISection.svelte"
import ComponentInfoSection from "./ComponentInfoSection.svelte"
import { import {
getBindableProperties, getBindableProperties,
getComponentBindableProperties, getComponentBindableProperties,
@ -55,9 +55,6 @@
</div> </div>
</span> </span>
{#if section == "settings"} {#if section == "settings"}
{#if componentDefinition?.info}
<ComponentInfoSection {componentDefinition} />
{/if}
<ComponentSettingsSection <ComponentSettingsSection
{componentInstance} {componentInstance}
{componentDefinition} {componentDefinition}

View File

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

@ -297,8 +297,12 @@
width: 100%; width: 100%;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
transition: background 130ms ease-out;
} }
.divider:hover { .divider:hover {
cursor: row-resize; cursor: row-resize;
} }
.divider:hover:after {
background: var(--spectrum-global-color-gray-300);
}
</style> </style>

View File

@ -110,7 +110,7 @@
if (mode === "table") { if (mode === "table") {
datasourceModal.show() datasourceModal.show()
} else if (mode === "blank") { } else if (mode === "blank") {
let templates = getTemplates($store, $tables.list) let templates = getTemplates($tables.list)
const blankScreenTemplate = templates.find( const blankScreenTemplate = templates.find(
t => t.id === "createFromScratch" t => t.id === "createFromScratch"
) )
@ -131,8 +131,7 @@
const completeDatasourceScreenCreation = async () => { const completeDatasourceScreenCreation = async () => {
const screens = selectedTemplates.map(template => { const screens = selectedTemplates.map(template => {
let screenTemplate = template.create() let screenTemplate = template.create()
screenTemplate.datasource = template.datasource screenTemplate.autoTableId = template.resourceId
screenTemplate.autoTableId = template.table
return screenTemplate return screenTemplate
}) })
await createScreens({ screens, screenAccessRole }) await createScreens({ screens, screenAccessRole })
@ -176,10 +175,10 @@
} }
</script> </script>
<Modal bind:this={datasourceModal}> <Modal bind:this={datasourceModal} autoFocus={false}>
<DatasourceModal <DatasourceModal
onConfirm={confirmScreenDatasources} onConfirm={confirmScreenDatasources}
initalScreens={!selectedTemplates ? [] : [...selectedTemplates]} initialScreens={!selectedTemplates ? [] : [...selectedTemplates]}
/> />
</Modal> </Modal>

View File

@ -1,41 +1,30 @@
<script> <script>
import { store } from "builderStore" import { ModalContent, Layout, notifications, Body } from "@budibase/bbui"
import { import { datasources } from "stores/backend"
ModalContent,
Layout,
notifications,
Icon,
Body,
} from "@budibase/bbui"
import { tables, datasources } from "stores/backend"
import getTemplates from "builderStore/store/screenTemplates"
import ICONS from "components/backend/DatasourceNavigator/icons" import ICONS from "components/backend/DatasourceNavigator/icons"
import { IntegrationNames } from "constants" import { IntegrationNames } from "constants"
import { onMount } from "svelte" import { onMount } from "svelte"
import rowListScreen from "builderStore/store/screenTemplates/rowListScreen"
import DatasourceTemplateRow from "./DatasourceTemplateRow.svelte"
export let onCancel export let onCancel
export let onConfirm export let onConfirm
export let initalScreens = [] export let initialScreens = []
let selectedScreens = [...initalScreens] let selectedScreens = [...initialScreens]
const toggleScreenSelection = (table, datasource) => { $: filteredSources = $datasources.list?.filter(datasource => {
if (selectedScreens.find(s => s.table === table._id)) { return datasource.source !== IntegrationNames.REST && datasource["entities"]
})
const toggleSelection = datasource => {
const { resourceId } = datasource
if (selectedScreens.find(s => s.resourceId === resourceId)) {
selectedScreens = selectedScreens.filter( selectedScreens = selectedScreens.filter(
screen => screen.table !== table._id screen => screen.resourceId !== resourceId
) )
} else { } else {
let partialTemplates = getTemplates($store, $tables.list).reduce( selectedScreens = [...selectedScreens, rowListScreen([datasource])[0]]
(acc, template) => {
if (template.table === table._id) {
template.datasource = datasource.name
acc.push(template)
}
return acc
},
[]
)
selectedScreens = [...partialTemplates, ...selectedScreens]
} }
} }
@ -45,18 +34,6 @@
}) })
} }
$: filteredSources = Array.isArray($datasources.list)
? $datasources.list.reduce((acc, datasource) => {
if (
datasource.source !== IntegrationNames.REST &&
datasource["entities"]
) {
acc.push(datasource)
}
return acc
}, [])
: []
onMount(async () => { onMount(async () => {
try { try {
await datasources.fetch() await datasources.fetch()
@ -81,6 +58,9 @@
</Body> </Body>
<Layout noPadding gap="S"> <Layout noPadding gap="S">
{#each filteredSources as datasource} {#each filteredSources as datasource}
{@const entities = Array.isArray(datasource.entities)
? datasource.entities
: Object.values(datasource.entities || {})}
<div class="data-source-wrap"> <div class="data-source-wrap">
<div class="data-source-header"> <div class="data-source-header">
<svelte:component <svelte:component
@ -90,64 +70,51 @@
/> />
<div class="data-source-name">{datasource.name}</div> <div class="data-source-name">{datasource.name}</div>
</div> </div>
{#if Array.isArray(datasource.entities)} <!-- List all tables -->
{#each datasource.entities.filter(table => table._id !== "ta_users") as table} {#each entities.filter(table => table._id !== "ta_users") as table}
<div {@const views = Object.values(table.views || {}).filter(
class="data-source-entry" view => view.version === 2
class:selected={selectedScreens.find( )}
x => x.table === table._id {@const datasource = {
)} ...table,
on:click={() => toggleScreenSelection(table, datasource)} // Legacy properties
> tableId: table._id,
<svg label: table.name,
width="16px" // New consistent properties
height="16px" resourceId: table._id,
class="spectrum-Icon" name: table.name,
style="color: white" type: "table",
focusable="false" }}
> {@const selected = selectedScreens.find(
<use xlink:href="#spectrum-icon-18-Table" /> screen => screen.resourceId === datasource.resourceId
</svg> )}
{table.name} <DatasourceTemplateRow
{#if selectedScreens.find(x => x.table === table._id)} on:click={() => toggleSelection(datasource)}
<span class="data-source-check"> {selected}
<Icon size="S" name="CheckmarkCircle" /> {datasource}
</span> />
{/if}
</div> <!-- List all views inside this table -->
{#each views as view}
{@const datasource = {
...view,
// Legacy properties
label: view.name,
// New consistent properties
resourceId: view.id,
name: view.name,
type: "viewV2",
}}
{@const selected = selectedScreens.find(
x => x.resourceId === datasource.resourceId
)}
<DatasourceTemplateRow
on:click={() => toggleSelection(datasource)}
{selected}
{datasource}
/>
{/each} {/each}
{/if} {/each}
{#if datasource["entities"] && !Array.isArray(datasource.entities)}
{#each Object.keys(datasource.entities).filter(table => table._id !== "ta_users") as table_key}
<div
class="data-source-entry"
class:selected={selectedScreens.find(
x => x.table === datasource.entities[table_key]._id
)}
on:click={() =>
toggleScreenSelection(
datasource.entities[table_key],
datasource
)}
>
<svg
width="16px"
height="16px"
class="spectrum-Icon"
style="color: white"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-Table" />
</svg>
{datasource.entities[table_key].name}
{#if selectedScreens.find(x => x.table === datasource.entities[table_key]._id)}
<span class="data-source-check">
<Icon size="S" name="CheckmarkCircle" />
</span>
{/if}
</div>
{/each}
{/if}
</div> </div>
{/each} {/each}
</Layout> </Layout>
@ -160,42 +127,10 @@
display: grid; display: grid;
grid-gap: var(--spacing-s); grid-gap: var(--spacing-s);
} }
.data-source-header { .data-source-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-m); gap: var(--spacing-m);
padding-bottom: var(--spacing-xs); padding-bottom: var(--spacing-xs);
} }
.data-source-entry {
cursor: pointer;
grid-gap: var(--spectrum-alias-grid-margin-xsmall);
padding: var(--spectrum-alias-item-padding-s);
background: var(--spectrum-alias-background-color-secondary);
transition: 0.3s all;
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px;
border-width: 1px;
display: flex;
align-items: center;
}
.data-source-entry:hover,
.selected {
background: var(--spectrum-alias-background-color-tertiary);
}
.data-source-entry .data-source-check {
margin-left: auto;
}
.data-source-entry :global(.spectrum-Icon) {
min-width: 16px;
}
.data-source-entry .data-source-check :global(.spectrum-Icon) {
color: var(--spectrum-global-color-green-600);
display: block;
}
</style> </style>

View File

@ -0,0 +1,42 @@
<script>
import { Icon } from "@budibase/bbui"
export let datasource
export let selected = false
$: icon = datasource.type === "viewV2" ? "Remove" : "Table"
</script>
<div class="data-source-entry" class:selected on:click>
<Icon name={icon} color="var(--spectrum-global-color-gray-600)" />
{datasource.name}
{#if selected}
<span class="data-source-check">
<Icon size="S" name="CheckmarkCircle" />
</span>
{/if}
</div>
<style>
.data-source-entry {
cursor: pointer;
grid-gap: var(--spacing-m);
padding: var(--spectrum-alias-item-padding-s);
background: var(--spectrum-alias-background-color-secondary);
transition: 0.3s all;
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px;
display: flex;
align-items: center;
}
.data-source-entry:hover,
.selected {
background: var(--spectrum-alias-background-color-tertiary);
}
.data-source-check {
margin-left: auto;
}
.data-source-check :global(.spectrum-Icon) {
color: var(--spectrum-global-color-green-600);
}
</style>

View File

@ -39,7 +39,7 @@
return publishedApps return publishedApps
} }
return publishedApps.filter(app => { return publishedApps.filter(app => {
if (sdk.users.isBuilder(user, app.appId)) { if (sdk.users.isBuilder(user, app.prodId)) {
return true return true
} }
if (!Object.keys(user?.roles).length && user?.userGroups) { if (!Object.keys(user?.roles).length && user?.userGroups) {
@ -142,7 +142,12 @@
<div class="group"> <div class="group">
<Layout gap="S" noPadding> <Layout gap="S" noPadding>
{#each userApps as app (app.appId)} {#each userApps as app (app.appId)}
<a class="app" target="_blank" href={getUrl(app)}> <a
class="app"
target="_blank"
rel="noreferrer"
href={getUrl(app)}
>
<div class="preview" use:gradient={{ seed: app.name }} /> <div class="preview" use:gradient={{ seed: app.name }} />
<div class="app-info"> <div class="app-info">
<Heading size="XS">{app.name}</Heading> <Heading size="XS">{app.name}</Heading>

View File

@ -21,6 +21,7 @@
import GroupIcon from "./_components/GroupIcon.svelte" import GroupIcon from "./_components/GroupIcon.svelte"
import GroupUsers from "./_components/GroupUsers.svelte" import GroupUsers from "./_components/GroupUsers.svelte"
import { sdk } from "@budibase/shared-core" import { sdk } from "@budibase/shared-core"
import { Constants } from "@budibase/frontend-core"
export let groupId export let groupId
@ -45,7 +46,7 @@
let loaded = false let loaded = false
let editModal, deleteModal let editModal, deleteModal
$: console.log(group)
$: scimEnabled = $features.isScimEnabled $: scimEnabled = $features.isScimEnabled
$: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled $: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled
$: group = $groups.find(x => x._id === groupId) $: group = $groups.find(x => x._id === groupId)
@ -57,8 +58,11 @@
) )
.map(app => ({ .map(app => ({
...app, ...app,
role: group?.roles?.[apps.getProdAppID(app.devId)], role: group?.builder?.apps.includes(apps.getProdAppID(app.devId))
? Constants.Roles.CREATOR
: group?.roles?.[apps.getProdAppID(app.devId)],
})) }))
$: console.log(groupApps)
$: { $: {
if (loaded && !group?._id) { if (loaded && !group?._id) {
$goto("./") $goto("./")

View File

@ -1,9 +1,19 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { sdk } from "@budibase/shared-core"
export let value export let value
export let row
$: count = getCount(Object.keys(value || {}).length)
$: count = Object.keys(value || {}).length const getCount = () => {
return sdk.users.hasAppBuilderPermissions(row)
? row.builder.apps.length +
Object.keys(row.roles || {}).filter(appId =>
row.builder.apps.includes(appId)
).length
: value?.length || 0
}
</script> </script>
<div class="align"> <div class="align">

View File

@ -89,7 +89,7 @@
$: scimEnabled = $features.isScimEnabled $: scimEnabled = $features.isScimEnabled
$: isSSO = !!user?.provider $: isSSO = !!user?.provider
$: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled $: readonly = !sdk.users.isAdmin($auth.user) || scimEnabled
$: privileged = sdk.users.isAdminOrBuilder(user) $: privileged = sdk.users.isAdminOrGlobalBuilder(user)
$: nameLabel = getNameLabel(user) $: nameLabel = getNameLabel(user)
$: filteredGroups = getFilteredGroups($groups, searchTerm) $: filteredGroups = getFilteredGroups($groups, searchTerm)
$: availableApps = getAvailableApps($apps, privileged, user?.roles) $: availableApps = getAvailableApps($apps, privileged, user?.roles)
@ -98,17 +98,14 @@
return y._id === userId return y._id === userId
}) })
}) })
$: globalRole = sdk.users.isAdmin(user) $: globalRole = sdk.users.isAdmin(user) ? "admin" : "appUser"
? "admin"
: sdk.users.isBuilder(user)
? "developer"
: "appUser"
const getAvailableApps = (appList, privileged, roles) => { const getAvailableApps = (appList, privileged, roles) => {
let availableApps = appList.slice() let availableApps = appList.slice()
if (!privileged) { if (!privileged) {
availableApps = availableApps.filter(x => { availableApps = availableApps.filter(x => {
return Object.keys(roles || {}).find(y => { let roleKeys = Object.keys(roles || {})
return roleKeys.concat(user?.builder?.apps).find(y => {
return x.appId === apps.extractAppId(y) return x.appId === apps.extractAppId(y)
}) })
}) })
@ -119,7 +116,7 @@
name: app.name, name: app.name,
devId: app.devId, devId: app.devId,
icon: app.icon, icon: app.icon,
role: privileged ? Constants.Roles.ADMIN : roles[prodAppId], role: getRole(prodAppId, roles),
} }
}) })
} }
@ -132,6 +129,18 @@
return groups.filter(group => group.name?.toLowerCase().includes(search)) return groups.filter(group => group.name?.toLowerCase().includes(search))
} }
const getRole = (prodAppId, roles) => {
if (privileged) {
return Constants.Roles.ADMIN
}
if (user?.builder?.apps?.includes(prodAppId)) {
return Constants.Roles.CREATOR
}
return roles[prodAppId]
}
const getNameLabel = user => { const getNameLabel = user => {
const { firstName, lastName, email } = user || {} const { firstName, lastName, email } = user || {}
if (!firstName && !lastName) { if (!firstName && !lastName) {

View File

@ -2,12 +2,16 @@
import { StatusLight } from "@budibase/bbui" import { StatusLight } from "@budibase/bbui"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
import { roles } from "stores/backend" import { roles } from "stores/backend"
import { Constants } from "@budibase/frontend-core"
import { capitalise } from "helpers"
export let value export let value
const getRoleLabel = roleId => { const getRoleLabel = roleId => {
const role = $roles.find(x => x._id === roleId) const role = $roles.find(x => x._id === roleId)
return role?.name || "Custom role" return roleId === Constants.Roles.CREATOR
? capitalise(Constants.Roles.CREATOR.toLowerCase())
: role?.name || "Custom role"
} }
</script> </script>

View File

@ -5,9 +5,22 @@
export let value export let value
export let row export let row
$: console.log(row)
$: priviliged = sdk.users.isAdminOrBuilder(row) $: priviliged = sdk.users.isAdminOrBuilder(row)
$: count = priviliged ? $apps.length : value?.length || 0 $: count = getCount(row)
const getCount = () => {
if (priviliged) {
return $apps.length
} else {
return sdk.users.hasAppBuilderPermissions(row)
? row.builder.apps.length +
Object.keys(row.roles || {}).filter(appId =>
row.builder.apps.includes(appId)
).length
: value?.length || 0
}
}
</script> </script>
<div class="align"> <div class="align">

View File

@ -5,7 +5,7 @@
export let row export let row
const TooltipMap = { const TooltipMap = {
appUser: "Only has access to published apps", appUser: "Only has access to assigned apps",
developer: "Access to the app builder", developer: "Access to the app builder",
admin: "Full access", admin: "Full access",
} }

View File

@ -1,6 +1,7 @@
export { database } from "./database" export { database } from "./database"
export { tables } from "./tables" export { tables } from "./tables"
export { views } from "./views" export { views } from "./views"
export { viewsV2 } from "./viewsV2"
export { permissions } from "./permissions" export { permissions } from "./permissions"
export { roles } from "./roles" export { roles } from "./roles"
export { datasources, ImportTableError } from "./datasources" export { datasources, ImportTableError } from "./datasources"

View File

@ -13,9 +13,22 @@ export function createPermissionStore() {
level, level,
}) })
}, },
remove: async ({ level, role, resource }) => {
return await API.removePermissionFromResource({
resourceId: resource,
roleId: role,
level,
})
},
forResource: async resourceId => { forResource: async resourceId => {
return (await API.getPermissionForResource(resourceId)).permissions
},
forResourceDetailed: async resourceId => {
return await API.getPermissionForResource(resourceId) return await API.getPermissionForResource(resourceId)
}, },
getDependantsInfo: async resourceId => {
return await API.getDependants(resourceId)
},
} }
} }

View File

@ -9,7 +9,10 @@ export function createViewsStore() {
const derivedStore = derived([store, tables], ([$store, $tables]) => { const derivedStore = derived([store, tables], ([$store, $tables]) => {
let list = [] let list = []
$tables.list?.forEach(table => { $tables.list?.forEach(table => {
list = list.concat(Object.values(table?.views || {})) const views = Object.values(table?.views || {}).filter(view => {
return view.version !== 2
})
list = list.concat(views)
}) })
return { return {
...$store, ...$store,

View File

@ -0,0 +1,102 @@
import { writable, derived, get } from "svelte/store"
import { tables } from "./"
import { API } from "api"
export function createViewsV2Store() {
const store = writable({
selectedViewId: null,
})
const derivedStore = derived([store, tables], ([$store, $tables]) => {
let list = []
$tables.list?.forEach(table => {
const views = Object.values(table?.views || {}).filter(view => {
return view.version === 2
})
list = list.concat(views)
})
return {
...$store,
list,
selected: list.find(view => view.id === $store.selectedViewId),
}
})
const select = id => {
store.update(state => ({
...state,
selectedViewId: id,
}))
}
const deleteView = async view => {
await API.viewV2.delete(view.id)
replaceView(view.id, null)
}
const create = async view => {
const savedViewResponse = await API.viewV2.create(view)
const savedView = savedViewResponse.data
replaceView(savedView.id, savedView)
return savedView
}
const save = async view => {
const res = await API.viewV2.update(view)
const savedView = res?.data
replaceView(view.id, savedView)
}
// Handles external updates of tables
const replaceView = (viewId, view) => {
if (!viewId) {
return
}
const existingView = get(derivedStore).list.find(view => view.id === viewId)
const tableIndex = get(tables).list.findIndex(table => {
return table._id === view?.tableId || table._id === existingView?.tableId
})
if (tableIndex === -1) {
return
}
// Handle deletion
if (!view) {
tables.update(state => {
delete state.list[tableIndex].views[existingView.name]
return state
})
return
}
// Add new view
if (!existingView) {
tables.update(state => {
state.list[tableIndex].views[view.name] = view
return state
})
}
// Update existing view
else {
tables.update(state => {
// Remove old view
delete state.list[tableIndex].views[existingView.name]
// Add new view
state.list[tableIndex].views[view.name] = view
return state
})
}
}
return {
subscribe: derivedStore.subscribe,
select,
delete: deleteView,
create,
save,
replaceView,
}
}
export const viewsV2 = createViewsV2Store()

View File

@ -78,7 +78,19 @@ export function createGroupsStore() {
}, },
getGroupAppIds: group => { getGroupAppIds: group => {
return Object.keys(group?.roles || {}) let groupAppIds = Object.keys(group?.roles || {})
if (group?.builder?.apps) {
groupAppIds = groupAppIds.concat(group.builder.apps)
}
return groupAppIds
},
addGroupAppBuilder: async (groupId, appId) => {
return await API.addGroupAppBuilder({ groupId, appId })
},
removeGroupAppBuilder: async (groupId, appId) => {
return await API.removeGroupAppBuilder({ groupId, appId })
}, },
} }

View File

@ -125,6 +125,13 @@ export const createLicensingStore = () => {
const syncAutomationsEnabled = license.features.includes( const syncAutomationsEnabled = license.features.includes(
Constants.Features.SYNC_AUTOMATIONS Constants.Features.SYNC_AUTOMATIONS
) )
const perAppBuildersEnabled = license.features.includes(
Constants.Features.APP_BUILDERS
)
const isViewPermissionsEnabled = license.features.includes(
Constants.Features.VIEW_PERMISSIONS
)
store.update(state => { store.update(state => {
return { return {
...state, ...state,
@ -140,6 +147,8 @@ export const createLicensingStore = () => {
auditLogsEnabled, auditLogsEnabled,
enforceableSSO, enforceableSSO,
syncAutomationsEnabled, syncAutomationsEnabled,
isViewPermissionsEnabled,
perAppBuildersEnabled,
} }
}) })
}, },

View File

@ -112,12 +112,16 @@ export function createUsersStore() {
return await API.saveUser(user) return await API.saveUser(user)
} }
async function addAppBuilder(userId, appId) {
return await API.addAppBuilder({ userId, appId })
}
async function removeAppBuilder(userId, appId) {
return await API.removeAppBuilder({ userId, appId })
}
const getUserRole = user => const getUserRole = user =>
sdk.users.isAdmin(user) sdk.users.isAdminOrGlobalBuilder(user) ? "admin" : "appUser"
? "admin"
: sdk.users.isBuilder(user)
? "developer"
: "appUser"
const refreshUsage = const refreshUsage =
fn => fn =>
@ -139,6 +143,8 @@ export function createUsersStore() {
getInvites, getInvites,
updateInvite, updateInvite,
getUserCountByApp, getUserCountByApp,
addAppBuilder,
removeAppBuilder,
// any operation that adds or deletes users // any operation that adds or deletes users
acceptInvite, acceptInvite,
create: refreshUsage(create), create: refreshUsage(create),

View File

@ -1,4 +1,4 @@
export { CommandWord, InitType, AnalyticsEvent } from "@budibase/types" 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" export const GENERATED_USER_EMAIL = "admin@admin.com"

View File

@ -4622,14 +4622,15 @@
"type": "field/sortable", "type": "field/sortable",
"label": "Sort by", "label": "Sort by",
"key": "sortColumn", "key": "sortColumn",
"placeholder": "None" "placeholder": "Default"
}, },
{ {
"type": "select", "type": "select",
"label": "Sort order", "label": "Sort order",
"key": "sortOrder", "key": "sortOrder",
"options": ["Ascending", "Descending"], "options": ["Ascending", "Descending"],
"defaultValue": "Ascending" "defaultValue": "Ascending",
"dependsOn": "sortColumn"
}, },
{ {
"type": "select", "type": "select",
@ -4737,7 +4738,7 @@
] ]
}, },
{ {
"label": "Fields", "label": "",
"type": "fieldConfiguration", "type": "fieldConfiguration",
"key": "sidePanelFields", "key": "sidePanelFields",
"nested": true, "nested": true,
@ -4747,17 +4748,7 @@
} }
}, },
{ {
"label": "Show delete", "label": "Save button",
"type": "boolean",
"key": "sidePanelShowDelete",
"nested": true,
"dependsOn": {
"setting": "clickBehaviour",
"value": "details"
}
},
{
"label": "Save label",
"type": "text", "type": "text",
"key": "sidePanelSaveLabel", "key": "sidePanelSaveLabel",
"defaultValue": "Save", "defaultValue": "Save",
@ -4768,7 +4759,7 @@
} }
}, },
{ {
"label": "Delete label", "label": "Delete button",
"type": "text", "type": "text",
"key": "sidePanelDeleteLabel", "key": "sidePanelDeleteLabel",
"defaultValue": "Delete", "defaultValue": "Delete",
@ -5281,20 +5272,9 @@
}, },
{ {
"type": "table", "type": "table",
"label": "Table", "label": "Data",
"key": "dataSource" "key": "dataSource"
}, },
{
"type": "text",
"label": "Row ID",
"key": "rowId",
"nested": true,
"dependsOn": {
"setting": "actionType",
"value": "Create",
"invert": true
}
},
{ {
"type": "text", "type": "text",
"label": "Title", "label": "Title",
@ -5302,116 +5282,55 @@
"nested": true "nested": true
}, },
{ {
"type": "select", "section": true,
"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",
"dependsOn": { "dependsOn": {
"setting": "actionType", "setting": "actionType",
"value": "Create", "value": "Create",
"invert": true "invert": true
} },
}, "name": "Row details",
{ "info": "<a href='https://docs.budibase.com/docs/form-block' target='_blank'>How to pass a row ID using bindings</a>",
"section": true,
"name": "Fields",
"settings": [ "settings": [
{ {
"type": "fieldConfiguration", "type": "text",
"label": "Fields", "label": "Row ID",
"key": "fields", "key": "rowId",
"selectAllFields": true "nested": true
}, },
{ {
"type": "select", "type": "text",
"label": "Field labels", "label": "Empty text",
"key": "labelPosition", "key": "noRowsMessage",
"defaultValue": "left", "defaultValue": "We couldn't find a row to display",
"options": [ "nested": true
{
"label": "Left",
"value": "left"
},
{
"label": "Above",
"value": "above"
}
]
},
{
"type": "boolean",
"label": "Disabled",
"key": "disabled",
"defaultValue": false,
"dependsOn": {
"setting": "actionType",
"value": "View",
"invert": true
}
} }
] ]
}, },
{ {
"section": true, "section": true,
"name": "Buttons", "name": "Buttons",
"dependsOn": {
"setting": "actionType",
"value": "View",
"invert": true
},
"settings": [ "settings": [
{
"type": "boolean",
"label": "Show save button",
"key": "showSaveButton",
"defaultValue": true,
"dependsOn": {
"setting": "actionType",
"value": "View",
"invert": true
}
},
{ {
"type": "text", "type": "text",
"key": "saveButtonLabel", "key": "saveButtonLabel",
"label": "Save button label", "label": "Save button",
"nested": true, "nested": true,
"defaultValue": "Save", "defaultValue": "Save"
"dependsOn": {
"setting": "showSaveButton",
"value": true
}
},
{
"type": "boolean",
"label": "Allow delete",
"key": "showDeleteButton",
"defaultValue": false,
"dependsOn": {
"setting": "actionType",
"value": "Update"
}
}, },
{ {
"type": "text", "type": "text",
"key": "deleteButtonLabel", "key": "deleteButtonLabel",
"label": "Delete button label", "label": "Delete button",
"nested": true, "nested": true,
"defaultValue": "Delete", "defaultValue": "Delete",
"dependsOn": { "dependsOn": {
"setting": "showDeleteButton", "setting": "actionType",
"value": true "value": "Update"
} }
}, },
{ {
@ -5429,7 +5348,67 @@
"type": "boolean", "type": "boolean",
"label": "Hide notifications", "label": "Hide notifications",
"key": "notificationOverride", "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
}
} }
] ]
} }
@ -5465,7 +5444,7 @@
"settings": [ "settings": [
{ {
"type": "table", "type": "table",
"label": "Table", "label": "Data",
"key": "dataSource", "key": "dataSource",
"required": true "required": true
}, },
@ -5556,7 +5535,7 @@
"settings": [ "settings": [
{ {
"type": "table", "type": "table",
"label": "Table", "label": "Data",
"key": "table", "key": "table",
"required": true "required": true
}, },
@ -5582,7 +5561,8 @@
"label": "Sort order", "label": "Sort order",
"key": "initialSortOrder", "key": "initialSortOrder",
"options": ["Ascending", "Descending"], "options": ["Ascending", "Descending"],
"defaultValue": "Ascending" "defaultValue": "Ascending",
"dependsOn": "initialSortColumn"
}, },
{ {
"type": "select", "type": "select",

View File

@ -272,12 +272,36 @@
return missing return missing
}) })
// Run any migrations
runMigrations(instance, settingsDefinition)
// Force an initial enrichment of the new settings // Force an initial enrichment of the new settings
enrichComponentSettings(get(context), settingsDefinitionMap, { enrichComponentSettings(get(context), settingsDefinitionMap, {
force: true, force: true,
}) })
} }
const runMigrations = (instance, settingsDefinition) => {
settingsDefinition.forEach(setting => {
// Migrate "table" settings to ensure they have a type and resource ID
if (setting.type === "table") {
const val = instance[setting.key]
if (val) {
if (!val.type) {
val.type = "table"
}
if (!val.resourceId) {
if (val.type === "viewV2") {
val.resourceId = val.id
} else {
val.resourceId = val.tableId
}
}
}
}
})
}
const getSettingsDefinitionMap = settingsDefinition => { const getSettingsDefinitionMap = settingsDefinition => {
let map = {} let map = {}
settingsDefinition?.forEach(setting => { settingsDefinition?.forEach(setting => {

View File

@ -29,6 +29,9 @@
paginate, paginate,
}) })
// Sanitize schema to remove hidden fields
$: schema = sanitizeSchema($fetch.schema)
// Build our action context // Build our action context
$: actions = [ $: actions = [
{ {
@ -66,7 +69,7 @@
rows: $fetch.rows, rows: $fetch.rows,
info: $fetch.info, info: $fetch.info,
datasource: dataSource || {}, datasource: dataSource || {},
schema: $fetch.schema, schema,
rowsLength: $fetch.rows.length, rowsLength: $fetch.rows.length,
// Undocumented properties. These aren't supposed to be used in builder // Undocumented properties. These aren't supposed to be used in builder
@ -94,6 +97,19 @@
}) })
} }
const sanitizeSchema = schema => {
if (!schema) {
return schema
}
let cloned = { ...schema }
Object.entries(cloned).forEach(([field, fieldSchema]) => {
if (fieldSchema.visible === false) {
delete cloned[field]
}
})
return cloned
}
const addQueryExtension = (key, extension) => { const addQueryExtension = (key, extension) => {
if (!key || !extension) { if (!key || !extension) {
return return

View File

@ -38,11 +38,8 @@
class:in-builder={$builderStore.inBuilder} class:in-builder={$builderStore.inBuilder}
> >
<Grid <Grid
tableId={table?.tableId} datasource={table}
{API} {API}
{allowAddRows}
{allowEditRows}
{allowDeleteRows}
{stripeRows} {stripeRows}
{initialFilter} {initialFilter}
{initialSortColumn} {initialSortColumn}
@ -50,9 +47,13 @@
{fixedRowHeight} {fixedRowHeight}
{columnWhitelist} {columnWhitelist}
{schemaOverrides} {schemaOverrides}
canAddRows={allowAddRows}
canEditRows={allowEditRows}
canDeleteRows={allowDeleteRows}
canEditColumns={false}
canExpandRows={false}
canSaveSchema={false}
showControls={false} showControls={false}
allowExpandRows={false}
allowSchemaChanges={false}
notifySuccess={notificationStore.actions.success} notifySuccess={notificationStore.actions.success}
notifyError={notificationStore.actions.error} notifyError={notificationStore.actions.error}
/> />

View File

@ -45,6 +45,9 @@
let enrichedSearchColumns let enrichedSearchColumns
let schemaLoaded = false let schemaLoaded = false
// Accommodate old config to ensure delete button does not reappear
$: deleteLabel = sidePanelShowDelete === false ? "" : sidePanelDeleteLabel
$: isDSPlus = dataSource?.type === "table" || dataSource?.type === "viewV2"
$: fetchSchema(dataSource) $: fetchSchema(dataSource)
$: enrichSearchColumns(searchColumns, schema).then( $: enrichSearchColumns(searchColumns, schema).then(
val => (enrichedSearchColumns = val) val => (enrichedSearchColumns = val)
@ -53,7 +56,7 @@
$: editTitle = getEditTitle(detailsFormBlockId, primaryDisplay) $: editTitle = getEditTitle(detailsFormBlockId, primaryDisplay)
$: normalFields = getNormalFields(schema) $: normalFields = getNormalFields(schema)
$: rowClickActions = $: rowClickActions =
clickBehaviour === "actions" || dataSource?.type !== "table" clickBehaviour === "actions" || !isDSPlus
? onClick ? onClick
: [ : [
{ {
@ -75,7 +78,7 @@
}, },
] ]
$: buttonClickActions = $: buttonClickActions =
titleButtonClickBehaviour === "actions" || dataSource?.type !== "table" titleButtonClickBehaviour === "actions" || !isDSPlus
? onClickTitleButton ? onClickTitleButton
: [ : [
{ {
@ -245,10 +248,8 @@
bind:id={detailsFormBlockId} bind:id={detailsFormBlockId}
props={{ props={{
dataSource, dataSource,
showSaveButton: true, saveButtonLabel: sidePanelSaveLabel || "Save", //always show
showDeleteButton: sidePanelShowDelete, deleteButtonLabel: deleteLabel, //respect config
saveButtonLabel: sidePanelSaveLabel,
deleteButtonLabel: sidePanelDeleteLabel,
actionType: "Update", actionType: "Update",
rowId: `{{ ${safe("state")}.${safe(stateKey)} }}`, rowId: `{{ ${safe("state")}.${safe(stateKey)} }}`,
fields: sidePanelFields || normalFields, fields: sidePanelFields || normalFields,

View File

@ -12,42 +12,59 @@
export let fields export let fields
export let labelPosition export let labelPosition
export let title export let title
export let showDeleteButton
export let showSaveButton
export let saveButtonLabel export let saveButtonLabel
export let deleteButtonLabel export let deleteButtonLabel
export let showSaveButton
export let showDeleteButton
export let rowId export let rowId
export let actionUrl export let actionUrl
export let noRowsMessage export let noRowsMessage
export let notificationOverride 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 { fetchDatasourceSchema } = getContext("sdk")
const convertOldFieldFormat = fields => { const convertOldFieldFormat = fields => {
if (typeof fields?.[0] === "string") { if (!fields) {
return fields.map(field => ({ name: field, displayName: field })) return []
} }
return fields.map(field => {
return fields 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) => { const getDefaultFields = (fields, schema) => {
if (schema && (!fields || fields.length === 0)) { if (!schema) {
const defaultFields = [] return []
Object.values(schema).forEach(field => {
if (field.autocolumn) return
defaultFields.push({
name: field.name,
displayName: field.name,
})
})
return defaultFields
} }
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 let schema
@ -56,7 +73,6 @@
$: formattedFields = convertOldFieldFormat(fields) $: formattedFields = convertOldFieldFormat(fields)
$: fieldsOrDefault = getDefaultFields(formattedFields, schema) $: fieldsOrDefault = getDefaultFields(formattedFields, schema)
$: fetchSchema(dataSource) $: fetchSchema(dataSource)
$: dataProvider = `{{ literal ${safe(providerId)} }}` $: dataProvider = `{{ literal ${safe(providerId)} }}`
$: filter = [ $: filter = [
@ -82,15 +98,12 @@
fields: fieldsOrDefault, fields: fieldsOrDefault,
labelPosition, labelPosition,
title, title,
saveButtonLabel, saveButtonLabel: saveLabel,
deleteButtonLabel, deleteButtonLabel: deleteLabel,
showSaveButton,
showDeleteButton,
schema, schema,
repeaterId, repeaterId,
notificationOverride, notificationOverride,
} }
const fetchSchema = async () => { const fetchSchema = async () => {
schema = (await fetchDatasourceSchema(dataSource)) || {} schema = (await fetchDatasourceSchema(dataSource)) || {}
} }

View File

@ -13,8 +13,6 @@
export let title export let title
export let saveButtonLabel export let saveButtonLabel
export let deleteButtonLabel export let deleteButtonLabel
export let showSaveButton
export let showDeleteButton
export let schema export let schema
export let repeaterId export let repeaterId
export let notificationOverride export let notificationOverride
@ -47,7 +45,7 @@
"##eventHandlerType": "Save Row", "##eventHandlerType": "Save Row",
parameters: { parameters: {
providerId: formId, providerId: formId,
tableId: dataSource?.tableId, tableId: dataSource?.resourceId,
notificationOverride, notificationOverride,
}, },
}, },
@ -80,7 +78,7 @@
"##eventHandlerType": "Delete Row", "##eventHandlerType": "Delete Row",
parameters: { parameters: {
confirm: true, confirm: true,
tableId: dataSource?.tableId, tableId: dataSource?.resourceId,
rowId: `{{ ${safe(repeaterId)}.${safe("_id")} }}`, rowId: `{{ ${safe(repeaterId)}.${safe("_id")} }}`,
revId: `{{ ${safe(repeaterId)}.${safe("_rev")} }}`, revId: `{{ ${safe(repeaterId)}.${safe("_rev")} }}`,
notificationOverride, notificationOverride,
@ -100,18 +98,33 @@
}, },
] ]
$: renderDeleteButton = showDeleteButton && actionType === "Update" $: renderDeleteButton = deleteButtonLabel && actionType === "Update"
$: renderSaveButton = showSaveButton && actionType !== "View" $: renderSaveButton = saveButtonLabel && actionType !== "View"
$: renderButtons = renderDeleteButton || renderSaveButton $: renderButtons = renderDeleteButton || renderSaveButton
$: renderHeader = renderButtons || title $: renderHeader = renderButtons || title
const getComponentForField = field => { const getComponentForField = field => {
if (!field || !schema?.[field]) { const fieldSchemaName = field.field || field.name
if (!fieldSchemaName || !schema?.[fieldSchemaName]) {
return null return null
} }
const type = schema[field].type const type = schema[fieldSchemaName].type
return FieldTypeToComponentMap[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> </script>
{#if fields?.length} {#if fields?.length}
@ -175,7 +188,7 @@
<BlockComponent <BlockComponent
type="button" type="button"
props={{ props={{
text: deleteButtonLabel || "Delete", text: deleteButtonLabel,
onClick: onDelete, onClick: onDelete,
quiet: true, quiet: true,
type: "secondary", type: "secondary",
@ -187,7 +200,7 @@
<BlockComponent <BlockComponent
type="button" type="button"
props={{ props={{
text: saveButtonLabel || "Save", text: saveButtonLabel,
onClick: onSave, onClick: onSave,
type: "cta", type: "cta",
}} }}
@ -200,15 +213,10 @@
{/if} {/if}
<BlockComponent type="fieldgroup" props={{ labelPosition }} order={1}> <BlockComponent type="fieldgroup" props={{ labelPosition }} order={1}>
{#each fields as field, idx} {#each fields as field, idx}
{#if getComponentForField(field.name)} {#if getComponentForField(field) && field.active}
<BlockComponent <BlockComponent
type={getComponentForField(field.name)} type={getComponentForField(field)}
props={{ props={getPropsForField(field)}
validation: field.validation,
field: field.name,
label: field.displayName,
placeholder: field.displayName,
}}
order={idx} order={idx}
/> />
{/if} {/if}

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