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,
name: MigrationName.EVENT_APP_BACKFILL,
},
{
type: MigrationType.APP,
name: MigrationName.TABLE_SETTINGS_LINKS_TO_ACTIONS,
},
{
type: MigrationType.GLOBAL,
name: MigrationName.EVENT_GLOBAL_BACKFILL,

View File

@ -1,18 +1,53 @@
export default function clickOutside(element, callbackFunction) {
function onClick(event) {
if (!element.contains(event.target)) {
callbackFunction(event)
const ignoredClasses = [".flatpickr-calendar", ".modal-container"]
let clickHandlers = []
/**
* 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) {
callbackFunction = newCallbackFunction
},
destroy() {
document.body.removeEventListener("click", onClick, true)
},
/**
* Adds or updates a click handler
*/
const updateHandler = (id, element, callback) => {
let existingHandler = clickHandlers.find(x => x.id === id)
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>
<div class="property-group-container">
{#if name}
<div class="property-group-name" on:click={onHeaderClick}>
<div class="name">{name}</div>
{#if collapsible}
<Icon size="S" name={show ? "Remove" : "Add"} />
{/if}
</div>
<div class="property-panel" class:show={show || !collapsible}>
{/if}
<div
class="property-panel"
class:show={show || !collapsible}
class:no-title={!name}
>
<slot />
</div>
</div>
@ -72,6 +78,9 @@
padding: var(--spacing-s) var(--spacing-xl) var(--spacing-xl)
var(--spacing-xl);
}
.property-panel.no-title {
padding: var(--spacing-xl);
}
.show {
display: flex;

View File

@ -23,6 +23,15 @@
let open = false
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 => {
let maskedDate = new Date(`0-${timestamp}`)
@ -252,6 +261,7 @@
width: 100vw;
height: 100vh;
z-index: 999;
max-height: 100%;
}
:global(.flatpickr-calendar) {
font-family: "Source Sans Pro", sans-serif;

View File

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

View File

@ -275,13 +275,14 @@
}
</script>
<div
{#key fields?.length}
<div
class="wrapper"
class:wrapper--quiet={quiet}
class:wrapper--compact={compact}
bind:offsetHeight={height}
style={`--row-height: ${rowHeight}px; --header-height: ${headerHeight}px;`}
>
>
{#if loading}
<div class="loading" style={heightStyle}>
<slot name="loadingIndicator">
@ -313,8 +314,8 @@
class:noBorderHeader={!showHeaderBorder}
class:spectrum-Table-headCell--alignCenter={schema[field]
.align === "Center"}
class:spectrum-Table-headCell--alignRight={schema[field].align ===
"Right"}
class:spectrum-Table-headCell--alignRight={schema[field]
.align === "Right"}
class:is-sortable={schema[field].sortable !== false}
class:is-sorted-desc={sortColumn === field &&
sortOrder === "Descending"}
@ -425,7 +426,8 @@
{/if}
</div>
{/if}
</div>
</div>
{/key}
<style>
/* Wrapper */

View File

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

View File

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

View File

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

View File

@ -1,13 +1,7 @@
import newRowScreen from "./newRowScreen"
import rowDetailScreen from "./rowDetailScreen"
import rowListScreen from "./rowListScreen"
import createFromScratchScreen from "./createFromScratchScreen"
const allTemplates = tables => [
...newRowScreen(tables),
...rowDetailScreen(tables),
...rowListScreen(tables),
]
const allTemplates = tables => [...rowListScreen(tables)]
// Allows us to apply common behaviour to all create() functions
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 { newRowUrl } from "./newRowScreen"
import { Screen } from "./utils/Screen"
import { Component } from "./utils/Component"
@ -21,12 +20,6 @@ const generateTableBlock = table => {
const tableBlock = new Component("@budibase/standard-components/tableblock")
tableBlock
.customProps({
linkRows: true,
linkURL: `${rowListUrl(table)}/:id`,
showAutoColumns: false,
showTitleButton: true,
titleButtonText: "Create new",
titleButtonURL: newRowUrl(table),
title: table.name,
dataSource: {
label: table.name,
@ -34,9 +27,14 @@ const generateTableBlock = table => {
tableId: table._id,
type: "table",
},
sortOrder: "Ascending",
size: "spectrum--medium",
paginate: true,
rowCount: 8,
clickBehaviour: "details",
showTitleButton: true,
titleButtonText: "Create row",
titleButtonClickBehaviour: "new",
})
.instanceName(`${table.name} - Table block`)
return tableBlock

View File

@ -1,137 +1,6 @@
import { Component } from "./Component"
import { rowListUrl } from "../rowListScreen"
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 = {
string: "stringfield",
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 S3DataSourceSelect from "./controls/S3DataSourceSelect.svelte"
import DataProviderSelect from "./controls/DataProviderSelect.svelte"
@ -25,6 +25,7 @@ import BarButtonList from "./controls/BarButtonList.svelte"
const componentMap = {
text: DrawerBindableCombobox,
select: Select,
radio: RadioGroup,
dataSource: DataSourceSelect,
"dataSource/s3": S3DataSourceSelect,
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 UpdateFieldValue } from "./UpdateFieldValue.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 { get } from "svelte/store"
import { store } from "builderStore"
// @ts-ignore
import ActionDefinitions from "./manifest.json"
// Defines which actions are available to configure in the front end.

View File

@ -116,6 +116,18 @@
"type": "application",
"component": "ShowNotification",
"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 CustomStylesSection from "./CustomStylesSection.svelte"
import ConditionalUISection from "./ConditionalUISection.svelte"
import ComponentInfoSection from "./ComponentInfoSection.svelte"
import {
getBindableProperties,
getComponentBindableProperties,
@ -29,6 +30,9 @@
{#if $selectedComponent}
{#key $selectedComponent._id}
<Panel {title} icon={componentDefinition?.icon} borderLeft>
{#if componentDefinition.info}
<ComponentInfoSection {componentDefinition} />
{/if}
<ComponentSettingsSection
{componentInstance}
{componentDefinition}

View File

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

View File

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

View File

@ -68,7 +68,7 @@
<span data-cy="data-source-modal">
<ModalContent
title="Create CRUD Screens"
title="Autogenerated screens"
confirmText="Confirm"
cancelText="Back"
onConfirm={confirmDatasourceSelection}
@ -77,7 +77,7 @@
size="L"
>
<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>
<Layout noPadding gap="S">
{#each filteredSources as datasource}

View File

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

View File

@ -127,9 +127,6 @@
// Handler for Datasource Screen Creation
const completeDatasourceScreenCreation = async () => {
// // Handle template selection
if (selectedTemplates?.length > 1) {
// Autoscreens, so create immediately
const screens = selectedTemplates.map(template => {
let screenTemplate = template.create()
screenTemplate.datasource = template.datasource
@ -138,7 +135,6 @@
})
await createScreens({ screens, screenAccessRole })
}
}
const confirmScreenBlank = async ({ screenUrl }) => {
blankScreenUrl = screenUrl

View File

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

View File

@ -9,7 +9,8 @@
"messagePassing": true,
"rowSelection": true,
"continueIfAction": true,
"showNotificationAction": true
"showNotificationAction": true,
"sidePanel": true
},
"layout": {
"name": "Layout",
@ -3669,7 +3670,7 @@
"Ascending",
"Descending"
],
"defaultValue": "Descending"
"defaultValue": "Ascending"
},
{
"type": "number",
@ -3736,12 +3737,6 @@
"key": "dataProvider",
"required": true
},
{
"type": "number",
"label": "Row count",
"key": "rowCount",
"defaultValue": 8
},
{
"type": "columns",
"label": "Columns",
@ -3765,6 +3760,12 @@
}
]
},
{
"type": "number",
"label": "Row count",
"key": "rowCount",
"defaultValue": 8
},
{
"type": "boolean",
"label": "Quiet",
@ -3775,12 +3776,6 @@
"label": "Compact",
"key": "compact"
},
{
"type": "boolean",
"label": "Show auto columns",
"key": "showAutoColumns",
"defaultValue": false
},
{
"type": "boolean",
"label": "Allow row selection",
@ -3788,30 +3783,20 @@
"defaultValue": false,
"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,
"name": "Advanced",
"name": "On Row Click",
"settings": [
{
"type": "field",
"label": "ID column for linking (appended to URL)",
"key": "linkColumn",
"placeholder": "Default"
"type": "event",
"key": "onClick",
"context": [
{
"label": "Clicked row",
"key": "row"
}
]
}
]
}
@ -4463,6 +4448,10 @@
"label": "Title",
"key": "title"
},
{
"section": true,
"name": "Table",
"settings": [
{
"type": "dataSource",
"label": "Data",
@ -4470,20 +4459,16 @@
"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": "columns",
"label": "Table Columns",
"key": "tableColumns",
"dependsOn": "dataSource",
"placeholder": "All columns",
"nested": true
},
{
"type": "field/sortable",
"label": "Sort Column",
"label": "Sort By",
"key": "sortColumn"
},
{
@ -4494,7 +4479,7 @@
"Ascending",
"Descending"
],
"defaultValue": "Descending"
"defaultValue": "Ascending"
},
{
"type": "select",
@ -4512,16 +4497,6 @@
}
]
},
{
"type": "boolean",
"label": "Paginate",
"key": "paginate",
"defaultValue": true
},
{
"section": true,
"name": "Table",
"settings": [
{
"type": "number",
"label": "Scroll Limit",
@ -4529,16 +4504,14 @@
"defaultValue": 8
},
{
"type": "columns",
"label": "Table Columns",
"key": "tableColumns",
"dependsOn": "dataSource",
"placeholder": "All columns",
"nested": true
"type": "boolean",
"label": "Paginate",
"key": "paginate",
"defaultValue": true
},
{
"type": "boolean",
"label": "Quiet table variant",
"label": "Quiet",
"key": "quiet"
},
{
@ -4546,11 +4519,6 @@
"label": "Compact",
"key": "compact"
},
{
"type": "boolean",
"label": "Show auto columns",
"key": "showAutoColumns"
},
{
"type": "boolean",
"label": "Allow row selection",
@ -4558,58 +4526,100 @@
"info": "Row selection is only compatible with internal or SQL tables"
},
{
"type": "boolean",
"label": "Link table rows",
"key": "linkRows"
"type": "filter",
"label": "Filtering",
"key": "filter"
},
{
"type": "boolean",
"label": "Open link in modal",
"key": "linkPeek"
},
{
"type": "url",
"label": "Link screen",
"key": "linkURL"
"type": "searchfield",
"label": "Search Fields",
"key": "searchColumns",
"placeholder": "Choose search fields",
"info": "Only the first 5 search fields will be used"
}
]
},
{
"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": [
{
"type": "boolean",
"key": "showTitleButton",
"label": "Show link button",
"label": "Show button above table",
"defaultValue": false
},
{
"type": "boolean",
"label": "Open link in modal",
"key": "titleButtonPeek"
},
{
"type": "text",
"key": "titleButtonText",
"label": "Button text"
"label": "Text",
"defaultValue": "Create row",
"dependsOn": "showTitleButton"
},
{
"type": "url",
"label": "Button link",
"key": "titleButtonURL"
"type": "radio",
"key": "titleButtonClickBehaviour",
"label": "On Click",
"dependsOn": "showTitleButton",
"defaultValue": "actions",
"info": "New row side panel is only compatible with internal or SQL tables",
"options": [
{
"label": "Run actions",
"value": "actions"
},
{
"label": "Show new row side panel",
"value": "new"
}
]
},
{
"section": true,
"name": "Advanced",
"settings": [
{
"type": "field",
"label": "ID column for linking (appended to URL)",
"key": "linkColumn",
"placeholder": "Default"
"type": "event",
"key": "onClickTitleButton",
"nested": true,
"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": {
"block": true,
"name": "Row Explorer Block",

View File

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

View File

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

View File

@ -1,7 +1,7 @@
<script>
import { getContext, setContext } from "svelte"
import { writable } from "svelte/store"
import { Heading, Icon } from "@budibase/bbui"
import { Heading, Icon, clickOutside } from "@budibase/bbui"
import { FieldTypes } from "constants"
import active from "svelte-spa-router/active"
import { RoleUtils } from "@budibase/frontend-core"
@ -16,6 +16,7 @@
builderStore,
currentRole,
environmentStore,
sidePanelStore,
} = sdk
const component = getContext("component")
const context = getContext("context")
@ -71,6 +72,7 @@
$context.device.width,
$context.device.height
)
$: autoCloseSidePanel = !$builderStore.inBuilder && $sidePanelStore.open
// Scroll navigation into view if selected
$: {
@ -150,6 +152,7 @@
class:desktop={!mobile}
class:mobile={!!mobile}
>
<div class="layout-body">
{#if typeClass !== "none"}
<div
class="interactive component navigation"
@ -244,19 +247,46 @@
<slot />
</div>
</div>
</div>
<div
id="side-panel-container"
class:open={$sidePanelStore.open}
use:clickOutside={autoCloseSidePanel ? sidePanelStore.actions.close : null}
class:builder={$builderStore.inBuilder}
>
<div class="side-panel-header">
<Icon
color="var(--spectrum-global-color-gray-600)"
name="RailRightClose"
hoverable
on:click={sidePanelStore.actions.close}
/>
</div>
</div>
</div>
<style>
/* 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 {
display: contents;
}
.layout {
.layout-body {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
height: 100%;
flex: 1 1 auto;
overflow: auto;
overflow-x: hidden;
@ -316,6 +346,43 @@
align-items: center;
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 {
display: flex;
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>
import { getContext } from "svelte"
import { generate } from "shortid"
import Block from "components/Block.svelte"
import BlockComponent from "components/BlockComponent.svelte"
import { makePropSafe as safe } from "@budibase/string-templates"
@ -13,50 +14,103 @@
export let sortOrder
export let paginate
export let tableColumns
export let showAutoColumns
export let rowCount
export let quiet
export let compact
export let size
export let allowSelectRows
export let linkRows
export let linkURL
export let linkColumn
export let linkPeek
export let clickBehaviour
export let onClick
export let showTitleButton
export let titleButtonText
export let titleButtonURL
export let titleButtonPeek
export let titleButtonClickBehaviour
export let onClickTitleButton
const { fetchDatasourceSchema } = getContext("sdk")
const { fetchDatasourceSchema, API } = getContext("sdk")
const stateKey = `ID_${generate()}`
let formId
let dataProviderId
let detailsFormBlockId
let detailsSidePanelId
let newRowSidePanelId
let schema
let primaryDisplay
let schemaLoaded = false
$: fetchSchema(dataSource)
$: enrichedSearchColumns = enrichSearchColumns(searchColumns, schema)
$: enrichedFilter = enrichFilter(filter, enrichedSearchColumns, formId)
$: titleButtonAction = [
$: editTitle = getEditTitle(detailsFormBlockId, primaryDisplay)
$: normalFields = getNormalFields(schema)
$: rowClickActions =
clickBehaviour === "actions" || dataSource?.type !== "table"
? onClick
: [
{
"##eventHandlerType": "Navigate To",
id: 0,
"##eventHandlerType": "Update State",
parameters: {
peek: titleButtonPeek,
url: titleButtonURL,
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
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, {
enrichRelationships: 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>
{#if schemaLoaded}
@ -129,7 +183,7 @@
<BlockComponent
type="button"
props={{
onClick: titleButtonAction,
onClick: buttonClickActions,
text: titleButtonText,
type: "cta",
}}
@ -145,7 +199,7 @@
props={{
dataSource,
filter: enrichedFilter,
sortColumn,
sortColumn: sortColumn || primaryDisplay,
sortOrder,
paginate,
limit: rowCount,
@ -158,19 +212,63 @@
props={{
dataProvider: `{{ literal ${safe(dataProviderId)} }}`,
columns: tableColumns,
showAutoColumns,
rowCount,
quiet,
compact,
allowSelectRows,
size,
linkRows,
linkURL,
linkColumn,
linkPeek,
onClick: rowClickActions,
}}
/>
</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>
</Block>
{/if}

View File

@ -43,6 +43,20 @@
{
"##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",
parameters: {
@ -63,6 +77,9 @@
{
"##eventHandlerType": "Close Screen Modal",
},
{
"##eventHandlerType": "Close Side Panel",
},
{
"##eventHandlerType": "Navigate To",
parameters: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,6 +24,7 @@ export {
dndIsNewComponent,
dndIsDragging,
} from "./dnd"
export { sidePanelStore } from "./sidePanel.js"
// Context stores are layered and duplicated, so it is not a singleton
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,
uploadStore,
rowSelectionStore,
sidePanelStore,
} from "stores"
import { API } from "api"
import { ActionTypes } from "constants"
@ -312,6 +313,17 @@ const showNotificationHandler = action => {
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 = {
["Save Row"]: saveRowHandler,
["Duplicate Row"]: duplicateRowHandler,
@ -331,6 +343,8 @@ const handlerMap = {
["Export Data"]: exportDataHandler,
["Continue if / Stop if"]: continueIfHandler,
["Show Notification"]: showNotificationHandler,
["Open Side Panel"]: OpenSidePanelHandler,
["Close Side Panel"]: CloseSidePanelHandler,
}
const confirmTextMap = {

View File

@ -25,6 +25,8 @@ export const styleable = (node, styles = {}) => {
// Creates event listeners and applies initial styles
const setupStyles = (newStyles = {}) => {
node.classList.add(`${newStyles.id}-dom`)
let baseStyles = {}
if (newStyles.empty) {
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
*/
async getInitialData() {
const { datasource, filter, sortColumn, paginate } = this.options
const { datasource, filter, paginate } = this.options
// Fetch datasource definition and determine feature flags
const definition = await this.getDefinition(datasource)
@ -135,6 +135,17 @@ export default class DataFetch {
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
let sortType = "string"
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 syncQuotas from "./functions/syncQuotas"
import * as appUrls from "./functions/appUrls"
import * as tableSettings from "./functions/tableSettings"
import * as backfill from "./functions/backfill"
/**
* Populate the migration function and additional configuration from
@ -73,6 +74,14 @@ export const buildMigrations = () => {
})
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 "./webhook"
export * from "./links"
export * from "./component"

View File

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

View File

@ -44,6 +44,7 @@ export enum MigrationName {
EVENT_GLOBAL_BACKFILL = "event_global_backfill",
EVENT_INSTALLATION_BACKFILL = "event_installation_backfill",
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
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"
integrity sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==
"@typescript-eslint/parser@4.28.0":
version "4.28.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-4.28.0.tgz#2404c16751a28616ef3abab77c8e51d680a12caa"
integrity sha512-7x4D22oPY8fDaOCvkuXtYYTQ6mTMmkivwEzS+7iml9F9VkHGbbZ3x4fHRwxAb5KeuSkLqfnYjs46tGx2Nour4A==
"@typescript-eslint/parser@5.45.0":
version "5.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.45.0.tgz#b18a5f6b3cf1c2b3e399e9d2df4be40d6b0ddd0e"
integrity sha512-brvs/WSM4fKUmF5Ot/gEve6qYiCMjm6w4HkHPfS6ZNmxTS0m0iNN4yOChImaCkqc1hRwFGqUyanMXuGal6oyyQ==
dependencies:
"@typescript-eslint/scope-manager" "4.28.0"
"@typescript-eslint/types" "4.28.0"
"@typescript-eslint/typescript-estree" "4.28.0"
debug "^4.3.1"
"@typescript-eslint/scope-manager" "5.45.0"
"@typescript-eslint/types" "5.45.0"
"@typescript-eslint/typescript-estree" "5.45.0"
debug "^4.3.4"
"@typescript-eslint/scope-manager@4.28.0":
version "4.28.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-4.28.0.tgz#6a3009d2ab64a30fc8a1e257a1a320067f36a0ce"
integrity sha512-eCALCeScs5P/EYjwo6se9bdjtrh8ByWjtHzOkC4Tia6QQWtQr3PHovxh3TdYTuFcurkYI4rmFsRFpucADIkseg==
"@typescript-eslint/scope-manager@5.45.0":
version "5.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.45.0.tgz#7a4ac1bfa9544bff3f620ab85947945938319a96"
integrity sha512-noDMjr87Arp/PuVrtvN3dXiJstQR1+XlQ4R1EvzG+NMgXi8CuMCXpb8JqNtFHKceVSQ985BZhfRdowJzbv4yKw==
dependencies:
"@typescript-eslint/types" "4.28.0"
"@typescript-eslint/visitor-keys" "4.28.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" "5.45.0"
"@typescript-eslint/visitor-keys" "5.45.0"
"@typescript-eslint/types@4.33.0":
version "4.33.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-4.33.0.tgz#a1e59036a3b53ae8430ceebf2a919dc7f9af6d72"
integrity sha512-zKp7CjQzLQImXEpLt2BUw1tvOMPfNoTAfb8l51evhYbOEEzdWyQNmHWWGPR6hwKJDAi+1VXSBmnhL9kyVTTOuQ==
"@typescript-eslint/typescript-estree@4.28.0":
version "4.28.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-4.28.0.tgz#e66d4e5aa2ede66fec8af434898fe61af10c71cf"
integrity sha512-m19UQTRtxMzKAm8QxfKpvh6OwQSXaW1CdZPoCaQuLwAq7VZMNuhJmZR4g5281s2ECt658sldnJfdpSZZaxUGMQ==
"@typescript-eslint/types@5.45.0":
version "5.45.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.45.0.tgz#794760b9037ee4154c09549ef5a96599621109c5"
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:
"@typescript-eslint/types" "4.28.0"
"@typescript-eslint/visitor-keys" "4.28.0"
debug "^4.3.1"
globby "^11.0.3"
is-glob "^4.0.1"
semver "^7.3.5"
"@typescript-eslint/types" "5.45.0"
"@typescript-eslint/visitor-keys" "5.45.0"
debug "^4.3.4"
globby "^11.1.0"
is-glob "^4.0.3"
semver "^7.3.7"
tsutils "^3.21.0"
"@typescript-eslint/typescript-estree@^4.33.0":
@ -1065,14 +1065,6 @@
semver "^7.3.5"
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":
version "4.33.0"
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"
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:
version "1.3.5"
resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0"
@ -2015,7 +2015,7 @@ debug@^3.1.0:
dependencies:
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"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
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"
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:
version "7.32.0"
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:
type-fest "^0.20.2"
globby@^11.0.3:
globby@^11.0.3, globby@^11.1.0:
version "11.1.0"
resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b"
integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==
@ -3606,7 +3611,7 @@ is-glob@^3.1.0:
dependencies:
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"
resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
@ -5843,6 +5848,13 @@ semver@^7.3.4:
dependencies:
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:
version "5.3.0"
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"
integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==
typescript@4.5.5:
version "4.5.5"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.5.5.tgz#d8c953832d28924a9e3d37c73d729c846c5896f3"
integrity sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==
typescript@4.7.3:
version "4.7.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.3.tgz#8364b502d5257b540f9de4c40be84c98e23a129d"
integrity sha512-WOkT3XYvrpXx4vMMqlD+8R8R37fZkjyLGlxavMc4iB8lrl8L0DeTcHbYgw/v0N/z9wAFsgBhcsF0ruoySS22mA==
typescript@^3.9.10, typescript@^3.9.5, typescript@^3.9.7:
version "3.9.10"