Merge branch 'develop' of github.com:Budibase/budibase into cheeks-fixes

This commit is contained in:
Andrew Kingston 2023-08-31 11:29:54 +01:00
commit bc8ebc42f9
145 changed files with 3290 additions and 1641 deletions

View File

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

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

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

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

@ -351,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
} }
} }
@ -464,7 +471,7 @@ const getComponentBindingCategory = (component, context, def) => {
*/ */
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")
@ -714,17 +721,25 @@ export const getActionBindings = (actions, actionId) => {
} }
/** /**
* 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)
} }
/** /**
@ -801,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)
@ -849,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) {
@ -864,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

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,26 @@ 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 => { 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 +31,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

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

@ -5,6 +5,7 @@
export let resourceId export let resourceId
export let disabled = false export let disabled = false
export let requiresLicence
let modal let modal
let resourcePermissions let resourcePermissions
@ -21,6 +22,7 @@
<Modal bind:this={modal}> <Modal bind:this={modal}>
<ManageAccessModal <ManageAccessModal
{resourceId} {resourceId}
{requiresLicence}
levels={$permissions} levels={$permissions}
permissions={resourcePermissions} permissions={resourcePermissions}
/> />

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,7 +1,7 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { Modal, ActionButton } from "@budibase/bbui" import { Modal, ActionButton } from "@budibase/bbui"
import CreateViewModal from "../../modals/CreateViewModal.svelte" import GridCreateViewModal from "../../modals/grid/GridCreateViewModal.svelte"
const { rows, columns } = getContext("grid") const { rows, columns } = getContext("grid")
@ -14,5 +14,5 @@
Add view Add view
</ActionButton> </ActionButton>
<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

@ -1,8 +1,29 @@
<script> <script>
import { licensing, admin } from "stores/portal"
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
}
var requiresLicence
$: {
if ($datasource.type === "viewV2" && !$licensing.isViewPermissionsEnabled) {
const requiredLicense = $admin?.cloud ? "Premium" : "Business"
requiresLicence = {
tier: requiredLicense,
message: `A ${requiredLicense} subscription is required to specify access level roles for this view.`,
}
}
}
</script> </script>
<ManageAccessButton resourceId={$tableId} /> <ManageAccessButton {resourceId} {requiresLicence} />

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

@ -7,11 +7,14 @@
notifications, notifications,
Body, Body,
ModalContent, ModalContent,
Tags,
Tag,
} from "@budibase/bbui" } from "@budibase/bbui"
import { capitalise } from "helpers" import { capitalise } from "helpers"
export let resourceId export let resourceId
export let permissions export let permissions
export let requiresLicence
async function changePermission(level, role) { async function changePermission(level, role) {
try { try {
@ -30,22 +33,36 @@
} }
</script> </script>
<ModalContent title="Manage Access" showCancelButton={false} confirmText="Done"> <ModalContent showCancelButton={false} confirmText="Done">
<Body size="S">Specify the minimum access level role for this data.</Body> <span slot="header">
<div class="row"> Manage Access
<Label extraSmall grey>Level</Label> {#if requiresLicence}
<Label extraSmall grey>Role</Label> <span class="lock-tag">
{#each Object.keys(permissions) as level} <Tags>
<Input value={capitalise(level)} disabled /> <Tag icon="LockClosed">{requiresLicence.tier}</Tag>
<Select </Tags>
value={permissions[level]} </span>
on:change={e => changePermission(level, e.detail)} {/if}
options={$roles} </span>
getOptionLabel={x => x.name} {#if requiresLicence}
getOptionValue={x => x._id} <Body size="S">{requiresLicence.message}</Body>
/> {:else}
{/each} <Body size="S">Specify the minimum access level role for this data.</Body>
</div> <div class="row">
<Label extraSmall grey>Level</Label>
<Label extraSmall grey>Role</Label>
{#each Object.keys(permissions) as level}
<Input value={capitalise(level)} disabled />
<Select
value={permissions[level]}
on:change={e => changePermission(level, e.detail)}
options={$roles}
getOptionLabel={x => x.name}
getOptionValue={x => x._id}
/>
{/each}
</div>
{/if}
</ModalContent> </ModalContent>
<style> <style>
@ -54,4 +71,8 @@
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);
}
</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

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

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

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

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

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

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

@ -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",
@ -5271,7 +5272,7 @@
}, },
{ {
"type": "table", "type": "table",
"label": "Table", "label": "Data",
"key": "dataSource" "key": "dataSource"
}, },
{ {
@ -5443,7 +5444,7 @@
"settings": [ "settings": [
{ {
"type": "table", "type": "table",
"label": "Table", "label": "Data",
"key": "dataSource", "key": "dataSource",
"required": true "required": true
}, },
@ -5534,7 +5535,7 @@
"settings": [ "settings": [
{ {
"type": "table", "type": "table",
"label": "Table", "label": "Data",
"key": "table", "key": "table",
"required": true "required": true
}, },
@ -5560,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

@ -47,7 +47,7 @@
// Accommodate old config to ensure delete button does not reappear // Accommodate old config to ensure delete button does not reappear
$: deleteLabel = sidePanelShowDelete === false ? "" : sidePanelDeleteLabel $: 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)
@ -56,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
: [ : [
{ {
@ -78,7 +78,7 @@
}, },
] ]
$: buttonClickActions = $: buttonClickActions =
titleButtonClickBehaviour === "actions" || dataSource?.type !== "table" titleButtonClickBehaviour === "actions" || !isDSPlus
? onClickTitleButton ? onClickTitleButton
: [ : [
{ {

View File

@ -45,7 +45,7 @@
"##eventHandlerType": "Save Row", "##eventHandlerType": "Save Row",
parameters: { parameters: {
providerId: formId, providerId: formId,
tableId: dataSource?.tableId, tableId: dataSource?.resourceId,
notificationOverride, notificationOverride,
}, },
}, },
@ -78,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,

View File

@ -45,7 +45,7 @@
return return
} }
nextIndicators[idx].visible = nextIndicators[idx].visible =
nextIndicators[idx].isSidePanel || entries[0].isIntersecting nextIndicators[idx].insideSidePanel || entries[0].isIntersecting
if (++callbackCount === observers.length) { if (++callbackCount === observers.length) {
indicators = nextIndicators indicators = nextIndicators
updating = false updating = false
@ -125,7 +125,7 @@
width: elBounds.width + 4, width: elBounds.width + 4,
height: elBounds.height + 4, height: elBounds.height + 4,
visible: false, visible: false,
isSidePanel: child.classList.contains("side-panel"), insideSidePanel: !!child.closest(".side-panel"),
}) })
}) })
} }

View File

@ -18,6 +18,8 @@ export const createDataSourceStore = () => {
// Extract table ID // Extract table ID
if (dataSource.type === "table" || dataSource.type === "view") { if (dataSource.type === "table" || dataSource.type === "view") {
dataSourceId = dataSource.tableId dataSourceId = dataSource.tableId
} else if (dataSource.type === "viewV2") {
dataSourceId = dataSource.id
} }
// Only one side of the relationship is required as a trigger, as it will // Only one side of the relationship is required as a trigger, as it will
@ -79,7 +81,7 @@ export const createDataSourceStore = () => {
// Fetch related table IDs from table schema // Fetch related table IDs from table schema
let schema let schema
if (options.invalidateRelationships) { if (options.invalidateRelationships && !dataSourceId?.includes("view_")) {
try { try {
const definition = await API.fetchTableDefinition(dataSourceId) const definition = await API.fetchTableDefinition(dataSourceId)
schema = definition?.schema schema = definition?.schema

View File

@ -42,7 +42,7 @@ const saveRowHandler = async (action, context) => {
} }
// Refresh related datasources // Refresh related datasources
await dataSourceStore.actions.invalidateDataSource(row.tableId, { await dataSourceStore.actions.invalidateDataSource(tableId, {
invalidateRelationships: true, invalidateRelationships: true,
}) })
@ -75,7 +75,7 @@ const duplicateRowHandler = async (action, context) => {
} }
// Refresh related datasources // Refresh related datasources
await dataSourceStore.actions.invalidateDataSource(row.tableId, { await dataSourceStore.actions.invalidateDataSource(tableId, {
invalidateRelationships: true, invalidateRelationships: true,
}) })

View File

@ -6,6 +6,7 @@ import RelationshipFetch from "@budibase/frontend-core/src/fetch/RelationshipFet
import NestedProviderFetch from "@budibase/frontend-core/src/fetch/NestedProviderFetch.js" import NestedProviderFetch from "@budibase/frontend-core/src/fetch/NestedProviderFetch.js"
import FieldFetch from "@budibase/frontend-core/src/fetch/FieldFetch.js" import FieldFetch from "@budibase/frontend-core/src/fetch/FieldFetch.js"
import JSONArrayFetch from "@budibase/frontend-core/src/fetch/JSONArrayFetch.js" import JSONArrayFetch from "@budibase/frontend-core/src/fetch/JSONArrayFetch.js"
import ViewV2Fetch from "@budibase/frontend-core/src/fetch/ViewV2Fetch.js"
/** /**
* Fetches the schema of any kind of datasource. * Fetches the schema of any kind of datasource.
@ -21,6 +22,7 @@ export const fetchDatasourceSchema = async (
const handler = { const handler = {
table: TableFetch, table: TableFetch,
view: ViewFetch, view: ViewFetch,
viewV2: ViewV2Fetch,
query: QueryFetch, query: QueryFetch,
link: RelationshipFetch, link: RelationshipFetch,
provider: NestedProviderFetch, provider: NestedProviderFetch,
@ -49,6 +51,15 @@ export const fetchDatasourceSchema = async (
return null return null
} }
// Strip hidden fields from views
if (datasource.type === "viewV2") {
Object.keys(schema).forEach(field => {
if (!schema[field].visible) {
delete schema[field]
}
})
}
// Enrich schema with relationships if required // Enrich schema with relationships if required
if (definition?.sql && options?.enrichRelationships) { if (definition?.sql && options?.enrichRelationships) {
const relationshipAdditions = await getRelationshipSchemaAdditions(schema) const relationshipAdditions = await getRelationshipSchemaAdditions(schema)

View File

@ -104,5 +104,27 @@ export const buildGroupsEndpoints = API => {
removeAppsFromGroup: async (groupId, appArray) => { removeAppsFromGroup: async (groupId, appArray) => {
return updateGroupResource(groupId, "apps", "remove", appArray) return updateGroupResource(groupId, "apps", "remove", appArray)
}, },
/**
* Add app builder to group
* @param groupId The group to update
* @param appId The app id where the builder will be added
*/
addGroupAppBuilder: async ({ groupId, appId }) => {
return await API.post({
url: `/api/global/groups/${groupId}/app/${appId}/builder`,
})
},
/**
* Remove app builder from group
* @param groupId The group to update
* @param appId The app id where the builder will be removed
*/
removeGroupAppBuilder: async ({ groupId, appId }) => {
return await API.delete({
url: `/api/global/groups/${groupId}/app/${appId}/builder`,
})
},
} }
} }

