Merge pull request #8788 from Budibase/side-panel

Side panels
This commit is contained in:
Andrew Kingston 2022-11-30 16:04:35 +00:00 committed by GitHub
commit ff53acfb9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1365 additions and 804 deletions

View File

@ -21,6 +21,10 @@ export const DEFINITIONS: MigrationDefinition[] = [
type: MigrationType.APP, type: MigrationType.APP,
name: MigrationName.EVENT_APP_BACKFILL, name: MigrationName.EVENT_APP_BACKFILL,
}, },
{
type: MigrationType.APP,
name: MigrationName.TABLE_SETTINGS_LINKS_TO_ACTIONS,
},
{ {
type: MigrationType.GLOBAL, type: MigrationType.GLOBAL,
name: MigrationName.EVENT_GLOBAL_BACKFILL, name: MigrationName.EVENT_GLOBAL_BACKFILL,

View File

@ -1,18 +1,53 @@
export default function clickOutside(element, callbackFunction) { const ignoredClasses = [".flatpickr-calendar", ".modal-container"]
function onClick(event) { let clickHandlers = []
if (!element.contains(event.target)) {
callbackFunction(event) /**
* Handle a body click event
*/
const handleClick = event => {
// Ignore click if needed
for (let className of ignoredClasses) {
if (event.target.closest(className)) {
return
} }
} }
document.body.addEventListener("click", onClick, true) // Process handlers
clickHandlers.forEach(handler => {
if (!handler.element.contains(event.target)) {
handler.callback?.(event)
}
})
}
document.documentElement.addEventListener("click", handleClick, true)
return { /**
update(newCallbackFunction) { * Adds or updates a click handler
callbackFunction = newCallbackFunction */
}, const updateHandler = (id, element, callback) => {
destroy() { let existingHandler = clickHandlers.find(x => x.id === id)
document.body.removeEventListener("click", onClick, true) if (!existingHandler) {
}, clickHandlers.push({ id, element, callback })
} else {
existingHandler.callback = callback
}
}
/**
* Removes a click handler
*/
const removeHandler = id => {
clickHandlers = clickHandlers.filter(x => x.id !== id)
}
/**
* Svelte action to apply a click outside handler for a certain element
*/
export default (element, callback) => {
const id = Math.random()
updateHandler(id, element, callback)
return {
update: newCallback => updateHandler(id, element, newCallback),
destroy: () => removeHandler(id),
} }
} }

View File

@ -19,13 +19,19 @@
</script> </script>
<div class="property-group-container"> <div class="property-group-container">
<div class="property-group-name" on:click={onHeaderClick}> {#if name}
<div class="name">{name}</div> <div class="property-group-name" on:click={onHeaderClick}>
{#if collapsible} <div class="name">{name}</div>
<Icon size="S" name={show ? "Remove" : "Add"} /> {#if collapsible}
{/if} <Icon size="S" name={show ? "Remove" : "Add"} />
</div> {/if}
<div class="property-panel" class:show={show || !collapsible}> </div>
{/if}
<div
class="property-panel"
class:show={show || !collapsible}
class:no-title={!name}
>
<slot /> <slot />
</div> </div>
</div> </div>
@ -72,6 +78,9 @@
padding: var(--spacing-s) var(--spacing-xl) var(--spacing-xl) padding: var(--spacing-s) var(--spacing-xl) var(--spacing-xl)
var(--spacing-xl); var(--spacing-xl);
} }
.property-panel.no-title {
padding: var(--spacing-xl);
}
.show { .show {
display: flex; display: flex;

View File

@ -23,6 +23,15 @@
let open = false let open = false
let flatpickr, flatpickrOptions let flatpickr, flatpickrOptions
// Another classic flatpickr issue. Errors were randomly being thrown due to
// flatpickr internal code. Making sure that "destroy" is a valid function
// fixes it. The sooner we remove flatpickr the better.
$: {
if (flatpickr && !flatpickr.destroy) {
flatpickr.destroy = () => {}
}
}
const resolveTimeStamp = timestamp => { const resolveTimeStamp = timestamp => {
let maskedDate = new Date(`0-${timestamp}`) let maskedDate = new Date(`0-${timestamp}`)
@ -252,6 +261,7 @@
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
z-index: 999; z-index: 999;
max-height: 100%;
} }
:global(.flatpickr-calendar) { :global(.flatpickr-calendar) {
font-family: "Source Sans Pro", sans-serif; font-family: "Source Sans Pro", sans-serif;

View File

@ -64,7 +64,7 @@
transition: color var(--spectrum-global-animation-duration-100, 130ms); transition: color var(--spectrum-global-animation-duration-100, 130ms);
} }
svg.hoverable:hover { svg.hoverable:hover {
color: var(--spectrum-alias-icon-color-selected-hover); color: var(--spectrum-alias-icon-color-selected-hover) !important;
cursor: pointer; cursor: pointer;
} }

View File

@ -275,157 +275,159 @@
} }
</script> </script>
<div {#key fields?.length}
class="wrapper" <div
class:wrapper--quiet={quiet} class="wrapper"
class:wrapper--compact={compact} class:wrapper--quiet={quiet}
bind:offsetHeight={height} class:wrapper--compact={compact}
style={`--row-height: ${rowHeight}px; --header-height: ${headerHeight}px;`} bind:offsetHeight={height}
> style={`--row-height: ${rowHeight}px; --header-height: ${headerHeight}px;`}
{#if loading} >
<div class="loading" style={heightStyle}> {#if loading}
<slot name="loadingIndicator"> <div class="loading" style={heightStyle}>
<ProgressCircle /> <slot name="loadingIndicator">
</slot> <ProgressCircle />
</div> </slot>
{:else} </div>
<div class="spectrum-Table" style={`${heightStyle}${gridStyle}`}> {:else}
{#if fields.length} <div class="spectrum-Table" style={`${heightStyle}${gridStyle}`}>
<div class="spectrum-Table-head"> {#if fields.length}
{#if showEditColumn} <div class="spectrum-Table-head">
<div
class:noBorderHeader={!showHeaderBorder}
class="spectrum-Table-headCell spectrum-Table-headCell--divider spectrum-Table-headCell--edit"
>
{#if allowSelectRows}
<Checkbox
bind:value={checkboxStatus}
on:change={toggleSelectAll}
/>
{:else}
Edit
{/if}
</div>
{/if}
{#each fields as field}
<div
class="spectrum-Table-headCell"
class:noBorderHeader={!showHeaderBorder}
class:spectrum-Table-headCell--alignCenter={schema[field]
.align === "Center"}
class:spectrum-Table-headCell--alignRight={schema[field].align ===
"Right"}
class:is-sortable={schema[field].sortable !== false}
class:is-sorted-desc={sortColumn === field &&
sortOrder === "Descending"}
class:is-sorted-asc={sortColumn === field &&
sortOrder === "Ascending"}
on:click={() => sortBy(schema[field])}
>
<div class="title">{getDisplayName(schema[field])}</div>
{#if schema[field]?.autocolumn}
<svg
class="spectrum-Icon spectrum-Table-autoIcon"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-MagicWand" />
</svg>
{/if}
{#if sortColumn === field}
<svg
class="spectrum-Icon spectrum-UIIcon-ArrowDown100 spectrum-Table-sortedIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Arrow100" />
</svg>
{/if}
{#if allowEditColumns && schema[field]?.editable !== false}
<svg
class="spectrum-Icon spectrum-Table-editIcon"
focusable="false"
on:click={e => editColumn(e, field)}
>
<use xlink:href="#spectrum-icon-18-Edit" />
</svg>
{/if}
</div>
{/each}
</div>
{/if}
{#if sortedRows?.length}
{#each sortedRows as row, idx}
<div class="spectrum-Table-row">
{#if showEditColumn} {#if showEditColumn}
<div <div
class:noBorderCheckbox={!showHeaderBorder} class:noBorderHeader={!showHeaderBorder}
class="spectrum-Table-cell spectrum-Table-cell--divider spectrum-Table-cell--edit" class="spectrum-Table-headCell spectrum-Table-headCell--divider spectrum-Table-headCell--edit"
on:click={e => {
toggleSelectRow(row)
e.stopPropagation()
}}
> >
<SelectEditRenderer {#if allowSelectRows}
data={row} <Checkbox
selected={selectedRows.findIndex( bind:value={checkboxStatus}
selectedRow => selectedRow._id === row._id on:change={toggleSelectAll}
) !== -1} />
onEdit={e => editRow(e, row)} {:else}
{allowSelectRows} Edit
{allowEditRows} {/if}
/>
</div> </div>
{/if} {/if}
{#each fields as field} {#each fields as field}
<div <div
class="spectrum-Table-cell" class="spectrum-Table-headCell"
class:spectrum-Table-cell--divider={!!schema[field].divider} class:noBorderHeader={!showHeaderBorder}
style={cellStyles[field]} class:spectrum-Table-headCell--alignCenter={schema[field]
on:click={() => { .align === "Center"}
if (!schema[field]?.preventSelectRow) { class:spectrum-Table-headCell--alignRight={schema[field]
dispatch("click", row) .align === "Right"}
toggleSelectRow(row) class:is-sortable={schema[field].sortable !== false}
} class:is-sorted-desc={sortColumn === field &&
}} sortOrder === "Descending"}
class:is-sorted-asc={sortColumn === field &&
sortOrder === "Ascending"}
on:click={() => sortBy(schema[field])}
> >
<CellRenderer <div class="title">{getDisplayName(schema[field])}</div>
{customRenderers} {#if schema[field]?.autocolumn}
{row} <svg
schema={schema[field]} class="spectrum-Icon spectrum-Table-autoIcon"
value={deepGet(row, field)} focusable="false"
on:clickrelationship >
on:buttonclick <use xlink:href="#spectrum-icon-18-MagicWand" />
> </svg>
<slot /> {/if}
</CellRenderer> {#if sortColumn === field}
<svg
class="spectrum-Icon spectrum-UIIcon-ArrowDown100 spectrum-Table-sortedIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Arrow100" />
</svg>
{/if}
{#if allowEditColumns && schema[field]?.editable !== false}
<svg
class="spectrum-Icon spectrum-Table-editIcon"
focusable="false"
on:click={e => editColumn(e, field)}
>
<use xlink:href="#spectrum-icon-18-Edit" />
</svg>
{/if}
</div> </div>
{/each} {/each}
</div> </div>
{/each} {/if}
{:else} {#if sortedRows?.length}
<div {#each sortedRows as row, idx}
class="placeholder" <div class="spectrum-Table-row">
class:placeholder--custom={customPlaceholder} {#if showEditColumn}
class:placeholder--no-fields={!fields?.length} <div
> class:noBorderCheckbox={!showHeaderBorder}
{#if customPlaceholder} class="spectrum-Table-cell spectrum-Table-cell--divider spectrum-Table-cell--edit"
<slot name="placeholder" /> on:click={e => {
{:else} toggleSelectRow(row)
<div class="placeholder-content"> e.stopPropagation()
<svg }}
class="spectrum-Icon spectrum-Icon--sizeXXL" >
focusable="false" <SelectEditRenderer
> data={row}
<use xlink:href="#spectrum-icon-18-Table" /> selected={selectedRows.findIndex(
</svg> selectedRow => selectedRow._id === row._id
<div>{placeholderText}</div> ) !== -1}
onEdit={e => editRow(e, row)}
{allowSelectRows}
{allowEditRows}
/>
</div>
{/if}
{#each fields as field}
<div
class="spectrum-Table-cell"
class:spectrum-Table-cell--divider={!!schema[field].divider}
style={cellStyles[field]}
on:click={() => {
if (!schema[field]?.preventSelectRow) {
dispatch("click", row)
toggleSelectRow(row)
}
}}
>
<CellRenderer
{customRenderers}
{row}
schema={schema[field]}
value={deepGet(row, field)}
on:clickrelationship
on:buttonclick
>
<slot />
</CellRenderer>
</div>
{/each}
</div> </div>
{/if} {/each}
</div> {:else}
{/if} <div
</div> class="placeholder"
{/if} class:placeholder--custom={customPlaceholder}
</div> class:placeholder--no-fields={!fields?.length}
>
{#if customPlaceholder}
<slot name="placeholder" />
{:else}
<div class="placeholder-content">
<svg
class="spectrum-Icon spectrum-Icon--sizeXXL"
focusable="false"
>
<use xlink:href="#spectrum-icon-18-Table" />
</svg>
<div>{placeholderText}</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
{/key}
<style> <style>
/* Wrapper */ /* Wrapper */

View File

@ -1,5 +1,6 @@
export const Events = { export const Events = {
COMPONENT_CREATED: "component:created", COMPONENT_CREATED: "component:created",
COMPONENT_UPDATED: "component:updated",
APP_VIEW_PUBLISHED: "app:view_published", APP_VIEW_PUBLISHED: "app:view_published",
} }

View File

@ -182,13 +182,21 @@ export const makeComponentUnique = component => {
return return
} }
// Replace component ID // Generate a full set of component ID replacements in this tree
const oldId = component._id const idReplacements = []
const newId = Helpers.uuid() const generateIdReplacements = (component, replacements) => {
let definition = JSON.stringify(component) const oldId = component._id
const newId = Helpers.uuid()
replacements.push([oldId, newId])
component._children?.forEach(x => generateIdReplacements(x, replacements))
}
generateIdReplacements(component, idReplacements)
// Replace all instances of this ID in HBS bindings // Replace all instances of this ID in HBS bindings
definition = definition.replace(new RegExp(oldId, "g"), newId) let definition = JSON.stringify(component)
idReplacements.forEach(([oldId, newId]) => {
definition = definition.replace(new RegExp(oldId, "g"), newId)
})
// Replace all instances of this ID in JS bindings // Replace all instances of this ID in JS bindings
const bindings = findHBSBlocks(definition) const bindings = findHBSBlocks(definition)
@ -201,7 +209,9 @@ export const makeComponentUnique = component => {
let js = decodeJSBinding(sanitizedBinding) let js = decodeJSBinding(sanitizedBinding)
if (js != null) { if (js != null) {
// Replace ID inside JS binding // Replace ID inside JS binding
js = js.replace(new RegExp(oldId, "g"), newId) idReplacements.forEach(([oldId, newId]) => {
js = js.replace(new RegExp(oldId, "g"), newId)
})
// Create new valid JS binding // Create new valid JS binding
let newBinding = encodeJSBinding(js) let newBinding = encodeJSBinding(js)
@ -218,9 +228,5 @@ export const makeComponentUnique = component => {
}) })
// Recurse on all children // Recurse on all children
component = JSON.parse(definition) return JSON.parse(definition)
return {
...component,
_children: component._children?.map(makeComponentUnique),
}
} }

View File

@ -45,6 +45,7 @@ const INITIAL_FRONTEND_STATE = {
messagePassing: false, messagePassing: false,
continueIfAction: false, continueIfAction: false,
showNotificationAction: false, showNotificationAction: false,
sidePanel: false,
}, },
errors: [], errors: [],
hasAppPackage: false, hasAppPackage: false,

View File

@ -1,13 +1,7 @@
import newRowScreen from "./newRowScreen"
import rowDetailScreen from "./rowDetailScreen"
import rowListScreen from "./rowListScreen" import rowListScreen from "./rowListScreen"
import createFromScratchScreen from "./createFromScratchScreen" import createFromScratchScreen from "./createFromScratchScreen"
const allTemplates = tables => [ const allTemplates = tables => [...rowListScreen(tables)]
...newRowScreen(tables),
...rowDetailScreen(tables),
...rowListScreen(tables),
]
// 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 = (frontendState, template) => () => {

View File

@ -1,72 +0,0 @@
import sanitizeUrl from "./utils/sanitizeUrl"
import { Screen } from "./utils/Screen"
import { Component } from "./utils/Component"
import { makeBreadcrumbContainer } from "./utils/commonComponents"
import { getSchemaForDatasource } from "../../dataBinding"
export default function (tables) {
return tables.map(table => {
return {
name: `${table.name} - New`,
create: () => createScreen(table),
id: NEW_ROW_TEMPLATE,
table: table._id,
}
})
}
export const newRowUrl = table => sanitizeUrl(`/${table.name}/new/row`)
export const NEW_ROW_TEMPLATE = "NEW_ROW_TEMPLATE"
const rowListUrl = table => sanitizeUrl(`/${table.name}`)
const getFields = schema => {
let columns = []
Object.entries(schema || {}).forEach(([field, fieldSchema]) => {
if (!field || !fieldSchema) {
return
}
if (!fieldSchema?.autocolumn) {
columns.push(field)
}
})
return columns
}
const generateFormBlock = table => {
const datasource = { type: "table", tableId: table._id }
const { schema } = getSchemaForDatasource(null, datasource, {
formSchema: true,
})
const formBlock = new Component("@budibase/standard-components/formblock")
formBlock
.customProps({
title: "New row",
actionType: "Create",
actionUrl: rowListUrl(table),
showDeleteButton: false,
showSaveButton: true,
fields: getFields(schema),
dataSource: {
label: table.name,
tableId: table._id,
type: "table",
},
labelPosition: "left",
size: "spectrum--medium",
})
.instanceName(`${table.name} - Form block`)
return formBlock
}
const createScreen = table => {
const formBlock = generateFormBlock(table)
const screen = new Screen()
.instanceName(`${table.name} - New`)
.route(newRowUrl(table))
return screen
.addChild(makeBreadcrumbContainer(table.name, "New row"))
.addChild(formBlock)
.json()
}

View File

@ -1,70 +0,0 @@
import sanitizeUrl from "./utils/sanitizeUrl"
import { Screen } from "./utils/Screen"
import { Component } from "./utils/Component"
import { makeBreadcrumbContainer } from "./utils/commonComponents"
import { getSchemaForDatasource } from "../../dataBinding"
export default function (tables) {
return tables.map(table => {
return {
name: `${table.name} - Detail`,
create: () => createScreen(table),
id: ROW_DETAIL_TEMPLATE,
table: table._id,
}
})
}
export const ROW_DETAIL_TEMPLATE = "ROW_DETAIL_TEMPLATE"
export const rowDetailUrl = table => sanitizeUrl(`/${table.name}/:id`)
const rowListUrl = table => sanitizeUrl(`/${table.name}`)
const getFields = schema => {
let columns = []
Object.entries(schema || {}).forEach(([field, fieldSchema]) => {
if (!field || !fieldSchema) {
return
}
if (!fieldSchema?.autocolumn) {
columns.push(field)
}
})
return columns
}
const generateFormBlock = table => {
const datasource = { type: "table", tableId: table._id }
const { schema } = getSchemaForDatasource(null, datasource, {
formSchema: true,
})
const formBlock = new Component("@budibase/standard-components/formblock")
formBlock
.customProps({
title: "Edit row",
actionType: "Update",
actionUrl: rowListUrl(table),
showDeleteButton: true,
showSaveButton: true,
fields: getFields(schema),
dataSource: {
label: table.name,
tableId: table._id,
type: "table",
},
labelPosition: "left",
size: "spectrum--medium",
})
.instanceName(`${table.name} - Form block`)
return formBlock
}
const createScreen = table => {
return new Screen()
.instanceName(`${table.name} - Detail`)
.route(rowDetailUrl(table))
.addChild(makeBreadcrumbContainer(table.name, "Edit row"))
.addChild(generateFormBlock(table))
.json()
}

View File

@ -1,5 +1,4 @@
import sanitizeUrl from "./utils/sanitizeUrl" import sanitizeUrl from "./utils/sanitizeUrl"
import { newRowUrl } from "./newRowScreen"
import { Screen } from "./utils/Screen" import { Screen } from "./utils/Screen"
import { Component } from "./utils/Component" import { Component } from "./utils/Component"
@ -21,12 +20,6 @@ const generateTableBlock = table => {
const tableBlock = new Component("@budibase/standard-components/tableblock") const tableBlock = new Component("@budibase/standard-components/tableblock")
tableBlock tableBlock
.customProps({ .customProps({
linkRows: true,
linkURL: `${rowListUrl(table)}/:id`,
showAutoColumns: false,
showTitleButton: true,
titleButtonText: "Create new",
titleButtonURL: newRowUrl(table),
title: table.name, title: table.name,
dataSource: { dataSource: {
label: table.name, label: table.name,
@ -34,9 +27,14 @@ const generateTableBlock = table => {
tableId: table._id, tableId: table._id,
type: "table", type: "table",
}, },
sortOrder: "Ascending",
size: "spectrum--medium", size: "spectrum--medium",
paginate: true, paginate: true,
rowCount: 8, rowCount: 8,
clickBehaviour: "details",
showTitleButton: true,
titleButtonText: "Create row",
titleButtonClickBehaviour: "new",
}) })
.instanceName(`${table.name} - Table block`) .instanceName(`${table.name} - Table block`)
return tableBlock return tableBlock

View File

@ -1,137 +1,6 @@
import { Component } from "./Component" import { Component } from "./Component"
import { rowListUrl } from "../rowListScreen"
import { getSchemaForDatasource } from "../../../dataBinding" import { getSchemaForDatasource } from "../../../dataBinding"
export function spectrumColor(number) {
// Acorn throws a parsing error in this file if the word g-l-o-b-a-l is found
// (without dashes - I can't even type it in a comment).
// God knows why. It seems to think optional chaining further down the
// file is invalid if the word g-l-o-b-a-l is found - hence the reason this
// statement is split into parts.
return "var(--spectrum-glo" + `bal-color-gray-${number})`
}
export function makeLinkComponent(tableName) {
return new Component("@budibase/standard-components/link")
.text(tableName)
.customProps({
url: `/${tableName.toLowerCase()}`,
openInNewTab: false,
color: spectrumColor(700),
size: "S",
align: "left",
})
}
export function makeMainForm() {
return new Component("@budibase/standard-components/form")
.normalStyle({
width: "600px",
})
.instanceName("Form")
}
export function makeBreadcrumbContainer(tableName, text) {
const link = makeLinkComponent(tableName).instanceName("Back Link")
const arrowText = new Component("@budibase/standard-components/text")
.type("none")
.normalStyle({
"margin-right": "4px",
"margin-left": "4px",
})
.text(">")
.instanceName("Arrow")
.customProps({
color: spectrumColor(700),
size: "S",
align: "left",
})
const identifierText = new Component("@budibase/standard-components/text")
.text(text)
.instanceName("Identifier")
.customProps({
color: spectrumColor(700),
size: "S",
align: "left",
})
return new Component("@budibase/standard-components/container")
.customProps({
gap: "N",
direction: "row",
hAlign: "left",
vAlign: "middle",
size: "shrink",
})
.normalStyle({
width: "600px",
"margin-right": "auto",
"margin-left": "auto",
})
.instanceName("Breadcrumbs")
.addChild(link)
.addChild(arrowText)
.addChild(identifierText)
}
export function makeSaveButton(table, formId) {
return new Component("@budibase/standard-components/button")
.text("Save")
.customProps({
type: "primary",
size: "M",
onClick: [
{
"##eventHandlerType": "Validate Form",
parameters: {
componentId: formId,
},
},
{
parameters: {
providerId: formId,
tableId: table._id,
},
"##eventHandlerType": "Save Row",
},
{
parameters: {
url: rowListUrl(table),
},
"##eventHandlerType": "Navigate To",
},
],
})
.instanceName("Save Button")
}
export function makeTitleContainer(title) {
const heading = new Component("@budibase/standard-components/heading")
.instanceName("Title")
.text(title)
.customProps({
size: "M",
align: "left",
})
return new Component("@budibase/standard-components/container")
.normalStyle({
"margin-top": "32px",
"margin-bottom": "32px",
})
.customProps({
direction: "row",
hAlign: "stretch",
vAlign: "middle",
size: "shrink",
gap: "M",
})
.instanceName("Title Container")
.addChild(heading)
}
const fieldTypeToComponentMap = { const fieldTypeToComponentMap = {
string: "stringfield", string: "stringfield",
number: "numberfield", number: "numberfield",

View File

@ -1,4 +1,4 @@
import { Checkbox, Select, Stepper } from "@budibase/bbui" import { Checkbox, Select, RadioGroup, Stepper } from "@budibase/bbui"
import DataSourceSelect from "./controls/DataSourceSelect.svelte" import DataSourceSelect from "./controls/DataSourceSelect.svelte"
import S3DataSourceSelect from "./controls/S3DataSourceSelect.svelte" import S3DataSourceSelect from "./controls/S3DataSourceSelect.svelte"
import DataProviderSelect from "./controls/DataProviderSelect.svelte" import DataProviderSelect from "./controls/DataProviderSelect.svelte"
@ -25,6 +25,7 @@ import BarButtonList from "./controls/BarButtonList.svelte"
const componentMap = { const componentMap = {
text: DrawerBindableCombobox, text: DrawerBindableCombobox,
select: Select, select: Select,
radio: RadioGroup,
dataSource: DataSourceSelect, dataSource: DataSourceSelect,
"dataSource/s3": S3DataSourceSelect, "dataSource/s3": S3DataSourceSelect,
dataProvider: DataProviderSelect, dataProvider: DataProviderSelect,

View File

@ -0,0 +1,8 @@
<div class="root">This action doesn't require any settings.</div>
<style>
.root {
max-width: 400px;
margin: 0 auto;
}
</style>

View File

@ -0,0 +1,37 @@
<script>
import { Select, Label } from "@budibase/bbui"
import { selectedScreen } from "builderStore"
import { findAllMatchingComponents } from "builderStore/componentUtils"
export let parameters
$: sidePanelOptions = getSidePanelOptions($selectedScreen)
const getSidePanelOptions = screen => {
const sidePanelComponents = findAllMatchingComponents(
screen.props,
component => component._component.endsWith("/sidepanel")
)
return sidePanelComponents.map(sidePanel => ({
label: sidePanel._instanceName,
value: sidePanel._id,
}))
}
</script>
<div class="root">
<Label small>Side Panel</Label>
<Select bind:value={parameters.id} options={sidePanelOptions} />
</div>
<style>
.root {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
grid-template-columns: 60px 1fr;
align-items: center;
max-width: 400px;
margin: 0 auto;
}
</style>

View File

@ -16,3 +16,5 @@ export { default as ExportData } from "./ExportData.svelte"
export { default as ContinueIf } from "./ContinueIf.svelte" export { default as ContinueIf } from "./ContinueIf.svelte"
export { default as UpdateFieldValue } from "./UpdateFieldValue.svelte" export { default as UpdateFieldValue } from "./UpdateFieldValue.svelte"
export { default as ShowNotification } from "./ShowNotification.svelte" export { default as ShowNotification } from "./ShowNotification.svelte"
export { default as OpenSidePanel } from "./OpenSidePanel.svelte"
export { default as CloseSidePanel } from "./CloseSidePanel.svelte"

View File

@ -1,6 +1,7 @@
import * as ActionComponents from "./actions" import * as ActionComponents from "./actions"
import { get } from "svelte/store" import { get } from "svelte/store"
import { store } from "builderStore" import { store } from "builderStore"
// @ts-ignore
import ActionDefinitions from "./manifest.json" import ActionDefinitions from "./manifest.json"
// Defines which actions are available to configure in the front end. // Defines which actions are available to configure in the front end.

View File

@ -116,6 +116,18 @@
"type": "application", "type": "application",
"component": "ShowNotification", "component": "ShowNotification",
"dependsOnFeature": "showNotificationAction" "dependsOnFeature": "showNotificationAction"
},
{
"name": "Open Side Panel",
"type": "application",
"component": "OpenSidePanel",
"dependsOnFeature": "sidePanel"
},
{
"name": "Close Side Panel",
"type": "application",
"component": "CloseSidePanel",
"dependsOnFeature": "sidePanel"
} }
] ]
} }

View File

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

View File

@ -5,6 +5,7 @@
import DesignSection from "./DesignSection.svelte" import DesignSection from "./DesignSection.svelte"
import CustomStylesSection from "./CustomStylesSection.svelte" import CustomStylesSection from "./CustomStylesSection.svelte"
import ConditionalUISection from "./ConditionalUISection.svelte" import ConditionalUISection from "./ConditionalUISection.svelte"
import ComponentInfoSection from "./ComponentInfoSection.svelte"
import { import {
getBindableProperties, getBindableProperties,
getComponentBindableProperties, getComponentBindableProperties,
@ -29,6 +30,9 @@
{#if $selectedComponent} {#if $selectedComponent}
{#key $selectedComponent._id} {#key $selectedComponent._id}
<Panel {title} icon={componentDefinition?.icon} borderLeft> <Panel {title} icon={componentDefinition?.icon} borderLeft>
{#if componentDefinition.info}
<ComponentInfoSection {componentDefinition} />
{/if}
<ComponentSettingsSection <ComponentSettingsSection
{componentInstance} {componentInstance}
{componentDefinition} {componentDefinition}

View File

@ -6,6 +6,7 @@
import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte" import ResetFieldsButton from "components/design/settings/controls/ResetFieldsButton.svelte"
import EjectBlockButton from "components/design/settings/controls/EjectBlockButton.svelte" import EjectBlockButton from "components/design/settings/controls/EjectBlockButton.svelte"
import { getComponentForSetting } from "components/design/settings/componentSettings" import { getComponentForSetting } from "components/design/settings/componentSettings"
import analytics, { Events } from "analytics"
export let componentDefinition export let componentDefinition
export let componentInstance export let componentInstance
@ -36,15 +37,26 @@
section.settings.forEach(setting => { section.settings.forEach(setting => {
setting.visible = canRenderControl(instance, setting, isScreen) setting.visible = canRenderControl(instance, setting, isScreen)
}) })
section.visible = section.settings.some(setting => setting.visible) section.visible =
section.name === "General" ||
section.settings.some(setting => setting.visible)
}) })
return sections return sections
} }
const updateSetting = async (key, value) => { const updateSetting = async (setting, value) => {
try { try {
await store.actions.components.updateSetting(key, value) await store.actions.components.updateSetting(setting.key, value)
// Send event if required
if (setting.sendEvents) {
analytics.captureEvent(Events.COMPONENT_UPDATED, {
name: componentInstance._component,
setting: setting.key,
value,
})
}
} catch (error) { } catch (error) {
notifications.error("Error updating component prop") notifications.error("Error updating component prop")
} }
@ -111,7 +123,7 @@
label="Name" label="Name"
key="_instanceName" key="_instanceName"
value={componentInstance._instanceName} value={componentInstance._instanceName}
onChange={val => updateSetting("_instanceName", val)} onChange={val => updateSetting({ key: "_instanceName" }, val)}
/> />
{/if} {/if}
{#each section.settings as setting (setting.key)} {#each section.settings as setting (setting.key)}
@ -124,7 +136,7 @@
value={componentInstance[setting.key]} value={componentInstance[setting.key]}
defaultValue={setting.defaultValue} defaultValue={setting.defaultValue}
nested={setting.nested} nested={setting.nested}
onChange={val => updateSetting(setting.key, val)} onChange={val => updateSetting(setting, val)}
highlighted={$store.highlightedSettingKey === setting.key} highlighted={$store.highlightedSettingKey === setting.key}
info={setting.info} info={setting.info}
props={{ props={{
@ -148,9 +160,11 @@
{#if idx === 0 && componentDefinition?.component?.endsWith("/fieldgroup")} {#if idx === 0 && componentDefinition?.component?.endsWith("/fieldgroup")}
<ResetFieldsButton {componentInstance} /> <ResetFieldsButton {componentInstance} />
{/if} {/if}
{#if idx === 0 && componentDefinition?.block}
<EjectBlockButton />
{/if}
</DetailSummary> </DetailSummary>
{/if} {/if}
{/each} {/each}
{#if componentDefinition?.block}
<DetailSummary name="Eject" collapsible={false}>
<EjectBlockButton />
</DetailSummary>
{/if}

View File

@ -17,7 +17,8 @@
"children": [ "children": [
"container", "container",
"section", "section",
"grid" "grid",
"sidepanel"
] ]
}, },
{ {

View File

@ -68,7 +68,7 @@
<span data-cy="data-source-modal"> <span data-cy="data-source-modal">
<ModalContent <ModalContent
title="Create CRUD Screens" title="Autogenerated screens"
confirmText="Confirm" confirmText="Confirm"
cancelText="Back" cancelText="Back"
onConfirm={confirmDatasourceSelection} onConfirm={confirmDatasourceSelection}
@ -77,7 +77,7 @@
size="L" size="L"
> >
<Body size="S"> <Body size="S">
Select which datasource you would like to use to create your screens Select which datasources you would like to use to create your screens
</Body> </Body>
<Layout noPadding gap="S"> <Layout noPadding gap="S">
{#each filteredSources as datasource} {#each filteredSources as datasource}

View File

@ -40,9 +40,9 @@
</script> </script>
<ModalContent <ModalContent
title={"Create CRUD Screens"} title="Autogenerated screens"
confirmText={"Done"} confirmText="Done"
cancelText={"Back"} cancelText="Back"
{onConfirm} {onConfirm}
{onCancel} {onCancel}
disabled={!!error} disabled={!!error}

View File

@ -127,17 +127,13 @@
// Handler for Datasource Screen Creation // Handler for Datasource Screen Creation
const completeDatasourceScreenCreation = async () => { const completeDatasourceScreenCreation = async () => {
// // Handle template selection const screens = selectedTemplates.map(template => {
if (selectedTemplates?.length > 1) { let screenTemplate = template.create()
// Autoscreens, so create immediately screenTemplate.datasource = template.datasource
const screens = selectedTemplates.map(template => { screenTemplate.autoTableId = template.table
let screenTemplate = template.create() return screenTemplate
screenTemplate.datasource = template.datasource })
screenTemplate.autoTableId = template.table await createScreens({ screens, screenAccessRole })
return screenTemplate
})
await createScreens({ screens, screenAccessRole })
}
} }
const confirmScreenBlank = async ({ screenUrl }) => { const confirmScreenBlank = async ({ screenUrl }) => {

View File

@ -62,7 +62,7 @@
<Layout> <Layout>
<Layout noPadding justifyItems="center"> <Layout noPadding justifyItems="center">
<img alt="logo" src={$organisation.logoUrl || Logo} /> <img alt="logo" src={$organisation.logoUrl || Logo} />
<Heading>Sign in to {company}</Heading> <Heading textAlign="center">Sign in to {company}</Heading>
</Layout> </Layout>
{#if loaded} {#if loaded}
<GoogleButton /> <GoogleButton />

View File

@ -9,7 +9,8 @@
"messagePassing": true, "messagePassing": true,
"rowSelection": true, "rowSelection": true,
"continueIfAction": true, "continueIfAction": true,
"showNotificationAction": true "showNotificationAction": true,
"sidePanel": true
}, },
"layout": { "layout": {
"name": "Layout", "name": "Layout",
@ -3669,7 +3670,7 @@
"Ascending", "Ascending",
"Descending" "Descending"
], ],
"defaultValue": "Descending" "defaultValue": "Ascending"
}, },
{ {
"type": "number", "type": "number",
@ -3736,12 +3737,6 @@
"key": "dataProvider", "key": "dataProvider",
"required": true "required": true
}, },
{
"type": "number",
"label": "Row count",
"key": "rowCount",
"defaultValue": 8
},
{ {
"type": "columns", "type": "columns",
"label": "Columns", "label": "Columns",
@ -3765,6 +3760,12 @@
} }
] ]
}, },
{
"type": "number",
"label": "Row count",
"key": "rowCount",
"defaultValue": 8
},
{ {
"type": "boolean", "type": "boolean",
"label": "Quiet", "label": "Quiet",
@ -3775,12 +3776,6 @@
"label": "Compact", "label": "Compact",
"key": "compact" "key": "compact"
}, },
{
"type": "boolean",
"label": "Show auto columns",
"key": "showAutoColumns",
"defaultValue": false
},
{ {
"type": "boolean", "type": "boolean",
"label": "Allow row selection", "label": "Allow row selection",
@ -3788,30 +3783,20 @@
"defaultValue": false, "defaultValue": false,
"info": "Row selection is only compatible with internal or SQL tables" "info": "Row selection is only compatible with internal or SQL tables"
}, },
{
"type": "boolean",
"label": "Link table rows",
"key": "linkRows"
},
{
"type": "boolean",
"label": "Open link screens in modal",
"key": "linkPeek"
},
{
"type": "url",
"label": "Link screen",
"key": "linkURL"
},
{ {
"section": true, "section": true,
"name": "Advanced", "name": "On Row Click",
"settings": [ "settings": [
{ {
"type": "field", "type": "event",
"label": "ID column for linking (appended to URL)", "key": "onClick",
"key": "linkColumn", "context": [
"placeholder": "Default" {
"label": "Clicked row",
"key": "row"
}
]
} }
] ]
} }
@ -4463,70 +4448,15 @@
"label": "Title", "label": "Title",
"key": "title" "key": "title"
}, },
{
"type": "dataSource",
"label": "Data",
"key": "dataSource",
"required": true
},
{
"type": "searchfield",
"label": "Search Columns",
"key": "searchColumns",
"placeholder": "Choose search columns",
"info": "Only the first 5 search columns will be used"
},
{
"type": "filter",
"label": "Filtering",
"key": "filter"
},
{
"type": "field/sortable",
"label": "Sort Column",
"key": "sortColumn"
},
{
"type": "select",
"label": "Sort Order",
"key": "sortOrder",
"options": [
"Ascending",
"Descending"
],
"defaultValue": "Descending"
},
{
"type": "select",
"label": "Size",
"key": "size",
"defaultValue": "spectrum--medium",
"options": [
{
"label": "Medium",
"value": "spectrum--medium"
},
{
"label": "Large",
"value": "spectrum--large"
}
]
},
{
"type": "boolean",
"label": "Paginate",
"key": "paginate",
"defaultValue": true
},
{ {
"section": true, "section": true,
"name": "Table", "name": "Table",
"settings": [ "settings": [
{ {
"type": "number", "type": "dataSource",
"label": "Scroll Limit", "label": "Data",
"key": "rowCount", "key": "dataSource",
"defaultValue": 8 "required": true
}, },
{ {
"type": "columns", "type": "columns",
@ -4536,9 +4466,52 @@
"placeholder": "All columns", "placeholder": "All columns",
"nested": true "nested": true
}, },
{
"type": "field/sortable",
"label": "Sort By",
"key": "sortColumn"
},
{
"type": "select",
"label": "Sort Order",
"key": "sortOrder",
"options": [
"Ascending",
"Descending"
],
"defaultValue": "Ascending"
},
{
"type": "select",
"label": "Size",
"key": "size",
"defaultValue": "spectrum--medium",
"options": [
{
"label": "Medium",
"value": "spectrum--medium"
},
{
"label": "Large",
"value": "spectrum--large"
}
]
},
{
"type": "number",
"label": "Scroll Limit",
"key": "rowCount",
"defaultValue": 8
},
{ {
"type": "boolean", "type": "boolean",
"label": "Quiet table variant", "label": "Paginate",
"key": "paginate",
"defaultValue": true
},
{
"type": "boolean",
"label": "Quiet",
"key": "quiet" "key": "quiet"
}, },
{ {
@ -4546,11 +4519,6 @@
"label": "Compact", "label": "Compact",
"key": "compact" "key": "compact"
}, },
{
"type": "boolean",
"label": "Show auto columns",
"key": "showAutoColumns"
},
{ {
"type": "boolean", "type": "boolean",
"label": "Allow row selection", "label": "Allow row selection",
@ -4558,58 +4526,100 @@
"info": "Row selection is only compatible with internal or SQL tables" "info": "Row selection is only compatible with internal or SQL tables"
}, },
{ {
"type": "boolean", "type": "filter",
"label": "Link table rows", "label": "Filtering",
"key": "linkRows" "key": "filter"
}, },
{ {
"type": "boolean", "type": "searchfield",
"label": "Open link in modal", "label": "Search Fields",
"key": "linkPeek" "key": "searchColumns",
}, "placeholder": "Choose search fields",
{ "info": "Only the first 5 search fields will be used"
"type": "url",
"label": "Link screen",
"key": "linkURL"
} }
] ]
}, },
{ {
"section": true, "section": true,
"name": "Title button", "name": "On row click",
"settings": [
{
"type": "radio",
"key": "clickBehaviour",
"sendEvents": true,
"defaultValue": "actions",
"info": "Details side panel is only compatible with internal or SQL tables",
"options": [
{
"label": "Run actions",
"value": "actions"
},
{
"label": "Show details side panel",
"value": "details"
}
]
},
{
"type": "event",
"key": "onClick",
"nested": true,
"dependsOn": {
"setting": "clickBehaviour",
"value": "actions"
},
"context": [
{
"label": "Clicked row",
"key": "row"
}
]
}
]
},
{
"section": true,
"name": "Button",
"settings": [ "settings": [
{ {
"type": "boolean", "type": "boolean",
"key": "showTitleButton", "key": "showTitleButton",
"label": "Show link button", "label": "Show button above table",
"defaultValue": false "defaultValue": false
}, },
{
"type": "boolean",
"label": "Open link in modal",
"key": "titleButtonPeek"
},
{ {
"type": "text", "type": "text",
"key": "titleButtonText", "key": "titleButtonText",
"label": "Button text" "label": "Text",
"defaultValue": "Create row",
"dependsOn": "showTitleButton"
}, },
{ {
"type": "url", "type": "radio",
"label": "Button link", "key": "titleButtonClickBehaviour",
"key": "titleButtonURL" "label": "On Click",
} "dependsOn": "showTitleButton",
] "defaultValue": "actions",
}, "info": "New row side panel is only compatible with internal or SQL tables",
{ "options": [
"section": true, {
"name": "Advanced", "label": "Run actions",
"settings": [ "value": "actions"
},
{
"label": "Show new row side panel",
"value": "new"
}
]
},
{ {
"type": "field", "type": "event",
"label": "ID column for linking (appended to URL)", "key": "onClickTitleButton",
"key": "linkColumn", "nested": true,
"placeholder": "Default" "dependsOn": {
"setting": "titleButtonClickBehaviour",
"value": "actions"
}
} }
] ]
} }
@ -5189,6 +5199,17 @@
} }
] ]
}, },
"sidepanel": {
"name": "Side Panel",
"icon": "RailRight",
"hasChildren": true,
"illegalChildren": [
"section"
],
"showEmptyState": false,
"draggable": false,
"info": "Side panels are hidden by default. They will only be revealed when triggered by the 'Open Side Panel' action."
},
"rowexplorer": { "rowexplorer": {
"block": true, "block": true,
"name": "Row Explorer Block", "name": "Row Explorer Block",

View File

@ -8,6 +8,7 @@
export let props export let props
export let styles export let styles
export let context export let context
export let name
export let order = 0 export let order = 0
export let containsSlot = false export let containsSlot = false
@ -26,7 +27,7 @@
_blockElementHasChildren: $$slots?.default ?? false, _blockElementHasChildren: $$slots?.default ?? false,
_component: `@budibase/standard-components/${type}`, _component: `@budibase/standard-components/${type}`,
_id: id, _id: id,
_instanceName: type[0].toUpperCase() + type.slice(1), _instanceName: name || type[0].toUpperCase() + type.slice(1),
_styles: { _styles: {
...styles, ...styles,
normal: styles?.normal || {}, normal: styles?.normal || {},

View File

@ -190,6 +190,7 @@
}, },
empty: emptyState, empty: emptyState,
selected, selected,
inSelectedPath,
name, name,
editing, editing,
type: instance._component, type: instance._component,

View File

@ -1,7 +1,7 @@
<script> <script>
import { getContext, setContext } from "svelte" import { getContext, setContext } from "svelte"
import { writable } from "svelte/store" import { writable } from "svelte/store"
import { Heading, Icon } from "@budibase/bbui" import { Heading, Icon, clickOutside } from "@budibase/bbui"
import { FieldTypes } from "constants" import { FieldTypes } from "constants"
import active from "svelte-spa-router/active" import active from "svelte-spa-router/active"
import { RoleUtils } from "@budibase/frontend-core" import { RoleUtils } from "@budibase/frontend-core"
@ -16,6 +16,7 @@
builderStore, builderStore,
currentRole, currentRole,
environmentStore, environmentStore,
sidePanelStore,
} = sdk } = sdk
const component = getContext("component") const component = getContext("component")
const context = getContext("context") const context = getContext("context")
@ -71,6 +72,7 @@
$context.device.width, $context.device.width,
$context.device.height $context.device.height
) )
$: autoCloseSidePanel = !$builderStore.inBuilder && $sidePanelStore.open
// Scroll navigation into view if selected // Scroll navigation into view if selected
$: { $: {
@ -150,113 +152,141 @@
class:desktop={!mobile} class:desktop={!mobile}
class:mobile={!!mobile} class:mobile={!!mobile}
> >
{#if typeClass !== "none"} <div class="layout-body">
<div {#if typeClass !== "none"}
class="interactive component navigation"
data-id="navigation"
data-name="Navigation"
data-icon="Link"
>
<div <div
class="nav-wrapper" class="interactive component navigation"
class:sticky data-id="navigation"
class:hidden={$routeStore.queryParams?.peek} data-name="Navigation"
class:clickable={$builderStore.inBuilder} data-icon="Link"
on:click={$builderStore.inBuilder
? builderStore.actions.clickNav
: null}
style={navStyle}
> >
<div class="nav nav--{typeClass} size--{navWidthClass}"> <div
<div class="nav-header"> class="nav-wrapper"
class:sticky
class:hidden={$routeStore.queryParams?.peek}
class:clickable={$builderStore.inBuilder}
on:click={$builderStore.inBuilder
? builderStore.actions.clickNav
: null}
style={navStyle}
>
<div class="nav nav--{typeClass} size--{navWidthClass}">
<div class="nav-header">
{#if validLinks?.length}
<div class="burger">
<Icon
hoverable
name="ShowMenu"
on:click={() => (mobileOpen = !mobileOpen)}
/>
</div>
{/if}
<div class="logo">
{#if !hideLogo}
<img
src={logoUrl || "https://i.imgur.com/Xhdt1YP.png"}
alt={title}
/>
{/if}
{#if !hideTitle && title}
<Heading size="S">{title}</Heading>
{/if}
</div>
<div class="portal">
<Icon hoverable name="Apps" on:click={navigateToPortal} />
</div>
</div>
<div
class="mobile-click-handler"
class:visible={mobileOpen}
on:click={() => (mobileOpen = false)}
/>
{#if validLinks?.length} {#if validLinks?.length}
<div class="burger"> <div class="links" class:visible={mobileOpen}>
<Icon {#each validLinks as { text, url }}
hoverable {#if isInternal(url)}
name="ShowMenu" <a
on:click={() => (mobileOpen = !mobileOpen)} class={FieldTypes.LINK}
/> href={url}
use:linkable
on:click={close}
use:active={url}
>
{text}
</a>
{:else}
<a
class={FieldTypes.LINK}
href={ensureExternal(url)}
on:click={close}
>
{text}
</a>
{/if}
{/each}
<div class="close">
<Icon
hoverable
name="Close"
on:click={() => (mobileOpen = false)}
/>
</div>
</div> </div>
{/if} {/if}
<div class="logo">
{#if !hideLogo}
<img
src={logoUrl || "https://i.imgur.com/Xhdt1YP.png"}
alt={title}
/>
{/if}
{#if !hideTitle && title}
<Heading size="S">{title}</Heading>
{/if}
</div>
<div class="portal">
<Icon hoverable name="Apps" on:click={navigateToPortal} />
</div>
</div> </div>
<div
class="mobile-click-handler"
class:visible={mobileOpen}
on:click={() => (mobileOpen = false)}
/>
{#if validLinks?.length}
<div class="links" class:visible={mobileOpen}>
{#each validLinks as { text, url }}
{#if isInternal(url)}
<a
class={FieldTypes.LINK}
href={url}
use:linkable
on:click={close}
use:active={url}
>
{text}
</a>
{:else}
<a
class={FieldTypes.LINK}
href={ensureExternal(url)}
on:click={close}
>
{text}
</a>
{/if}
{/each}
<div class="close">
<Icon
hoverable
name="Close"
on:click={() => (mobileOpen = false)}
/>
</div>
</div>
{/if}
</div> </div>
</div> </div>
{/if}
{#if !$builderStore.inBuilder && licensing.logoEnabled() && $environmentStore.cloud}
<FreeLogo />
{/if}
<div class="main-wrapper">
<div class="main size--{pageWidthClass}">
<slot />
</div>
</div> </div>
{/if} </div>
<div
{#if !$builderStore.inBuilder && licensing.logoEnabled() && $environmentStore.cloud} id="side-panel-container"
<FreeLogo /> class:open={$sidePanelStore.open}
{/if} use:clickOutside={autoCloseSidePanel ? sidePanelStore.actions.close : null}
class:builder={$builderStore.inBuilder}
<div class="main-wrapper"> >
<div class="main size--{pageWidthClass}"> <div class="side-panel-header">
<slot /> <Icon
color="var(--spectrum-global-color-gray-600)"
name="RailRightClose"
hoverable
on:click={sidePanelStore.actions.close}
/>
</div> </div>
</div> </div>
</div> </div>
<style> <style>
/* Main components */ /* Main components */
.layout {
height: 100%;
flex: 1 1 auto;
display: flex;
flex-direction: row;
justify-content: center;
align-items: stretch;
z-index: 1;
border-top: 1px solid var(--spectrum-global-color-gray-300);
overflow: hidden;
position: relative;
}
.component { .component {
display: contents; display: contents;
} }
.layout { .layout-body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
height: 100%;
flex: 1 1 auto; flex: 1 1 auto;
overflow: auto; overflow: auto;
overflow-x: hidden; overflow-x: hidden;
@ -316,6 +346,43 @@
align-items: center; align-items: center;
gap: var(--spacing-xl); gap: var(--spacing-xl);
} }
#side-panel-container {
max-width: calc(100vw - 40px);
background: var(--spectrum-global-color-gray-50);
z-index: 3;
padding: var(--spacing-xl);
display: flex;
flex-direction: column;
gap: 30px;
overflow-y: auto;
overflow-x: hidden;
transition: transform 130ms ease-out;
position: absolute;
width: 400px;
right: 0;
transform: translateX(100%);
height: 100%;
}
#side-panel-container.builder {
transform: translateX(0);
opacity: 0;
pointer-events: none;
}
#side-panel-container.open {
transform: translateX(0);
box-shadow: 0 0 40px 10px rgba(0, 0, 0, 0.1);
}
#side-panel-container.builder.open {
opacity: 1;
pointer-events: all;
}
.side-panel-header {
display: flex;
flex-direction: row;
justify-content: flex-end;
}
.main-wrapper { .main-wrapper {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -0,0 +1,78 @@
<script>
import { getContext } from "svelte"
const component = getContext("component")
const { styleable, sidePanelStore, builderStore, dndIsDragging } =
getContext("sdk")
// Automatically show and hide the side panel when inside the builder.
// For some unknown reason, svelte reactivity breaks if we reference the
// reactive variable "open" inside the following expression, or if we define
// "open" above this expression.
$: {
if ($builderStore.inBuilder) {
if (
$component.inSelectedPath &&
$sidePanelStore.contentId !== $component.id
) {
sidePanelStore.actions.open($component.id)
} else if (
!$component.inSelectedPath &&
$sidePanelStore.contentId === $component.id &&
!$dndIsDragging
) {
sidePanelStore.actions.close()
}
}
}
// Derive visibility
$: open = $sidePanelStore.contentId === $component.id
const showInSidePanel = (el, visible) => {
const update = visible => {
const target = document.getElementById("side-panel-container")
const node = el
if (visible) {
if (!target.contains(node)) {
target.appendChild(node)
}
} else {
if (target.contains(node)) {
target.removeChild(node)
}
}
}
// Apply initial visibility
update(visible)
return { update }
}
</script>
<div
use:styleable={$component.styles}
use:showInSidePanel={open}
class="side-panel"
class:open
>
<slot />
</div>
<style>
.side-panel {
flex: 1 1 auto;
display: none;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-xl);
}
.side-panel.open {
display: flex;
}
.side-panel :global(.component > *) {
max-width: 100%;
}
</style>

View File

@ -1,5 +1,6 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { generate } from "shortid"
import Block from "components/Block.svelte" import Block from "components/Block.svelte"
import BlockComponent from "components/BlockComponent.svelte" import BlockComponent from "components/BlockComponent.svelte"
import { makePropSafe as safe } from "@budibase/string-templates" import { makePropSafe as safe } from "@budibase/string-templates"
@ -13,50 +14,103 @@
export let sortOrder export let sortOrder
export let paginate export let paginate
export let tableColumns export let tableColumns
export let showAutoColumns
export let rowCount export let rowCount
export let quiet export let quiet
export let compact export let compact
export let size export let size
export let allowSelectRows export let allowSelectRows
export let linkRows export let clickBehaviour
export let linkURL export let onClick
export let linkColumn
export let linkPeek
export let showTitleButton export let showTitleButton
export let titleButtonText export let titleButtonText
export let titleButtonURL export let titleButtonClickBehaviour
export let titleButtonPeek export let onClickTitleButton
const { fetchDatasourceSchema } = getContext("sdk") const { fetchDatasourceSchema, API } = getContext("sdk")
const stateKey = `ID_${generate()}`
let formId let formId
let dataProviderId let dataProviderId
let detailsFormBlockId
let detailsSidePanelId
let newRowSidePanelId
let schema let schema
let primaryDisplay
let schemaLoaded = false let schemaLoaded = false
$: fetchSchema(dataSource) $: fetchSchema(dataSource)
$: enrichedSearchColumns = enrichSearchColumns(searchColumns, schema) $: enrichedSearchColumns = enrichSearchColumns(searchColumns, schema)
$: enrichedFilter = enrichFilter(filter, enrichedSearchColumns, formId) $: enrichedFilter = enrichFilter(filter, enrichedSearchColumns, formId)
$: titleButtonAction = [ $: editTitle = getEditTitle(detailsFormBlockId, primaryDisplay)
{ $: normalFields = getNormalFields(schema)
"##eventHandlerType": "Navigate To", $: rowClickActions =
parameters: { clickBehaviour === "actions" || dataSource?.type !== "table"
peek: titleButtonPeek, ? onClick
url: titleButtonURL, : [
}, {
}, id: 0,
] "##eventHandlerType": "Update State",
parameters: {
key: stateKey,
type: "set",
persist: false,
value: `{{ ${safe("eventContext")}.${safe("row")}._id }}`,
},
},
{
id: 1,
"##eventHandlerType": "Open Side Panel",
parameters: {
id: detailsSidePanelId,
},
},
]
$: buttonClickActions =
clickBehaviour === "actions" || dataSource?.type !== "table"
? onClickTitleButton
: [
{
id: 0,
"##eventHandlerType": "Open Side Panel",
parameters: {
id: newRowSidePanelId,
},
},
]
// Load the datasource schema so we can determine column types // Load the datasource schema so we can determine column types
const fetchSchema = async dataSource => { const fetchSchema = async dataSource => {
if (dataSource) { if (dataSource?.type === "table") {
const definition = await API.fetchTableDefinition(dataSource?.tableId)
schema = definition.schema
primaryDisplay = definition.primaryDisplay
} else if (dataSource) {
schema = await fetchDatasourceSchema(dataSource, { schema = await fetchDatasourceSchema(dataSource, {
enrichRelationships: true, enrichRelationships: true,
}) })
} }
schemaLoaded = true schemaLoaded = true
} }
const getNormalFields = schema => {
if (!schema) {
return []
}
return Object.entries(schema)
.filter(entry => {
return !entry[1].autocolumn
})
.map(entry => entry[0])
}
const getEditTitle = (detailsFormBlockId, primaryDisplay) => {
if (!primaryDisplay || !detailsFormBlockId) {
return "Edit"
}
const prefix = safe(detailsFormBlockId + "-repeater")
const binding = `${prefix}.${safe(primaryDisplay)}`
return `{{#if ${binding}}}{{${binding}}}{{else}}Details{{/if}}`
}
</script> </script>
{#if schemaLoaded} {#if schemaLoaded}
@ -129,7 +183,7 @@
<BlockComponent <BlockComponent
type="button" type="button"
props={{ props={{
onClick: titleButtonAction, onClick: buttonClickActions,
text: titleButtonText, text: titleButtonText,
type: "cta", type: "cta",
}} }}
@ -145,7 +199,7 @@
props={{ props={{
dataSource, dataSource,
filter: enrichedFilter, filter: enrichedFilter,
sortColumn, sortColumn: sortColumn || primaryDisplay,
sortOrder, sortOrder,
paginate, paginate,
limit: rowCount, limit: rowCount,
@ -158,19 +212,63 @@
props={{ props={{
dataProvider: `{{ literal ${safe(dataProviderId)} }}`, dataProvider: `{{ literal ${safe(dataProviderId)} }}`,
columns: tableColumns, columns: tableColumns,
showAutoColumns,
rowCount, rowCount,
quiet, quiet,
compact, compact,
allowSelectRows, allowSelectRows,
size, size,
linkRows, onClick: rowClickActions,
linkURL,
linkColumn,
linkPeek,
}} }}
/> />
</BlockComponent> </BlockComponent>
{#if clickBehaviour === "details"}
<BlockComponent
name="Details side panel"
type="sidepanel"
bind:id={detailsSidePanelId}
context="details-side-panel"
order={2}
>
<BlockComponent
name="Details form block"
type="formblock"
bind:id={detailsFormBlockId}
props={{
dataSource,
showSaveButton: true,
showDeleteButton: true,
actionType: "Update",
rowId: `{{ ${safe("state")}.${safe(stateKey)} }}`,
fields: normalFields,
title: editTitle,
labelPosition: "left",
}}
/>
</BlockComponent>
{/if}
{#if showTitleButton && titleButtonClickBehaviour === "new"}
<BlockComponent
name="New row side panel"
type="sidepanel"
bind:id={newRowSidePanelId}
context="new-side-panel"
order={3}
>
<BlockComponent
name="New row form block"
type="formblock"
props={{
dataSource,
showSaveButton: true,
showDeleteButton: false,
actionType: "Create",
fields: normalFields,
title: "Create Row",
labelPosition: "left",
}}
/>
</BlockComponent>
{/if}
</BlockComponent> </BlockComponent>
</Block> </Block>
{/if} {/if}

View File

@ -43,6 +43,20 @@
{ {
"##eventHandlerType": "Close Screen Modal", "##eventHandlerType": "Close Screen Modal",
}, },
{
"##eventHandlerType": "Close Side Panel",
},
// Clear a create form once submitted
...(actionType !== "Create"
? []
: [
{
"##eventHandlerType": "Clear Form",
parameters: {
componentId: formId,
},
},
]),
{ {
"##eventHandlerType": "Navigate To", "##eventHandlerType": "Navigate To",
parameters: { parameters: {
@ -63,6 +77,9 @@
{ {
"##eventHandlerType": "Close Screen Modal", "##eventHandlerType": "Close Screen Modal",
}, },
{
"##eventHandlerType": "Close Side Panel",
},
{ {
"##eventHandlerType": "Navigate To", "##eventHandlerType": "Navigate To",
parameters: { parameters: {

View File

@ -66,7 +66,7 @@
$: initialValues = getInitialValues(actionType, dataSource, $context) $: initialValues = getInitialValues(actionType, dataSource, $context)
$: resetKey = Helpers.hashString( $: resetKey = Helpers.hashString(
JSON.stringify(initialValues) + JSON.stringify(schema) + disabled JSON.stringify(initialValues) + JSON.stringify(dataSource) + disabled
) )
</script> </script>

View File

@ -35,10 +35,10 @@ export { default as tag } from "./Tag.svelte"
export { default as markdownviewer } from "./MarkdownViewer.svelte" export { default as markdownviewer } from "./MarkdownViewer.svelte"
export { default as embeddedmap } from "./embedded-map/EmbeddedMap.svelte" export { default as embeddedmap } from "./embedded-map/EmbeddedMap.svelte"
export { default as grid } from "./Grid.svelte" export { default as grid } from "./Grid.svelte"
export { default as sidepanel } from "./SidePanel.svelte"
export * from "./charts" export * from "./charts"
export * from "./forms" export * from "./forms"
export * from "./table" export * from "./table"
export * from "./blocks" export * from "./blocks"
export * from "./dynamic-filter" export * from "./dynamic-filter"

View File

@ -7,20 +7,16 @@
export let dataProvider export let dataProvider
export let columns export let columns
export let showAutoColumns
export let rowCount export let rowCount
export let quiet export let quiet
export let size export let size
export let linkRows
export let linkURL
export let linkColumn
export let linkPeek
export let allowSelectRows export let allowSelectRows
export let compact export let compact
export let onClick
const loading = getContext("loading") const loading = getContext("loading")
const component = getContext("component") const component = getContext("component")
const { styleable, getAction, ActionTypes, routeStore, rowSelectionStore } = const { styleable, getAction, ActionTypes, rowSelectionStore } =
getContext("sdk") getContext("sdk")
const customColumnKey = `custom-${Math.random()}` const customColumnKey = `custom-${Math.random()}`
const customRenderers = [ const customRenderers = [
@ -29,18 +25,19 @@
component: SlotRenderer, component: SlotRenderer,
}, },
] ]
let selectedRows = [] let selectedRows = []
$: hasChildren = $component.children $: hasChildren = $component.children
$: data = dataProvider?.rows || [] $: data = dataProvider?.rows || []
$: fullSchema = dataProvider?.schema ?? {} $: fullSchema = dataProvider?.schema ?? {}
$: fields = getFields(fullSchema, columns, showAutoColumns) $: fields = getFields(fullSchema, columns, false)
$: schema = getFilteredSchema(fullSchema, fields, hasChildren) $: schema = getFilteredSchema(fullSchema, fields, hasChildren)
$: setSorting = getAction( $: setSorting = getAction(
dataProvider?.id, dataProvider?.id,
ActionTypes.SetDataProviderSorting ActionTypes.SetDataProviderSorting
) )
$: table = dataProvider?.datasource?.type === "table" $: table = dataProvider?.datasource?.type === "table"
$: { $: {
rowSelectionStore.actions.updateSelection( rowSelectionStore.actions.updateSelection(
$component.id, $component.id,
@ -118,17 +115,10 @@
}) })
} }
const onClick = e => { const handleClick = e => {
if (!linkRows || !linkURL) { if (onClick) {
return onClick({ row: e.detail })
} }
const col = linkColumn || "_id"
const id = e.detail?.[col]
if (!id) {
return
}
const split = linkURL.split("/:")
routeStore.actions.navigate(`${split[0]}/${id}`, linkPeek)
} }
onDestroy(() => { onDestroy(() => {
@ -153,7 +143,7 @@
disableSorting disableSorting
autoSortColumns={!columns?.length} autoSortColumns={!columns?.length}
on:sort={onSort} on:sort={onSort}
on:click={onClick} on:click={handleClick}
> >
<div class="skeleton" slot="loadingIndicator"> <div class="skeleton" slot="loadingIndicator">
<Skeleton /> <Skeleton />

View File

@ -36,8 +36,7 @@
// Util to get the inner DOM node by a component ID // Util to get the inner DOM node by a component ID
const getDOMNode = id => { const getDOMNode = id => {
const component = document.getElementsByClassName(id)[0] return document.getElementsByClassName(`${id}-dom`)[0]
return [...component.children][0]
} }
// Util to calculate the variance of a set of data // Util to calculate the variance of a set of data

View File

@ -43,7 +43,8 @@
if (callbackCount >= observers.length) { if (callbackCount >= observers.length) {
return return
} }
nextIndicators[idx].visible = entries[0].isIntersecting nextIndicators[idx].visible =
nextIndicators[idx].isSidePanel || entries[0].isIntersecting
if (++callbackCount === observers.length) { if (++callbackCount === observers.length) {
indicators = nextIndicators indicators = nextIndicators
updating = false updating = false
@ -91,8 +92,9 @@
// Extract valid children // Extract valid children
// Sanity limit of 100 active indicators // Sanity limit of 100 active indicators
const children = Array.from(parents) const children = Array.from(
.map(parent => parent?.children?.[0]) document.getElementsByClassName(`${componentId}-dom`)
)
.filter(x => x != null) .filter(x => x != null)
.slice(0, 100) .slice(0, 100)
@ -121,6 +123,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"),
}) })
}) })
} }

View File

@ -10,6 +10,8 @@ import {
componentStore, componentStore,
currentRole, currentRole,
environmentStore, environmentStore,
sidePanelStore,
dndIsDragging,
} from "stores" } from "stores"
import { styleable } from "utils/styleable" import { styleable } from "utils/styleable"
import { linkable } from "utils/linkable" import { linkable } from "utils/linkable"
@ -30,6 +32,8 @@ export default {
uploadStore, uploadStore,
componentStore, componentStore,
environmentStore, environmentStore,
sidePanelStore,
dndIsDragging,
currentRole, currentRole,
styleable, styleable,
linkable, linkable,

View File

@ -24,6 +24,7 @@ export {
dndIsNewComponent, dndIsNewComponent,
dndIsDragging, dndIsDragging,
} from "./dnd" } from "./dnd"
export { sidePanelStore } from "./sidePanel.js"
// Context stores are layered and duplicated, so it is not a singleton // Context stores are layered and duplicated, so it is not a singleton
export { createContextStore } from "./context" export { createContextStore } from "./context"

View File

@ -0,0 +1,37 @@
import { writable, derived } from "svelte/store"
export const createSidePanelStore = () => {
const initialState = {
contentId: null,
}
const store = writable(initialState)
const derivedStore = derived(store, $store => {
return {
...$store,
open: $store.contentId != null,
}
})
const open = id => {
store.update(state => {
state.contentId = id
return state
})
}
const close = () => {
store.update(state => {
state.contentId = null
return state
})
}
return {
subscribe: derivedStore.subscribe,
actions: {
open,
close,
},
}
}
export const sidePanelStore = createSidePanelStore()

View File

@ -10,6 +10,7 @@ import {
dataSourceStore, dataSourceStore,
uploadStore, uploadStore,
rowSelectionStore, rowSelectionStore,
sidePanelStore,
} from "stores" } from "stores"
import { API } from "api" import { API } from "api"
import { ActionTypes } from "constants" import { ActionTypes } from "constants"
@ -312,6 +313,17 @@ const showNotificationHandler = action => {
notificationStore.actions[type]?.(message, autoDismiss) notificationStore.actions[type]?.(message, autoDismiss)
} }
const OpenSidePanelHandler = action => {
const { id } = action.parameters
if (id) {
sidePanelStore.actions.open(id)
}
}
const CloseSidePanelHandler = () => {
sidePanelStore.actions.close()
}
const handlerMap = { const handlerMap = {
["Save Row"]: saveRowHandler, ["Save Row"]: saveRowHandler,
["Duplicate Row"]: duplicateRowHandler, ["Duplicate Row"]: duplicateRowHandler,
@ -331,6 +343,8 @@ const handlerMap = {
["Export Data"]: exportDataHandler, ["Export Data"]: exportDataHandler,
["Continue if / Stop if"]: continueIfHandler, ["Continue if / Stop if"]: continueIfHandler,
["Show Notification"]: showNotificationHandler, ["Show Notification"]: showNotificationHandler,
["Open Side Panel"]: OpenSidePanelHandler,
["Close Side Panel"]: CloseSidePanelHandler,
} }
const confirmTextMap = { const confirmTextMap = {

View File

@ -25,6 +25,8 @@ export const styleable = (node, styles = {}) => {
// Creates event listeners and applies initial styles // Creates event listeners and applies initial styles
const setupStyles = (newStyles = {}) => { const setupStyles = (newStyles = {}) => {
node.classList.add(`${newStyles.id}-dom`)
let baseStyles = {} let baseStyles = {}
if (newStyles.empty) { if (newStyles.empty) {
baseStyles.border = "2px dashed var(--spectrum-global-color-gray-400)" baseStyles.border = "2px dashed var(--spectrum-global-color-gray-400)"

View File

@ -117,7 +117,7 @@ export default class DataFetch {
* Fetches a fresh set of data from the server, resetting pagination * Fetches a fresh set of data from the server, resetting pagination
*/ */
async getInitialData() { async getInitialData() {
const { datasource, filter, sortColumn, paginate } = this.options const { datasource, filter, paginate } = this.options
// Fetch datasource definition and determine feature flags // Fetch datasource definition and determine feature flags
const definition = await this.getDefinition(datasource) const definition = await this.getDefinition(datasource)
@ -135,6 +135,17 @@ export default class DataFetch {
return return
} }
// If no sort order, default to descending
if (!this.options.sortOrder) {
this.options.sortOrder = "ascending"
}
// If no sort column, use the first field in the schema
if (!this.options.sortColumn) {
this.options.sortColumn = Object.keys(schema)[0]
}
const { sortColumn } = this.options
// Determine what sort type to use // Determine what sort type to use
let sortType = "string" let sortType = "string"
if (sortColumn) { if (sortColumn) {

View File

@ -0,0 +1,145 @@
import { getScreenParams } from "../../db/utils"
import { Screen } from "@budibase/types"
import { makePropSafe as safe } from "@budibase/string-templates"
/**
* Date:
* November 2022
*
* Description:
* Update table settings to use actions instead of links. We do not remove the
* legacy values here as we cannot guarantee that their apps are up-t-date.
* It is safe to simply save both the new and old structure in the definition.
*
* Migration 1:
* Legacy "linkRows", "linkURL", "linkPeek" and "linkColumn" settings on tables
* and table blocks are migrated into a "Navigate To" action under the new
* "onClick" setting.
*
* Migration 2:
* Legacy "titleButtonURL" and "titleButtonPeek" settings on table blocks are
* migrated into a "Navigate To" action under the new "onClickTitleButton"
* setting.
*/
export const run = async (appDb: any) => {
// Get all app screens
let screens: Screen[]
try {
screens = (
await appDb.allDocs(
getScreenParams(null, {
include_docs: true,
})
)
).rows.map((row: any) => row.doc)
} catch (e) {
// sometimes the metadata document doesn't exist
// exit early instead of failing the migration
console.error("Error retrieving app metadata. Skipping", e)
return
}
// Recursively update any relevant components and mutate the screen docs
for (let screen of screens) {
const changed = migrateTableSettings(screen.props)
// Save screen if we updated it
if (changed) {
await appDb.put(screen)
console.log(
`Screen ${screen.routing?.route} contained table settings which were migrated`
)
}
}
}
// Recursively searches and mutates a screen doc to migrate table component
// and table block settings
const migrateTableSettings = (component: any) => {
let changed = false
if (!component) {
return changed
}
// Migration 1: migrate table row click settings
if (
component._component.endsWith("/table") ||
component._component.endsWith("/tableblock")
) {
const { linkRows, linkURL, linkPeek, linkColumn, onClick } = component
if (linkRows && !onClick) {
const column = linkColumn || "_id"
const action = convertLinkSettingToAction(linkURL, !!linkPeek, column)
if (action) {
changed = true
component.onClick = action
if (component._component.endsWith("/tableblock")) {
component.clickBehaviour = "actions"
}
}
}
}
// Migration 2: migrate table block title button settings
if (component._component.endsWith("/tableblock")) {
const {
showTitleButton,
titleButtonURL,
titleButtonPeek,
onClickTitleButton,
} = component
if (showTitleButton && !onClickTitleButton) {
const action = convertLinkSettingToAction(
titleButtonURL,
!!titleButtonPeek
)
if (action) {
changed = true
component.onClickTitleButton = action
component.titleButtonClickBehaviour = "actions"
}
}
}
// Recurse down the tree as needed
component._children?.forEach((child: any) => {
const childChanged = migrateTableSettings(child)
changed = changed || childChanged
})
return changed
}
// Util ti convert the legacy settings into a navigation action structure
const convertLinkSettingToAction = (
linkURL: string,
linkPeek: boolean,
linkColumn?: string
) => {
// Sanity check we have a URL
if (!linkURL) {
return null
}
// Default URL to the old URL setting
let url = linkURL
// If we enriched the old URL with a column, update the url
if (linkColumn && linkURL.includes("/:")) {
// Convert old link URL setting, which is a screen URL, into a valid
// binding using the new clicked row binding
const split = linkURL.split("/:")
const col = linkColumn || "_id"
const binding = `{{ ${safe("eventContext")}.${safe("row")}.${safe(col)} }}`
url = `${split[0]}/${binding}`
}
// Create action structure
return [
{
"##eventHandlerType": "Navigate To",
parameters: {
url,
peek: linkPeek,
},
},
]
}

View File

@ -0,0 +1,144 @@
import { App, Screen } from "@budibase/types"
import { db as dbCore } from "@budibase/backend-core"
import TestConfig from "../../../tests/utilities/TestConfiguration"
import { run as runMigration } from "../tableSettings"
describe("run", () => {
const config = new TestConfig(false)
let app: App
let screen: Screen
beforeAll(async () => {
await config.init()
app = await config.createApp("testApp")
screen = await config.createScreen()
})
afterAll(config.end)
it("migrates table block row on click settings", async () => {
// Add legacy table block as first child
screen.props._children = [
{
_instanceName: "Table Block",
_styles: {},
_component: "@budibase/standard-components/tableblock",
_id: "foo",
linkRows: true,
linkURL: "/rows/:id",
linkPeek: true,
linkColumn: "name",
},
]
await config.createScreen(screen)
// Run migration
screen = await dbCore.doWithDB(app.appId, async (db: any) => {
await runMigration(db)
return await db.get(screen._id)
})
// Verify new "onClick" setting
const onClick = screen.props._children?.[0].onClick
expect(onClick).toBeDefined()
expect(onClick.length).toBe(1)
expect(onClick[0]["##eventHandlerType"]).toBe("Navigate To")
expect(onClick[0].parameters.url).toBe(
`/rows/{{ [eventContext].[row].[name] }}`
)
expect(onClick[0].parameters.peek).toBeTruthy()
})
it("migrates table row on click settings", async () => {
// Add legacy table block as first child
screen.props._children = [
{
_instanceName: "Table",
_styles: {},
_component: "@budibase/standard-components/table",
_id: "foo",
linkRows: true,
linkURL: "/rows/:id",
linkPeek: true,
linkColumn: "name",
},
]
await config.createScreen(screen)
// Run migration
screen = await dbCore.doWithDB(app.appId, async (db: any) => {
await runMigration(db)
return await db.get(screen._id)
})
// Verify new "onClick" setting
const onClick = screen.props._children?.[0].onClick
expect(onClick).toBeDefined()
expect(onClick.length).toBe(1)
expect(onClick[0]["##eventHandlerType"]).toBe("Navigate To")
expect(onClick[0].parameters.url).toBe(
`/rows/{{ [eventContext].[row].[name] }}`
)
expect(onClick[0].parameters.peek).toBeTruthy()
})
it("migrates table block title button settings", async () => {
// Add legacy table block as first child
screen.props._children = [
{
_instanceName: "Table Block",
_styles: {},
_component: "@budibase/standard-components/tableblock",
_id: "foo",
showTitleButton: true,
titleButtonURL: "/url",
titleButtonPeek: true,
},
]
await config.createScreen(screen)
// Run migration
screen = await dbCore.doWithDB(app.appId, async (db: any) => {
await runMigration(db)
return await db.get(screen._id)
})
// Verify new "onClickTitleButton" setting
const onClick = screen.props._children?.[0].onClickTitleButton
expect(onClick).toBeDefined()
expect(onClick.length).toBe(1)
expect(onClick[0]["##eventHandlerType"]).toBe("Navigate To")
expect(onClick[0].parameters.url).toBe("/url")
expect(onClick[0].parameters.peek).toBeTruthy()
})
it("ignores components that have already been migrated", async () => {
// Add legacy table block as first child
screen.props._children = [
{
_instanceName: "Table Block",
_styles: {},
_component: "@budibase/standard-components/tableblock",
_id: "foo",
linkRows: true,
linkURL: "/rows/:id",
linkPeek: true,
linkColumn: "name",
onClick: "foo",
},
]
const initialDefinition = JSON.stringify(screen.props._children?.[0])
await config.createScreen(screen)
// Run migration
screen = await dbCore.doWithDB(app.appId, async (db: any) => {
await runMigration(db)
return await db.get(screen._id)
})
// Verify new "onClick" setting
const newDefinition = JSON.stringify(screen.props._children?.[0])
expect(initialDefinition).toEqual(newDefinition)
})
})

View File

@ -12,6 +12,7 @@ import env from "../environment"
import * as userEmailViewCasing from "./functions/userEmailViewCasing" import * as userEmailViewCasing from "./functions/userEmailViewCasing"
import * as syncQuotas from "./functions/syncQuotas" import * as syncQuotas from "./functions/syncQuotas"
import * as appUrls from "./functions/appUrls" import * as appUrls from "./functions/appUrls"
import * as tableSettings from "./functions/tableSettings"
import * as backfill from "./functions/backfill" import * as backfill from "./functions/backfill"
/** /**
* Populate the migration function and additional configuration from * Populate the migration function and additional configuration from
@ -73,6 +74,14 @@ export const buildMigrations = () => {
}) })
break break
} }
case MigrationName.TABLE_SETTINGS_LINKS_TO_ACTIONS: {
serverMigrations.push({
...definition,
appOpts: { dev: true },
fn: tableSettings.run,
})
break
}
} }
} }

View File

@ -0,0 +1,9 @@
import { Document } from "../document"
export interface Component extends Document {
_instanceName: string
_styles: { [key: string]: any }
_component: string
_children?: Component[]
[key: string]: any
}

View File

@ -13,3 +13,4 @@ export * from "./user"
export * from "./backup" export * from "./backup"
export * from "./webhook" export * from "./webhook"
export * from "./links" export * from "./links"
export * from "./component"

View File

@ -1,10 +1,7 @@
import { Document } from "../document" import { Document } from "../document"
import { Component } from "./component"
export interface ScreenProps extends Document { export interface ScreenProps extends Component {
_instanceName: string
_styles: { [key: string]: any }
_component: string
_children: ScreenProps[]
size?: string size?: string
gap?: string gap?: string
direction?: string direction?: string

View File

@ -44,6 +44,7 @@ export enum MigrationName {
EVENT_GLOBAL_BACKFILL = "event_global_backfill", EVENT_GLOBAL_BACKFILL = "event_global_backfill",
EVENT_INSTALLATION_BACKFILL = "event_installation_backfill", EVENT_INSTALLATION_BACKFILL = "event_installation_backfill",
GLOBAL_INFO_SYNC_USERS = "global_info_sync_users", GLOBAL_INFO_SYNC_USERS = "global_info_sync_users",
TABLE_SETTINGS_LINKS_TO_ACTIONS = "table_settings_links_to_actions",
// increment this number to re-activate this migration // increment this number to re-activate this migration
SYNC_QUOTAS = "sync_quotas_1", SYNC_QUOTAS = "sync_quotas_1",
} }

100
yarn.lock
View File

@ -1011,45 +1011,45 @@
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301"
integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw== integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==
"@typescript-eslint/parser@4.28.0": "@typescript-eslint/parser@5.45.0":
version "4.28.0" version "5.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.28.0.tgz#2404c16751a28616ef3abab77c8e51d680a12caa" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.45.0.tgz#b18a5f6b3cf1c2b3e399e9d2df4be40d6b0ddd0e"
integrity sha512-7x4D22oPY8fDaOCvkuXtYYTQ6mTMmkivwEzS+7iml9F9VkHGbbZ3x4fHRwxAb5KeuSkLqfnYjs46tGx2Nour4A== integrity sha512-brvs/WSM4fKUmF5Ot/gEve6qYiCMjm6w4HkHPfS6ZNmxTS0m0iNN4yOChImaCkqc1hRwFGqUyanMXuGal6oyyQ==
dependencies: dependencies:
"@typescript-eslint/scope-manager" "4.28.0" "@typescript-eslint/scope-manager" "5.45.0"
"@typescript-eslint/types" "4.28.0" "@typescript-eslint/types" "5.45.0"
"@typescript-eslint/typescript-estree" "4.28.0" "@typescript-eslint/typescript-estree" "5.45.0"
debug "^4.3.1" debug "^4.3.4"
"@typescript-eslint/scope-manager@4.28.0": "@typescript-eslint/scope-manager@5.45.0":
version "4.28.0" version "5.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.28.0.tgz#6a3009d2ab64a30fc8a1e257a1a320067f36a0ce" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.45.0.tgz#7a4ac1bfa9544bff3f620ab85947945938319a96"
integrity sha512-eCALCeScs5P/EYjwo6se9bdjtrh8ByWjtHzOkC4Tia6QQWtQr3PHovxh3TdYTuFcurkYI4rmFsRFpucADIkseg== integrity sha512-noDMjr87Arp/PuVrtvN3dXiJstQR1+XlQ4R1EvzG+NMgXi8CuMCXpb8JqNtFHKceVSQ985BZhfRdowJzbv4yKw==
dependencies: dependencies:
"@typescript-eslint/types" "4.28.0" "@typescript-eslint/types" "5.45.0"
"@typescript-eslint/visitor-keys" "4.28.0" "@typescript-eslint/visitor-keys" "5.45.0"
"@typescript-eslint/types@4.28.0":
version "4.28.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.28.0.tgz#a33504e1ce7ac51fc39035f5fe6f15079d4dafb0"
integrity sha512-p16xMNKKoiJCVZY5PW/AfILw2xe1LfruTcfAKBj3a+wgNYP5I9ZEKNDOItoRt53p4EiPV6iRSICy8EPanG9ZVA==
"@typescript-eslint/types@4.33.0": "@typescript-eslint/types@4.33.0":
version "4.33.0" version "4.33.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.33.0.tgz#a1e59036a3b53ae8430ceebf2a919dc7f9af6d72" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.33.0.tgz#a1e59036a3b53ae8430ceebf2a919dc7f9af6d72"
integrity sha512-zKp7CjQzLQImXEpLt2BUw1tvOMPfNoTAfb8l51evhYbOEEzdWyQNmHWWGPR6hwKJDAi+1VXSBmnhL9kyVTTOuQ== integrity sha512-zKp7CjQzLQImXEpLt2BUw1tvOMPfNoTAfb8l51evhYbOEEzdWyQNmHWWGPR6hwKJDAi+1VXSBmnhL9kyVTTOuQ==
"@typescript-eslint/typescript-estree@4.28.0": "@typescript-eslint/types@5.45.0":
version "4.28.0" version "5.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.28.0.tgz#e66d4e5aa2ede66fec8af434898fe61af10c71cf" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.45.0.tgz#794760b9037ee4154c09549ef5a96599621109c5"
integrity sha512-m19UQTRtxMzKAm8QxfKpvh6OwQSXaW1CdZPoCaQuLwAq7VZMNuhJmZR4g5281s2ECt658sldnJfdpSZZaxUGMQ== integrity sha512-QQij+u/vgskA66azc9dCmx+rev79PzX8uDHpsqSjEFtfF2gBUTRCpvYMh2gw2ghkJabNkPlSUCimsyBEQZd1DA==
"@typescript-eslint/typescript-estree@5.45.0":
version "5.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.45.0.tgz#f70a0d646d7f38c0dfd6936a5e171a77f1e5291d"
integrity sha512-maRhLGSzqUpFcZgXxg1qc/+H0bT36lHK4APhp0AEUVrpSwXiRAomm/JGjSG+kNUio5kAa3uekCYu/47cnGn5EQ==
dependencies: dependencies:
"@typescript-eslint/types" "4.28.0" "@typescript-eslint/types" "5.45.0"
"@typescript-eslint/visitor-keys" "4.28.0" "@typescript-eslint/visitor-keys" "5.45.0"
debug "^4.3.1" debug "^4.3.4"
globby "^11.0.3" globby "^11.1.0"
is-glob "^4.0.1" is-glob "^4.0.3"
semver "^7.3.5" semver "^7.3.7"
tsutils "^3.21.0" tsutils "^3.21.0"
"@typescript-eslint/typescript-estree@^4.33.0": "@typescript-eslint/typescript-estree@^4.33.0":
@ -1065,14 +1065,6 @@
semver "^7.3.5" semver "^7.3.5"
tsutils "^3.21.0" tsutils "^3.21.0"
"@typescript-eslint/visitor-keys@4.28.0":
version "4.28.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.28.0.tgz#255c67c966ec294104169a6939d96f91c8a89434"
integrity sha512-PjJyTWwrlrvM5jazxYF5ZPs/nl0kHDZMVbuIcbpawVXaDPelp3+S9zpOz5RmVUfS/fD5l5+ZXNKnWhNYjPzCvw==
dependencies:
"@typescript-eslint/types" "4.28.0"
eslint-visitor-keys "^2.0.0"
"@typescript-eslint/visitor-keys@4.33.0": "@typescript-eslint/visitor-keys@4.33.0":
version "4.33.0" version "4.33.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.33.0.tgz#2a22f77a41604289b7a186586e9ec48ca92ef1dd" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-4.33.0.tgz#2a22f77a41604289b7a186586e9ec48ca92ef1dd"
@ -1081,6 +1073,14 @@
"@typescript-eslint/types" "4.33.0" "@typescript-eslint/types" "4.33.0"
eslint-visitor-keys "^2.0.0" eslint-visitor-keys "^2.0.0"
"@typescript-eslint/visitor-keys@5.45.0":
version "5.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.45.0.tgz#e0d160e9e7fdb7f8da697a5b78e7a14a22a70528"
integrity sha512-jc6Eccbn2RtQPr1s7th6jJWQHBHI6GBVQkCHoJFQ5UreaKm59Vxw+ynQUPPY2u2Amquc+7tmEoC2G52ApsGNNg==
dependencies:
"@typescript-eslint/types" "5.45.0"
eslint-visitor-keys "^3.3.0"
JSONStream@^1.0.4, JSONStream@^1.3.4: JSONStream@^1.0.4, JSONStream@^1.3.4:
version "1.3.5" version "1.3.5"
resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0"
@ -2015,7 +2015,7 @@ debug@^3.1.0:
dependencies: dependencies:
ms "^2.1.1" ms "^2.1.1"
debug@^4.0.0, debug@^4.3.1, debug@^4.3.3: debug@^4.0.0, debug@^4.3.1, debug@^4.3.3, debug@^4.3.4:
version "4.3.4" version "4.3.4"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
@ -2446,6 +2446,11 @@ eslint-visitor-keys@^2.0.0:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303"
integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==
eslint-visitor-keys@^3.3.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826"
integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==
eslint@^7.28.0: eslint@^7.28.0:
version "7.32.0" version "7.32.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d" resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d"
@ -3081,7 +3086,7 @@ globals@^13.6.0, globals@^13.9.0:
dependencies: dependencies:
type-fest "^0.20.2" type-fest "^0.20.2"
globby@^11.0.3: globby@^11.0.3, globby@^11.1.0:
version "11.1.0" version "11.1.0"
resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b"
integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==
@ -3606,7 +3611,7 @@ is-glob@^3.1.0:
dependencies: dependencies:
is-extglob "^2.1.0" is-extglob "^2.1.0"
is-glob@^4.0.0, is-glob@^4.0.1: is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3:
version "4.0.3" version "4.0.3"
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
@ -5843,6 +5848,13 @@ semver@^7.3.4:
dependencies: dependencies:
lru-cache "^6.0.0" lru-cache "^6.0.0"
semver@^7.3.7:
version "7.3.8"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.8.tgz#07a78feafb3f7b32347d725e33de7e2a2df67798"
integrity sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==
dependencies:
lru-cache "^6.0.0"
semver@~5.3.0: semver@~5.3.0:
version "5.3.0" version "5.3.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f" resolved "https://registry.yarnpkg.com/semver/-/semver-5.3.0.tgz#9b2ce5d3de02d17c6012ad326aa6b4d0cf54f94f"
@ -6559,10 +6571,10 @@ typedarray@^0.0.6:
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
typescript@4.5.5: typescript@4.7.3:
version "4.5.5" version "4.7.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.3.tgz#8364b502d5257b540f9de4c40be84c98e23a129d"
integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA== integrity sha512-WOkT3XYvrpXx4vMMqlD+8R8R37fZkjyLGlxavMc4iB8lrl8L0DeTcHbYgw/v0N/z9wAFsgBhcsF0ruoySS22mA==
typescript@^3.9.10, typescript@^3.9.5, typescript@^3.9.7: typescript@^3.9.10, typescript@^3.9.5, typescript@^3.9.7:
version "3.9.10" version "3.9.10"