View File

@ -23,6 +23,7 @@ import { buildTemplateEndpoints } from "./templates"
import { buildUserEndpoints } from "./user" import { buildUserEndpoints } from "./user"
import { buildSelfEndpoints } from "./self" import { buildSelfEndpoints } from "./self"
import { buildViewEndpoints } from "./views" import { buildViewEndpoints } from "./views"
import { buildViewV2Endpoints } from "./viewsV2"
import { buildLicensingEndpoints } from "./licensing" import { buildLicensingEndpoints } from "./licensing"
import { buildGroupsEndpoints } from "./groups" import { buildGroupsEndpoints } from "./groups"
import { buildPluginEndpoints } from "./plugins" import { buildPluginEndpoints } from "./plugins"
@ -279,5 +280,6 @@ export const createAPIClient = config => {
...buildEventEndpoints(API), ...buildEventEndpoints(API),
...buildAuditLogsEndpoints(API), ...buildAuditLogsEndpoints(API),
...buildLogsEndpoints(API), ...buildLogsEndpoints(API),
viewV2: buildViewV2Endpoints(API),
} }
} }

View File

@ -23,7 +23,23 @@ export const buildRowEndpoints = API => ({
return return
} }
return await API.post({ return await API.post({
url: `/api/${row.tableId}/rows`, url: `/api/${row._viewId || row.tableId}/rows`,
body: row,
suppressErrors,
})
},
/**
* Patches a row in a table.
* @param row the row to patch
* @param suppressErrors whether or not to suppress error notifications
*/
patchRow: async (row, suppressErrors = false) => {
if (!row?.tableId && !row?._viewId) {
return
}
return await API.patch({
url: `/api/${row._viewId || row.tableId}/rows`,
body: row, body: row,
suppressErrors, suppressErrors,
}) })
@ -31,7 +47,7 @@ export const buildRowEndpoints = API => ({
/** /**
* Deletes a row from a table. * Deletes a row from a table.
* @param tableId the ID of the table to delete from * @param tableId the ID of the table or view to delete from
* @param rowId the ID of the row to delete * @param rowId the ID of the row to delete
* @param revId the rev of the row to delete * @param revId the rev of the row to delete
*/ */
@ -50,10 +66,13 @@ export const buildRowEndpoints = API => ({
/** /**
* Deletes multiple rows from a table. * Deletes multiple rows from a table.
* @param tableId the table ID to delete the rows from * @param tableId the table or view ID to delete the rows from
* @param rows the array of rows to delete * @param rows the array of rows to delete
*/ */
deleteRows: async ({ tableId, rows }) => { deleteRows: async ({ tableId, rows }) => {
rows?.forEach(row => {
delete row?._viewId
})
return await API.delete({ return await API.delete({
url: `/api/${tableId}/rows`, url: `/api/${tableId}/rows`,
body: { body: {

View File

@ -156,13 +156,14 @@ export const buildUserEndpoints = API => ({
return await API.post({ return await API.post({
url: "/api/global/users/onboard", url: "/api/global/users/onboard",
body: payload.map(invite => { body: payload.map(invite => {
const { email, admin, builder, apps } = invite const { email, admin, builder, apps, appBuilders } = invite
return { return {
email, email,
userInfo: { userInfo: {
admin: admin ? { global: true } : undefined, admin: admin ? { global: true } : undefined,
builder: builder ? { global: true } : undefined, builder: builder ? { global: true } : undefined,
apps: apps ? apps : undefined, apps: apps ? apps : undefined,
appBuilders,
}, },
} }
}), }),
@ -175,10 +176,12 @@ export const buildUserEndpoints = API => ({
* @param invite the invite code sent in the email * @param invite the invite code sent in the email
*/ */
updateUserInvite: async invite => { updateUserInvite: async invite => {
console.log(invite)
await API.post({ await API.post({
url: `/api/global/users/invite/update/${invite.code}`, url: `/api/global/users/invite/update/${invite.code}`,
body: { body: {
apps: invite.apps, apps: invite.apps,
appBuilders: invite.appBuilders,
}, },
}) })
}, },
@ -250,4 +253,26 @@ export const buildUserEndpoints = API => ({
url: `/api/global/users/count/${appId}`, url: `/api/global/users/count/${appId}`,
}) })
}, },
/**
* Adds a per app builder to the selected app
* @param appId the applications id
* @param userId The id of the user to add as a builder
*/
addAppBuilder: async ({ userId, appId }) => {
return await API.post({
url: `/api/global/users/${userId}/app/${appId}/builder`,
})
},
/**
* Removes a per app builder to the selected app
* @param appId the applications id
* @param userId The id of the user to remove as a builder
*/
removeAppBuilder: async ({ userId, appId }) => {
return await API.delete({
url: `/api/global/users/${userId}/app/${appId}/builder`,
})
},
}) })

View File

@ -0,0 +1,72 @@
export const buildViewV2Endpoints = API => ({
/**
* Fetches the definition of a view
* @param viewId the ID of the view to fetch
*/
fetchDefinition: async viewId => {
return await API.get({
url: `/api/v2/views/${viewId}`,
})
},
/**
* Create a new view
* @param view the view object
*/
create: async view => {
return await API.post({
url: `/api/v2/views`,
body: view,
})
},
/**
* Updates a view
* @param view the view object
*/
update: async view => {
return await API.put({
url: `/api/v2/views/${view.id}`,
body: view,
})
},
/**
* Fetches all rows in a view
* @param viewId the id of the view
* @param query the search query
* @param paginate whether to paginate or not
* @param limit page size
* @param bookmark pagination cursor
* @param sort sort column
* @param sortOrder sort order
* @param sortType sort type (text or numeric)
*/
fetch: async ({
viewId,
query,
paginate,
limit,
bookmark,
sort,
sortOrder,
sortType,
}) => {
return await API.post({
url: `/api/v2/views/${viewId}/search`,
body: {
query,
paginate,
limit,
bookmark,
sort,
sortOrder,
sortType,
},
})
},
/**
* Delete a view
* @param viewId the id of the view
*/
delete: async viewId => {
return await API.delete({ url: `/api/v2/views/${viewId}` })
},
})

View File

@ -34,7 +34,7 @@
column.schema.autocolumn || column.schema.autocolumn ||
column.schema.disabled || column.schema.disabled ||
column.schema.type === "formula" || column.schema.type === "formula" ||
(!$config.allowEditRows && row._id) (!$config.canEditRows && row._id)
// Register this cell API if the row is focused // Register this cell API if the row is focused
$: { $: {
@ -58,9 +58,14 @@
isReadonly: () => readonly, isReadonly: () => readonly,
getType: () => column.schema.type, getType: () => column.schema.type,
getValue: () => row[column.name], getValue: () => row[column.name],
setValue: value => { setValue: (value, options = { save: true }) => {
validation.actions.setError(cellId, null) validation.actions.setError(cellId, null)
updateValue(row._id, column.name, value) updateValue({
rowId: row._id,
column: column.name,
value,
save: options?.save,
})
}, },
} }
</script> </script>

View File

@ -40,7 +40,7 @@
<div <div
on:click={select} on:click={select}
class="checkbox" class="checkbox"
class:visible={$config.allowDeleteRows && class:visible={$config.canDeleteRows &&
(disableNumber || rowSelected || rowHovered || rowFocused)} (disableNumber || rowSelected || rowHovered || rowFocused)}
> >
<Checkbox value={rowSelected} {disabled} /> <Checkbox value={rowSelected} {disabled} />
@ -48,14 +48,14 @@
{#if !disableNumber} {#if !disableNumber}
<div <div
class="number" class="number"
class:visible={!$config.allowDeleteRows || class:visible={!$config.canDeleteRows ||
!(rowSelected || rowHovered || rowFocused)} !(rowSelected || rowHovered || rowFocused)}
> >
{row.__idx + 1} {row.__idx + 1}
</div> </div>
{/if} {/if}
{/if} {/if}
{#if rowSelected && $config.allowDeleteRows} {#if rowSelected && $config.canDeleteRows}
<div class="delete" on:click={() => dispatch("request-bulk-delete")}> <div class="delete" on:click={() => dispatch("request-bulk-delete")}>
<Icon <Icon
name="Delete" name="Delete"
@ -64,7 +64,7 @@
/> />
</div> </div>
{:else} {:else}
<div class="expand" class:visible={$config.allowExpandRows && expandable}> <div class="expand" class:visible={$config.canExpandRows && expandable}>
<Icon <Icon
size="S" size="S"
name="Maximize" name="Maximize"

View File

@ -56,6 +56,7 @@
popover.hide() popover.hide()
editIsOpen = false editIsOpen = false
} }
const onMouseDown = e => { const onMouseDown = e => {
if (e.button === 0 && orderable) { if (e.button === 0 && orderable) {
timeout = setTimeout(() => { timeout = setTimeout(() => {
@ -116,6 +117,7 @@
columns.actions.saveChanges() columns.actions.saveChanges()
open = false open = false
} }
onMount(() => subscribe("close-edit-column", cancelEdit)) onMount(() => subscribe("close-edit-column", cancelEdit))
</script> </script>
@ -170,7 +172,6 @@
align="right" align="right"
offset={0} offset={0}
popoverTarget={document.getElementById(`grid-${rand}`)} popoverTarget={document.getElementById(`grid-${rand}`)}
animate={false}
customZindex={100} customZindex={100}
> >
{#if editIsOpen} {#if editIsOpen}
@ -187,7 +188,7 @@
<MenuItem <MenuItem
icon="Edit" icon="Edit"
on:click={editColumn} on:click={editColumn}
disabled={!$config.allowSchemaChanges || column.schema.disabled} disabled={!$config.canEditColumns || column.schema.disabled}
> >
Edit column Edit column
</MenuItem> </MenuItem>
@ -195,7 +196,6 @@
icon="Label" icon="Label"
on:click={makeDisplayColumn} on:click={makeDisplayColumn}
disabled={idx === "sticky" || disabled={idx === "sticky" ||
!$config.allowSchemaChanges ||
bannedDisplayColumnTypes.includes(column.schema.type)} bannedDisplayColumnTypes.includes(column.schema.type)}
> >
Use as display column Use as display column

View File

@ -3,7 +3,7 @@
import { ActionButton, Popover, Toggle, Icon } from "@budibase/bbui" import { ActionButton, Popover, Toggle, Icon } from "@budibase/bbui"
import { getColumnIcon } from "../lib/utils" import { getColumnIcon } from "../lib/utils"
const { columns, stickyColumn } = getContext("grid") const { columns, stickyColumn, dispatch } = getContext("grid")
let open = false let open = false
let anchor let anchor
@ -11,33 +11,36 @@
$: anyHidden = $columns.some(col => !col.visible) $: anyHidden = $columns.some(col => !col.visible)
$: text = getText($columns) $: text = getText($columns)
const toggleVisibility = (column, visible) => { const toggleVisibility = async (column, visible) => {
columns.update(state => { columns.update(state => {
const index = state.findIndex(col => col.name === column.name) const index = state.findIndex(col => col.name === column.name)
state[index].visible = visible state[index].visible = visible
return state.slice() return state.slice()
}) })
columns.actions.saveChanges() await columns.actions.saveChanges()
dispatch(visible ? "show-column" : "hide-column")
} }
const showAll = () => { const showAll = async () => {
columns.update(state => { columns.update(state => {
return state.map(col => ({ return state.map(col => ({
...col, ...col,
visible: true, visible: true,
})) }))
}) })
columns.actions.saveChanges() await columns.actions.saveChanges()
dispatch("show-column")
} }
const hideAll = () => { const hideAll = async () => {
columns.update(state => { columns.update(state => {
return state.map(col => ({ return state.map(col => ({
...col, ...col,
visible: false, visible: false,
})) }))
}) })
columns.actions.saveChanges() await columns.actions.saveChanges()
dispatch("hide-column")
} }
const getText = columns => { const getText = columns => {

View File

@ -8,8 +8,14 @@
SmallRowHeight, SmallRowHeight,
} from "../lib/constants" } from "../lib/constants"
const { stickyColumn, columns, rowHeight, table, fixedRowHeight } = const {
getContext("grid") stickyColumn,
columns,
rowHeight,
definition,
fixedRowHeight,
datasource,
} = getContext("grid")
// Some constants for column width options // Some constants for column width options
const smallColSize = 120 const smallColSize = 120
@ -60,8 +66,8 @@
] ]
const changeRowHeight = height => { const changeRowHeight = height => {
columns.actions.saveTable({ datasource.actions.saveDefinition({
...$table, ...$definition,
rowHeight: height, rowHeight: height,
}) })
} }

View File

@ -8,7 +8,6 @@
let anchor let anchor
$: columnOptions = getColumnOptions($stickyColumn, $columns) $: columnOptions = getColumnOptions($stickyColumn, $columns)
$: checkValidSortColumn($sort.column, $stickyColumn, $columns)
$: orderOptions = getOrderOptions($sort.column, columnOptions) $: orderOptions = getOrderOptions($sort.column, columnOptions)
const getColumnOptions = (stickyColumn, columns) => { const getColumnOptions = (stickyColumn, columns) => {
@ -46,8 +45,8 @@
const updateSortColumn = e => { const updateSortColumn = e => {
sort.update(state => ({ sort.update(state => ({
...state,
column: e.detail, column: e.detail,
order: e.detail ? state.order : "ascending",
})) }))
} }
@ -57,29 +56,6 @@
order: e.detail, order: e.detail,
})) }))
} }
// Ensure we never have a sort column selected that is not visible
const checkValidSortColumn = (sortColumn, stickyColumn, columns) => {
if (!sortColumn) {
return
}
if (
sortColumn !== stickyColumn?.name &&
!columns.some(col => col.name === sortColumn)
) {
if (stickyColumn) {
sort.update(state => ({
...state,
column: stickyColumn.name,
}))
} else {
sort.update(state => ({
...state,
column: columns[0]?.name,
}))
}
}
}
</script> </script>
<div bind:this={anchor}> <div bind:this={anchor}>
@ -98,21 +74,23 @@
<Popover bind:open {anchor} align="left"> <Popover bind:open {anchor} align="left">
<div class="content"> <div class="content">
<Select <Select
placeholder={null} placeholder="Default"
value={$sort.column} value={$sort.column}
options={columnOptions} options={columnOptions}
autoWidth autoWidth
on:change={updateSortColumn} on:change={updateSortColumn}
label="Column" label="Column"
/> />
<Select {#if $sort.column}
placeholder={null} <Select
value={$sort.order} placeholder={null}
options={orderOptions} value={$sort.order || "ascending"}
autoWidth options={orderOptions}
on:change={updateSortOrder} autoWidth
label="Order" on:change={updateSortOrder}
/> label="Order"
/>
{/if}
</div> </div>
</Popover> </Popover>

View File

@ -1,5 +1,6 @@
<script> <script>
import { setContext, onMount } from "svelte" import { setContext, onMount } from "svelte"
import { writable } from "svelte/store"
import { fade } from "svelte/transition" import { fade } from "svelte/transition"
import { clickOutside, ProgressCircle } from "@budibase/bbui" import { clickOutside, ProgressCircle } from "@budibase/bbui"
import { createEventManagers } from "../lib/events" import { createEventManagers } from "../lib/events"
@ -28,14 +29,15 @@
} from "../lib/constants" } from "../lib/constants"
export let API = null export let API = null
export let tableId = null export let datasource = null
export let schemaOverrides = null export let schemaOverrides = null
export let columnWhitelist = null export let columnWhitelist = null
export let allowAddRows = true export let canAddRows = true
export let allowExpandRows = true export let canExpandRows = true
export let allowEditRows = true export let canEditRows = true
export let allowDeleteRows = true export let canDeleteRows = true
export let allowSchemaChanges = true export let canEditColumns = true
export let canSaveSchema = true
export let stripeRows = false export let stripeRows = false
export let collaboration = true export let collaboration = true
export let showAvatars = true export let showAvatars = true
@ -50,11 +52,14 @@
// Unique identifier for DOM nodes inside this instance // Unique identifier for DOM nodes inside this instance
const rand = Math.random() const rand = Math.random()
// Store props in a store for reference in other stores
const props = writable($$props)
// Build up context // Build up context
let context = { let context = {
API: API || createAPIClient(), API: API || createAPIClient(),
rand, rand,
props: $$props, props,
} }
context = { ...context, ...createEventManagers() } context = { ...context, ...createEventManagers() }
context = attachStores(context) context = attachStores(context)
@ -71,19 +76,19 @@
contentLines, contentLines,
gridFocused, gridFocused,
error, error,
canAddRows,
} = context } = context
// Keep config store up to date with props // Keep config store up to date with props
$: config.set({ $: props.set({
tableId, datasource,
schemaOverrides, schemaOverrides,
columnWhitelist, columnWhitelist,
allowAddRows, canAddRows,
allowExpandRows, canExpandRows,
allowEditRows, canEditRows,
allowDeleteRows, canDeleteRows,
allowSchemaChanges, canEditColumns,
canSaveSchema,
stripeRows, stripeRows,
collaboration, collaboration,
showAvatars, showAvatars,
@ -155,7 +160,7 @@
</HeaderRow> </HeaderRow>
<GridBody /> <GridBody />
</div> </div>
{#if $canAddRows} {#if $config.canAddRows}
<NewRow /> <NewRow />
{/if} {/if}
<div class="overlays"> <div class="overlays">
@ -179,7 +184,7 @@
<ProgressCircle /> <ProgressCircle />
</div> </div>
{/if} {/if}
{#if allowDeleteRows} {#if $config.canDeleteRows}
<BulkDeleteHandler /> <BulkDeleteHandler />
{/if} {/if}
<KeyboardManager /> <KeyboardManager />

View File

@ -9,10 +9,10 @@
renderedRows, renderedRows,
renderedColumns, renderedColumns,
rowVerticalInversionIndex, rowVerticalInversionIndex,
canAddRows,
hoveredRowId, hoveredRowId,
dispatch, dispatch,
isDragging, isDragging,
config,
} = getContext("grid") } = getContext("grid")
let body let body
@ -43,7 +43,7 @@
invertY={idx >= $rowVerticalInversionIndex} invertY={idx >= $rowVerticalInversionIndex}
/> />
{/each} {/each}
{#if $canAddRows} {#if $config.canAddRows}
<div <div
class="blank" class="blank"
class:highlighted={$hoveredRowId === BlankRowID} class:highlighted={$hoveredRowId === BlankRowID}

View File

@ -1,12 +1,11 @@
<script> <script>
import NewColumnButton from "./NewColumnButton.svelte" import NewColumnButton from "./NewColumnButton.svelte"
import { getContext } from "svelte" import { getContext } from "svelte"
import GridScrollWrapper from "./GridScrollWrapper.svelte" import GridScrollWrapper from "./GridScrollWrapper.svelte"
import HeaderCell from "../cells/HeaderCell.svelte" import HeaderCell from "../cells/HeaderCell.svelte"
import { TempTooltip, TooltipType } from "@budibase/bbui" import { TempTooltip, TooltipType } from "@budibase/bbui"
const { renderedColumns, config, hasNonAutoColumn, tableId, loading } = const { renderedColumns, config, hasNonAutoColumn, datasource, loading } =
getContext("grid") getContext("grid")
</script> </script>
@ -20,8 +19,8 @@
{/each} {/each}
</div> </div>
</GridScrollWrapper> </GridScrollWrapper>
{#if $config.allowSchemaChanges} {#if $config.canEditColumns}
{#key $tableId} {#key $datasource}
<TempTooltip <TempTooltip
text="Click here to create your first column" text="Click here to create your first column"
type={TooltipType.Info} type={TooltipType.Info}

View File

@ -7,6 +7,7 @@
let anchor let anchor
let open = false let open = false
$: columnsWidth = $renderedColumns.reduce( $: columnsWidth = $renderedColumns.reduce(
(total, col) => (total += col.width), (total, col) => (total += col.width),
0 0
@ -17,6 +18,7 @@
const close = () => { const close = () => {
open = false open = false
} }
onMount(() => subscribe("close-edit-column", close)) onMount(() => subscribe("close-edit-column", close))
</script> </script>
@ -35,7 +37,6 @@
align={$renderedColumns.length ? "right" : "left"} align={$renderedColumns.length ? "right" : "left"}
offset={0} offset={0}
popoverTarget={document.getElementById(`add-column-button`)} popoverTarget={document.getElementById(`add-column-button`)}
animate={false}
customZindex={100} customZindex={100}
> >
<div <div

View File

@ -17,7 +17,7 @@
dispatch, dispatch,
rows, rows,
focusedCellAPI, focusedCellAPI,
tableId, datasource,
subscribe, subscribe,
renderedRows, renderedRows,
renderedColumns, renderedColumns,
@ -28,7 +28,7 @@
columnHorizontalInversionIndex, columnHorizontalInversionIndex,
selectedRows, selectedRows,
loading, loading,
canAddRows, config,
} = getContext("grid") } = getContext("grid")
let visible = false let visible = false
@ -38,7 +38,7 @@
$: firstColumn = $stickyColumn || $renderedColumns[0] $: firstColumn = $stickyColumn || $renderedColumns[0]
$: width = GutterWidth + ($stickyColumn?.width || 0) $: width = GutterWidth + ($stickyColumn?.width || 0)
$: $tableId, (visible = false) $: $datasource, (visible = false)
$: invertY = shouldInvertY(offset, $rowVerticalInversionIndex, $renderedRows) $: invertY = shouldInvertY(offset, $rowVerticalInversionIndex, $renderedRows)
$: selectedRowCount = Object.values($selectedRows).length $: selectedRowCount = Object.values($selectedRows).length
$: hasNoRows = !$rows.length $: hasNoRows = !$rows.length
@ -120,8 +120,8 @@
document.addEventListener("keydown", handleKeyPress) document.addEventListener("keydown", handleKeyPress)
} }
const updateValue = (rowId, columnName, val) => { const updateValue = ({ column, value }) => {
newRow[columnName] = val newRow[column] = value
} }
const addViaModal = () => { const addViaModal = () => {
@ -154,7 +154,7 @@
condition={hasNoRows && !$loading} condition={hasNoRows && !$loading}
type={TooltipType.Info} type={TooltipType.Info}
> >
{#if !visible && !selectedRowCount && $canAddRows} {#if !visible && !selectedRowCount && $config.canAddRows}
<div <div
class="new-row-fab" class="new-row-fab"
on:click={() => dispatch("add-row-inline")} on:click={() => dispatch("add-row-inline")}

View File

@ -16,7 +16,7 @@
renderedRows, renderedRows,
focusedCellId, focusedCellId,
hoveredRowId, hoveredRowId,
canAddRows, config,
selectedCellMap, selectedCellMap,
focusedRow, focusedRow,
scrollLeft, scrollLeft,
@ -94,7 +94,7 @@
{/if} {/if}
</div> </div>
{/each} {/each}
{#if $canAddRows} {#if $config.canAddRows}
<div <div
class="row new" class="row new"
on:mouseenter={$isDragging on:mouseenter={$isDragging

View File

@ -3,18 +3,21 @@ import { createWebsocket } from "../../../utils"
import { SocketEvent, GridSocketEvent } from "@budibase/shared-core" import { SocketEvent, GridSocketEvent } from "@budibase/shared-core"
export const createGridWebsocket = context => { export const createGridWebsocket = context => {
const { rows, tableId, users, focusedCellId, table, API } = context const { rows, datasource, users, focusedCellId, definition, API } = context
const socket = createWebsocket("/socket/grid") const socket = createWebsocket("/socket/grid")
const connectToTable = tableId => { const connectToDatasource = datasource => {
if (!socket.connected) { if (!socket.connected) {
return return
} }
// Identify which table we are editing // Identify which table we are editing
const appId = API.getAppID() const appId = API.getAppID()
socket.emit( socket.emit(
GridSocketEvent.SelectTable, GridSocketEvent.SelectDatasource,
{ tableId, appId }, {
datasource,
appId,
},
({ users: gridUsers }) => { ({ users: gridUsers }) => {
users.set(gridUsers) users.set(gridUsers)
} }
@ -23,7 +26,7 @@ export const createGridWebsocket = context => {
// Built-in events // Built-in events
socket.on("connect", () => { socket.on("connect", () => {
connectToTable(get(tableId)) connectToDatasource(get(datasource))
}) })
socket.on("connect_error", err => { socket.on("connect_error", err => {
console.log("Failed to connect to grid websocket:", err.message) console.log("Failed to connect to grid websocket:", err.message)
@ -48,16 +51,19 @@ export const createGridWebsocket = context => {
}) })
// Table events // Table events
socket.onOther(GridSocketEvent.TableChange, ({ table: newTable }) => { socket.onOther(
// Only update table if one exists. If the table was deleted then we don't GridSocketEvent.DatasourceChange,
// want to know - let the builder navigate away ({ datasource: newDatasource }) => {
if (newTable) { // Only update definition if one exists. If the datasource was deleted
table.set(newTable) // then we don't want to know - let the builder navigate away
if (newDatasource) {
definition.set(newDatasource)
}
} }
}) )
// Change websocket connection when table changes // Change websocket connection when table changes
tableId.subscribe(connectToTable) datasource.subscribe(connectToDatasource)
// Notify selected cell changes // Notify selected cell changes
focusedCellId.subscribe($focusedCellId => { focusedCellId.subscribe($focusedCellId => {

View File

@ -4,7 +4,7 @@
import { NewRowID } from "../lib/constants" import { NewRowID } from "../lib/constants"
const { const {
enrichedRows, rows,
focusedCellId, focusedCellId,
visibleColumns, visibleColumns,
focusedRow, focusedRow,
@ -16,7 +16,6 @@
config, config,
menu, menu,
gridFocused, gridFocused,
canAddRows,
} = getContext("grid") } = getContext("grid")
const ignoredOriginSelectors = [ const ignoredOriginSelectors = [
@ -46,12 +45,12 @@
e.preventDefault() e.preventDefault()
focusFirstCell() focusFirstCell()
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { } else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
if ($canAddRows) { if ($config.canAddRows) {
e.preventDefault() e.preventDefault()
dispatch("add-row-inline") dispatch("add-row-inline")
} }
} else if (e.key === "Delete" || e.key === "Backspace") { } else if (e.key === "Delete" || e.key === "Backspace") {
if (Object.keys($selectedRows).length && $config.allowDeleteRows) { if (Object.keys($selectedRows).length && $config.canDeleteRows) {
dispatch("request-bulk-delete") dispatch("request-bulk-delete")
} }
} }
@ -100,7 +99,7 @@
} }
break break
case "Enter": case "Enter":
if ($canAddRows) { if ($config.canAddRows) {
dispatch("add-row-inline") dispatch("add-row-inline")
} }
} }
@ -120,7 +119,7 @@
break break
case "Delete": case "Delete":
case "Backspace": case "Backspace":
if (Object.keys($selectedRows).length && $config.allowDeleteRows) { if (Object.keys($selectedRows).length && $config.canDeleteRows) {
dispatch("request-bulk-delete") dispatch("request-bulk-delete")
} else { } else {
deleteSelectedCell() deleteSelectedCell()
@ -131,7 +130,7 @@
break break
case " ": case " ":
case "Space": case "Space":
if ($config.allowDeleteRows) { if ($config.canDeleteRows) {
toggleSelectRow() toggleSelectRow()
} }
break break
@ -143,7 +142,7 @@
// Focuses the first cell in the grid // Focuses the first cell in the grid
const focusFirstCell = () => { const focusFirstCell = () => {
const firstRow = $enrichedRows[0] const firstRow = $rows[0]
if (!firstRow) { if (!firstRow) {
return return
} }
@ -184,7 +183,7 @@
if (!$focusedRow) { if (!$focusedRow) {
return return
} }
const newRow = $enrichedRows[$focusedRow.__idx + delta] const newRow = $rows[$focusedRow.__idx + delta]
if (newRow) { if (newRow) {
const split = $focusedCellId.split("-") const split = $focusedCellId.split("-")
$focusedCellId = `${newRow._id}-${split[1]}` $focusedCellId = `${newRow._id}-${split[1]}`
@ -216,13 +215,15 @@
if ($focusedCellAPI && !$focusedCellAPI.isReadonly()) { if ($focusedCellAPI && !$focusedCellAPI.isReadonly()) {
const type = $focusedCellAPI.getType() const type = $focusedCellAPI.getType()
if (type === "number" && keyCodeIsNumber(keyCode)) { if (type === "number" && keyCodeIsNumber(keyCode)) {
$focusedCellAPI.setValue(parseInt(key)) // Update the value locally but don't save it yet
$focusedCellAPI.setValue(parseInt(key), { save: false })
$focusedCellAPI.focus() $focusedCellAPI.focus()
} else if ( } else if (
["string", "barcodeqr", "longform"].includes(type) && ["string", "barcodeqr", "longform"].includes(type) &&
(keyCodeIsLetter(keyCode) || keyCodeIsNumber(keyCode)) (keyCodeIsLetter(keyCode) || keyCodeIsNumber(keyCode))
) { ) {
$focusedCellAPI.setValue(key) // Update the value locally but don't save it yet
$focusedCellAPI.setValue(key, { save: false })
$focusedCellAPI.focus() $focusedCellAPI.focus()
} }
} }

View File

@ -17,7 +17,6 @@
focusedCellAPI, focusedCellAPI,
focusedRowId, focusedRowId,
notifications, notifications,
canAddRows,
} = getContext("grid") } = getContext("grid")
$: style = makeStyle($menu) $: style = makeStyle($menu)
@ -68,9 +67,7 @@
</MenuItem> </MenuItem>
<MenuItem <MenuItem
icon="Maximize" icon="Maximize"
disabled={isNewRow || disabled={isNewRow || !$config.canEditRows || !$config.canExpandRows}
!$config.allowEditRows ||
!$config.allowExpandRows}
on:click={() => dispatch("edit-row", $focusedRow)} on:click={() => dispatch("edit-row", $focusedRow)}
on:click={menu.actions.close} on:click={menu.actions.close}
> >
@ -94,14 +91,14 @@
</MenuItem> </MenuItem>
<MenuItem <MenuItem
icon="Duplicate" icon="Duplicate"
disabled={isNewRow || !$canAddRows} disabled={isNewRow || !$config.canAddRows}
on:click={duplicate} on:click={duplicate}
> >
Duplicate row Duplicate row
</MenuItem> </MenuItem>
<MenuItem <MenuItem
icon="Delete" icon="Delete"
disabled={isNewRow || !$config.allowDeleteRows} disabled={isNewRow || !$config.canDeleteRows}
on:click={deleteRow} on:click={deleteRow}
> >
Delete row Delete row

View File

@ -8,7 +8,7 @@ export const createStores = () => {
} }
} }
export const deriveStores = context => { export const createActions = context => {
const { copiedCell, focusedCellAPI } = context const { copiedCell, focusedCellAPI } = context
const copy = () => { const copy = () => {

View File

@ -35,20 +35,10 @@ export const createStores = () => {
[] []
) )
// Checks if we have a certain column by name
const hasColumn = column => {
const $columns = get(columns)
const $sticky = get(stickyColumn)
return $columns.some(col => col.name === column) || $sticky?.name === column
}
return { return {
columns: { columns: {
...columns, ...columns,
subscribe: enrichedColumns.subscribe, subscribe: enrichedColumns.subscribe,
actions: {
hasColumn,
},
}, },
stickyColumn, stickyColumn,
visibleColumns, visibleColumns,
@ -56,12 +46,35 @@ export const createStores = () => {
} }
export const deriveStores = context => { export const deriveStores = context => {
const { table, columns, stickyColumn, API, dispatch, config } = context const { columns, stickyColumn } = context
// Updates the tables primary display column // Derive if we have any normal columns
const hasNonAutoColumn = derived(
[columns, stickyColumn],
([$columns, $stickyColumn]) => {
let allCols = $columns || []
if ($stickyColumn) {
allCols = [...allCols, $stickyColumn]
}
const normalCols = allCols.filter(column => {
return !column.schema?.autocolumn
})
return normalCols.length > 0
}
)
return {
hasNonAutoColumn,
}
}
export const createActions = context => {
const { columns, stickyColumn, datasource, definition } = context
// Updates the datasources primary display column
const changePrimaryDisplay = async column => { const changePrimaryDisplay = async column => {
return await saveTable({ return await datasource.actions.saveDefinition({
...get(table), ...get(definition),
primaryDisplay: column, primaryDisplay: column,
}) })
} }
@ -83,29 +96,14 @@ export const deriveStores = context => {
await saveChanges() await saveChanges()
} }
// Derive if we have any normal columns // Persists column changes by saving metadata against datasource schema
const hasNonAutoColumn = derived(
[columns, stickyColumn],
([$columns, $stickyColumn]) => {
let allCols = $columns || []
if ($stickyColumn) {
allCols = [...allCols, $stickyColumn]
}
const normalCols = allCols.filter(column => {
return !column.schema?.autocolumn
})
return normalCols.length > 0
}
)
// Persists column changes by saving metadata against table schema
const saveChanges = async () => { const saveChanges = async () => {
const $columns = get(columns) const $columns = get(columns)
const $table = get(table) const $definition = get(definition)
const $stickyColumn = get(stickyColumn) const $stickyColumn = get(stickyColumn)
const newSchema = cloneDeep($table.schema) const newSchema = cloneDeep($definition.schema)
// Build new updated table schema // Build new updated datasource schema
Object.keys(newSchema).forEach(column => { Object.keys(newSchema).forEach(column => {
// Respect order specified by columns // Respect order specified by columns
const index = $columns.findIndex(x => x.name === column) const index = $columns.findIndex(x => x.name === column)
@ -125,31 +123,17 @@ export const deriveStores = context => {
} }
}) })
await saveTable({ ...$table, schema: newSchema }) await datasource.actions.saveDefinition({
} ...$definition,
schema: newSchema,
const saveTable = async newTable => { })
// Update local state
table.set(newTable)
// Update server
if (get(config).allowSchemaChanges) {
await API.saveTable(newTable)
}
// Broadcast change to external state can be updated, as this change
// will not be received by the builder websocket because we caused it ourselves
dispatch("updatetable", newTable)
} }
return { return {
hasNonAutoColumn,
columns: { columns: {
...columns, ...columns,
actions: { actions: {
...columns.actions,
saveChanges, saveChanges,
saveTable,
changePrimaryDisplay, changePrimaryDisplay,
changeAllColumnWidths, changeAllColumnWidths,
}, },
@ -158,51 +142,7 @@ export const deriveStores = context => {
} }
export const initialise = context => { export const initialise = context => {
const { table, columns, stickyColumn, schemaOverrides, columnWhitelist } = const { definition, columns, stickyColumn, schema } = context
context
const schema = derived(
[table, schemaOverrides, columnWhitelist],
([$table, $schemaOverrides, $columnWhitelist]) => {
if (!$table?.schema) {
return null
}
let newSchema = { ...$table?.schema }
// Edge case to temporarily allow deletion of duplicated user
// fields that were saved with the "disabled" flag set.
// By overriding the saved schema we ensure only overrides can
// set the disabled flag.
// TODO: remove in future
Object.keys(newSchema).forEach(field => {
newSchema[field] = {
...newSchema[field],
disabled: false,
}
})
// Apply schema overrides
Object.keys($schemaOverrides || {}).forEach(field => {
if (newSchema[field]) {
newSchema[field] = {
...newSchema[field],
...$schemaOverrides[field],
}
}
})
// Apply whitelist if specified
if ($columnWhitelist?.length) {
Object.keys(newSchema).forEach(key => {
if (!$columnWhitelist.includes(key)) {
delete newSchema[key]
}
})
}
return newSchema
}
)
// Merge new schema fields with existing schema in order to preserve widths // Merge new schema fields with existing schema in order to preserve widths
schema.subscribe($schema => { schema.subscribe($schema => {
@ -211,12 +151,12 @@ export const initialise = context => {
stickyColumn.set(null) stickyColumn.set(null)
return return
} }
const $table = get(table) const $definition = get(definition)
// Find primary display // Find primary display
let primaryDisplay let primaryDisplay
if ($table.primaryDisplay && $schema[$table.primaryDisplay]) { if ($definition.primaryDisplay && $schema[$definition.primaryDisplay]) {
primaryDisplay = $table.primaryDisplay primaryDisplay = $definition.primaryDisplay
} }
// Get field list // Get field list

View File

@ -1,12 +1,12 @@
import { writable } from "svelte/store"
import { derivedMemo } from "../../../utils" import { derivedMemo } from "../../../utils"
import { derived } from "svelte/store"
export const createStores = context => { export const createStores = context => {
const config = writable(context.props) const { props } = context
const getProp = prop => derivedMemo(config, $config => $config[prop]) const getProp = prop => derivedMemo(props, $props => $props[prop])
// Derive and memoize some props so that we can react to them in isolation // Derive and memoize some props so that we can react to them in isolation
const tableId = getProp("tableId") const datasource = getProp("datasource")
const initialSortColumn = getProp("initialSortColumn") const initialSortColumn = getProp("initialSortColumn")
const initialSortOrder = getProp("initialSortOrder") const initialSortOrder = getProp("initialSortOrder")
const initialFilter = getProp("initialFilter") const initialFilter = getProp("initialFilter")
@ -17,8 +17,7 @@ export const createStores = context => {
const notifyError = getProp("notifyError") const notifyError = getProp("notifyError")
return { return {
config, datasource,
tableId,
initialSortColumn, initialSortColumn,
initialSortOrder, initialSortOrder,
initialFilter, initialFilter,
@ -29,3 +28,31 @@ export const createStores = context => {
notifyError, notifyError,
} }
} }
export const deriveStores = context => {
const { props, hasNonAutoColumn } = context
// Derive features
const config = derived(
[props, hasNonAutoColumn],
([$props, $hasNonAutoColumn]) => {
let config = { ...$props }
// Disable some features if we're editing a view
if ($props.datasource?.type === "viewV2") {
config.canEditColumns = false
}
// Disable adding rows if we don't have any valid columns
if (!$hasNonAutoColumn) {
config.canAddRows = false
}
return config
}
)
return {
config,
}
}

View File

@ -0,0 +1,131 @@
import { derived, get, writable } from "svelte/store"
export const createStores = () => {
const definition = writable(null)
return {
definition,
}
}
export const deriveStores = context => {
const { definition, schemaOverrides, columnWhitelist } = context
const schema = derived(
[definition, schemaOverrides, columnWhitelist],
([$definition, $schemaOverrides, $columnWhitelist]) => {
if (!$definition?.schema) {
return null
}
let newSchema = { ...$definition?.schema }
// Apply schema overrides
Object.keys($schemaOverrides || {}).forEach(field => {
if (newSchema[field]) {
newSchema[field] = {
...newSchema[field],
...$schemaOverrides[field],
}
}
})
// Apply whitelist if specified
if ($columnWhitelist?.length) {
Object.keys(newSchema).forEach(key => {
if (!$columnWhitelist.includes(key)) {
delete newSchema[key]
}
})
}
return newSchema
}
)
return {
schema,
}
}
export const createActions = context => {
const { datasource, definition, config, dispatch, table, viewV2 } = context
// Gets the appropriate API for the configured datasource type
const getAPI = () => {
const $datasource = get(datasource)
switch ($datasource?.type) {
case "table":
return table
case "viewV2":
return viewV2
default:
return null
}
}
// Refreshes the datasource definition
const refreshDefinition = async () => {
return await getAPI()?.actions.refreshDefinition()
}
// Saves the datasource definition
const saveDefinition = async newDefinition => {
// Update local state
definition.set(newDefinition)
// Update server
if (get(config).canSaveSchema) {
await getAPI()?.actions.saveDefinition(newDefinition)
}
// Broadcast change to external state can be updated, as this change
// will not be received by the builder websocket because we caused it ourselves
dispatch("updatedatasource", newDefinition)
}
// Adds a row to the datasource
const addRow = async row => {
return await getAPI()?.actions.addRow(row)
}
// Updates an existing row in the datasource
const updateRow = async row => {
return await getAPI()?.actions.updateRow(row)
}
// Deletes rows from the datasource
const deleteRows = async rows => {
return await getAPI()?.actions.deleteRows(rows)
}
// Gets a single row from a datasource
const getRow = async id => {
return await getAPI()?.actions.getRow(id)
}
// Checks if a certain datasource config is valid
const isDatasourceValid = datasource => {
return getAPI()?.actions.isDatasourceValid(datasource)
}
// Checks if this datasource can use a specific column by name
const canUseColumn = name => {
return getAPI()?.actions.canUseColumn(name)
}
return {
datasource: {
...datasource,
actions: {
refreshDefinition,
saveDefinition,
addRow,
updateRow,
deleteRows,
getRow,
isDatasourceValid,
canUseColumn,
},
},
}
}

View File

@ -1,10 +1,10 @@
import { writable } from "svelte/store" import { writable, get } from "svelte/store"
export const createStores = context => { export const createStores = context => {
const { props } = context const { props } = context
// Initialise to default props // Initialise to default props
const filter = writable(props.initialFilter) const filter = writable(get(props).initialFilter)
return { return {
filter, filter,

View File

@ -15,16 +15,20 @@ import * as Config from "./config"
import * as Sort from "./sort" import * as Sort from "./sort"
import * as Filter from "./filter" import * as Filter from "./filter"
import * as Notifications from "./notifications" import * as Notifications from "./notifications"
import * as Table from "./table"
import * as ViewV2 from "./viewV2"
import * as Datasource from "./datasource"
const DependencyOrderedStores = [ const DependencyOrderedStores = [
Config,
Notifications,
Sort, Sort,
Filter, Filter,
Bounds, Bounds,
Scroll, Scroll,
Rows, Table,
ViewV2,
Datasource,
Columns, Columns,
Rows,
UI, UI,
Validation, Validation,
Resize, Resize,
@ -34,6 +38,8 @@ const DependencyOrderedStores = [
Menu, Menu,
Pagination, Pagination,
Clipboard, Clipboard,
Config,
Notifications,
] ]
export const attachStores = context => { export const attachStores = context => {
@ -47,6 +53,11 @@ export const attachStores = context => {
context = { ...context, ...store.deriveStores?.(context) } context = { ...context, ...store.deriveStores?.(context) }
} }
// Action creation
for (let store of DependencyOrderedStores) {
context = { ...context, ...store.createActions?.(context) }
}
// Initialise any store logic // Initialise any store logic
for (let store of DependencyOrderedStores) { for (let store of DependencyOrderedStores) {
store.initialise?.(context) store.initialise?.(context)

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