Merge remote-tracking branch 'origin/master' into feature/builder-filtering-update

This commit is contained in:
Dean 2024-08-22 09:26:28 +01:00
commit f6723cf77b
111 changed files with 4531 additions and 1844 deletions

View File

@ -23,7 +23,7 @@ data:
jwtSecret: {{ template "budibase.defaultsecret" .Values.globals.jwtSecret }} jwtSecret: {{ template "budibase.defaultsecret" .Values.globals.jwtSecret }}
objectStoreAccess: {{ template "budibase.defaultsecret" .Values.services.objectStore.accessKey }} objectStoreAccess: {{ template "budibase.defaultsecret" .Values.services.objectStore.accessKey }}
objectStoreSecret: {{ template "budibase.defaultsecret" .Values.services.objectStore.secretKey }} objectStoreSecret: {{ template "budibase.defaultsecret" .Values.services.objectStore.secretKey }}
bbEncryptionKey: {{ template "budibase.defaultsecret" "" }} bbEncryptionKey: {{ template "budibase.defaultsecret" .Values.globals.bbEncryptionKey }}
apiEncryptionKey: {{ template "budibase.defaultsecret" "" }} apiEncryptionKey: {{ template "budibase.defaultsecret" .Values.globals.apiEncryptionKey }}
{{- end }} {{- end }}
{{- end }} {{- end }}

View File

@ -1,6 +1,6 @@
{ {
"$schema": "node_modules/lerna/schemas/lerna-schema.json", "$schema": "node_modules/lerna/schemas/lerna-schema.json",
"version": "2.30.4", "version": "2.31.1",
"npmClient": "yarn", "npmClient": "yarn",
"packages": [ "packages": [
"packages/*", "packages/*",

View File

@ -36,6 +36,7 @@ import {
} from "@budibase/types" } from "@budibase/types"
import environment from "../environment" import environment from "../environment"
import { dataFilters, helpers } from "@budibase/shared-core" import { dataFilters, helpers } from "@budibase/shared-core"
import { cloneDeep } from "lodash"
type QueryFunction = (query: SqlQuery | SqlQuery[], operation: Operation) => any type QueryFunction = (query: SqlQuery | SqlQuery[], operation: Operation) => any
@ -268,6 +269,7 @@ class InternalBuilder {
} }
private parseFilters(filters: SearchFilters): SearchFilters { private parseFilters(filters: SearchFilters): SearchFilters {
filters = cloneDeep(filters)
for (const op of Object.values(BasicOperator)) { for (const op of Object.values(BasicOperator)) {
const filter = filters[op] const filter = filters[op]
if (!filter) { if (!filter) {
@ -337,7 +339,7 @@ class InternalBuilder {
if (!filters) { if (!filters) {
return query return query
} }
filters = this.parseFilters(filters) filters = this.parseFilters({ ...filters })
const aliases = this.query.tableAliases const aliases = this.query.tableAliases
// if all or specified in filters, then everything is an or // if all or specified in filters, then everything is an or
const allOr = filters.allOr const allOr = filters.allOr
@ -371,10 +373,11 @@ class InternalBuilder {
), ),
castedTypeValue.values castedTypeValue.values
) )
} else if (!opts?.relationship && !isRelationshipField) { } else if (!isRelationshipField) {
const alias = getTableAlias(tableName) const alias = getTableAlias(tableName)
fn(alias ? `${alias}.${updatedKey}` : updatedKey, value) fn(alias ? `${alias}.${updatedKey}` : updatedKey, value)
} else if (opts?.relationship && isRelationshipField) { }
if (opts?.relationship && isRelationshipField) {
const [filterTableName, property] = updatedKey.split(".") const [filterTableName, property] = updatedKey.split(".")
const alias = getTableAlias(filterTableName) const alias = getTableAlias(filterTableName)
fn(alias ? `${alias}.${property}` : property, value) fn(alias ? `${alias}.${property}` : property, value)
@ -465,18 +468,20 @@ class InternalBuilder {
if (filters.$and) { if (filters.$and) {
const { $and } = filters const { $and } = filters
query = query.where(x => { for (const condition of $and.conditions) {
for (const condition of $and.conditions) { query = query.where(b => {
x = this.addFilters(x, condition, opts) this.addFilters(b, condition, opts)
} })
}) }
} }
if (filters.$or) { if (filters.$or) {
const { $or } = filters const { $or } = filters
query = query.where(x => { query = query.where(b => {
for (const condition of $or.conditions) { for (const condition of $or.conditions) {
x = this.addFilters(x, { ...condition, allOr: true }, opts) b.orWhere(c =>
this.addFilters(c, { ...condition, allOr: true }, opts)
)
} }
}) })
} }

View File

@ -72,6 +72,7 @@
"@spectrum-css/switch": "1.0.2", "@spectrum-css/switch": "1.0.2",
"@spectrum-css/table": "3.0.1", "@spectrum-css/table": "3.0.1",
"@spectrum-css/tabs": "3.2.12", "@spectrum-css/tabs": "3.2.12",
"@spectrum-css/tag": "3.0.0",
"@spectrum-css/tags": "3.0.2", "@spectrum-css/tags": "3.0.2",
"@spectrum-css/textfield": "3.0.1", "@spectrum-css/textfield": "3.0.1",
"@spectrum-css/toast": "3.0.1", "@spectrum-css/toast": "3.0.1",

View File

@ -6,8 +6,8 @@
export let onConfirm export let onConfirm
export let onCancel export let onCancel
export let screenUrl export let route
export let screenRole export let role
export let confirmText = "Continue" export let confirmText = "Continue"
const appPrefix = "/app" const appPrefix = "/app"
@ -15,17 +15,17 @@
let error let error
let modal let modal
$: appUrl = screenUrl $: appUrl = route
? `${window.location.origin}${appPrefix}${screenUrl}` ? `${window.location.origin}${appPrefix}${route}`
: `${window.location.origin}${appPrefix}` : `${window.location.origin}${appPrefix}`
const routeChanged = event => { const routeChanged = event => {
if (!event.detail.startsWith("/")) { if (!event.detail.startsWith("/")) {
screenUrl = "/" + event.detail route = "/" + event.detail
} }
touched = true touched = true
screenUrl = sanitizeUrl(screenUrl) route = sanitizeUrl(route)
if (routeExists(screenUrl)) { if (routeExists(route)) {
error = "This URL is already taken for this access role" error = "This URL is already taken for this access role"
} else { } else {
error = null error = null
@ -33,19 +33,19 @@
} }
const routeExists = url => { const routeExists = url => {
if (!screenRole) { if (!role) {
return false return false
} }
return get(screenStore).screens.some( return get(screenStore).screens.some(
screen => screen =>
screen.routing.route.toLowerCase() === url.toLowerCase() && screen.routing.route.toLowerCase() === url.toLowerCase() &&
screen.routing.roleId === screenRole screen.routing.roleId === role
) )
} }
const confirmScreenDetails = async () => { const confirmScreenDetails = async () => {
await onConfirm({ await onConfirm({
screenUrl, route,
}) })
} }
</script> </script>
@ -58,13 +58,13 @@
onConfirm={confirmScreenDetails} onConfirm={confirmScreenDetails}
{onCancel} {onCancel}
cancelText={"Back"} cancelText={"Back"}
disabled={!screenUrl || error || !touched} disabled={!route || error || !touched}
> >
<form on:submit|preventDefault={() => modal.confirm()}> <form on:submit|preventDefault={() => modal.confirm()}>
<Input <Input
label="Enter a URL for the new screen" label="Enter a URL for the new screen"
{error} {error}
bind:value={screenUrl} bind:value={route}
on:change={routeChanged} on:change={routeChanged}
/> />
<div class="app-server" title={appUrl}> <div class="app-server" title={appUrl}>

View File

@ -29,7 +29,7 @@
on:click={() => onSelect(data)} on:click={() => onSelect(data)}
> >
<span class="spectrum-Menu-itemLabel"> <span class="spectrum-Menu-itemLabel">
{data.datasource?.name ? `${data.datasource.name} - ` : ""}{data.label} {data.datasourceName ? `${data.datasourceName} - ` : ""}{data.label}
</span> </span>
<svg <svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon" class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"

View File

@ -34,6 +34,7 @@
import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte" import ClientBindingPanel from "components/common/bindings/ClientBindingPanel.svelte"
import DataSourceCategory from "components/design/settings/controls/DataSourceSelect/DataSourceCategory.svelte" import DataSourceCategory from "components/design/settings/controls/DataSourceSelect/DataSourceCategory.svelte"
import { API } from "api" import { API } from "api"
import { datasourceSelect as format } from "helpers/data/format"
export let value = {} export let value = {}
export let otherSources export let otherSources
@ -51,24 +52,15 @@
let modal let modal
$: text = value?.label ?? "Choose an option" $: text = value?.label ?? "Choose an option"
$: tables = $tablesStore.list.map(m => ({ $: tables = $tablesStore.list.map(table =>
label: m.name, format.table(table, $datasources.list)
tableId: m._id, )
type: "table",
datasource: $datasources.list.find(
ds => ds._id === m.sourceId || m.datasourceId
),
}))
$: viewsV1 = $viewsStore.list.map(view => ({ $: viewsV1 = $viewsStore.list.map(view => ({
...view, ...view,
label: view.name, label: view.name,
type: "view", type: "view",
})) }))
$: viewsV2 = $viewsV2Store.list.map(view => ({ $: viewsV2 = $viewsV2Store.list.map(format.viewV2)
...view,
label: view.name,
type: "viewV2",
}))
$: views = [...(viewsV1 || []), ...(viewsV2 || [])] $: views = [...(viewsV1 || []), ...(viewsV2 || [])]
$: queries = $queriesStore.list $: queries = $queriesStore.list
.filter(q => showAllQueries || q.queryVerb === "read" || q.readable) .filter(q => showAllQueries || q.queryVerb === "read" || q.readable)

View File

@ -2,24 +2,14 @@
import { Select } from "@budibase/bbui" import { Select } from "@budibase/bbui"
import { createEventDispatcher, onMount } from "svelte" import { createEventDispatcher, onMount } from "svelte"
import { tables as tablesStore, viewsV2 } from "stores/builder" import { tables as tablesStore, viewsV2 } from "stores/builder"
import { tableSelect as format } from "helpers/data/format"
export let value export let value
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: tables = $tablesStore.list.map(table => ({ $: tables = $tablesStore.list.map(format.table)
type: "table", $: views = $viewsV2.list.map(format.viewV2)
label: table.name,
tableId: table._id,
resourceId: table._id,
}))
$: views = $viewsV2.list.map(view => ({
type: "viewV2",
id: view.id,
label: view.name,
tableId: view.tableId,
resourceId: view.id,
}))
$: options = [...(tables || []), ...(views || [])] $: options = [...(tables || []), ...(views || [])]
const onChange = e => { const onChange = e => {

View File

@ -0,0 +1,33 @@
export const datasourceSelect = {
table: (table, datasources) => {
const sourceId = table.sourceId || table.datasourceId
const datasource = datasources.find(ds => ds._id === sourceId)
return {
label: table.name,
tableId: table._id,
type: "table",
datasourceName: datasource?.name,
}
},
viewV2: view => ({
...view,
label: view.name,
type: "viewV2",
}),
}
export const tableSelect = {
table: table => ({
type: "table",
label: table.name,
tableId: table._id,
resourceId: table._id,
}),
viewV2: view => ({
type: "viewV2",
id: view.id,
label: view.name,
tableId: view.tableId,
resourceId: view.id,
}),
}

View File

@ -1,5 +1,4 @@
<script> <script>
import { helpers } from "@budibase/shared-core"
import { DetailSummary, notifications } from "@budibase/bbui" import { DetailSummary, notifications } from "@budibase/bbui"
import { componentStore, builderStore } from "stores/builder" import { componentStore, builderStore } from "stores/builder"
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte" import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
@ -8,6 +7,7 @@
import { getComponentForSetting } from "components/design/settings/componentSettings" import { getComponentForSetting } from "components/design/settings/componentSettings"
import InfoDisplay from "./InfoDisplay.svelte" import InfoDisplay from "./InfoDisplay.svelte"
import analytics, { Events } from "analytics" import analytics, { Events } from "analytics"
import { shouldDisplaySetting } from "@budibase/frontend-core"
export let componentDefinition export let componentDefinition
export let componentInstance export let componentInstance
@ -48,7 +48,7 @@
// Filter out settings which shouldn't be rendered // Filter out settings which shouldn't be rendered
sections.forEach(section => { sections.forEach(section => {
section.visible = shouldDisplay(instance, section) section.visible = shouldDisplaySetting(instance, section)
if (!section.visible) { if (!section.visible) {
return return
} }
@ -88,46 +88,6 @@
} }
} }
const shouldDisplay = (instance, setting) => {
let dependsOn = setting.dependsOn
if (dependsOn && !Array.isArray(dependsOn)) {
dependsOn = [dependsOn]
}
if (!dependsOn?.length) {
return true
}
// Ensure all conditions are met
return dependsOn.every(condition => {
let dependantSetting = condition
let dependantValues = null
let invert = !!condition.invert
if (typeof condition === "object") {
dependantSetting = condition.setting
dependantValues = condition.value
}
if (!dependantSetting) {
return false
}
// Ensure values is an array
if (!Array.isArray(dependantValues)) {
dependantValues = [dependantValues]
}
// If inverting, we want to ensure that we don't have any matches.
// If not inverting, we want to ensure that we do have any matches.
const currentVal = helpers.deepGet(instance, dependantSetting)
const anyMatches = dependantValues.some(dependantVal => {
if (dependantVal == null) {
return currentVal != null && currentVal !== false && currentVal !== ""
}
return dependantVal === currentVal
})
return anyMatches !== invert
})
}
const canRenderControl = (instance, setting, isScreen, includeHidden) => { const canRenderControl = (instance, setting, isScreen, includeHidden) => {
// Prevent rendering on click setting for screens // Prevent rendering on click setting for screens
if (setting?.type === "event" && isScreen) { if (setting?.type === "event" && isScreen) {
@ -142,7 +102,7 @@
if (setting.hidden && !includeHidden) { if (setting.hidden && !includeHidden) {
return false return false
} }
return shouldDisplay(instance, setting) return shouldDisplaySetting(instance, setting)
} }
</script> </script>

View File

@ -14,11 +14,90 @@
import sanitizeUrl from "helpers/sanitizeUrl" import sanitizeUrl from "helpers/sanitizeUrl"
import ButtonActionEditor from "components/design/settings/controls/ButtonActionEditor/ButtonActionEditor.svelte" import ButtonActionEditor from "components/design/settings/controls/ButtonActionEditor/ButtonActionEditor.svelte"
import { getBindableProperties } from "dataBinding" import { getBindableProperties } from "dataBinding"
import BarButtonList from "components/design/settings/controls/BarButtonList.svelte"
$: bindings = getBindableProperties($selectedScreen, null) $: bindings = getBindableProperties($selectedScreen, null)
$: screenSettings = getScreenSettings($selectedScreen)
let errors = {} let errors = {}
const getScreenSettings = screen => {
let settings = [
{
key: "routing.homeScreen",
control: Checkbox,
props: {
text: "Set as home screen",
},
},
{
key: "routing.route",
label: "Route",
control: Input,
parser: val => {
if (!val.startsWith("/")) {
val = "/" + val
}
return sanitizeUrl(val)
},
validate: route => {
const existingRoute = screen.routing.route
if (route !== existingRoute && routeTaken(route)) {
return "That URL is already in use for this role"
}
return null
},
},
{
key: "routing.roleId",
label: "Access",
control: RoleSelect,
validate: role => {
const existingRole = screen.routing.roleId
if (role !== existingRole && roleTaken(role)) {
return "That role is already in use for this URL"
}
return null
},
},
{
key: "onLoad",
label: "On screen load",
control: ButtonActionEditor,
},
{
key: "width",
label: "Width",
control: Select,
props: {
options: ["Extra small", "Small", "Medium", "Large", "Max"],
placeholder: "Default",
disabled: !!screen.layoutId,
},
},
{
key: "props.layout",
label: "Layout",
defaultValue: "flex",
control: BarButtonList,
props: {
options: [
{
barIcon: "ModernGridView",
value: "flex",
},
{
barIcon: "ViewGrid",
value: "grid",
},
],
},
},
]
return settings
}
const routeTaken = url => { const routeTaken = url => {
const roleId = get(selectedScreen).routing.roleId || "BASIC" const roleId = get(selectedScreen).routing.roleId || "BASIC"
return get(screenStore).screens.some( return get(screenStore).screens.some(
@ -71,61 +150,6 @@
} }
} }
$: screenSettings = [
{
key: "routing.homeScreen",
control: Checkbox,
props: {
text: "Set as home screen",
},
},
{
key: "routing.route",
label: "Route",
control: Input,
parser: val => {
if (!val.startsWith("/")) {
val = "/" + val
}
return sanitizeUrl(val)
},
validate: route => {
const existingRoute = get(selectedScreen).routing.route
if (route !== existingRoute && routeTaken(route)) {
return "That URL is already in use for this role"
}
return null
},
},
{
key: "routing.roleId",
label: "Access",
control: RoleSelect,
validate: role => {
const existingRole = get(selectedScreen).routing.roleId
if (role !== existingRole && roleTaken(role)) {
return "That role is already in use for this URL"
}
return null
},
},
{
key: "onLoad",
label: "On screen load",
control: ButtonActionEditor,
},
{
key: "width",
label: "Width",
control: Select,
props: {
options: ["Extra small", "Small", "Medium", "Large", "Max"],
placeholder: "Default",
disabled: !!$selectedScreen.layoutId,
},
},
]
const removeCustomLayout = async () => { const removeCustomLayout = async () => {
return screenStore.removeCustomLayout(get(selectedScreen)) return screenStore.removeCustomLayout(get(selectedScreen))
} }
@ -149,6 +173,7 @@
value={Helpers.deepGet($selectedScreen, setting.key)} value={Helpers.deepGet($selectedScreen, setting.key)}
onChange={val => setScreenSetting(setting, val)} onChange={val => setScreenSetting(setting, val)}
props={{ ...setting.props, error: errors[setting.key] }} props={{ ...setting.props, error: errors[setting.key] }}
defaultValue={setting.defaultValue}
{bindings} {bindings}
/> />
{/each} {/each}

View File

@ -33,7 +33,7 @@
{/each} {/each}
</div> </div>
</div> </div>
<Layout gap="S" paddingX="L" paddingY="XL"> <Layout gap="XS" paddingX="L" paddingY="XL">
{#if activeTab === "theme"} {#if activeTab === "theme"}
<ThemePanel /> <ThemePanel />
{:else} {:else}

View File

@ -144,7 +144,12 @@
const rootComponent = get(selectedScreen).props const rootComponent = get(selectedScreen).props
const component = findComponent(rootComponent, data.id) const component = findComponent(rootComponent, data.id)
componentStore.copy(component) componentStore.copy(component)
await componentStore.paste(component) await componentStore.paste(
component,
data.mode,
null,
data.selectComponent
)
} else if (type === "preview-loaded") { } else if (type === "preview-loaded") {
// Wait for this event to show the client library if intelligent // Wait for this event to show the client library if intelligent
// loading is supported // loading is supported
@ -246,13 +251,13 @@
<!-- svelte-ignore a11y-no-static-element-interactions --> <!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="component-container"> <div
class="component-container"
class:tablet={$previewStore.previewDevice === "tablet"}
class:mobile={$previewStore.previewDevice === "mobile"}
>
{#if loading} {#if loading}
<div <div class={`loading ${$themeStore.baseTheme} ${$themeStore.theme}`}>
class={`loading ${$themeStore.baseTheme} ${$themeStore.theme}`}
class:tablet={$previewStore.previewDevice === "tablet"}
class:mobile={$previewStore.previewDevice === "mobile"}
>
<ClientAppSkeleton <ClientAppSkeleton
sideNav={$navigationStore?.navigation === "Left"} sideNav={$navigationStore?.navigation === "Left"}
hideFooter hideFooter
@ -275,6 +280,7 @@
src="/app/preview" src="/app/preview"
class:hidden={loading || error} class:hidden={loading || error}
/> />
<div class="underlay" />
<div <div
class="add-component" class="add-component"
class:active={isAddingComponent} class:active={isAddingComponent}
@ -293,34 +299,13 @@
/> />
<style> <style>
.loading {
position: absolute;
container-type: inline-size;
width: 100%;
height: 100%;
border: 2px solid transparent;
box-sizing: border-box;
}
.loading.tablet {
width: calc(1024px + 6px);
max-height: calc(768px + 6px);
}
.loading.mobile {
width: calc(390px + 6px);
max-height: calc(844px + 6px);
}
.component-container { .component-container {
grid-row-start: middle;
grid-column-start: middle;
display: grid; display: grid;
place-items: center; place-items: center;
position: relative; position: relative;
overflow: hidden;
margin: auto; margin: auto;
height: 100%; height: 100%;
--client-padding: 6px;
} }
.component-container iframe { .component-container iframe {
border: 0; border: 0;
@ -329,6 +314,33 @@
width: 100%; width: 100%;
background-color: transparent; background-color: transparent;
} }
.loading,
.underlay {
position: absolute;
left: 50%;
top: 50%;
transform: translateX(-50%) translateY(-50%);
width: calc(100% - var(--client-padding) * 2);
height: calc(100% - var(--client-padding) * 2);
}
.tablet .loading,
.tablet .underlay {
max-width: 1024px;
max-height: 768px;
}
.mobile .loading,
.mobile .underlay {
max-width: 390px;
max-height: 844px;
}
.underlay {
background: var(--spectrum-global-color-gray-200);
z-index: -1;
padding: 2px;
}
.center { .center {
position: absolute; position: absolute;
width: 100%; width: 100%;

View File

@ -20,7 +20,7 @@
let confirmDeleteDialog let confirmDeleteDialog
let screenDetailsModal let screenDetailsModal
const createDuplicateScreen = async ({ screenName, screenUrl }) => { const createDuplicateScreen = async ({ route }) => {
// Create a dupe and ensure it is unique // Create a dupe and ensure it is unique
let duplicateScreen = Helpers.cloneDeep(screen) let duplicateScreen = Helpers.cloneDeep(screen)
delete duplicateScreen._id delete duplicateScreen._id
@ -28,9 +28,8 @@
duplicateScreen.props = makeComponentUnique(duplicateScreen.props) duplicateScreen.props = makeComponentUnique(duplicateScreen.props)
// Attach the new name and URL // Attach the new name and URL
duplicateScreen.routing.route = sanitizeUrl(screenUrl) duplicateScreen.routing.route = sanitizeUrl(route)
duplicateScreen.routing.homeScreen = false duplicateScreen.routing.homeScreen = false
duplicateScreen.props._instanceName = screenName
try { try {
// Create the screen // Create the screen
@ -136,8 +135,8 @@
<Modal bind:this={screenDetailsModal}> <Modal bind:this={screenDetailsModal}>
<ScreenDetailsModal <ScreenDetailsModal
onConfirm={createDuplicateScreen} onConfirm={createDuplicateScreen}
screenUrl={screen?.routing.route} route={screen?.routing.route}
screenRole={screen?.routing.roleId} role={screen?.routing.roleId}
confirmText="Duplicate" confirmText="Duplicate"
/> />
</Modal> </Modal>

View File

@ -1,8 +1,9 @@
<script> <script>
import ScreenDetailsModal from "components/design/ScreenDetailsModal.svelte" import ScreenDetailsModal from "components/design/ScreenDetailsModal.svelte"
import DatasourceModal from "./DatasourceModal.svelte" import DatasourceModal from "./DatasourceModal.svelte"
import sanitizeUrl from "helpers/sanitizeUrl" import TypeModal from "./TypeModal.svelte"
import FormTypeModal from "./FormTypeModal.svelte" import tableTypes from "./tableTypes"
import formTypes from "./formTypes"
import { Modal, notifications } from "@budibase/bbui" import { Modal, notifications } from "@budibase/bbui"
import { import {
screenStore, screenStore,
@ -11,14 +12,9 @@
builderStore, builderStore,
} from "stores/builder" } from "stores/builder"
import { auth } from "stores/portal" import { auth } from "stores/portal"
import { get } from "svelte/store"
import { capitalise } from "helpers"
import { goto } from "@roxi/routify" import { goto } from "@roxi/routify"
import { TOUR_KEYS } from "components/portal/onboarding/tours.js" import { TOUR_KEYS } from "components/portal/onboarding/tours.js"
import blankScreen from "templates/blankScreen" import * as screenTemplating from "templates/screenTemplating"
import formScreen from "templates/formScreen"
import gridScreen from "templates/gridScreen"
import gridDetailsScreen from "templates/gridDetailsScreen"
import { Roles } from "constants/backend" import { Roles } from "constants/backend"
let mode let mode
@ -26,16 +22,19 @@
let screenDetailsModal let screenDetailsModal
let datasourceModal let datasourceModal
let formTypeModal let formTypeModal
let tableTypeModal
let selectedTablesAndViews = [] let selectedTablesAndViews = []
let permissions = {} let permissions = {}
$: screens = $screenStore.screens
export const show = newMode => { export const show = newMode => {
mode = newMode mode = newMode
selectedTablesAndViews = [] selectedTablesAndViews = []
permissions = {} permissions = {}
if (mode === "grid" || mode === "gridDetails" || mode === "form") { if (mode === "table" || mode === "form") {
datasourceModal.show() datasourceModal.show()
} else if (mode === "blank") { } else if (mode === "blank") {
screenDetailsModal.show() screenDetailsModal.show()
@ -44,136 +43,83 @@
} }
} }
const createScreen = async screen => { const createScreen = async screenTemplate => {
try { try {
// Check we aren't clashing with an existing URL return await screenStore.save(screenTemplate)
if (hasExistingUrl(screen.routing.route, screen.routing.roleId)) {
let suffix = 2
let candidateUrl = makeCandidateUrl(screen, suffix)
while (hasExistingUrl(candidateUrl, screen.routing.roleId)) {
candidateUrl = makeCandidateUrl(screen, ++suffix)
}
screen.routing.route = candidateUrl
}
screen.routing.route = sanitizeUrl(screen.routing.route)
return await screenStore.save(screen)
} catch (error) { } catch (error) {
console.error(error) console.error(error)
notifications.error("Error creating screens") notifications.error("Error creating screens")
} }
} }
const addNavigationLink = async screen => const createScreens = async screenTemplates => {
await navigationStore.saveLink( const newScreens = []
screen.routing.route,
capitalise(screen.routing.route.split("/")[1]),
screen.routing.roleId
)
// Checks if any screens exist in the store with the given route and for (let screenTemplate of screenTemplates) {
// currently selected role await addNavigationLink(
const hasExistingUrl = (url, screenAccessRole) => { screenTemplate.data,
const screens = get(screenStore).screens.filter( screenTemplate.navigationLinkLabel
s => s.routing.roleId === screenAccessRole )
) newScreens.push(await createScreen(screenTemplate.data))
return !!screens.find(s => s.routing?.route === url) }
return newScreens
} }
// Constructs a candidate URL for a new screen, appending a given suffix to the const addNavigationLink = async (screen, linkLabel) => {
// screen's URL if (linkLabel == null) return
// e.g. "/sales/:id" => "/sales-1/:id"
const makeCandidateUrl = (screen, suffix) => { await navigationStore.saveLink(
let url = screen.routing?.route || "" screen.routing.route,
if (url.startsWith("/")) { linkLabel,
url = url.slice(1) screen.routing.roleId
} )
if (!url.includes("/")) {
return `/${url}-${suffix}`
} else {
const split = url.split("/")
return `/${split[0]}-${suffix}/${split.slice(1).join("/")}`
}
} }
const onSelectDatasources = async () => { const onSelectDatasources = async () => {
if (mode === "form") { if (mode === "form") {
formTypeModal.show() formTypeModal.show()
} else if (mode === "grid") { } else if (mode === "table") {
await createGridScreen() tableTypeModal.show()
} else if (mode === "gridDetails") {
await createGridDetailsScreen()
} }
} }
const createBlankScreen = async ({ screenUrl }) => { const createBlankScreen = async ({ route }) => {
const screenTemplate = blankScreen(screenUrl) const screenTemplates = screenTemplating.blank({ route, screens })
const screen = await createScreen(screenTemplate)
await addNavigationLink(screenTemplate)
loadNewScreen(screen) const newScreens = await createScreens(screenTemplates)
loadNewScreen(newScreens[0])
} }
const createGridScreen = async () => { const createTableScreen = async type => {
let firstScreen = null const screenTemplates = selectedTablesAndViews.flatMap(tableOrView =>
screenTemplating.table({
for (let tableOrView of selectedTablesAndViews) { screens,
const screenTemplate = gridScreen(
tableOrView, tableOrView,
permissions[tableOrView.id] type,
) permissions: permissions[tableOrView.id],
})
)
const screen = await createScreen(screenTemplate) const newScreens = await createScreens(screenTemplates)
await addNavigationLink(screen) loadNewScreen(newScreens[0])
firstScreen ??= screen
}
loadNewScreen(firstScreen)
} }
const createGridDetailsScreen = async () => { const createFormScreen = async type => {
let firstScreen = null const screenTemplates = selectedTablesAndViews.flatMap(tableOrView =>
screenTemplating.form({
for (let tableOrView of selectedTablesAndViews) { screens,
const screenTemplate = gridDetailsScreen(
tableOrView, tableOrView,
permissions[tableOrView.id] type,
) permissions: permissions[tableOrView.id],
})
)
const screen = await createScreen(screenTemplate) const newScreens = await createScreens(screenTemplates)
await addNavigationLink(screen)
firstScreen ??= screen if (type === "update" || type === "create") {
}
loadNewScreen(firstScreen)
}
const createFormScreen = async formType => {
let firstScreen = null
for (let tableOrView of selectedTablesAndViews) {
const screenTemplate = formScreen(
tableOrView,
formType,
permissions[tableOrView.id]
)
const screen = await createScreen(screenTemplate)
// Only add a navigation link for `Create`, as both `Update` and `View`
// require an `id` in their URL in order to function.
if (formType === "Create") {
await addNavigationLink(screen)
}
firstScreen ??= screen
}
if (formType === "Update" || formType === "Create") {
const associatedTour = const associatedTour =
formType === "Update" type === "update"
? TOUR_KEYS.BUILDER_FORM_VIEW_UPDATE ? TOUR_KEYS.BUILDER_FORM_VIEW_UPDATE
: TOUR_KEYS.BUILDER_FORM_CREATE : TOUR_KEYS.BUILDER_FORM_CREATE
@ -183,7 +129,7 @@
} }
} }
loadNewScreen(firstScreen) loadNewScreen(newScreens[0])
} }
const loadNewScreen = screen => { const loadNewScreen = screen => {
@ -199,7 +145,11 @@
} }
const fetchPermission = resourceId => { const fetchPermission = resourceId => {
permissions[resourceId] = { loading: true, read: null, write: null } permissions[resourceId] = {
loading: true,
read: Roles.BASIC,
write: Roles.BASIC,
}
permissionsStore permissionsStore
.forResource(resourceId) .forResource(resourceId)
@ -218,8 +168,8 @@
if (permissions[resourceId]?.loading) { if (permissions[resourceId]?.loading) {
permissions[resourceId] = { permissions[resourceId] = {
loading: false, loading: false,
read: Roles.PUBLIC, read: Roles.BASIC,
write: Roles.PUBLIC, write: Roles.BASIC,
} }
} }
}) })
@ -250,18 +200,31 @@
<Modal bind:this={datasourceModal} autoFocus={false}> <Modal bind:this={datasourceModal} autoFocus={false}>
<DatasourceModal <DatasourceModal
{selectedTablesAndViews} {selectedTablesAndViews}
{permissions}
onConfirm={onSelectDatasources} onConfirm={onSelectDatasources}
on:toggle={handleTableOrViewToggle} on:toggle={handleTableOrViewToggle}
/> />
</Modal> </Modal>
<Modal bind:this={tableTypeModal}>
<TypeModal
title="Choose how you want to manage rows"
types={tableTypes}
onConfirm={createTableScreen}
onCancel={() => {
tableTypeModal.hide()
datasourceModal.show()
}}
/>
</Modal>
<Modal bind:this={screenDetailsModal}> <Modal bind:this={screenDetailsModal}>
<ScreenDetailsModal onConfirm={createBlankScreen} /> <ScreenDetailsModal onConfirm={createBlankScreen} />
</Modal> </Modal>
<Modal bind:this={formTypeModal}> <Modal bind:this={formTypeModal}>
<FormTypeModal <TypeModal
title="Select form type"
types={formTypes}
onConfirm={createFormScreen} onConfirm={createFormScreen}
onCancel={() => { onCancel={() => {
formTypeModal.hide() formTypeModal.hide()

View File

@ -1,14 +1,14 @@
<script> <script>
import { ModalContent, Layout, notifications, Body } from "@budibase/bbui" import { Body, ModalContent, Layout, notifications } from "@budibase/bbui"
import { datasources as datasourcesStore } from "stores/builder" import { datasources as datasourcesStore } from "stores/builder"
import ICONS from "components/backend/DatasourceNavigator/icons" import ICONS from "components/backend/DatasourceNavigator/icons"
import { IntegrationNames } from "constants" import { IntegrationNames } from "constants"
import { createEventDispatcher, onMount } from "svelte" import { createEventDispatcher, onMount } from "svelte"
import TableOrViewOption from "./TableOrViewOption.svelte" import TableOrViewOption from "./TableOrViewOption.svelte"
import * as format from "helpers/data/format"
export let onConfirm export let onConfirm
export let selectedTablesAndViews export let selectedTablesAndViews
export let permissions
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
@ -21,38 +21,37 @@
icon: "Remove", icon: "Remove",
name: view.name, name: view.name,
id: view.id, id: view.id,
clientData: { tableSelectFormat: format.tableSelect.viewV2(view),
...view, datasourceSelectFormat: format.datasourceSelect.viewV2(view),
type: "viewV2",
label: view.name,
},
})) }))
} }
const getTablesAndViews = datasource => { const getTablesAndViews = datasource => {
let tablesAndViews = [] let tablesAndViews = []
const rawTables = Array.isArray(datasource.entities) const tables = Array.isArray(datasource.entities)
? datasource.entities ? datasource.entities
: Object.values(datasource.entities ?? {}) : Object.values(datasource.entities ?? {})
for (const rawTable of rawTables) { for (const table of tables) {
if (rawTable._id === "ta_users") { if (table._id === "ta_users") {
continue continue
} }
const table = { const formattedTable = {
icon: "Table", icon: "Table",
name: rawTable.name, name: table.name,
id: rawTable._id, id: table._id,
clientData: { tableSelectFormat: format.tableSelect.table(table),
...rawTable, datasourceSelectFormat: format.datasourceSelect.table(
label: rawTable.name, table,
tableId: rawTable._id, $datasourcesStore.list
type: "table", ),
},
} }
tablesAndViews = tablesAndViews.concat([table, ...getViews(rawTable)]) tablesAndViews = tablesAndViews.concat([
formattedTable,
...getViews(table),
])
} }
return tablesAndViews return tablesAndViews
@ -96,60 +95,76 @@
}) })
</script> </script>
<span> <ModalContent
<ModalContent title="Autogenerated screens"
title="Autogenerated screens" confirmText="Next"
confirmText="Confirm" cancelText="Cancel"
cancelText="Back" {onConfirm}
{onConfirm} disabled={!selectedTablesAndViews.length}
disabled={!selectedTablesAndViews.length} size="L"
size="L" >
> <Body size="S">
<Body size="S"> Select which datasources 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 datasources as datasource}
{#each datasources as datasource} <div class="datasource">
<div class="data-source-wrap"> <div class="header">
<div class="data-source-header"> <svelte:component
<svelte:component this={datasource.iconComponent}
this={datasource.iconComponent} height="18"
height="24" width="18"
width="24" />
/> <h2>{datasource.name}</h2>
<div class="data-source-name">{datasource.name}</div>
</div>
<!-- List all tables -->
{#each datasource.tablesAndViews as tableOrView}
{@const selected = selectedTablesAndViews.some(
selected => selected.id === tableOrView.id
)}
<TableOrViewOption
roles={permissions[tableOrView.id]}
on:click={() => toggleSelection(tableOrView)}
{selected}
{tableOrView}
/>
{/each}
</div> </div>
{/each} <!-- List all tables -->
</Layout> {#each datasource.tablesAndViews as tableOrView}
</ModalContent> {@const selected = selectedTablesAndViews.some(
</span> selected => selected.id === tableOrView.id
)}
<TableOrViewOption
on:click={() => toggleSelection(tableOrView)}
{selected}
{tableOrView}
/>
{/each}
</div>
{/each}
</Layout>
</ModalContent>
<style> <style>
.data-source-wrap { .datasource {
padding-bottom: var(--spectrum-alias-item-padding-s); padding-bottom: 15px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
grid-gap: var(--spacing-s); grid-gap: var(--spacing-s);
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
} }
.data-source-header {
.datasource:last-child {
padding-bottom: 0;
}
.header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--spacing-m); padding-bottom: var(--spacing-m);
padding-bottom: var(--spacing-xs); }
.header :global(svg) {
flex-shrink: 0;
}
.header h2 {
padding-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 14px;
font-weight: 400;
margin: 0;
margin-left: 10px;
} }
</style> </style>

View File

@ -1,115 +0,0 @@
<script>
import { ModalContent, Layout, Body, Icon } from "@budibase/bbui"
let type = null
export let onCancel = () => {}
export let onConfirm = () => {}
</script>
<span>
<ModalContent
title="Select form type"
confirmText="Done"
cancelText="Back"
onConfirm={() => onConfirm(type)}
{onCancel}
disabled={!type}
size="L"
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<Layout noPadding gap="S">
<div
class="form-type"
class:selected={type === "Create"}
on:click={() => (type = "Create")}
>
<div class="form-type-wrap">
<div class="form-type-content">
<Body noPadding>Create a new row</Body>
<Body size="S">
For capturing and storing new data from your users
</Body>
</div>
{#if type === "Create"}
<span class="form-type-check">
<Icon size="S" name="CheckmarkCircle" />
</span>
{/if}
</div>
</div>
<div
class="form-type"
class:selected={type === "Update"}
on:click={() => (type = "Update")}
>
<div class="form-type-wrap">
<div class="form-type-content">
<Body noPadding>Update an existing row</Body>
<Body size="S">For viewing and updating existing data</Body>
</div>
{#if type === "Update"}
<span class="form-type-check">
<Icon size="S" name="CheckmarkCircle" />
</span>
{/if}
</div>
</div>
<div
class="form-type"
class:selected={type === "View"}
on:click={() => (type = "View")}
>
<div class="form-type-wrap">
<div class="form-type-content">
<Body noPadding>View an existing row</Body>
<Body size="S">For a read only view of your data</Body>
</div>
{#if type === "View"}
<span class="form-type-check">
<Icon size="S" name="CheckmarkCircle" />
</span>
{/if}
</div>
</div>
</Layout>
</ModalContent>
</span>
<style>
.form-type {
cursor: pointer;
gap: var(--spacing-s);
padding: var(--spacing-m) var(--spacing-xl);
background: var(--spectrum-alias-background-color-secondary);
transition: 0.3s all;
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px;
display: flex;
flex-direction: column;
}
.selected,
.form-type:hover {
background: var(--spectrum-alias-background-color-tertiary);
}
.form-type-wrap {
display: flex;
align-items: center;
justify-content: space-between;
}
.form-type :global(p:nth-child(2)) {
color: var(--grey-6);
}
.form-type-check {
margin-left: auto;
}
.form-type-check :global(.spectrum-Icon) {
color: var(--spectrum-global-color-green-600);
}
.form-type-content {
gap: var(--spacing-s);
display: flex;
flex-direction: column;
}
</style>

View File

@ -1,49 +1,14 @@
<script> <script>
import { Icon, AbsTooltip } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import RoleIcon from "components/common/RoleIcon.svelte"
export let tableOrView export let tableOrView
export let roles
export let selected = false export let selected = false
$: hideRoles = roles == undefined || roles?.loading
</script> </script>
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div role="button" tabindex="0" class="datasource" class:selected on:click> <div role="button" tabindex="0" class="datasource" class:selected on:click>
<div class="content"> <Icon name={tableOrView.icon} />
<Icon name={tableOrView.icon} /> <span>{tableOrView.name}</span>
<span>{tableOrView.name}</span>
</div>
<div class:hideRoles class="roles">
<AbsTooltip
type="info"
text={`Screens that only read data will be generated with access "${roles?.read?.toLowerCase()}"`}
>
<div class="role">
<span>read</span>
<RoleIcon
size="XS"
id={roles?.read}
disabled={roles?.loading !== false}
/>
</div>
</AbsTooltip>
<AbsTooltip
type="info"
text={`Screens that write data will be generated with access "${roles?.write?.toLowerCase()}"`}
>
<div class="role">
<span>write</span>
<RoleIcon
size="XS"
id={roles?.write}
disabled={roles?.loading !== false}
/>
</div>
</AbsTooltip>
</div>
</div> </div>
<style> <style>
@ -52,18 +17,8 @@
border: 1px solid var(--spectrum-global-color-gray-300); border: 1px solid var(--spectrum-global-color-gray-300);
transition: 160ms all; transition: 160ms all;
border-radius: 4px; border-radius: 4px;
display: flex;
align-items: center;
user-select: none; user-select: none;
background-color: var(--background);
}
.datasource :global(svg) {
transition: 160ms all;
color: var(--spectrum-global-color-gray-600);
}
.content {
padding: var(--spectrum-alias-item-padding-s); padding: var(--spectrum-alias-item-padding-s);
display: flex; display: flex;
align-items: center; align-items: center;
@ -71,7 +26,12 @@
min-width: 0; min-width: 0;
} }
.content span { .datasource :global(svg) {
transition: 160ms all;
color: var(--spectrum-global-color-gray-600);
}
.datasource span {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -84,29 +44,4 @@
.selected { .selected {
border: 1px solid var(--blue) !important; border: 1px solid var(--blue) !important;
} }
.roles {
margin-left: auto;
display: flex;
flex-direction: column;
align-items: end;
padding-right: var(--spectrum-alias-item-padding-s);
opacity: 0.5;
transition: opacity 160ms;
}
.hideRoles {
opacity: 0;
pointer-events: none;
}
.role {
display: flex;
align-items: center;
}
.role span {
font-size: 11px;
margin-right: 5px;
}
</style> </style>

View File

@ -0,0 +1,82 @@
<script>
import { ModalContent, Layout, Body } from "@budibase/bbui"
let selectedType = null
export let title
export let types
export let onCancel = () => {}
export let onConfirm = () => {}
</script>
<ModalContent
{title}
confirmText="Done"
cancelText="Back"
onConfirm={() => onConfirm(selectedType)}
{onCancel}
disabled={!selectedType}
size="L"
>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<Layout noPadding gap="S">
{#each types as type}
<div
class="type"
class:selected={selectedType === type.id}
on:click={() => (selectedType = type.id)}
>
<div class="image">
<img alt={type.img.alt} src={type.img.src} />
</div>
<div class="typeContent">
<Body noPadding>{type.title}</Body>
<Body size="S">{type.description}</Body>
</div>
</div>
{/each}
</Layout>
</ModalContent>
<style>
.type {
cursor: pointer;
gap: var(--spacing-s);
background: var(--spectrum-alias-background-color-secondary);
transition: 0.3s all;
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px;
display: flex;
overflow: hidden;
}
.type:hover {
border: 1px solid var(--grey-5);
}
.type.selected {
border: 1px solid var(--blue);
}
.type :global(p:nth-child(2)) {
color: var(--grey-6);
}
.typeContent {
box-sizing: border-box;
padding: var(--spacing-m) var(--spacing-xl);
flex-grow: 1;
gap: var(--spacing-s);
display: flex;
flex-direction: column;
}
.image {
min-width: 133px;
height: 73px;
background-color: var(--grey-2);
}
.image img {
height: 100%;
}
</style>

View File

@ -0,0 +1,35 @@
import formView from "./images/formView.svg"
import formUpdate from "./images/formUpdate.svg"
import formCreate from "./images/formCreate.svg"
const tableTypes = [
{
id: "create",
img: {
alt: "A form containing new data",
src: formCreate,
},
title: "Create a new row",
description: "For capturing and storing new data from your users",
},
{
id: "update",
img: {
alt: "A form containing edited data",
src: formUpdate,
},
title: "Update an existing row",
description: "For viewing and updating existing data",
},
{
id: "view",
img: {
alt: "A form containing read-only data",
src: formView,
},
title: "View an existing row",
description: "For a read only view of your data",
},
]
export default tableTypes

View File

@ -0,0 +1,15 @@
<svg width="118" height="65" viewBox="0 0 118 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="118" height="65" fill="#34BB84"/>
<rect width="118" height="65" fill="#34BB84"/>
<mask id="path-3-inside-1_51_2" fill="white">
<path d="M22 12H94V65H22V12Z"/>
</mask>
<path d="M22 12H94V65H22V12Z" fill="url(#paint0_linear_51_2)"/>
<path d="M22 12V11H21V12H22ZM94 12H95V11H94V12ZM22 13H94V11H22V13ZM93 12V65H95V12H93ZM23 65V12H21V65H23Z" fill="white" fill-opacity="0.2" mask="url(#path-3-inside-1_51_2)"/>
<defs>
<linearGradient id="paint0_linear_51_2" x1="22" y1="12" x2="92.4561" y2="66.9785" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.8"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 771 B

View File

@ -0,0 +1,20 @@
<svg width="118" height="65" viewBox="0 0 118 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="118" height="65" fill="#FD4F01"/>
<rect width="118" height="65" fill="#FD4F01"/>
<mask id="path-3-inside-1_49_949" fill="white">
<path d="M22 12H94V65H22V12Z"/>
</mask>
<path d="M22 12H94V65H22V12Z" fill="url(#paint0_linear_49_949)"/>
<path d="M22 12V11H21V12H22ZM94 12H95V11H94V12ZM22 13H94V11H22V13ZM93 12V65H95V12H93ZM23 65V12H21V65H23Z" fill="white" fill-opacity="0.2" mask="url(#path-3-inside-1_49_949)"/>
<rect x="46" y="29" width="15" height="4" fill="white" fill-opacity="0.97"/>
<rect x="46" y="45" width="15" height="4" fill="white" fill-opacity="0.97"/>
<rect x="46" y="35" width="25" height="6" fill="white" fill-opacity="0.97"/>
<rect x="46" y="51" width="25" height="6" fill="white" fill-opacity="0.97"/>
<path d="M46 21H71V25H46V21Z" fill="white" fill-opacity="0.97"/>
<defs>
<linearGradient id="paint0_linear_49_949" x1="22" y1="12" x2="92.4561" y2="66.9785" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.6"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,20 @@
<svg width="118" height="65" viewBox="0 0 118 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="118" height="65" fill="#FD4F01"/>
<rect width="118" height="65" fill="#FD4F01"/>
<mask id="path-3-inside-1_49_940" fill="white">
<path d="M22 12H94V65H22V12Z"/>
</mask>
<path d="M22 12H94V65H22V12Z" fill="url(#paint0_linear_49_940)"/>
<path d="M22 12V11H21V12H22ZM94 12H95V11H94V12ZM22 13H94V11H22V13ZM93 12V65H95V12H93ZM23 65V12H21V65H23Z" fill="white" fill-opacity="0.2" mask="url(#path-3-inside-1_49_940)"/>
<rect x="46" y="29" width="15" height="4" fill="white" fill-opacity="0.97"/>
<rect x="46" y="45" width="15" height="4" fill="white" fill-opacity="0.5"/>
<rect x="46" y="35" width="25" height="6" fill="white" fill-opacity="0.97"/>
<path d="M46 21H71V25H46V21Z" fill="white" fill-opacity="0.5"/>
<rect x="46" y="51" width="25" height="6" fill="white" fill-opacity="0.5"/>
<defs>
<linearGradient id="paint0_linear_49_940" x1="22" y1="12" x2="92.4561" y2="66.9785" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.6"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,20 @@
<svg width="118" height="65" viewBox="0 0 118 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="118" height="65" fill="#FD4F01"/>
<rect width="118" height="65" fill="#FD4F01"/>
<mask id="path-3-inside-1_49_931" fill="white">
<path d="M22 12H94V65H22V12Z"/>
</mask>
<path d="M22 12H94V65H22V12Z" fill="url(#paint0_linear_49_931)"/>
<path d="M22 12V11H21V12H22ZM94 12H95V11H94V12ZM22 13H94V11H22V13ZM93 12V65H95V12H93ZM23 65V12H21V65H23Z" fill="white" fill-opacity="0.2" mask="url(#path-3-inside-1_49_931)"/>
<rect x="46" y="29" width="15" height="4" fill="white" fill-opacity="0.5"/>
<rect x="46" y="45" width="15" height="4" fill="white" fill-opacity="0.5"/>
<rect x="46" y="35" width="25" height="6" fill="white" fill-opacity="0.5"/>
<path d="M46 21H71V25H46V21Z" fill="white" fill-opacity="0.5"/>
<rect x="46" y="51" width="25" height="6" fill="white" fill-opacity="0.5"/>
<defs>
<linearGradient id="paint0_linear_49_931" x1="22" y1="12" x2="92.4561" y2="66.9785" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.6"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,44 @@
<svg width="118" height="65" viewBox="0 0 118 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="118" height="65" fill="#765FFE"/>
<mask id="path-2-inside-1_4_100" fill="white">
<path d="M22 12H94V65H22V12Z"/>
</mask>
<path d="M22 12H94V65H22V12Z" fill="url(#paint0_linear_4_100)"/>
<path d="M22 12V11H21V12H22ZM94 12H95V11H94V12ZM22 13H94V11H22V13ZM93 12V65H95V12H93ZM23 65V12H21V65H23Z" fill="white" fill-opacity="0.2" mask="url(#path-2-inside-1_4_100)"/>
<path d="M35.6901 23.0003H44.8169V28.0003H35.6901V23.0003Z" fill="white" fill-opacity="0.9"/>
<path d="M35.6901 28.5004H44.8169V33.5004H35.6901V28.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M35.6901 34H44.8169V39H35.6901V34Z" fill="white" fill-opacity="0.5"/>
<path d="M35.6901 39.5002H44.8169V44.5002H35.6901V39.5002Z" fill="white" fill-opacity="0.5"/>
<path d="M35.6901 45.0003H44.8169V50.0003H35.6901V45.0003Z" fill="white" fill-opacity="0.5"/>
<path d="M35.6901 50.5004H44.8169V55.5004H35.6901V50.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M45.3234 23.0003H54.4502V28.0003H45.3234V23.0003Z" fill="white" fill-opacity="0.9"/>
<path d="M45.3234 28.5004H54.4502V33.5004H45.3234V28.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M45.3234 34H54.4502V39H45.3234V34Z" fill="white" fill-opacity="0.5"/>
<path d="M45.3234 39.5002H54.4502V44.5002H45.3234V39.5002Z" fill="white" fill-opacity="0.5"/>
<path d="M45.3234 45.0003H54.4502V50.0003H45.3234V45.0003Z" fill="white" fill-opacity="0.5"/>
<path d="M45.3234 50.5004H54.4502V55.5004H45.3234V50.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M54.9576 23.0003H64.0844V28.0003H54.9576V23.0003Z" fill="white" fill-opacity="0.9"/>
<path d="M54.9576 28.5004H64.0844V33.5004H54.9576V28.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M54.9576 34H64.0844V39H54.9576V34Z" fill="white" fill-opacity="0.5"/>
<path d="M54.9576 39.5002H64.0844V44.5002H54.9576V39.5002Z" fill="white" fill-opacity="0.5"/>
<path d="M54.9576 45.0003H64.0844V50.0003H54.9576V45.0003Z" fill="white" fill-opacity="0.5"/>
<path d="M54.9576 50.5004H64.0844V55.5004H54.9576V50.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M64.5915 23.0003H73.7183V28.0003H64.5915V23.0003Z" fill="white" fill-opacity="0.9"/>
<path d="M64.5915 28.5004H73.7183V33.5004H64.5915V28.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M64.5915 34H73.7183V39H64.5915V34Z" fill="white" fill-opacity="0.5"/>
<path d="M64.5915 39.5002H73.7183V44.5002H64.5915V39.5002Z" fill="white" fill-opacity="0.5"/>
<path d="M64.5915 45.0003H73.7183V50.0003H64.5915V45.0003Z" fill="white" fill-opacity="0.5"/>
<path d="M64.5915 50.5004H73.7183V55.5004H64.5915V50.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M74.2253 23.0003H83.3521V28.0003H74.2253V23.0003Z" fill="white" fill-opacity="0.9"/>
<path d="M74.2253 28.5004H83.3521V33.5004H74.2253V28.5004Z" fill="white" fill-opacity="0.5"/>
<path d="M74.2253 34H83.3521V39H74.2253V34Z" fill="white" fill-opacity="0.5"/>
<path d="M74.2253 39.5002H83.3521V44.5002H74.2253V39.5002Z" fill="white" fill-opacity="0.5"/>
<path d="M74.2253 45.0003H83.3521V50.0003H74.2253V45.0003Z" fill="white" fill-opacity="0.5"/>
<path d="M74.2253 50.5004H83.3521V55.5004H74.2253V50.5004Z" fill="white" fill-opacity="0.5"/>
<defs>
<linearGradient id="paint0_linear_4_100" x1="22" y1="12" x2="92.4561" y2="66.9785" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.6"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1,28 @@
<svg width="118" height="65" viewBox="0 0 118 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="118" height="65" fill="#765FFE"/>
<mask id="path-2-inside-1_4_138" fill="white">
<path d="M22 12H94V65H22V12Z"/>
</mask>
<path d="M22 12H94V65H22V12Z" fill="url(#paint0_linear_4_138)"/>
<path d="M22 12V11H21V12H22ZM94 12H95V11H94V12ZM22 13H94V11H22V13ZM93 12V65H95V12H93ZM23 65V12H21V65H23Z" fill="white" fill-opacity="0.2" mask="url(#path-2-inside-1_4_138)"/>
<g filter="url(#filter0_d_4_138)">
<rect x="42" y="17" width="33" height="44" fill="white" fill-opacity="0.4" shape-rendering="crispEdges"/>
<rect x="42.5" y="17.5" width="32" height="43" stroke="white" stroke-opacity="0.3" shape-rendering="crispEdges"/>
</g>
<defs>
<filter id="filter0_d_4_138" x="39" y="15" width="39" height="50" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4_138"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_4_138" result="shape"/>
</filter>
<linearGradient id="paint0_linear_4_138" x1="22" y1="12" x2="92.4561" y2="66.9785" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.6"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -0,0 +1,20 @@
<svg width="118" height="65" viewBox="0 0 118 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="118" height="65" fill="#765FFE"/>
<rect width="118" height="65" fill="#765FFE"/>
<mask id="path-3-inside-1_56_20" fill="white">
<path d="M22 12H94V65H22V12Z"/>
</mask>
<path d="M22 12H94V65H22V12Z" fill="url(#paint0_linear_56_20)"/>
<path d="M22 12V11H21V12H22ZM94 12H95V11H94V12ZM22 13H94V11H22V13ZM93 12V65H95V12H93ZM23 65V12H21V65H23Z" fill="white" fill-opacity="0.2" mask="url(#path-3-inside-1_56_20)"/>
<rect x="46" y="29" width="15" height="4" fill="white" fill-opacity="0.97"/>
<rect x="46" y="45" width="15" height="4" fill="white" fill-opacity="0.5"/>
<rect x="46" y="35" width="25" height="6" fill="white" fill-opacity="0.97"/>
<path d="M46 21H71V25H46V21Z" fill="white" fill-opacity="0.5"/>
<rect x="46" y="51" width="25" height="6" fill="white" fill-opacity="0.5"/>
<defs>
<linearGradient id="paint0_linear_56_20" x1="22" y1="12" x2="92.4561" y2="66.9785" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.6"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,28 @@
<svg width="118" height="65" viewBox="0 0 118 65" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="118" height="65" fill="#765FFE"/>
<mask id="path-2-inside-1_4_103" fill="white">
<path d="M22 12H94V65H22V12Z"/>
</mask>
<path d="M22 12H94V65H22V12Z" fill="url(#paint0_linear_4_103)"/>
<path d="M22 12V11H21V12H22ZM94 12H95V11H94V12ZM22 13H94V11H22V13ZM93 12V65H95V12H93ZM23 65V12H21V65H23Z" fill="white" fill-opacity="0.2" mask="url(#path-2-inside-1_4_103)"/>
<g filter="url(#filter0_d_4_103)">
<rect x="70" y="17" width="20" height="44" fill="white" fill-opacity="0.4" shape-rendering="crispEdges"/>
<rect x="70.5" y="17.5" width="19" height="43" stroke="white" stroke-opacity="0.3" shape-rendering="crispEdges"/>
</g>
<defs>
<filter id="filter0_d_4_103" x="67" y="15" width="26" height="50" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="1"/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4_103"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_4_103" result="shape"/>
</filter>
<linearGradient id="paint0_linear_4_103" x1="22" y1="12" x2="92.4561" y2="66.9785" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.6"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@ -1,10 +1,9 @@
<script> <script>
import { Body } from "@budibase/bbui" import { Body } from "@budibase/bbui"
import CreationPage from "components/common/CreationPage.svelte" import CreationPage from "components/common/CreationPage.svelte"
import blankImage from "./images/blank.png" import blank from "./images/blank.svg"
import tableInline from "./images/tableInline.png" import table from "./images/tableInline.svg"
import tableDetails from "./images/tableDetails.png" import form from "./images/formUpdate.svg"
import formImage from "./images/form.png"
import CreateScreenModal from "./CreateScreenModal.svelte" import CreateScreenModal from "./CreateScreenModal.svelte"
import { screenStore } from "stores/builder" import { screenStore } from "stores/builder"
@ -30,37 +29,27 @@
<div class="cards"> <div class="cards">
<div class="card" on:click={() => createScreenModal.show("blank")}> <div class="card" on:click={() => createScreenModal.show("blank")}>
<div class="image"> <div class="image">
<img alt="" src={blankImage} /> <img alt="A blank screen" src={blank} />
</div> </div>
<div class="text"> <div class="text">
<Body size="S">Blank screen</Body> <Body size="S">Blank</Body>
<Body size="XS">Add an empty blank screen</Body> <Body size="XS">Add an empty blank screen</Body>
</div> </div>
</div> </div>
<div class="card" on:click={() => createScreenModal.show("grid")}> <div class="card" on:click={() => createScreenModal.show("table")}>
<div class="image"> <div class="image">
<img alt="" src={tableInline} /> <img alt="A table of data" src={table} />
</div> </div>
<div class="text"> <div class="text">
<Body size="S">Table with inline editing</Body> <Body size="S">Table</Body>
<Body size="XS">View, edit and delete rows inline</Body> <Body size="XS">List rows in a table</Body>
</div>
</div>
<div class="card" on:click={() => createScreenModal.show("gridDetails")}>
<div class="image">
<img alt="" src={tableDetails} />
</div>
<div class="text">
<Body size="S">Table with details panel</Body>
<Body size="XS">Manage your row details in a side panel</Body>
</div> </div>
</div> </div>
<div class="card" on:click={() => createScreenModal.show("form")}> <div class="card" on:click={() => createScreenModal.show("form")}>
<div class="image"> <div class="image">
<img alt="" src={formImage} /> <img alt="A form containing data" src={form} />
</div> </div>
<div class="text"> <div class="text">
<Body size="S">Form</Body> <Body size="S">Form</Body>
@ -114,8 +103,9 @@
} }
.card .image { .card .image {
min-height: 130px;
min-width: 235px; min-width: 235px;
height: 127px;
background-color: var(--grey-2);
} }
.text { .text {

View File

@ -0,0 +1,45 @@
import tableInline from "./images/tableInline.svg"
import tableSidePanel from "./images/tableSidePanel.svg"
import tableModal from "./images/tableModal.svg"
import tableNewScreen from "./images/tableNewScreen.svg"
const tableTypes = [
{
id: "inline",
img: {
alt: "A table of data",
src: tableInline,
},
title: "Inline",
description: "Manage data directly on your table",
},
{
id: "sidePanel",
img: {
alt: "A side panel",
src: tableSidePanel,
},
title: "Side panel",
description: "Open row details in a side panel",
},
{
id: "modal",
img: {
alt: "A modal",
src: tableModal,
},
title: "Modal",
description: "Open row details in a modal",
},
{
id: "newScreen",
img: {
alt: "A new screen",
src: tableNewScreen,
},
title: "New screen",
description: "View row details on a separate screen",
},
]
export default tableTypes

View File

@ -603,15 +603,26 @@ export class ComponentStore extends BudiStore {
return return
} }
// Determine the next component to select after deletion // Determine the next component to select, and select it before deletion
// to avoid an intermediate state of no component selection
const state = get(this.store) const state = get(this.store)
let nextSelectedComponentId let nextId
if (state.selectedComponentId === component._id) { if (state.selectedComponentId === component._id) {
nextSelectedComponentId = this.getNext() nextId = this.getNext()
if (!nextSelectedComponentId) { if (!nextId) {
nextSelectedComponentId = this.getPrevious() nextId = this.getPrevious()
} }
} }
if (nextId) {
// If this is the nav, select the screen instead
if (nextId.endsWith("-navigation")) {
nextId = nextId.replace("-navigation", "-screen")
}
this.update(state => {
state.selectedComponentId = nextId
return state
})
}
// Patch screen // Patch screen
await screenStore.patch(screen => { await screenStore.patch(screen => {
@ -630,14 +641,6 @@ export class ComponentStore extends BudiStore {
child => child._id !== component._id child => child._id !== component._id
) )
}) })
// Update selected component if required
if (nextSelectedComponentId) {
this.update(state => {
state.selectedComponentId = nextSelectedComponentId
return state
})
}
} }
copy(component, cut = false, selectParent = true) { copy(component, cut = false, selectParent = true) {
@ -645,6 +648,7 @@ export class ComponentStore extends BudiStore {
this.update(state => { this.update(state => {
state.componentToPaste = cloneDeep(component) state.componentToPaste = cloneDeep(component)
state.componentToPaste.isCut = cut state.componentToPaste.isCut = cut
state.componentToPaste.screenId = get(screenStore).selectedScreenId
return state return state
}) })
@ -679,7 +683,7 @@ export class ComponentStore extends BudiStore {
* @param {object} targetScreen * @param {object} targetScreen
* @returns * @returns
*/ */
async paste(targetComponent, mode, targetScreen) { async paste(targetComponent, mode, targetScreen, selectComponent = true) {
const state = get(this.store) const state = get(this.store)
if (!state.componentToPaste) { if (!state.componentToPaste) {
return return
@ -703,8 +707,10 @@ export class ComponentStore extends BudiStore {
return false return false
} }
const cut = componentToPaste.isCut const cut = componentToPaste.isCut
const sourceScreenId = componentToPaste.screenId
const originalId = componentToPaste._id const originalId = componentToPaste._id
delete componentToPaste.isCut delete componentToPaste.isCut
delete componentToPaste.screenId
// Make new component unique if copying // Make new component unique if copying
if (!cut) { if (!cut) {
@ -712,6 +718,19 @@ export class ComponentStore extends BudiStore {
} }
newComponentId = componentToPaste._id newComponentId = componentToPaste._id
// Strip grid position metadata if pasting into a new screen, but keep
// alignment metadata
if (sourceScreenId && sourceScreenId !== screen._id) {
for (let style of Object.keys(componentToPaste._styles?.normal || {})) {
if (
style.startsWith("--grid") &&
(style.endsWith("-start") || style.endsWith("-end"))
) {
delete componentToPaste._styles.normal[style]
}
}
}
// Delete old component if cutting // Delete old component if cutting
if (cut) { if (cut) {
const parent = findComponentParent(screen.props, originalId) const parent = findComponentParent(screen.props, originalId)
@ -754,12 +773,13 @@ export class ComponentStore extends BudiStore {
await screenStore.patch(patch, targetScreenId) await screenStore.patch(patch, targetScreenId)
// Select the new component // Select the new component
this.update(state => { if (selectComponent) {
state.selectedScreenId = targetScreenId this.update(state => {
state.selectedComponentId = newComponentId state.selectedScreenId = targetScreenId
state.selectedComponentId = newComponentId
return state return state
}) })
}
componentTreeNodesStore.makeNodeVisible(newComponentId) componentTreeNodesStore.makeNodeVisible(newComponentId)
} }

View File

@ -1,6 +1,6 @@
import { v4 } from "uuid" import { v4 } from "uuid"
import { Component } from "templates/Component" import { Component } from "templates/Component"
import { Screen } from "templates/Screen" import { Screen } from "templates/screenTemplating/Screen"
import { get } from "svelte/store" import { get } from "svelte/store"
import { import {
BUDIBASE_INTERNAL_DB_ID, BUDIBASE_INTERNAL_DB_ID,

View File

@ -2,11 +2,11 @@ import { Helpers } from "@budibase/bbui"
import { BaseStructure } from "./BaseStructure" import { BaseStructure } from "./BaseStructure"
export class Component extends BaseStructure { export class Component extends BaseStructure {
constructor(name) { constructor(name, _id = Helpers.uuid()) {
super(false) super(false)
this._children = [] this._children = []
this._json = { this._json = {
_id: Helpers.uuid(), _id,
_component: name, _component: name,
_styles: { _styles: {
normal: {}, normal: {},
@ -50,4 +50,32 @@ export class Component extends BaseStructure {
this._json.text = text this._json.text = text
return this return this
} }
getId() {
return this._json._id
}
gridDesktopColSpan(start, end) {
this._json._styles.normal["--grid-desktop-col-start"] = start
this._json._styles.normal["--grid-desktop-col-end"] = end
return this
}
gridDesktopRowSpan(start, end) {
this._json._styles.normal["--grid-desktop-row-start"] = start
this._json._styles.normal["--grid-desktop-row-end"] = end
return this
}
gridMobileColSpan(start, end) {
this._json._styles.normal["--grid-mobile-col-start"] = start
this._json._styles.normal["--grid-mobile-col-end"] = end
return this
}
gridMobileRowSpan(start, end) {
this._json._styles.normal["--grid-mobile-row-start"] = start
this._json._styles.normal["--grid-mobile-row-end"] = end
return this
}
} }

View File

@ -1,7 +0,0 @@
import { Screen } from "./Screen"
const blankScreen = route => {
return new Screen().instanceName("New Screen").route(route).json()
}
export default blankScreen

View File

@ -1,49 +0,0 @@
import { Screen } from "./Screen"
import { Component } from "./Component"
import sanitizeUrl from "helpers/sanitizeUrl"
export const FORM_TEMPLATE = "FORM_TEMPLATE"
export const formUrl = (tableOrView, actionType) => {
if (actionType === "Create") {
return sanitizeUrl(`/${tableOrView.name}/new`)
} else if (actionType === "Update") {
return sanitizeUrl(`/${tableOrView.name}/edit/:id`)
} else if (actionType === "View") {
return sanitizeUrl(`/${tableOrView.name}/view/:id`)
}
}
export const getRole = (permissions, actionType) => {
if (actionType === "View") {
return permissions.read
}
return permissions.write
}
const generateMultistepFormBlock = (tableOrView, actionType) => {
const multistepFormBlock = new Component(
"@budibase/standard-components/multistepformblock"
)
multistepFormBlock
.customProps({
actionType,
dataSource: tableOrView.clientData,
steps: [{}],
rowId: actionType === "new" ? undefined : `{{ url.id }}`,
})
.instanceName(`${tableOrView.name} - Multistep Form block`)
return multistepFormBlock
}
const createScreen = (tableOrView, actionType, permissions) => {
return new Screen()
.route(formUrl(tableOrView, actionType))
.instanceName(`${tableOrView.name} - Form`)
.role(getRole(permissions, actionType))
.autoTableId(tableOrView.id)
.addChild(generateMultistepFormBlock(tableOrView, actionType))
.json()
}
export default createScreen

View File

@ -1,30 +0,0 @@
import sanitizeUrl from "helpers/sanitizeUrl"
import { Screen } from "./Screen"
import { Component } from "./Component"
const gridUrl = tableOrView => sanitizeUrl(`/${tableOrView.name}`)
const createScreen = (tableOrView, permissions) => {
const heading = new Component("@budibase/standard-components/heading")
.instanceName("Table heading")
.customProps({
text: tableOrView.name,
})
const gridBlock = new Component("@budibase/standard-components/gridblock")
.instanceName(`${tableOrView.name} - Table`)
.customProps({
table: tableOrView.clientData,
})
return new Screen()
.route(gridUrl(tableOrView))
.instanceName(`${tableOrView.name} - List`)
.role(permissions.write)
.autoTableId(tableOrView.id)
.addChild(heading)
.addChild(gridBlock)
.json()
}
export default createScreen

View File

@ -1,4 +1,4 @@
import { BaseStructure } from "./BaseStructure" import { BaseStructure } from "../BaseStructure"
import { Helpers } from "@budibase/bbui" import { Helpers } from "@budibase/bbui"
export class Screen extends BaseStructure { export class Screen extends BaseStructure {
@ -18,6 +18,7 @@ export class Screen extends BaseStructure {
}, },
_children: [], _children: [],
_instanceName: "", _instanceName: "",
layout: "flex",
direction: "column", direction: "column",
hAlign: "stretch", hAlign: "stretch",
vAlign: "top", vAlign: "top",

View File

@ -0,0 +1,25 @@
import { Screen } from "./Screen"
import { capitalise } from "helpers"
import getValidRoute from "./getValidRoute"
import { Roles } from "constants/backend"
const blank = ({ route, screens }) => {
const validRoute = getValidRoute(screens, route, Roles.BASIC)
const template = new Screen()
.instanceName("Blank screen")
.customProps({ layout: "grid" })
.role(Roles.BASIC)
.route(validRoute)
.json()
return [
{
data: template,
navigationLinkLabel:
validRoute === "/" ? null : capitalise(validRoute.split("/")[1]),
},
]
}
export default blank

View File

@ -0,0 +1,67 @@
import { Screen } from "./Screen"
import { Component } from "../Component"
import getValidRoute from "./getValidRoute"
export const getTypeSpecificRoute = (tableOrView, type) => {
if (type === "create") {
return `/${tableOrView.name}/new`
} else if (type === "update") {
return `/${tableOrView.name}/edit/:id`
} else if (type === "view") {
return `/${tableOrView.name}/view/:id`
}
}
const getRole = (permissions, type) => {
if (type === "view") {
return permissions.read
}
return permissions.write
}
const getActionType = type => {
if (type === "create") {
return "Create"
}
if (type === "update") {
return "Update"
}
if (type === "view") {
return "View"
}
}
const form = ({ tableOrView, type, permissions, screens }) => {
const typeSpecificRoute = getTypeSpecificRoute(tableOrView, type)
const role = getRole(permissions, type)
const multistepFormBlock = new Component(
"@budibase/standard-components/multistepformblock"
)
.customProps({
actionType: getActionType(type),
dataSource: tableOrView.tableSelectFormat,
steps: [{}],
rowId: type === "new" ? undefined : `{{ url.id }}`,
})
.instanceName(`${tableOrView.name} - Multistep Form block`)
const template = new Screen()
.route(getValidRoute(screens, typeSpecificRoute, role))
.instanceName(`${tableOrView.name} - Form`)
.role(role)
.autoTableId(tableOrView.id)
.addChild(multistepFormBlock)
.json()
return [
{
data: template,
navigationLinkLabel:
type === "create" ? `Create ${tableOrView.name}` : null,
},
]
}
export default form

View File

@ -0,0 +1,35 @@
import sanitizeUrl from "helpers/sanitizeUrl"
const arbitraryMax = 10000
const isScreenUrlValid = (screens, url, role) => {
return !screens.some(
screen => screen.routing?.route === url && screen.routing?.roleId === role
)
}
const getValidScreenUrl = (screens, url, role) => {
const [firstPathSegment = "", ...restPathSegments] = url
.split("/")
.filter(segment => segment !== "")
const restOfPath =
restPathSegments.length > 0 ? `/${restPathSegments.join("/")}` : ""
const naiveUrl = sanitizeUrl(`/${firstPathSegment}${restOfPath}`)
if (isScreenUrlValid(screens, naiveUrl, role)) {
return naiveUrl
}
for (let suffix = 2; suffix < arbitraryMax; suffix++) {
const suffixedUrl = sanitizeUrl(
`/${firstPathSegment}-${suffix}${restOfPath}`
)
if (isScreenUrlValid(screens, suffixedUrl, role)) {
return suffixedUrl
}
}
}
export default getValidScreenUrl

View File

@ -0,0 +1,3 @@
export { default as blank } from "./blank"
export { default as form } from "./form"
export { default as table } from "./table"

View File

@ -0,0 +1,25 @@
import inline from "./inline"
import modal from "./modal"
import sidePanel from "./sidePanel"
import newScreen from "./newScreen"
const createScreen = ({ tableOrView, type, permissions, screens }) => {
if (type === "inline") {
return inline({ tableOrView, permissions, screens })
}
if (type === "modal") {
return modal({ tableOrView, permissions, screens })
}
if (type === "sidePanel") {
return sidePanel({ tableOrView, permissions, screens })
}
if (type === "newScreen") {
return newScreen({ tableOrView, permissions, screens })
}
throw new Error(`Unrecognized table type ${type}`)
}
export default createScreen

View File

@ -0,0 +1,41 @@
import { Screen } from "../Screen"
import { Component } from "../../Component"
import { capitalise } from "helpers"
import getValidRoute from "../getValidRoute"
const inline = ({ tableOrView, permissions, screens }) => {
const heading = new Component("@budibase/standard-components/heading")
.instanceName("Table heading")
.customProps({
text: tableOrView.name,
})
.gridDesktopColSpan(1, 13)
.gridDesktopRowSpan(1, 3)
const tableBlock = new Component("@budibase/standard-components/gridblock")
.instanceName(`${tableOrView.name} - Table`)
.customProps({
table: tableOrView.datasourceSelectFormat,
})
.gridDesktopColSpan(1, 13)
.gridDesktopRowSpan(3, 21)
const screenTemplate = new Screen()
.route(getValidRoute(screens, tableOrView.name, permissions.write))
.instanceName(`${tableOrView.name} - List`)
.customProps({ layout: "grid" })
.role(permissions.write)
.autoTableId(tableOrView.id)
.addChild(heading)
.addChild(tableBlock)
.json()
return [
{
data: screenTemplate,
navigationLinkLabel: capitalise(tableOrView.name),
},
]
}
export default inline

View File

@ -0,0 +1,157 @@
import { Screen } from "../Screen"
import { Component } from "../../Component"
import { generate } from "shortid"
import { makePropSafe as safe } from "@budibase/string-templates"
import { Utils } from "@budibase/frontend-core"
import { capitalise } from "helpers"
import getValidRoute from "../getValidRoute"
const modal = ({ tableOrView, permissions, screens }) => {
/*
Create Row
*/
const createRowModal = new Component("@budibase/standard-components/modal")
.instanceName("New row modal")
.customProps({
size: "large",
})
const buttonGroup = new Component("@budibase/standard-components/buttongroup")
const createButton = new Component("@budibase/standard-components/button")
createButton.customProps({
onClick: [
{
id: 0,
"##eventHandlerType": "Open Modal",
parameters: {
id: createRowModal._json._id,
},
},
],
text: "Create row",
type: "cta",
})
buttonGroup
.instanceName(`${tableOrView.name} - Create`)
.customProps({
hAlign: "right",
buttons: [createButton.json()],
})
.gridDesktopColSpan(7, 13)
.gridDesktopRowSpan(1, 3)
const heading = new Component("@budibase/standard-components/heading")
.instanceName("Table heading")
.customProps({
text: tableOrView.name,
})
.gridDesktopColSpan(1, 7)
.gridDesktopRowSpan(1, 3)
const createFormBlock = new Component(
"@budibase/standard-components/formblock"
)
createFormBlock.instanceName("Create row form block").customProps({
dataSource: tableOrView.tableSelectFormat,
labelPosition: "left",
buttonPosition: "top",
actionType: "Create",
title: "Create row",
buttons: Utils.buildFormBlockButtonConfig({
_id: createFormBlock._json._id,
showDeleteButton: false,
showSaveButton: true,
saveButtonLabel: "Save",
actionType: "Create",
dataSource: tableOrView.tableSelectFormat,
}),
})
createRowModal.addChild(createFormBlock)
/*
Edit Row
*/
const stateKey = `ID_${generate()}`
const detailsModal = new Component("@budibase/standard-components/modal")
.instanceName("Edit row modal")
.customProps({
size: "large",
})
const editFormBlock = new Component("@budibase/standard-components/formblock")
editFormBlock.instanceName("Edit row form block").customProps({
dataSource: tableOrView.tableSelectFormat,
labelPosition: "left",
buttonPosition: "top",
actionType: "Update",
title: "Edit",
rowId: `{{ ${safe("state")}.${safe(stateKey)} }}`,
buttons: Utils.buildFormBlockButtonConfig({
_id: editFormBlock._json._id,
showDeleteButton: true,
showSaveButton: true,
saveButtonLabel: "Save",
deleteButtonLabel: "Delete",
actionType: "Update",
dataSource: tableOrView.tableSelectFormat,
}),
})
detailsModal.addChild(editFormBlock)
const tableBlock = new Component("@budibase/standard-components/gridblock")
tableBlock
.customProps({
table: tableOrView.datasourceSelectFormat,
allowAddRows: false,
allowEditRows: false,
allowDeleteRows: false,
onRowClick: [
{
id: 0,
"##eventHandlerType": "Update State",
parameters: {
key: stateKey,
type: "set",
persist: false,
value: `{{ ${safe("eventContext")}.${safe("row")}._id }}`,
},
},
{
id: 1,
"##eventHandlerType": "Open Modal",
parameters: {
id: detailsModal._json._id,
},
},
],
})
.instanceName(`${tableOrView.name} - Table`)
.gridDesktopColSpan(1, 13)
.gridDesktopRowSpan(3, 21)
const template = new Screen()
.route(getValidRoute(screens, tableOrView.name, permissions.write))
.instanceName(`${tableOrView.name} - List and details`)
.customProps({ layout: "grid" })
.role(permissions.write)
.autoTableId(tableOrView.id)
.addChild(heading)
.addChild(buttonGroup)
.addChild(tableBlock)
.addChild(createRowModal)
.addChild(detailsModal)
.json()
return [
{
data: template,
navigationLinkLabel: capitalise(tableOrView.name),
},
]
}
export default modal

View File

@ -0,0 +1,334 @@
import { Screen } from "../Screen"
import { Component } from "../../Component"
import { capitalise } from "helpers"
import { makePropSafe as safe } from "@budibase/string-templates"
import getValidRoute from "../getValidRoute"
import { Helpers } from "@budibase/bbui"
const getTableScreenTemplate = ({
route,
updateScreenRoute,
createScreenRoute,
tableOrView,
permissions,
gridLayout,
}) => {
const buttonGroup = new Component("@budibase/standard-components/buttongroup")
const createButton = new Component("@budibase/standard-components/button")
createButton.customProps({
onClick: [
{
"##eventHandlerType": "Navigate To",
parameters: {
type: "url",
url: createScreenRoute,
},
},
],
text: "Create row",
type: "cta",
})
buttonGroup
.instanceName(`${tableOrView.name} - Create`)
.customProps({
hAlign: "right",
buttons: [createButton.json()],
})
.gridDesktopColSpan(7, 13)
.gridDesktopRowSpan(1, 3)
const heading = new Component("@budibase/standard-components/heading")
.instanceName("Table heading")
.customProps({
text: tableOrView.name,
})
.gridDesktopColSpan(1, 7)
.gridDesktopRowSpan(1, 3)
const updateScreenRouteSegments = updateScreenRoute.split(":id")
if (updateScreenRouteSegments.length !== 2) {
throw new Error("Provided edit screen route is invalid")
}
const tableBlock = new Component("@budibase/standard-components/gridblock")
.instanceName(`${tableOrView.name} - Table`)
.customProps({
table: tableOrView.datasourceSelectFormat,
allowAddRows: false,
allowEditRows: false,
allowDeleteRows: false,
onRowClick: [
{
id: 0,
"##eventHandlerType": "Navigate To",
parameters: {
type: "url",
url: `${updateScreenRouteSegments[0]}{{ ${safe(
"eventContext"
)}.${safe("row")}._id }}${updateScreenRouteSegments[1]}`,
},
},
],
})
.gridDesktopColSpan(1, 13)
.gridDesktopRowSpan(3, 21)
const template = new Screen()
.route(route)
.instanceName(`${tableOrView.name} - List`)
.customProps({ layout: gridLayout ? "grid" : "flex" })
.role(permissions.write)
.autoTableId(tableOrView.id)
.addChild(heading)
.addChild(buttonGroup)
.addChild(tableBlock)
.json()
return {
data: template,
navigationLinkLabel: capitalise(tableOrView.name),
}
}
const getUpdateScreenTemplate = ({
route,
tableScreenRoute,
tableOrView,
permissions,
}) => {
const formBlockId = Helpers.uuid()
const formId = `${formBlockId}-form`
const repeaterId = `${formBlockId}-repeater`
const backButton = new Component("@budibase/standard-components/button")
.instanceName("Back button")
.customProps({
type: "primary",
icon: "ri-arrow-go-back-fill",
text: "Back",
onClick: [
{
"##eventHandlerType": "Navigate To",
parameters: {
type: "url",
url: tableScreenRoute,
},
},
],
})
const deleteButton = new Component("@budibase/standard-components/button")
.instanceName("Delete button")
.customProps({
type: "secondary",
text: "Delete",
onClick: [
{
"##eventHandlerType": "Delete Row",
parameters: {
confirm: true,
tableId: tableOrView.id,
rowId: `{{ ${safe(repeaterId)}.${safe("_id")} }}`,
revId: `{{ ${safe(repeaterId)}.${safe("_rev")} }}`,
},
},
{
"##eventHandlerType": "Navigate To",
parameters: {
type: "url",
url: tableScreenRoute,
},
},
],
})
const saveButton = new Component("@budibase/standard-components/button")
.instanceName("Save button")
.customProps({
type: "cta",
text: "Save",
onClick: [
{
"##eventHandlerType": "Validate Form",
parameters: {
componentId: formId,
},
},
{
"##eventHandlerType": "Save Row",
parameters: {
providerId: formId,
tableId: tableOrView.id,
},
},
{
"##eventHandlerType": "Navigate To",
parameters: {
type: "url",
url: tableScreenRoute,
},
},
],
})
const updateFormBlock = new Component(
"@budibase/standard-components/formblock",
formBlockId
)
.instanceName("Update row form block")
.customProps({
dataSource: tableOrView.tableSelectFormat,
labelPosition: "left",
buttonPosition: "top",
actionType: "Update",
title: `Update ${tableOrView.name} row`,
buttons: [backButton.json(), saveButton.json(), deleteButton.json()],
})
const template = new Screen()
.route(route)
.instanceName(`Update row`)
.role(permissions.write)
.autoTableId(tableOrView.id)
.addChild(updateFormBlock)
.json()
return {
data: template,
navigationLinkLabel: null,
}
}
const getCreateScreenTemplate = ({
route,
tableScreenRoute,
tableOrView,
permissions,
}) => {
const formBlockId = Helpers.uuid()
const formId = `${formBlockId}-form`
const backButton = new Component("@budibase/standard-components/button")
.instanceName("Back button")
.customProps({
type: "primary",
icon: "ri-arrow-go-back-fill",
text: "Back",
onClick: [
{
"##eventHandlerType": "Navigate To",
parameters: {
type: "url",
url: tableScreenRoute,
},
},
],
})
const saveButton = new Component("@budibase/standard-components/button")
.instanceName("Save button")
.customProps({
type: "cta",
text: "Save",
onClick: [
{
"##eventHandlerType": "Validate Form",
parameters: {
componentId: formId,
},
},
{
"##eventHandlerType": "Save Row",
parameters: {
providerId: formId,
tableId: tableOrView.id,
},
},
{
"##eventHandlerType": "Navigate To",
parameters: {
type: "url",
url: tableScreenRoute,
},
},
],
})
const createFormBlock = new Component(
"@budibase/standard-components/formblock",
formBlockId
)
.instanceName("Create row form block")
.customProps({
dataSource: tableOrView.tableSelectFormat,
labelPosition: "left",
buttonPosition: "top",
actionType: "Create",
title: `Create ${tableOrView.name} row`,
buttons: [backButton.json(), saveButton.json()],
})
const template = new Screen()
.route(route)
.instanceName("Create row")
.role(permissions.write)
.autoTableId(tableOrView.id)
.addChild(createFormBlock)
.json()
return {
data: template,
navigationLinkLabel: null,
}
}
const newScreen = ({ tableOrView, permissions, screens }) => {
const tableScreenRoute = getValidRoute(
screens,
tableOrView.name,
permissions.write
)
const updateScreenRoute = getValidRoute(
screens,
`/${tableOrView.name}/edit/:id`,
permissions.write
)
const createScreenRoute = getValidRoute(
screens,
`/${tableOrView.name}/new`,
permissions.write
)
const tableScreenTemplate = getTableScreenTemplate({
route: tableScreenRoute,
updateScreenRoute,
createScreenRoute,
permissions,
tableOrView,
gridLayout: true,
})
const updateScreenTemplate = getUpdateScreenTemplate({
route: updateScreenRoute,
tableScreenRoute,
tableOrView,
permissions,
gridLayout: false,
})
const createScreenTemplate = getCreateScreenTemplate({
route: createScreenRoute,
tableScreenRoute,
tableOrView,
permissions,
gridLayout: false,
})
return [tableScreenTemplate, updateScreenTemplate, createScreenTemplate]
}
export default newScreen

View File

@ -1,13 +1,12 @@
import sanitizeUrl from "helpers/sanitizeUrl" import { Screen } from "../Screen"
import { Screen } from "./Screen" import { Component } from "../../Component"
import { Component } from "./Component"
import { generate } from "shortid" import { generate } from "shortid"
import { makePropSafe as safe } from "@budibase/string-templates" import { makePropSafe as safe } from "@budibase/string-templates"
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import { capitalise } from "helpers"
import getValidRoute from "../getValidRoute"
const gridDetailsUrl = tableOrView => sanitizeUrl(`/${tableOrView.name}`) const sidePanel = ({ tableOrView, permissions, screens }) => {
const createScreen = (tableOrView, permissions) => {
/* /*
Create Row Create Row
*/ */
@ -32,32 +31,28 @@ const createScreen = (tableOrView, permissions) => {
type: "cta", type: "cta",
}) })
buttonGroup.instanceName(`${tableOrView.name} - Create`).customProps({ buttonGroup
hAlign: "right", .instanceName(`${tableOrView.name} - Create`)
buttons: [createButton.json()],
})
const gridHeader = new Component("@budibase/standard-components/container")
.instanceName("Heading container")
.customProps({ .customProps({
direction: "row", hAlign: "right",
hAlign: "stretch", buttons: [createButton.json()],
}) })
.gridDesktopColSpan(7, 13)
.gridDesktopRowSpan(1, 3)
const heading = new Component("@budibase/standard-components/heading") const heading = new Component("@budibase/standard-components/heading")
.instanceName("Table heading") .instanceName("Table heading")
.customProps({ .customProps({
text: tableOrView.name, text: tableOrView.name,
}) })
.gridDesktopColSpan(1, 7)
gridHeader.addChild(heading) .gridDesktopRowSpan(1, 3)
gridHeader.addChild(buttonGroup)
const createFormBlock = new Component( const createFormBlock = new Component(
"@budibase/standard-components/formblock" "@budibase/standard-components/formblock"
) )
createFormBlock.instanceName("Create row form block").customProps({ createFormBlock.instanceName("Create row form block").customProps({
dataSource: tableOrView.clientData, dataSource: tableOrView.tableSelectFormat,
labelPosition: "left", labelPosition: "left",
buttonPosition: "top", buttonPosition: "top",
actionType: "Create", actionType: "Create",
@ -68,7 +63,7 @@ const createScreen = (tableOrView, permissions) => {
showSaveButton: true, showSaveButton: true,
saveButtonLabel: "Save", saveButtonLabel: "Save",
actionType: "Create", actionType: "Create",
dataSource: tableOrView.clientData, dataSource: tableOrView.tableSelectFormat,
}), }),
}) })
@ -84,7 +79,7 @@ const createScreen = (tableOrView, permissions) => {
const editFormBlock = new Component("@budibase/standard-components/formblock") const editFormBlock = new Component("@budibase/standard-components/formblock")
editFormBlock.instanceName("Edit row form block").customProps({ editFormBlock.instanceName("Edit row form block").customProps({
dataSource: tableOrView.clientData, dataSource: tableOrView.tableSelectFormat,
labelPosition: "left", labelPosition: "left",
buttonPosition: "top", buttonPosition: "top",
actionType: "Update", actionType: "Update",
@ -97,16 +92,16 @@ const createScreen = (tableOrView, permissions) => {
saveButtonLabel: "Save", saveButtonLabel: "Save",
deleteButtonLabel: "Delete", deleteButtonLabel: "Delete",
actionType: "Update", actionType: "Update",
dataSource: tableOrView.clientData, dataSource: tableOrView.tableSelectFormat,
}), }),
}) })
detailsSidePanel.addChild(editFormBlock) detailsSidePanel.addChild(editFormBlock)
const gridBlock = new Component("@budibase/standard-components/gridblock") const tableBlock = new Component("@budibase/standard-components/gridblock")
gridBlock tableBlock
.customProps({ .customProps({
table: tableOrView.clientData, table: tableOrView.datasourceSelectFormat,
allowAddRows: false, allowAddRows: false,
allowEditRows: false, allowEditRows: false,
allowDeleteRows: false, allowDeleteRows: false,
@ -131,17 +126,28 @@ const createScreen = (tableOrView, permissions) => {
], ],
}) })
.instanceName(`${tableOrView.name} - Table`) .instanceName(`${tableOrView.name} - Table`)
.gridDesktopColSpan(1, 13)
.gridDesktopRowSpan(3, 21)
return new Screen() const template = new Screen()
.route(gridDetailsUrl(tableOrView)) .route(getValidRoute(screens, tableOrView.name, permissions.write))
.instanceName(`${tableOrView.name} - List and details`) .instanceName(`${tableOrView.name} - List and details`)
.customProps({ layout: "grid" })
.role(permissions.write) .role(permissions.write)
.autoTableId(tableOrView.resourceId) .autoTableId(tableOrView.id)
.addChild(gridHeader) .addChild(heading)
.addChild(gridBlock) .addChild(buttonGroup)
.addChild(tableBlock)
.addChild(createRowSidePanel) .addChild(createRowSidePanel)
.addChild(detailsSidePanel) .addChild(detailsSidePanel)
.json() .json()
return [
{
data: template,
navigationLinkLabel: capitalise(tableOrView.name),
},
]
} }
export default createScreen export default sidePanel

View File

@ -18,14 +18,37 @@
"numberLike": { "numberLike": {
"supported": ["number", "boolean"], "supported": ["number", "boolean"],
"partialSupport": [ "partialSupport": [
{ "type": "longform", "message": "stringAsNumber" }, {
{ "type": "string", "message": "stringAsNumber" }, "type": "longform",
{ "type": "bigint", "message": "stringAsNumber" }, "message": "stringAsNumber"
{ "type": "options", "message": "stringAsNumber" }, },
{ "type": "formula", "message": "stringAsNumber" }, {
{ "type": "datetime", "message": "dateAsNumber" } "type": "string",
"message": "stringAsNumber"
},
{
"type": "bigint",
"message": "stringAsNumber"
},
{
"type": "options",
"message": "stringAsNumber"
},
{
"type": "formula",
"message": "stringAsNumber"
},
{
"type": "datetime",
"message": "dateAsNumber"
}
], ],
"unsupported": [{ "type": "json", "message": "jsonPrimitivesOnly" }] "unsupported": [
{
"type": "json",
"message": "jsonPrimitivesOnly"
}
]
}, },
"stringLike": { "stringLike": {
"supported": [ "supported": [
@ -37,19 +60,47 @@
"boolean", "boolean",
"datetime" "datetime"
], ],
"unsupported": [{ "type": "json", "message": "jsonPrimitivesOnly" }] "unsupported": [
{
"type": "json",
"message": "jsonPrimitivesOnly"
}
]
}, },
"datetimeLike": { "datetimeLike": {
"supported": ["datetime"], "supported": ["datetime"],
"partialSupport": [ "partialSupport": [
{ "type": "longform", "message": "stringAsDate" }, {
{ "type": "string", "message": "stringAsDate" }, "type": "longform",
{ "type": "options", "message": "stringAsDate" }, "message": "stringAsDate"
{ "type": "formula", "message": "stringAsDate" }, },
{ "type": "bigint", "message": "stringAsDate" }, {
{ "type": "number", "message": "numberAsDate" } "type": "string",
"message": "stringAsDate"
},
{
"type": "options",
"message": "stringAsDate"
},
{
"type": "formula",
"message": "stringAsDate"
},
{
"type": "bigint",
"message": "stringAsDate"
},
{
"type": "number",
"message": "numberAsDate"
}
], ],
"unsupported": [{ "type": "json", "message": "jsonPrimitivesOnly" }] "unsupported": [
{
"type": "json",
"message": "jsonPrimitivesOnly"
}
]
} }
}, },
"layout": { "layout": {
@ -114,11 +165,37 @@
"icon": "Selection", "icon": "Selection",
"hasChildren": true, "hasChildren": true,
"size": { "size": {
"width": 400, "width": 500,
"height": 200 "height": 200
}, },
"grid": {
"hAlign": "stretch",
"vAlign": "stretch"
},
"styles": ["padding", "size", "background", "border", "shadow"], "styles": ["padding", "size", "background", "border", "shadow"],
"settings": [ "settings": [
{
"type": "select",
"label": "Layout",
"key": "layout",
"showInBar": true,
"barStyle": "buttons",
"options": [
{
"label": "Flex",
"value": "flex",
"barIcon": "ModernGridView",
"barTitle": "Flex layout"
},
{
"label": "Grid",
"value": "grid",
"barIcon": "ViewGrid",
"barTitle": "Grid layout"
}
],
"defaultValue": "grid"
},
{ {
"type": "select", "type": "select",
"label": "Direction", "label": "Direction",
@ -139,7 +216,12 @@
"barTitle": "Row layout" "barTitle": "Row layout"
} }
], ],
"defaultValue": "column" "defaultValue": "column",
"dependsOn": {
"setting": "layout",
"value": "grid",
"invert": true
}
}, },
{ {
"type": "select", "type": "select",
@ -173,7 +255,12 @@
"barTitle": "Align stretched horizontally" "barTitle": "Align stretched horizontally"
} }
], ],
"defaultValue": "stretch" "defaultValue": "stretch",
"dependsOn": {
"setting": "layout",
"value": "grid",
"invert": true
}
}, },
{ {
"type": "select", "type": "select",
@ -207,7 +294,12 @@
"barTitle": "Align stretched vertically" "barTitle": "Align stretched vertically"
} }
], ],
"defaultValue": "top" "defaultValue": "top",
"dependsOn": {
"setting": "layout",
"value": "grid",
"invert": true
}
}, },
{ {
"type": "select", "type": "select",
@ -229,7 +321,12 @@
"barTitle": "Grow container" "barTitle": "Grow container"
} }
], ],
"defaultValue": "shrink" "defaultValue": "shrink",
"dependsOn": {
"setting": "layout",
"value": "grid",
"invert": true
}
}, },
{ {
"type": "select", "type": "select",
@ -255,7 +352,12 @@
"value": "L" "value": "L"
} }
], ],
"defaultValue": "M" "defaultValue": "M",
"dependsOn": {
"setting": "layout",
"value": "grid",
"invert": true
}
}, },
{ {
"type": "boolean", "type": "boolean",
@ -263,7 +365,12 @@
"key": "wrap", "key": "wrap",
"showInBar": true, "showInBar": true,
"barIcon": "ModernGridView", "barIcon": "ModernGridView",
"barTitle": "Wrap" "barTitle": "Wrap",
"dependsOn": {
"setting": "layout",
"value": "grid",
"invert": true
}
}, },
{ {
"type": "event", "type": "event",
@ -280,8 +387,12 @@
"illegalChildren": ["section"], "illegalChildren": ["section"],
"showEmptyState": false, "showEmptyState": false,
"size": { "size": {
"width": 400, "width": 600,
"height": 100 "height": 200
},
"grid": {
"hAlign": "stretch",
"vAlign": "stretch"
}, },
"settings": [ "settings": [
{ {
@ -302,6 +413,14 @@
"name": "Button group", "name": "Button group",
"icon": "Button", "icon": "Button",
"hasChildren": false, "hasChildren": false,
"size": {
"width": 200,
"height": 60
},
"grid": {
"hAlign": "stretch",
"vAlign": "stretch"
},
"settings": [ "settings": [
{ {
"section": true, "section": true,
@ -484,9 +603,13 @@
"icon": "Button", "icon": "Button",
"editable": true, "editable": true,
"size": { "size": {
"width": 105, "width": 120,
"height": 32 "height": 32
}, },
"grid": {
"hAlign": "center",
"vAlign": "center"
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -648,8 +771,12 @@
"illegalChildren": ["section"], "illegalChildren": ["section"],
"hasChildren": true, "hasChildren": true,
"size": { "size": {
"width": 400, "width": 500,
"height": 100 "height": 200
},
"grid": {
"hAlign": "stretch",
"vAlign": "stretch"
}, },
"settings": [ "settings": [
{ {
@ -1143,6 +1270,10 @@
"width": 100, "width": 100,
"height": 25 "height": 25
}, },
"grid": {
"hAlign": "center",
"vAlign": "center"
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -1220,6 +1351,7 @@
"icon": "Images", "icon": "Images",
"hasChildren": true, "hasChildren": true,
"styles": ["size"], "styles": ["size"],
"showEmptyState": false,
"size": { "size": {
"width": 400, "width": 400,
"height": 300 "height": 300
@ -1285,6 +1417,10 @@
"width": 25, "width": 25,
"height": 25 "height": 25
}, },
"grid": {
"hAlign": "center",
"vAlign": "center"
},
"settings": [ "settings": [
{ {
"type": "icon", "type": "icon",
@ -1598,6 +1734,10 @@
"width": 260, "width": 260,
"height": 143 "height": 143
}, },
"grid": {
"hAlign": "center",
"vAlign": "center"
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -1631,6 +1771,10 @@
"width": 400, "width": 400,
"height": 100 "height": 100
}, },
"grid": {
"hAlign": "stretch",
"vAlign": "stretch"
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -1647,7 +1791,11 @@
"icon": "GraphBarVertical", "icon": "GraphBarVertical",
"size": { "size": {
"width": 600, "width": 600,
"height": 400 "height": 420
},
"grid": {
"hAlign": "stretch",
"vAlign": "center"
}, },
"settings": [ "settings": [
{ {
@ -1816,7 +1964,11 @@
"icon": "GraphTrend", "icon": "GraphTrend",
"size": { "size": {
"width": 600, "width": 600,
"height": 400 "height": 420
},
"grid": {
"hAlign": "stretch",
"vAlign": "center"
}, },
"settings": [ "settings": [
{ {
@ -1980,7 +2132,11 @@
"icon": "GraphAreaStacked", "icon": "GraphAreaStacked",
"size": { "size": {
"width": 600, "width": 600,
"height": 400 "height": 420
},
"grid": {
"hAlign": "stretch",
"vAlign": "center"
}, },
"settings": [ "settings": [
{ {
@ -2156,7 +2312,11 @@
"icon": "GraphPie", "icon": "GraphPie",
"size": { "size": {
"width": 600, "width": 600,
"height": 400 "height": 420
},
"grid": {
"hAlign": "stretch",
"vAlign": "center"
}, },
"settings": [ "settings": [
{ {
@ -2296,7 +2456,11 @@
"icon": "GraphDonut", "icon": "GraphDonut",
"size": { "size": {
"width": 600, "width": 600,
"height": 400 "height": 420
},
"grid": {
"hAlign": "stretch",
"vAlign": "center"
}, },
"settings": [ "settings": [
{ {
@ -2436,7 +2600,11 @@
"icon": "GraphBarVerticalStacked", "icon": "GraphBarVerticalStacked",
"size": { "size": {
"width": 600, "width": 600,
"height": 400 "height": 420
},
"grid": {
"hAlign": "stretch",
"vAlign": "center"
}, },
"settings": [ "settings": [
{ {
@ -2553,7 +2721,11 @@
"icon": "Histogram", "icon": "Histogram",
"size": { "size": {
"width": 600, "width": 600,
"height": 400 "height": 420
},
"grid": {
"hAlign": "stretch",
"vAlign": "center"
}, },
"settings": [ "settings": [
{ {
@ -2704,11 +2876,15 @@
"UpdateFieldValue", "UpdateFieldValue",
"ScrollTo" "ScrollTo"
], ],
"styles": ["size"], "styles": ["padding", "size", "background", "border", "shadow"],
"size": { "size": {
"width": 400, "width": 400,
"height": 400 "height": 400
}, },
"grid": {
"hAlign": "stretch",
"vAlign": "stretch"
},
"settings": [ "settings": [
{ {
"type": "select", "type": "select",
@ -2864,7 +3040,7 @@
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
"height": 32 "height": 60
}, },
"settings": [ "settings": [
{ {
@ -2895,7 +3071,7 @@
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
"height": 32 "height": 60
}, },
"settings": [ "settings": [
{ {
@ -3031,7 +3207,7 @@
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
"height": 50 "height": 60
}, },
"settings": [ "settings": [
{ {
@ -3133,7 +3309,7 @@
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
"height": 50 "height": 60
}, },
"settings": [ "settings": [
{ {
@ -3219,7 +3395,7 @@
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
"height": 50 "height": 60
}, },
"settings": [ "settings": [
{ {
@ -3305,7 +3481,7 @@
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
"height": 50 "height": 60
}, },
"settings": [ "settings": [
{ {
@ -3519,7 +3695,7 @@
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
"height": 50 "height": 60
}, },
"settings": [ "settings": [
{ {
@ -3725,8 +3901,8 @@
"editable": true, "editable": true,
"requiredAncestors": ["form"], "requiredAncestors": ["form"],
"size": { "size": {
"width": 20, "width": 400,
"height": 20 "height": 60
}, },
"settings": [ "settings": [
{ {
@ -3852,7 +4028,7 @@
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
"height": 150 "height": 100
}, },
"settings": [ "settings": [
{ {
@ -3976,7 +4152,7 @@
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
"height": 50 "height": 60
}, },
"settings": [ "settings": [
{ {
@ -4094,7 +4270,7 @@
"styles": ["size"], "styles": ["size"],
"size": { "size": {
"width": 400, "width": 400,
"height": 50 "height": 60
}, },
"settings": [ "settings": [
{ {
@ -4167,7 +4343,10 @@
"label": "High", "label": "High",
"value": 3136 "value": 3136
}, },
{ "label": "Custom", "value": "custom" } {
"label": "Custom",
"value": "custom"
}
] ]
}, },
{ {
@ -4251,7 +4430,7 @@
"styles": ["size"], "styles": ["size"],
"size": { "size": {
"width": 400, "width": 400,
"height": 50 "height": 60
}, },
"settings": [ "settings": [
{ {
@ -4303,6 +4482,10 @@
"width": 400, "width": 400,
"height": 320 "height": 320
}, },
"grid": {
"hAlign": "stretch",
"vAlign": "stretch"
},
"settings": [ "settings": [
{ {
"type": "dataProvider", "type": "dataProvider",
@ -4602,7 +4785,7 @@
"editable": true, "editable": true,
"size": { "size": {
"width": 400, "width": 400,
"height": 50 "height": 60
}, },
"settings": [ "settings": [
{ {
@ -4872,8 +5055,12 @@
"hasChildren": true, "hasChildren": true,
"actions": ["RefreshDatasource"], "actions": ["RefreshDatasource"],
"size": { "size": {
"width": 400, "width": 500,
"height": 100 "height": 200
},
"grid": {
"hAlign": "stretch",
"vAlign": "stretch"
}, },
"settings": [ "settings": [
{ {
@ -5126,6 +5313,10 @@
"width": 300, "width": 300,
"height": 120 "height": 120
}, },
"grid": {
"hAlign": "center",
"vAlign": "center"
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -5190,6 +5381,10 @@
"width": 100, "width": 100,
"height": 35 "height": 35
}, },
"grid": {
"hAlign": "center",
"vAlign": "center"
},
"settings": [ "settings": [
{ {
"type": "dataProvider", "type": "dataProvider",
@ -5236,6 +5431,14 @@
"name": "Chart Block", "name": "Chart Block",
"icon": "GraphPie", "icon": "GraphPie",
"hasChildren": false, "hasChildren": false,
"size": {
"width": 600,
"height": 420
},
"grid": {
"hAlign": "stretch",
"vAlign": "center"
},
"settings": [ "settings": [
{ {
"type": "select", "type": "select",
@ -6159,6 +6362,10 @@
"width": 600, "width": 600,
"height": 400 "height": 400
}, },
"grid": {
"hAlign": "stretch",
"vAlign": "stretch"
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -6368,8 +6575,12 @@
"illegalChildren": ["section", "rowexplorer"], "illegalChildren": ["section", "rowexplorer"],
"hasChildren": true, "hasChildren": true,
"size": { "size": {
"width": 400, "width": 500,
"height": 100 "height": 200
},
"grid": {
"hAlign": "stretch",
"vAlign": "stretch"
}, },
"settings": [ "settings": [
{ {
@ -6624,6 +6835,10 @@
"width": 400, "width": 400,
"height": 100 "height": 100
}, },
"grid": {
"hAlign": "stretch",
"vAlign": "start"
},
"settings": [ "settings": [
{ {
"type": "text", "type": "text",
@ -6640,10 +6855,14 @@
"hasChildren": false, "hasChildren": false,
"ejectable": false, "ejectable": false,
"size": { "size": {
"width": 400, "width": 600,
"height": 400 "height": 400
}, },
"styles": ["size"], "grid": {
"hAlign": "stretch",
"vAlign": "start"
},
"styles": ["padding", "size", "background", "border", "shadow"],
"settings": [ "settings": [
{ {
"type": "table", "type": "table",
@ -6827,13 +7046,17 @@
"formblock": { "formblock": {
"name": "Form Block", "name": "Form Block",
"icon": "Form", "icon": "Form",
"styles": ["size"], "styles": ["padding", "size", "background", "border", "shadow"],
"block": true, "block": true,
"info": "Form blocks are only compatible with internal or SQL tables", "info": "Form blocks are only compatible with internal or SQL tables",
"size": { "size": {
"width": 400, "width": 600,
"height": 400 "height": 400
}, },
"grid": {
"hAlign": "stretch",
"vAlign": "start"
},
"settings": [ "settings": [
{ {
"type": "table", "type": "table",
@ -6983,6 +7206,7 @@
"name": "Side Panel", "name": "Side Panel",
"icon": "RailRight", "icon": "RailRight",
"hasChildren": true, "hasChildren": true,
"ignoresLayout": true,
"illegalChildren": ["section", "sidepanel", "modal"], "illegalChildren": ["section", "sidepanel", "modal"],
"showEmptyState": false, "showEmptyState": false,
"draggable": false, "draggable": false,
@ -7006,6 +7230,7 @@
"icon": "MBox", "icon": "MBox",
"hasChildren": true, "hasChildren": true,
"illegalChildren": ["section", "modal", "sidepanel"], "illegalChildren": ["section", "modal", "sidepanel"],
"ignoresLayout": true,
"showEmptyState": false, "showEmptyState": false,
"draggable": false, "draggable": false,
"info": "Modals are hidden by default. They will only be revealed when triggered by the 'Open Modal' action.", "info": "Modals are hidden by default. They will only be revealed when triggered by the 'Open Modal' action.",
@ -7052,8 +7277,12 @@
"name": "Row Explorer Block", "name": "Row Explorer Block",
"icon": "PersonalizationField", "icon": "PersonalizationField",
"size": { "size": {
"width": 600, "width": 800,
"height": 400 "height": 426
},
"grid": {
"hAlign": "stretch",
"vAlign": "stretch"
}, },
"settings": [ "settings": [
{ {
@ -7167,23 +7396,6 @@
"scope": "local" "scope": "local"
} }
}, },
"grid": {
"name": "Grid",
"icon": "ViewGrid",
"hasChildren": true,
"settings": [
{
"type": "number",
"key": "cols",
"label": "Columns"
},
{
"type": "number",
"key": "rows",
"label": "Rows"
}
]
},
"gridblock": { "gridblock": {
"name": "Table", "name": "Table",
"icon": "Table", "icon": "Table",
@ -7192,6 +7404,10 @@
"width": 600, "width": 600,
"height": 400 "height": 400
}, },
"grid": {
"hAlign": "stretch",
"vAlign": "stretch"
},
"settings": [ "settings": [
{ {
"type": "dataSource", "type": "dataSource",

View File

@ -235,9 +235,6 @@
/> />
{/key} {/key}
<!-- Modal container to ensure they sit on top -->
<div class="modal-container" />
<!-- Layers on top of app --> <!-- Layers on top of app -->
<NotificationDisplay /> <NotificationDisplay />
<ConfirmationDisplay /> <ConfirmationDisplay />
@ -284,7 +281,7 @@
visibility: hidden; visibility: hidden;
padding: 0; padding: 0;
margin: 0; margin: 0;
overflow: hidden; overflow: clip;
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -301,7 +298,7 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative; position: relative;
overflow: hidden; overflow: clip;
background-color: transparent; background-color: transparent;
} }
@ -311,7 +308,7 @@
} }
#app-root { #app-root {
overflow: hidden; overflow: clip;
height: 100%; height: 100%;
width: 100%; width: 100%;
display: flex; display: flex;
@ -327,6 +324,7 @@
justify-content: flex-start; justify-content: flex-start;
align-items: stretch; align-items: stretch;
overflow: hidden; overflow: hidden;
position: relative;
} }
.error { .error {
@ -356,22 +354,16 @@
} }
/* Preview styles */ /* Preview styles */
/* The additional 6px of size is to account for 4px padding and 2px border */
#clip-root.preview { #clip-root.preview {
padding: 2px; padding: 6px;
} }
#clip-root.tablet-preview { #clip-root.tablet-preview {
width: calc(1024px + 6px); width: calc(1024px + 12px);
height: calc(768px + 6px); height: calc(768px + 12px);
} }
#clip-root.mobile-preview { #clip-root.mobile-preview {
width: calc(390px + 6px); width: calc(390px + 12px);
height: calc(844px + 6px); height: calc(844px + 12px);
}
.preview #app-root {
border: 1px solid var(--spectrum-global-color-gray-300);
border-radius: 4px;
} }
/* Print styles */ /* Print styles */

View File

@ -39,8 +39,10 @@
getActionContextKey, getActionContextKey,
getActionDependentContextKeys, getActionDependentContextKeys,
} from "../utils/buttonActions.js" } from "../utils/buttonActions.js"
import { gridLayout } from "utils/grid.js"
export let instance = {} export let instance = {}
export let parent = null
export let isLayout = false export let isLayout = false
export let isRoot = false export let isRoot = false
export let isBlock = false export let isBlock = false
@ -102,8 +104,8 @@
let settingsDefinitionMap let settingsDefinitionMap
let missingRequiredSettings = false let missingRequiredSettings = false
// Temporary styles which can be added in the app preview for things like DND. // Temporary styles which can be added in the app preview for things like
// We clear these whenever a new instance is received. // DND. We clear these whenever a new instance is received.
let ephemeralStyles let ephemeralStyles
// Single string of all HBS blocks, used to check if we use a certain binding // Single string of all HBS blocks, used to check if we use a certain binding
@ -193,19 +195,37 @@
$: pad = pad || (interactive && hasChildren && inDndPath) $: pad = pad || (interactive && hasChildren && inDndPath)
$: $dndIsDragging, (pad = false) $: $dndIsDragging, (pad = false)
// Themes
$: currentTheme = $context?.device?.theme $: currentTheme = $context?.device?.theme
$: darkMode = !currentTheme?.includes("light") $: darkMode = !currentTheme?.includes("light")
// Apply ephemeral styles (such as when resizing grid components)
$: normalStyles = {
...instance._styles?.normal,
...ephemeralStyles,
}
// Metadata to pass into grid action to apply CSS
const insideGrid =
parent?._component.endsWith("/container") && parent?.layout === "grid"
$: gridMetadata = {
insideGrid,
ignoresLayout: definition?.ignoresLayout === true,
id,
interactive,
styles: normalStyles,
draggable,
definition,
errored: errorState,
}
// Update component context // Update component context
$: store.set({ $: store.set({
id, id,
children: children.length, children: children.length,
styles: { styles: {
...instance._styles, ...instance._styles,
normal: { normal: normalStyles,
...instance._styles?.normal,
...ephemeralStyles,
},
custom: customCSS, custom: customCSS,
id, id,
empty: emptyState, empty: emptyState,
@ -242,6 +262,9 @@
lastInstanceKey = instanceKey lastInstanceKey = instanceKey
} }
// Reset ephemeral state
ephemeralStyles = null
// Pull definition and constructor // Pull definition and constructor
const component = instance._component const component = instance._component
constructor = componentStore.actions.getComponentConstructor(component) constructor = componentStore.actions.getComponentConstructor(component)
@ -561,19 +584,22 @@
} }
} }
const scrollIntoView = () => { const scrollIntoView = async () => {
// Don't scroll into view if we selected this component because we were const className = insideGrid ? id : `${id}-dom`
// starting dragging on it const node = document.getElementsByClassName(className)[0]
if (get(dndIsDragging)) {
return
}
const node = document.getElementsByClassName(id)?.[0]?.children[0]
if (!node) { if (!node) {
return return
} }
node.style.scrollMargin = "100px" // Don't scroll into view if we selected this component because we were
// starting dragging on it
if (
get(dndIsDragging) ||
(insideGrid && node.classList.contains("dragging"))
) {
return
}
node.scrollIntoView({ node.scrollIntoView({
behavior: "smooth", behavior: "instant",
block: "nearest", block: "nearest",
inline: "start", inline: "start",
}) })
@ -650,6 +676,7 @@
data-name={name} data-name={name}
data-icon={icon} data-icon={icon}
data-parent={$component.id} data-parent={$component.id}
use:gridLayout={gridMetadata}
> >
{#if errorState} {#if errorState}
<ComponentErrorState <ComponentErrorState
@ -660,7 +687,7 @@
<svelte:component this={constructor} bind:this={ref} {...initialSettings}> <svelte:component this={constructor} bind:this={ref} {...initialSettings}>
{#if children.length} {#if children.length}
{#each children as child (child._id)} {#each children as child (child._id)}
<svelte:self instance={child} /> <svelte:self instance={child} parent={instance} />
{/each} {/each}
{:else if emptyState} {:else if emptyState}
{#if isRoot} {#if isRoot}
@ -687,7 +714,7 @@
border-radius: 4px !important; border-radius: 4px !important;
transition: padding 260ms ease-out, border 260ms ease-out; transition: padding 260ms ease-out, border 260ms ease-out;
} }
.interactive :global(*) { .interactive {
cursor: default; cursor: default !important;
} }
</style> </style>

View File

@ -71,4 +71,7 @@
div { div {
position: relative; position: relative;
} }
div :global(> .component > *) {
flex: 1 1 auto;
}
</style> </style>

View File

@ -13,8 +13,9 @@
const onLoadActions = memo() const onLoadActions = memo()
// Get the screen definition for the current route // Get the screen definition for the current route
$: screenDefinition = $screenStore.activeScreen?.props $: screen = $screenStore.activeScreen
$: onLoadActions.set($screenStore.activeScreen?.onLoad) $: screenDefinition = { ...screen?.props, addEmptyRows: true }
$: onLoadActions.set(screen?.onLoad)
$: runOnLoadActions($onLoadActions, params) $: runOnLoadActions($onLoadActions, params)
// Enrich and execute any on load actions. // Enrich and execute any on load actions.

View File

@ -1,8 +1,7 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import Placeholder from "./Placeholder.svelte"
const { styleable, builderStore } = getContext("sdk") const { styleable } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
export let url export let url
@ -19,20 +18,11 @@
} }
</script> </script>
{#if url} <div class="outer" use:styleable={$component.styles}>
<div class="outer" use:styleable={$component.styles}> <div class="inner" {style}>
<div class="inner" {style}> <slot />
<slot />
</div>
</div> </div>
{:else if $builderStore.inBuilder} </div>
<div
class="placeholder"
use:styleable={{ ...$component.styles, empty: true }}
>
<Placeholder />
</div>
{/if}
<style> <style>
.outer { .outer {
@ -49,9 +39,4 @@
background-size: cover; background-size: cover;
background-position: center center; background-position: center center;
} }
.placeholder {
display: grid;
place-items: center;
}
</style> </style>

View File

@ -19,6 +19,11 @@
gap, gap,
wrap: true, wrap: true,
}} }}
styles={{
normal: {
height: "100%",
},
}}
> >
{#each buttons as { text, type, quiet, disabled, onClick, size, icon, gap }} {#each buttons as { text, type, quiet, disabled, onClick, size, icon, gap }}
<BlockComponent <BlockComponent

View File

@ -1,102 +0,0 @@
<script>
import { getContext } from "svelte"
const component = getContext("component")
const { styleable, builderStore } = getContext("sdk")
export let cols = 12
export let rows = 12
// Deliberately non-reactive as we want this fixed whenever the grid renders
const defaultColSpan = Math.ceil((cols + 1) / 2)
const defaultRowSpan = Math.ceil((rows + 1) / 2)
$: coords = generateCoords(rows, cols)
const generateCoords = (rows, cols) => {
let grid = []
for (let row = 0; row < rows; row++) {
for (let col = 0; col < cols; col++) {
grid.push({ row, col })
}
}
return grid
}
</script>
<div
class="grid"
use:styleable={{
...$component.styles,
normal: {
...$component.styles?.normal,
"--cols": cols,
"--rows": rows,
"--default-col-span": defaultColSpan,
"--default-row-span": defaultRowSpan,
gap: "0 !important",
},
}}
data-rows={rows}
data-cols={cols}
>
{#if $builderStore.inBuilder}
<div class="underlay">
{#each coords as _}
<div class="placeholder" />
{/each}
</div>
{/if}
<slot />
</div>
<style>
/*
Ensure all children of containers which are top level children of
grids do not overflow
*/
:global(.grid > .component > .valid-container > .component > *) {
max-height: 100%;
max-width: 100%;
}
/* Ensure all top level children have some grid styles set */
:global(.grid > .component > *) {
overflow: hidden;
width: auto;
height: auto;
grid-column-start: 1;
grid-column-end: var(--default-col-span);
grid-row-start: 1;
grid-row-end: var(--default-row-span);
max-height: 100%;
max-width: 100%;
}
.grid {
position: relative;
height: 400px;
}
.grid,
.underlay {
display: grid;
grid-template-rows: repeat(var(--rows), 1fr);
grid-template-columns: repeat(var(--cols), 1fr);
}
.underlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
grid-gap: 2px;
background-color: var(--spectrum-global-color-gray-200);
border: 2px solid var(--spectrum-global-color-gray-200);
}
.underlay {
z-index: -1;
}
.placeholder {
background-color: var(--spectrum-global-color-gray-100);
}
</style>

View File

@ -198,7 +198,7 @@
overflow: hidden; overflow: hidden;
height: 410px; height: 410px;
} }
div.in-builder :global(*) { div.in-builder :global(> *) {
pointer-events: none; pointer-events: none !important;
} }
</style> </style>

View File

@ -36,7 +36,6 @@
export let logoLinkUrl export let logoLinkUrl
export let openLogoLinkInNewTab export let openLogoLinkInNewTab
export let textAlign export let textAlign
export let embedded = false export let embedded = false
const NavigationClasses = { const NavigationClasses = {
@ -339,6 +338,7 @@
/> />
</div> </div>
</div> </div>
<div class="modal-container" />
</div> </div>
<style> <style>
@ -353,6 +353,9 @@
z-index: 1; z-index: 1;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
/* Deliberately unitless as we need to do unitless calculations in grids */
--grid-spacing: 4;
} }
.component { .component {
display: contents; display: contents;
@ -415,7 +418,6 @@
color: var(--navTextColor); color: var(--navTextColor);
opacity: 1; opacity: 1;
} }
.nav :global(h1) { .nav :global(h1) {
color: var(--navTextColor); color: var(--navTextColor);
} }
@ -481,9 +483,10 @@
position: relative; position: relative;
padding: 32px; padding: 32px;
} }
.main.size--max { .main:not(.size--max):has(.screenslot-dom > .component > .grid) {
padding: 0; padding: calc(32px - var(--grid-spacing) * 2px);
} }
.layout--none .main { .layout--none .main {
padding: 0; padding: 0;
} }
@ -502,6 +505,9 @@
.size--max { .size--max {
width: 100%; width: 100%;
} }
.main.size--max {
padding: 0;
}
/* Nav components */ /* Nav components */
.burger { .burger {
@ -613,6 +619,10 @@
.mobile:not(.layout--none) .main { .mobile:not(.layout--none) .main {
padding: 16px; padding: 16px;
} }
.mobile:not(.layout--none)
.main:not(.size--max):has(.screenslot-dom > .component > .grid) {
padding: 6px;
}
.mobile .main.size--max { .mobile .main.size--max {
padding: 0; padding: 0;
} }

View File

@ -1,7 +1,7 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import Placeholder from "./Placeholder.svelte" import Placeholder from "./Placeholder.svelte"
import Container from "./Container.svelte" import Container from "./container/Container.svelte"
const { Provider, ContextScopes } = getContext("sdk") const { Provider, ContextScopes } = getContext("sdk")
const component = getContext("component") const component = getContext("component")

View File

@ -57,4 +57,7 @@
.spectrum-Tag--sizeL { .spectrum-Tag--sizeL {
padding: 0 var(--spectrum-global-dimension-size-150); padding: 0 var(--spectrum-global-dimension-size-150);
} }
.spectrum-Tag-label {
height: auto;
}
</style> </style>

View File

@ -0,0 +1,12 @@
<script>
import GridContainer from "./GridContainer.svelte"
import FlexContainer from "./FlexContainer.svelte"
export let layout = "flex"
$: component = layout === "grid" ? GridContainer : FlexContainer
</script>
<svelte:component this={component} {...$$props}>
<slot />
</svelte:component>

View File

@ -12,7 +12,7 @@
export let wrap export let wrap
export let onClick export let onClick
$: directionClass = direction ? `valid-container direction-${direction}` : "" $: directionClass = direction ? `flex-container direction-${direction}` : ""
$: hAlignClass = hAlign ? `hAlign-${hAlign}` : "" $: hAlignClass = hAlign ? `hAlign-${hAlign}` : ""
$: vAlignClass = vAlign ? `vAlign-${vAlign}` : "" $: vAlignClass = vAlign ? `vAlign-${vAlign}` : ""
$: sizeClass = size ? `size-${size}` : "" $: sizeClass = size ? `size-${size}` : ""
@ -39,11 +39,11 @@
</div> </div>
<style> <style>
.valid-container { .flex-container {
display: flex; display: flex;
max-width: 100%; max-width: 100%;
} }
.valid-container :global(.component > *) { .flex-container :global(.component > *) {
max-width: 100%; max-width: 100%;
} }
.direction-row { .direction-row {

View File

@ -0,0 +1,264 @@
<script>
import { getContext, onMount } from "svelte"
import { writable } from "svelte/store"
import { GridRowHeight, GridColumns } from "constants"
import { memo } from "@budibase/frontend-core"
export let addEmptyRows = false
const component = getContext("component")
const { styleable, builderStore } = getContext("sdk")
const context = getContext("context")
let width
let height
let ref
let children = writable({})
let mounted = false
let styles = memo({})
$: inBuilder = $builderStore.inBuilder
$: requiredRows = calculateRequiredRows(
$children,
mobile,
addEmptyRows && inBuilder
)
$: requiredHeight = requiredRows * GridRowHeight
$: availableRows = Math.floor(height / GridRowHeight)
$: rows = Math.max(requiredRows, availableRows)
$: mobile = $context.device.mobile
$: empty = $component.empty
$: colSize = width / GridColumns
$: styles.set({
...$component.styles,
normal: {
...$component.styles?.normal,
"--height": `${requiredHeight}px`,
"--min-height": $component.styles?.normal?.height || 0,
"--cols": GridColumns,
"--rows": rows,
"--col-size": colSize,
"--row-size": GridRowHeight,
},
empty: false,
})
// Calculates the minimum number of rows required to render all child
// components, on a certain device type
const calculateRequiredRows = (children, mobile, addEmptyRows) => {
const key = mobile ? "mobileRowEnd" : "desktopRowEnd"
let max = 2
for (let id of Object.keys(children)) {
if (children[id][key] > max) {
max = children[id][key]
}
}
let requiredRows = max - 1
if (addEmptyRows) {
return Math.ceil((requiredRows + 10) / 10) * 10
} else {
return requiredRows
}
}
// Stores metadata about a child node as constraints for determining grid size
const storeChild = node => {
children.update(state => ({
...state,
[node.dataset.id]: {
desktopRowEnd: parseInt(node.dataset.gridDesktopRowEnd),
mobileRowEnd: parseInt(node.dataset.gridMobileRowEnd),
},
}))
}
// Removes constraint metadata for a certain child node
const removeChild = node => {
children.update(state => {
delete state[node.dataset.id]
return { ...state }
})
}
onMount(() => {
let observer
// Set up an observer to watch for changes in metadata attributes of child
// components, as well as child addition and deletion
observer = new MutationObserver(mutations => {
for (let mutation of mutations) {
const { target, type, addedNodes, removedNodes } = mutation
if (target === ref) {
if (addedNodes[0]?.classList?.contains("component")) {
// We've added a new child component inside the grid, so we need
// to consider it when determining required rows
storeChild(addedNodes[0])
} else if (removedNodes[0]?.classList?.contains("component")) {
// We've removed a child component inside the grid, so we need
// to stop considering it when determining required rows
removeChild(removedNodes[0])
}
} else if (
type === "attributes" &&
target.parentNode === ref &&
target.classList.contains("component")
) {
// We've updated the size or position of a child
storeChild(target)
}
}
})
observer.observe(ref, {
childList: true,
attributes: true,
subtree: true,
attributeFilter: [
"data-grid-desktop-row-end",
"data-grid-mobile-row-end",
],
})
// Now that the observer is set up, we mark the grid as mounted to mount
// our child components
mounted = true
// Cleanup our observer
return () => {
observer?.disconnect()
}
})
</script>
<div
bind:this={ref}
class="grid"
class:mobile
bind:clientWidth={width}
bind:clientHeight={height}
use:styleable={$styles}
data-cols={GridColumns}
data-col-size={colSize}
>
{#if inBuilder}
<div class="underlay">
{#each { length: GridColumns * rows } as _, idx}
<div class="placeholder" class:first-col={idx % GridColumns === 0} />
{/each}
</div>
{/if}
<!-- Only render the slot if not empty, as we don't want the placeholder -->
{#if !empty && mounted}
<slot />
{/if}
</div>
<style>
.grid,
.underlay {
height: var(--height) !important;
min-height: var(--min-height) !important;
max-height: none !important;
display: grid;
gap: 0;
grid-template-rows: repeat(var(--rows), calc(var(--row-size) * 1px));
grid-template-columns: repeat(var(--cols), calc(var(--col-size) * 1px));
position: relative;
}
.underlay {
display: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-top: 1px solid var(--spectrum-global-color-gray-900);
opacity: 0.1;
pointer-events: none;
}
.underlay {
z-index: 0;
}
.placeholder {
border-bottom: 1px solid var(--spectrum-global-color-gray-900);
border-right: 1px solid var(--spectrum-global-color-gray-900);
}
.placeholder.first-col {
border-left: 1px solid var(--spectrum-global-color-gray-900);
}
/* Highlight grid lines when resizing children */
:global(.grid.highlight > .underlay) {
display: grid;
}
/* Highlight sibling borders when resizing childern */
:global(.grid.highlight > .component:not(.dragging)) {
outline: 2px solid var(--spectrum-global-color-static-blue-200);
pointer-events: none !important;
}
:global(.grid.highlight > .component.dragging) {
z-index: 999 !important;
}
/* Ensure all top level children have grid styles applied */
.grid :global(> .component:not(.ignores-layout)) {
display: flex;
overflow: auto;
pointer-events: all;
position: relative;
padding: calc(var(--grid-spacing) * 1px);
margin: calc(var(--grid-spacing) * 1px);
/* On desktop, use desktop metadata and fall back to mobile */
--col-start: var(--grid-desktop-col-start, var(--grid-mobile-col-start));
--col-end: var(--grid-desktop-col-end, var(--grid-mobile-col-end));
--row-start: var(--grid-desktop-row-start, var(--grid-mobile-row-start));
--row-end: var(--grid-desktop-row-end, var(--grid-mobile-row-end));
--h-align: var(--grid-desktop-h-align, var(--grid-mobile-h-align));
--v-align: var(--grid-desktop-v-align, var(--grid-mobile-v-align));
/* Ensure grid metadata falls within limits */
grid-column-start: min(max(1, var(--col-start)), var(--cols)) !important;
grid-column-end: min(
max(2, var(--col-end)),
calc(var(--cols) + 1)
) !important;
grid-row-start: max(1, var(--row-start)) !important;
grid-row-end: max(2, var(--row-end)) !important;
/* Flex container styles */
flex-direction: column;
align-items: var(--h-align);
justify-content: var(--v-align);
}
/* On mobile, use mobile metadata and fall back to desktop */
.grid.mobile :global(> .component) {
--col-start: var(--grid-mobile-col-start, var(--grid-desktop-col-start));
--col-end: var(--grid-mobile-col-end, var(--grid-desktop-col-end));
--row-start: var(--grid-mobile-row-start, var(--grid-desktop-row-start));
--row-end: var(--grid-mobile-row-end, var(--grid-desktop-row-end));
--h-align: var(--grid-mobile-h-align, var(--grid-desktop-h-align));
--v-align: var(--grid-mobile-v-align, var(--grid-desktop-v-align));
}
/* Handle grid children which need to fill the outer component wrapper */
.grid :global(> .component > *) {
flex: 0 0 auto !important;
}
.grid:not(.mobile)
:global(> .component[data-grid-desktop-v-align="stretch"] > *) {
flex: 1 1 0 !important;
height: 0 !important;
}
.grid.mobile :global(> .component[data-grid-mobile-v-align="stretch"] > *) {
flex: 1 1 0 !important;
height: 0 !important;
}
/* Grid specific CSS overrides for certain components */
.grid :global(> .component > img) {
object-fit: contain;
max-height: 100%;
}
</style>

View File

@ -13,7 +13,7 @@ import "@spectrum-css/page/dist/index-vars.css"
export { default as Placeholder } from "./Placeholder.svelte" export { default as Placeholder } from "./Placeholder.svelte"
// User facing components // User facing components
export { default as container } from "./Container.svelte" export { default as container } from "./container/Container.svelte"
export { default as section } from "./Section.svelte" export { default as section } from "./Section.svelte"
export { default as dataprovider } from "./DataProvider.svelte" export { default as dataprovider } from "./DataProvider.svelte"
export { default as divider } from "./Divider.svelte" export { default as divider } from "./Divider.svelte"
@ -35,7 +35,6 @@ export { default as spectrumcard } from "./SpectrumCard.svelte"
export { default as tag } from "./Tag.svelte" 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 sidepanel } from "./SidePanel.svelte" export { default as sidepanel } from "./SidePanel.svelte"
export { default as modal } from "./Modal.svelte" export { default as modal } from "./Modal.svelte"
export { default as gridblock } from "./GridBlock.svelte" export { default as gridblock } from "./GridBlock.svelte"

View File

@ -13,6 +13,7 @@
import { Utils } from "@budibase/frontend-core" import { Utils } from "@budibase/frontend-core"
import { findComponentById } from "utils/components.js" import { findComponentById } from "utils/components.js"
import { DNDPlaceholderID } from "constants" import { DNDPlaceholderID } from "constants"
import { isGridEvent } from "utils/grid"
const ThrottleRate = 130 const ThrottleRate = 130
@ -25,15 +26,6 @@
// Local flag for whether we are awaiting an async drop event // Local flag for whether we are awaiting an async drop event
let dropping = false let dropping = false
// Util to check if a DND event originates from a grid (or inside a grid).
// This is important as we do not handle grid DND in this handler.
const isGridEvent = e => {
return e.target
?.closest?.(".component")
?.parentNode?.closest?.(".component")
?.childNodes[0]?.classList.contains("grid")
}
// 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 => {
return document.getElementsByClassName(`${id}-dom`)[0] return document.getElementsByClassName(`${id}-dom`)[0]
@ -267,7 +259,7 @@
// Check if we're adding a new component rather than moving one // Check if we're adding a new component rather than moving one
if (source.newComponentType) { if (source.newComponentType) {
dropping = true dropping = true
await builderStore.actions.dropNewComponent( builderStore.actions.dropNewComponent(
source.newComponentType, source.newComponentType,
drop.parent, drop.parent,
drop.index drop.index

View File

@ -1,7 +1,7 @@
<script> <script>
import { onMount } from "svelte" import { onMount } from "svelte"
import { DNDPlaceholderID } from "constants" import { DNDPlaceholderID } from "constants"
import { domDebounce } from "utils/domDebounce.js" import { Utils } from "@budibase/frontend-core"
let left, top, height, width let left, top, height, width
@ -19,7 +19,7 @@
width = bounds.width width = bounds.width
} }
} }
const debouncedUpdate = domDebounce(updatePosition) const debouncedUpdate = Utils.domDebounce(updatePosition)
onMount(() => { onMount(() => {
const interval = setInterval(debouncedUpdate, 100) const interval = setInterval(debouncedUpdate, 100)

View File

@ -1,112 +1,96 @@
<script> <script>
import { onMount, onDestroy } from "svelte" import { onMount, onDestroy, getContext } from "svelte"
import { builderStore, componentStore } from "stores" import { builderStore, componentStore } from "stores"
import { Utils } from "@budibase/frontend-core" import { Utils, memo } from "@budibase/frontend-core"
import { GridRowHeight } from "constants"
import {
isGridEvent,
GridParams,
getGridVar,
Devices,
GridDragModes,
} from "utils/grid"
const context = getContext("context")
// Smallest possible 1x1 transparent GIF
const ghost = new Image(1, 1)
ghost.src =
"data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
let dragInfo let dragInfo
let gridStyles let styles = memo()
let id
// Grid CSS variables
$: device = $context.device.mobile ? Devices.Mobile : Devices.Desktop
$: vars = {
colStart: getGridVar(device, GridParams.ColStart),
colEnd: getGridVar(device, GridParams.ColEnd),
rowStart: getGridVar(device, GridParams.RowStart),
rowEnd: getGridVar(device, GridParams.RowEnd),
}
// Some memoisation of primitive types for performance // Some memoisation of primitive types for performance
$: jsonStyles = JSON.stringify(gridStyles) $: id = dragInfo?.id
$: id = dragInfo?.id || id
// Set ephemeral grid styles on the dragged component // Set ephemeral styles
$: instance = componentStore.actions.getComponentInstance(id) $: instance = componentStore.actions.getComponentInstance(id)
$: $instance?.setEphemeralStyles({ $: $instance?.setEphemeralStyles($styles)
...gridStyles,
...(gridStyles ? { "z-index": 999 } : null),
})
// Util to check if a DND event originates from a grid (or inside a grid). // Sugar for a combination of both min and max
// This is important as we do not handle grid DND in this handler. const minMax = (value, min, max) => Math.min(max, Math.max(min, value))
const isGridEvent = e => {
return (
e.target
.closest?.(".component")
?.parentNode.closest(".component")
?.childNodes[0].classList.contains("grid") ||
e.target.classList.contains("anchor")
)
}
// Util to get the inner DOM node by a component ID const processEvent = Utils.domDebounce((mouseX, mouseY) => {
const getDOMNode = id => {
const component = document.getElementsByClassName(id)[0]
return [...component.children][0]
}
const processEvent = Utils.throttle((mouseX, mouseY) => {
if (!dragInfo?.grid) { if (!dragInfo?.grid) {
return return
} }
const { mode, side, grid, domGrid } = dragInfo
const { mode, side, gridId, grid } = dragInfo const { startX, startY, rowStart, rowEnd, colStart, colEnd } = grid
const { if (!domGrid) {
startX, return
startY, }
rowStart,
rowEnd,
colStart,
colEnd,
rowDeltaMin,
rowDeltaMax,
colDeltaMin,
colDeltaMax,
} = grid
const domGrid = getDOMNode(gridId)
const cols = parseInt(domGrid.dataset.cols) const cols = parseInt(domGrid.dataset.cols)
const rows = parseInt(domGrid.dataset.rows) const colSize = parseInt(domGrid.dataset.colSize)
const { width, height } = domGrid.getBoundingClientRect()
const colWidth = width / cols
const diffX = mouseX - startX const diffX = mouseX - startX
let deltaX = Math.round(diffX / colWidth) let deltaX = Math.round(diffX / colSize)
const rowHeight = height / rows
const diffY = mouseY - startY const diffY = mouseY - startY
let deltaY = Math.round(diffY / rowHeight) let deltaY = Math.round(diffY / GridRowHeight)
if (mode === GridDragModes.Move) {
if (mode === "move") { deltaX = minMax(deltaX, 1 - colStart, cols + 1 - colEnd)
deltaY = Math.min(Math.max(deltaY, rowDeltaMin), rowDeltaMax) deltaY = Math.max(deltaY, 1 - rowStart)
deltaX = Math.min(Math.max(deltaX, colDeltaMin), colDeltaMax)
const newStyles = { const newStyles = {
"grid-row-start": rowStart + deltaY, [vars.colStart]: colStart + deltaX,
"grid-row-end": rowEnd + deltaY, [vars.colEnd]: colEnd + deltaX,
"grid-column-start": colStart + deltaX, [vars.rowStart]: rowStart + deltaY,
"grid-column-end": colEnd + deltaX, [vars.rowEnd]: rowEnd + deltaY,
} }
if (JSON.stringify(newStyles) !== jsonStyles) { styles.set(newStyles)
gridStyles = newStyles } else if (mode === GridDragModes.Resize) {
}
} else if (mode === "resize") {
let newStyles = {} let newStyles = {}
if (side === "right") { if (side === "right") {
newStyles["grid-column-end"] = Math.max(colEnd + deltaX, colStart + 1) newStyles[vars.colEnd] = Math.max(colEnd + deltaX, colStart + 1)
} else if (side === "left") { } else if (side === "left") {
newStyles["grid-column-start"] = Math.min(colStart + deltaX, colEnd - 1) newStyles[vars.colStart] = Math.min(colStart + deltaX, colEnd - 1)
} else if (side === "top") { } else if (side === "top") {
newStyles["grid-row-start"] = Math.min(rowStart + deltaY, rowEnd - 1) newStyles[vars.rowStart] = Math.min(rowStart + deltaY, rowEnd - 1)
} else if (side === "bottom") { } else if (side === "bottom") {
newStyles["grid-row-end"] = Math.max(rowEnd + deltaY, rowStart + 1) newStyles[vars.rowEnd] = Math.max(rowEnd + deltaY, rowStart + 1)
} else if (side === "bottom-right") { } else if (side === "bottom-right") {
newStyles["grid-column-end"] = Math.max(colEnd + deltaX, colStart + 1) newStyles[vars.colEnd] = Math.max(colEnd + deltaX, colStart + 1)
newStyles["grid-row-end"] = Math.max(rowEnd + deltaY, rowStart + 1) newStyles[vars.rowEnd] = Math.max(rowEnd + deltaY, rowStart + 1)
} else if (side === "bottom-left") { } else if (side === "bottom-left") {
newStyles["grid-column-start"] = Math.min(colStart + deltaX, colEnd - 1) newStyles[vars.colStart] = Math.min(colStart + deltaX, colEnd - 1)
newStyles["grid-row-end"] = Math.max(rowEnd + deltaY, rowStart + 1) newStyles[vars.rowEnd] = Math.max(rowEnd + deltaY, rowStart + 1)
} else if (side === "top-right") { } else if (side === "top-right") {
newStyles["grid-column-end"] = Math.max(colEnd + deltaX, colStart + 1) newStyles[vars.colEnd] = Math.max(colEnd + deltaX, colStart + 1)
newStyles["grid-row-start"] = Math.min(rowStart + deltaY, rowEnd - 1) newStyles[vars.rowStart] = Math.min(rowStart + deltaY, rowEnd - 1)
} else if (side === "top-left") { } else if (side === "top-left") {
newStyles["grid-column-start"] = Math.min(colStart + deltaX, colEnd - 1) newStyles[vars.colStart] = Math.min(colStart + deltaX, colEnd - 1)
newStyles["grid-row-start"] = Math.min(rowStart + deltaY, rowEnd - 1) newStyles[vars.rowStart] = Math.min(rowStart + deltaY, rowEnd - 1)
}
if (JSON.stringify(newStyles) !== jsonStyles) {
gridStyles = newStyles
} }
styles.set(newStyles)
} }
}, 100) })
const handleEvent = e => { const handleEvent = e => {
e.preventDefault() e.preventDefault()
@ -121,77 +105,64 @@
} }
// Hide drag ghost image // Hide drag ghost image
e.dataTransfer.setDragImage(new Image(), 0, 0) e.dataTransfer.setDragImage(ghost, 0, 0)
// Extract state // Extract state
let mode, id, side let mode, id, side
if (e.target.classList.contains("anchor")) { if (e.target.dataset.indicator === "true") {
// Handle resize mode = e.target.dataset.dragMode
mode = "resize"
id = e.target.dataset.id id = e.target.dataset.id
side = e.target.dataset.side side = e.target.dataset.side
} else { } else {
// Handle move // Handle move
mode = "move" mode = GridDragModes.Move
const component = e.target.closest(".component") const component = e.target.closest(".component")
id = component.dataset.id id = component.dataset.id
} }
// Find grid parent // If holding ctrl/cmd then leave behind a duplicate of this component
const domComponent = getDOMNode(id) if (mode === GridDragModes.Move && (e.ctrlKey || e.metaKey)) {
const gridId = domComponent?.closest(".grid")?.parentNode.dataset.id builderStore.actions.duplicateComponent(id, "above", false)
if (!gridId) { }
// Find grid parent and read from DOM
const domComponent = document.getElementsByClassName(id)[0]
const domGrid = domComponent?.closest(".grid")
if (!domGrid) {
return return
} }
const styles = getComputedStyle(domComponent)
// Show as active
domComponent.classList.add("dragging")
domGrid.classList.add("highlight")
builderStore.actions.selectComponent(id)
// Update state // Update state
dragInfo = { dragInfo = {
domTarget: e.target, domTarget: e.target,
domComponent,
domGrid,
id, id,
gridId, gridId: domGrid.parentNode.dataset.id,
mode, mode,
side, side,
grid: {
startX: e.clientX,
startY: e.clientY,
rowStart: parseInt(styles["grid-row-start"]),
rowEnd: parseInt(styles["grid-row-end"]),
colStart: parseInt(styles["grid-column-start"]),
colEnd: parseInt(styles["grid-column-end"]),
},
} }
// Add event handler to clear all drag state when dragging ends // Add event handler to clear all drag state when dragging ends
dragInfo.domTarget.addEventListener("dragend", stopDragging) dragInfo.domTarget.addEventListener("dragend", stopDragging)
} }
// Callback when entering a potential drop target
const onDragEnter = e => {
// Skip if we aren't validly dragging currently
if (!dragInfo || dragInfo.grid) {
return
}
const domGrid = getDOMNode(dragInfo.gridId)
const gridCols = parseInt(domGrid.dataset.cols)
const gridRows = parseInt(domGrid.dataset.rows)
const domNode = getDOMNode(dragInfo.id)
const styles = window.getComputedStyle(domNode)
if (domGrid) {
const minMax = (value, min, max) => Math.min(max, Math.max(min, value))
const getStyle = x => parseInt(styles?.[x] || "0")
const getColStyle = x => minMax(getStyle(x), 1, gridCols + 1)
const getRowStyle = x => minMax(getStyle(x), 1, gridRows + 1)
dragInfo.grid = {
startX: e.clientX,
startY: e.clientY,
rowStart: getRowStyle("grid-row-start"),
rowEnd: getRowStyle("grid-row-end"),
colStart: getColStyle("grid-column-start"),
colEnd: getColStyle("grid-column-end"),
rowDeltaMin: 1 - getRowStyle("grid-row-start"),
rowDeltaMax: gridRows + 1 - getRowStyle("grid-row-end"),
colDeltaMin: 1 - getColStyle("grid-column-start"),
colDeltaMax: gridCols + 1 - getColStyle("grid-column-end"),
}
handleEvent(e)
}
}
const onDragOver = e => { const onDragOver = e => {
if (!dragInfo?.grid) { if (!dragInfo) {
return return
} }
handleEvent(e) handleEvent(e)
@ -199,30 +170,33 @@
// Callback when drag stops (whether dropped or not) // Callback when drag stops (whether dropped or not)
const stopDragging = async () => { const stopDragging = async () => {
// Save changes if (!dragInfo) {
if (gridStyles) { return
await builderStore.actions.updateStyles(gridStyles, dragInfo.id)
} }
const { id, domTarget, domGrid, domComponent } = dragInfo
// Reset listener // Reset DOM
if (dragInfo?.domTarget) { domComponent.classList.remove("dragging")
dragInfo.domTarget.removeEventListener("dragend", stopDragging) domGrid.classList.remove("highlight")
domTarget.removeEventListener("dragend", stopDragging)
// Save changes
if ($styles) {
builderStore.actions.updateStyles($styles, id)
} }
// Reset state // Reset state
dragInfo = null dragInfo = null
gridStyles = null styles.set(null)
} }
onMount(() => { onMount(() => {
document.addEventListener("dragstart", onDragStart, false) document.addEventListener("dragstart", onDragStart, false)
document.addEventListener("dragenter", onDragEnter, false)
document.addEventListener("dragover", onDragOver, false) document.addEventListener("dragover", onDragOver, false)
}) })
onDestroy(() => { onDestroy(() => {
document.removeEventListener("dragstart", onDragStart, false) document.removeEventListener("dragstart", onDragStart, false)
document.removeEventListener("dragenter", onDragEnter, false)
document.removeEventListener("dragover", onDragOver, false) document.removeEventListener("dragover", onDragOver, false)
}) })
</script> </script>

View File

@ -0,0 +1,42 @@
<script>
import { Icon } from "@budibase/bbui"
import { builderStore } from "stores"
export let style
export let value
export let icon
export let title
export let componentId
export let active
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
{title}
class:active
on:click={() => {
builderStore.actions.updateStyles({ [style]: value }, componentId)
}}
>
<Icon name={icon} size="S" />
</div>
<style>
div {
padding: 6px;
border-radius: 2px;
color: var(--spectrum-global-color-gray-700);
display: flex;
transition: color 0.13s ease-in-out, background-color 0.13s ease-in-out;
}
div:hover {
background-color: var(--spectrum-global-color-gray-200);
cursor: pointer;
}
.active,
.active:hover {
background-color: rgba(13, 102, 208, 0.1);
color: var(--spectrum-global-color-blue-600);
}
</style>

View File

@ -1,5 +1,6 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { GridDragModes } from "utils/grid"
export let top export let top
export let left export let left
@ -34,9 +35,20 @@
class:line class:line
style="top: {top}px; left: {left}px; width: {width}px; height: {height}px; --color: {color}; --zIndex: {zIndex};" style="top: {top}px; left: {left}px; width: {width}px; height: {height}px; --color: {color}; --zIndex: {zIndex};"
class:withText={!!text} class:withText={!!text}
class:vCompact={height < 40}
class:hCompact={width < 40}
> >
{#if text || icon} {#if text || icon}
<div class="label" class:flipped class:line class:right={alignRight}> <div
class="label"
class:flipped
class:line
class:right={alignRight}
draggable="true"
data-indicator="true"
data-drag-mode={GridDragModes.Move}
data-id={componentId}
>
{#if icon} {#if icon}
<Icon name={icon} size="S" color="white" /> <Icon name={icon} size="S" color="white" />
{/if} {/if}
@ -50,8 +62,10 @@
{#if showResizeAnchors} {#if showResizeAnchors}
{#each AnchorSides as side} {#each AnchorSides as side}
<div <div
draggable="true"
class="anchor {side}" class="anchor {side}"
draggable="true"
data-indicator="true"
data-drag-mode={GridDragModes.Resize}
data-side={side} data-side={side}
data-id={componentId} data-id={componentId}
> >
@ -99,6 +113,7 @@
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
pointer-events: all;
} }
.label.line { .label.line {
transform: translateY(-50%); transform: translateY(-50%);
@ -123,7 +138,7 @@
/* Anchor */ /* Anchor */
.anchor { .anchor {
--size: 24px; --size: 20px;
position: absolute; position: absolute;
width: var(--size); width: var(--size);
height: var(--size); height: var(--size);
@ -131,53 +146,84 @@
display: grid; display: grid;
place-items: center; place-items: center;
border-radius: 50%; border-radius: 50%;
transform: translateX(-50%) translateY(-50%);
} }
.anchor-inner { .anchor-inner {
width: 12px; width: calc(var(--size) / 2);
height: 12px; height: calc(var(--size) / 2);
background: white; background: white;
border: 2px solid var(--color); border: 2px solid var(--color);
pointer-events: none; pointer-events: none;
border-radius: 2px;
} }
/* Thinner anchors for each edge */
.anchor.right,
.anchor.left {
height: calc(var(--size) * 2);
}
.anchor.top,
.anchor.bottom {
width: calc(var(--size) * 2);
}
.anchor.right .anchor-inner,
.anchor.left .anchor-inner {
height: calc(var(--size) * 1.2);
width: calc(var(--size) * 0.3);
}
.anchor.top .anchor-inner,
.anchor.bottom .anchor-inner {
width: calc(var(--size) * 1.2);
height: calc(var(--size) * 0.3);
}
/* Hide side indicators when they don't fit */
.indicator.hCompact .anchor.top,
.indicator.hCompact .anchor.bottom,
.indicator.vCompact .anchor.left,
.indicator.vCompact .anchor.right {
display: none;
}
/* Anchor positions */
.anchor.right { .anchor.right {
right: calc(var(--size) / -2 - 1px); left: calc(100% + 1px);
top: calc(50% - var(--size) / 2); top: 50%;
cursor: e-resize; cursor: e-resize;
} }
.anchor.left { .anchor.left {
left: calc(var(--size) / -2 - 1px); left: -1px;
top: calc(50% - var(--size) / 2); top: 50%;
cursor: w-resize; cursor: w-resize;
} }
.anchor.bottom { .anchor.bottom {
left: calc(50% - var(--size) / 2 + 1px); left: 50%;
bottom: calc(var(--size) / -2 - 1px); top: calc(100% + 1px);
cursor: s-resize; cursor: s-resize;
} }
.anchor.top { .anchor.top {
left: calc(50% - var(--size) / 2 + 1px); left: 50%;
top: calc(var(--size) / -2 - 1px); top: -1px;
cursor: n-resize; cursor: n-resize;
} }
.anchor.bottom-right { .anchor.bottom-right {
right: calc(var(--size) / -2 - 1px); top: 100%;
bottom: calc(var(--size) / -2 - 1px); left: 100%;
cursor: se-resize; cursor: se-resize;
} }
.anchor.bottom-left { .anchor.bottom-left {
left: calc(var(--size) / -2 - 1px); left: 0;
bottom: calc(var(--size) / -2 - 1px); top: 100%;
cursor: sw-resize; cursor: sw-resize;
} }
.anchor.top-right { .anchor.top-right {
right: calc(var(--size) / -2 - 1px); left: 100%;
top: calc(var(--size) / -2 - 1px); top: 0;
cursor: ne-resize; cursor: ne-resize;
} }
.anchor.top-left { .anchor.top-left {
left: calc(var(--size) / -2 - 1px); left: 0;
top: calc(var(--size) / -2 - 1px); top: 0;
cursor: nw-resize; cursor: nw-resize;
} }
</style> </style>

View File

@ -1,8 +1,8 @@
<script> <script>
import { onMount, onDestroy } from "svelte" import { onMount, onDestroy } from "svelte"
import Indicator from "./Indicator.svelte" import Indicator from "./Indicator.svelte"
import { domDebounce } from "utils/domDebounce"
import { builderStore } from "stores" import { builderStore } from "stores"
import { memo, Utils } from "@budibase/frontend-core"
export let componentId = null export let componentId = null
export let color = null export let color = null
@ -10,7 +10,10 @@
export let prefix = null export let prefix = null
export let allowResizeAnchors = false export let allowResizeAnchors = false
// Offset = 6 (clip-root padding) - 1 (half the border thickness)
const config = memo($$props)
const errorColor = "var(--spectrum-global-color-static-red-600)" const errorColor = "var(--spectrum-global-color-static-red-600)"
const mutationObserver = new MutationObserver(() => debouncedUpdate())
const defaultState = () => ({ const defaultState = () => ({
// Cached props // Cached props
componentId, componentId,
@ -29,38 +32,49 @@
let interval let interval
let state = defaultState() let state = defaultState()
let nextState = null let observingMutations = false
let updating = false let updating = false
let observers = [] let intersectionObservers = []
let callbackCount = 0 let callbackCount = 0
let nextState
$: componentId, reset()
$: visibleIndicators = state.indicators.filter(x => x.visible) $: visibleIndicators = state.indicators.filter(x => x.visible)
$: offset = $builderStore.inBuilder ? 0 : 2 $: offset = $builderStore.inBuilder ? 5 : -1
$: $$props, debouncedUpdate() $: config.set({
componentId,
color,
zIndex,
prefix,
allowResizeAnchors,
})
const checkInsideGrid = id => { // Update position when any props change
const component = document.getElementsByClassName(id)[0] $: $config, debouncedUpdate()
const domNode = component?.children[0]
// Ignore grid itself const reset = () => {
if (domNode?.classList.contains("grid")) { mutationObserver.disconnect()
return false observingMutations = false
} updating = false
}
return component?.parentNode const observeMutations = element => {
?.closest?.(".component") mutationObserver.observe(element, {
?.childNodes[0]?.classList.contains("grid") attributes: true,
attributeFilter: ["style"],
})
observingMutations = true
} }
const createIntersectionCallback = idx => entries => { const createIntersectionCallback = idx => entries => {
if (callbackCount >= observers.length) { if (callbackCount >= intersectionObservers.length) {
return return
} }
nextState.indicators[idx].visible = nextState.indicators[idx].visible =
nextState.indicators[idx].insideModal || nextState.indicators[idx].insideModal ||
nextState.indicators[idx].insideSidePanel || nextState.indicators[idx].insideSidePanel ||
entries[0].isIntersecting entries[0].isIntersecting
if (++callbackCount === observers.length) { if (++callbackCount === intersectionObservers.length) {
state = nextState state = nextState
updating = false updating = false
} }
@ -76,76 +90,95 @@
state = defaultState() state = defaultState()
return return
} }
let elements = document.getElementsByClassName(componentId)
// Reset state if (!elements.length) {
state = defaultState()
return
}
updating = true updating = true
callbackCount = 0 callbackCount = 0
observers.forEach(o => o.disconnect()) intersectionObservers.forEach(o => o.disconnect())
observers = [] intersectionObservers = []
nextState = defaultState() nextState = defaultState()
// Start observing mutations if this is the first time we've seen our
// component in the DOM
if (!observingMutations) {
observeMutations(elements[0])
}
// Check if we're inside a grid // Check if we're inside a grid
if (allowResizeAnchors) { if (allowResizeAnchors) {
nextState.insideGrid = checkInsideGrid(componentId) nextState.insideGrid = elements[0]?.dataset.insideGrid === "true"
} }
// Determine next set of indicators // Get text to display
const parents = document.getElementsByClassName(componentId) nextState.text = elements[0].dataset.name
if (parents.length) { if (nextState.prefix) {
nextState.text = parents[0].dataset.name nextState.text = `${nextState.prefix} ${nextState.text}`
if (nextState.prefix) {
nextState.text = `${nextState.prefix} ${nextState.text}`
}
if (parents[0].dataset.icon) {
nextState.icon = parents[0].dataset.icon
}
} }
nextState.error = parents?.[0]?.classList.contains("error") if (elements[0].dataset.icon) {
nextState.icon = elements[0].dataset.icon
}
nextState.error = elements[0].classList.contains("error")
// Batch reads to minimize reflow // Batch reads to minimize reflow
const scrollX = window.scrollX const scrollX = window.scrollX
const scrollY = window.scrollY const scrollY = window.scrollY
// Extract valid children // Extract valid children
// Sanity limit of 100 active indicators // Sanity limit of active indicators
const children = Array.from( if (!nextState.insideGrid) {
document.getElementsByClassName(`${componentId}-dom`) elements = document.getElementsByClassName(`${componentId}-dom`)
) }
elements = Array.from(elements)
.filter(x => x != null) .filter(x => x != null)
.slice(0, 100) .slice(0, 100)
const multi = elements.length > 1
// If there aren't any nodes then reset // If there aren't any nodes then reset
if (!children.length) { if (!elements.length) {
state = defaultState() state = defaultState()
updating = false
return return
} }
const device = document.getElementById("app-root") const device = document.getElementById("app-root")
const deviceBounds = device.getBoundingClientRect() const deviceBounds = device.getBoundingClientRect()
children.forEach((child, idx) => { nextState.indicators = elements.map((element, idx) => {
const callback = createIntersectionCallback(idx) const elBounds = element.getBoundingClientRect()
const threshold = children.length > 1 ? 1 : 0 let indicator = {
const observer = new IntersectionObserver(callback, { top: Math.round(elBounds.top + scrollY - deviceBounds.top + offset),
threshold, left: Math.round(elBounds.left + scrollX - deviceBounds.left + offset),
root: device, width: Math.round(elBounds.width + 2),
}) height: Math.round(elBounds.height + 2),
observer.observe(child) visible: true,
observers.push(observer) }
const elBounds = child.getBoundingClientRect() // If observing more than one node then we need to use an intersection
nextState.indicators.push({ // observer to determine whether each indicator should be visible
top: elBounds.top + scrollY - deviceBounds.top - offset, if (multi) {
left: elBounds.left + scrollX - deviceBounds.left - offset, const callback = createIntersectionCallback(idx)
width: elBounds.width + 4, const intersectionObserver = new IntersectionObserver(callback, {
height: elBounds.height + 4, threshold: 1,
visible: false, root: device,
insideSidePanel: !!child.closest(".side-panel"), })
insideModal: !!child.closest(".modal-content"), intersectionObserver.observe(element)
}) intersectionObservers.push(intersectionObserver)
indicator.visible = false
indicator.insideSidePanel = !!element.closest(".side-panel")
indicator.insideModal = !!element.closest(".modal-content")
}
return indicator
}) })
// Immediately apply the update if we're just observing a single node
if (!multi) {
state = nextState
updating = false
}
} }
const debouncedUpdate = domDebounce(updatePosition) const debouncedUpdate = Utils.domDebounce(updatePosition)
onMount(() => { onMount(() => {
debouncedUpdate() debouncedUpdate()
@ -154,9 +187,9 @@
}) })
onDestroy(() => { onDestroy(() => {
mutationObserver.disconnect()
clearInterval(interval) clearInterval(interval)
document.removeEventListener("scroll", debouncedUpdate, true) document.removeEventListener("scroll", debouncedUpdate, true)
observers.forEach(o => o.disconnect())
}) })
</script> </script>

View File

@ -1,117 +1,180 @@
<script> <script>
import { onMount, onDestroy } from "svelte" import { onMount, onDestroy, getContext } from "svelte"
import SettingsButton from "./SettingsButton.svelte" import SettingsButton from "./SettingsButton.svelte"
import GridStylesButton from "./GridStylesButton.svelte"
import SettingsColorPicker from "./SettingsColorPicker.svelte" import SettingsColorPicker from "./SettingsColorPicker.svelte"
import SettingsPicker from "./SettingsPicker.svelte" import SettingsPicker from "./SettingsPicker.svelte"
import { builderStore, componentStore, dndIsDragging } from "stores" import { builderStore, componentStore, dndIsDragging } from "stores"
import { domDebounce } from "utils/domDebounce" import { Utils, shouldDisplaySetting } from "@budibase/frontend-core"
import { getGridVar, GridParams, Devices } from "utils/grid"
const context = getContext("context")
const verticalOffset = 36 const verticalOffset = 36
const horizontalOffset = 2 const horizontalOffset = 2
const observer = new MutationObserver(() => debouncedUpdate())
let top = 0 let top = 0
let left = 0 let left = 0
let interval let interval
let self let self
let measured = false let measured = false
let observing = false
let insideGrid = false
let gridHAlign
let gridVAlign
$: id = $builderStore.selectedComponentId $: id = $builderStore.selectedComponentId
$: id, reset()
$: component = $componentStore.selectedComponent
$: definition = $componentStore.selectedComponentDefinition
$: instance = componentStore.actions.getComponentInstance(id) $: instance = componentStore.actions.getComponentInstance(id)
$: state = $instance?.state $: state = $instance?.state
$: definition = $componentStore.selectedComponentDefinition
$: showBar = $: showBar =
definition?.showSettingsBar !== false && definition?.showSettingsBar !== false &&
!$dndIsDragging && !$dndIsDragging &&
definition && definition &&
!$state?.errorState !$state?.errorState
$: { $: settings = getBarSettings(component, definition)
if (!showBar) {
measured = false
}
}
$: settings = getBarSettings(definition)
$: isRoot = id === $builderStore.screen?.props?._id $: isRoot = id === $builderStore.screen?.props?._id
$: showGridStyles =
insideGrid &&
(definition?.grid?.hAlign !== "stretch" ||
definition?.grid?.vAlign !== "stretch")
$: mobile = $context.device.mobile
$: device = mobile ? Devices.Mobile : Devices.Desktop
$: gridHAlignVar = getGridVar(device, GridParams.HAlign)
$: gridVAlignVar = getGridVar(device, GridParams.VAlign)
const getBarSettings = definition => { const reset = () => {
observer.disconnect()
measured = false
observing = false
insideGrid = false
}
const startObserving = domBoundary => {
observer.observe(domBoundary, {
attributes: true,
attributeFilter: ["style"],
})
observing = true
}
const getBarSettings = (component, definition) => {
let allSettings = [] let allSettings = []
definition?.settings?.forEach(setting => { definition?.settings?.forEach(setting => {
if (setting.section) { if (setting.section && shouldDisplaySetting(component, setting)) {
allSettings = allSettings.concat(setting.settings || []) allSettings = allSettings.concat(setting.settings || [])
} else { } else {
allSettings.push(setting) allSettings.push(setting)
} }
}) })
return allSettings.filter(setting => setting.showInBar && !setting.hidden) return allSettings.filter(
setting =>
setting.showInBar &&
!setting.hidden &&
shouldDisplaySetting(component, setting)
)
} }
const updatePosition = () => { const updatePosition = () => {
if (!showBar) { if (!showBar) {
return return
} }
const id = $builderStore.selectedComponentId
const parent = document.getElementsByClassName(id)?.[0]
const element = parent?.children?.[0]
// The settings bar is higher in the dom tree than the selection indicators // Find DOM boundary and ensure it is valid
// as we want to be able to render the settings bar wider than the screen, let domBoundary = document.getElementsByClassName(id)[0]
// or outside the screen. if (!domBoundary) {
// Therefore we use the clip root rather than the app root to determine return reset()
// its position. }
const device = document.getElementById("clip-root")
if (element && self) {
// Batch reads to minimize reflow
const deviceBounds = device.getBoundingClientRect()
const elBounds = element.getBoundingClientRect()
const width = self.offsetWidth
const height = self.offsetHeight
const { scrollX, scrollY, innerWidth } = window
// Vertically, always render above unless no room, then render inside // If we're inside a grid, allow time for buttons to render
let newTop = elBounds.top + scrollY - verticalOffset - height const nextInsideGrid = domBoundary.dataset.insideGrid === "true"
if (newTop < deviceBounds.top - 50) { if (nextInsideGrid && !insideGrid) {
newTop = deviceBounds.top - 50 insideGrid = true
} return
if (newTop < 0) { } else {
newTop = 0 insideGrid = nextInsideGrid
} }
const deviceBottom = deviceBounds.top + deviceBounds.height
if (newTop > deviceBottom - 44) {
newTop = deviceBottom - 44
}
//If element is at the very top of the screen, put the bar below the element // Get the correct DOM boundary depending if we're inside a grid or not
if (elBounds.top < elBounds.height && elBounds.height < 80) { if (!insideGrid) {
newTop = elBounds.bottom + verticalOffset domBoundary =
} domBoundary.getElementsByClassName(`${id}-dom`)[0] ||
domBoundary.children?.[0]
}
if (!domBoundary || !self) {
return reset()
}
// Horizontally, try to center first. // Start observing if required
// Failing that, render to left edge of component. if (!observing) {
// Failing that, render to right edge of component, startObserving(domBoundary)
// Failing that, render to window left edge and accept defeat. }
let elCenter = elBounds.left + scrollX + elBounds.width / 2
let newLeft = elCenter - width / 2 // Batch reads to minimize reflow
const deviceEl = document.getElementById("clip-root")
const deviceBounds = deviceEl.getBoundingClientRect()
const elBounds = domBoundary.getBoundingClientRect()
const width = self.offsetWidth
const height = self.offsetHeight
const { scrollX, scrollY, innerWidth } = window
// Read grid metadata from data attributes
if (insideGrid) {
if (mobile) {
gridHAlign = domBoundary.dataset.gridMobileHAlign
gridVAlign = domBoundary.dataset.gridMobileVAlign
} else {
gridHAlign = domBoundary.dataset.gridDesktopHAlign
gridVAlign = domBoundary.dataset.gridDesktopVAlign
}
}
// Vertically, always render above unless no room, then render inside
let newTop = elBounds.top + scrollY - verticalOffset - height
if (newTop < deviceBounds.top - 50) {
newTop = deviceBounds.top - 50
}
if (newTop < 0) {
newTop = 0
}
const deviceBottom = deviceBounds.top + deviceBounds.height
if (newTop > deviceBottom - 44) {
newTop = deviceBottom - 44
}
//If element is at the very top of the screen, put the bar below the element
if (elBounds.top < elBounds.height && elBounds.height < 80) {
newTop = elBounds.bottom + verticalOffset
}
// Horizontally, try to center first.
// Failing that, render to left edge of component.
// Failing that, render to right edge of component,
// Failing that, render to window left edge and accept defeat.
let elCenter = elBounds.left + scrollX + elBounds.width / 2
let newLeft = elCenter - width / 2
if (newLeft < 0 || newLeft + width > innerWidth) {
newLeft = elBounds.left + scrollX - horizontalOffset
if (newLeft < 0 || newLeft + width > innerWidth) { if (newLeft < 0 || newLeft + width > innerWidth) {
newLeft = elBounds.left + scrollX - horizontalOffset newLeft = elBounds.right + scrollX - width + horizontalOffset
if (newLeft < 0 || newLeft + width > innerWidth) { if (newLeft < 0 || newLeft + width > innerWidth) {
newLeft = elBounds.right + scrollX - width + horizontalOffset newLeft = horizontalOffset
if (newLeft < 0 || newLeft + width > innerWidth) {
newLeft = horizontalOffset
}
} }
} }
// Only update state when things changes to minimize renders
if (Math.round(newTop) !== Math.round(top)) {
top = newTop
}
if (Math.round(newLeft) !== Math.round(left)) {
left = newLeft
}
measured = true
} }
// Only update state when things changes to minimize renders
if (Math.round(newTop) !== Math.round(top)) {
top = newTop
}
if (Math.round(newLeft) !== Math.round(left)) {
left = newLeft
}
measured = true
} }
const debouncedUpdate = domDebounce(updatePosition) const debouncedUpdate = Utils.domDebounce(updatePosition)
onMount(() => { onMount(() => {
debouncedUpdate() debouncedUpdate()
@ -122,16 +185,85 @@
onDestroy(() => { onDestroy(() => {
clearInterval(interval) clearInterval(interval)
document.removeEventListener("scroll", debouncedUpdate, true) document.removeEventListener("scroll", debouncedUpdate, true)
reset()
}) })
</script> </script>
{#if showBar} {#if showBar}
<div <div
class="bar" class="bar"
style="top: {top}px; left: {left}px;" style="top:{top}px; left:{left}px;"
bind:this={self} bind:this={self}
class:visible={measured} class:visible={measured}
> >
{#if showGridStyles}
<GridStylesButton
style={gridHAlignVar}
value="start"
icon="AlignLeft"
title="Align left"
active={gridHAlign === "start"}
componentId={id}
/>
<GridStylesButton
style={gridHAlignVar}
value="center"
icon="AlignCenter"
title="Align center"
active={gridHAlign === "center"}
componentId={id}
/>
<GridStylesButton
style={gridHAlignVar}
value="end"
icon="AlignRight"
title="Align right"
active={gridHAlign === "end"}
componentId={id}
/>
<GridStylesButton
style={gridHAlignVar}
value="stretch"
icon="MoveLeftRight"
title="Stretch horizontally"
active={gridHAlign === "stretch"}
componentId={id}
/>
<div class="divider" />
<GridStylesButton
style={gridVAlignVar}
value="start"
icon="AlignTop"
title="Align top"
active={gridVAlign === "start"}
componentId={id}
/>
<GridStylesButton
style={gridVAlignVar}
value="center"
icon="AlignMiddle"
title="Align middle"
active={gridVAlign === "center"}
componentId={id}
/>
<GridStylesButton
style={gridVAlignVar}
value="end"
icon="AlignBottom"
title="Align bottom"
active={gridVAlign === "end"}
componentId={id}
/>
<GridStylesButton
style={gridVAlignVar}
value="stretch"
icon="MoveUpDown"
title="Stretch vertically"
active={gridVAlign === "stretch"}
componentId={id}
/>
<div class="divider" />
{/if}
{#each settings as setting, idx} {#each settings as setting, idx}
{#if setting.type === "select"} {#if setting.type === "select"}
{#if setting.barStyle === "buttons"} {#if setting.barStyle === "buttons"}
@ -141,6 +273,7 @@
value={option.value} value={option.value}
icon={option.barIcon} icon={option.barIcon}
title={option.barTitle || option.label} title={option.barTitle || option.label}
{component}
/> />
{/each} {/each}
{:else} {:else}
@ -148,6 +281,7 @@
prop={setting.key} prop={setting.key}
options={setting.options} options={setting.options}
label={setting.label} label={setting.label}
{component}
/> />
{/if} {/if}
{:else if setting.type === "boolean"} {:else if setting.type === "boolean"}
@ -156,9 +290,10 @@
icon={setting.barIcon} icon={setting.barIcon}
title={setting.barTitle || setting.label} title={setting.barTitle || setting.label}
bool bool
{component}
/> />
{:else if setting.type === "color"} {:else if setting.type === "color"}
<SettingsColorPicker prop={setting.key} /> <SettingsColorPicker prop={setting.key} {component} />
{/if} {/if}
{#if setting.barSeparator !== false && (settings.length != idx + 1 || !isRoot)} {#if setting.barSeparator !== false && (settings.length != idx + 1 || !isRoot)}
<div class="divider" /> <div class="divider" />

View File

@ -1,17 +1,19 @@
<script> <script>
import { Icon } from "@budibase/bbui" import { Icon } from "@budibase/bbui"
import { builderStore, componentStore } from "stores" import { builderStore } from "stores"
import { createEventDispatcher } from "svelte" import { createEventDispatcher } from "svelte"
export let prop export let prop
export let value export let value
export let icon export let icon
export let title export let title
export let rotate = false
export let bool = false export let bool = false
export let active = false
export let component
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
$: currentValue = $componentStore.selectedComponent?.[prop]
$: currentValue = component?.[prop]
$: active = prop && (bool ? !!currentValue : currentValue === value) $: active = prop && (bool ? !!currentValue : currentValue === value)
</script> </script>
@ -19,7 +21,6 @@
<!-- svelte-ignore a11y-click-events-have-key-events --> <!-- svelte-ignore a11y-click-events-have-key-events -->
<div <div
{title} {title}
class:rotate
class:active class:active
on:click={() => { on:click={() => {
if (prop) { if (prop) {
@ -49,7 +50,4 @@
background-color: rgba(13, 102, 208, 0.1); background-color: rgba(13, 102, 208, 0.1);
color: var(--spectrum-global-color-blue-600); color: var(--spectrum-global-color-blue-600);
} }
.rotate {
transform: rotate(90deg);
}
</style> </style>

View File

@ -1,10 +1,11 @@
<script> <script>
import { ColorPicker } from "@budibase/bbui" import { ColorPicker } from "@budibase/bbui"
import { builderStore, componentStore } from "stores" import { builderStore } from "stores"
export let prop export let prop
export let component
$: currentValue = $componentStore.selectedComponent?.[prop] $: currentValue = component?.[prop]
</script> </script>
<div> <div>

View File

@ -1,12 +1,13 @@
<script> <script>
import { Select } from "@budibase/bbui" import { Select } from "@budibase/bbui"
import { builderStore, componentStore } from "stores" import { builderStore } from "stores"
export let prop export let prop
export let options export let options
export let label export let label
export let component
$: currentValue = $componentStore.selectedComponent?.[prop] $: currentValue = component?.[prop]
</script> </script>
<div> <div>

View File

@ -15,3 +15,6 @@ export const ActionTypes = {
export const DNDPlaceholderID = "dnd-placeholder" export const DNDPlaceholderID = "dnd-placeholder"
export const ScreenslotType = "screenslot" export const ScreenslotType = "screenslot"
export const GridRowHeight = 24
export const GridColumns = 12
export const GridSpacing = 4

View File

@ -41,13 +41,20 @@ const createBuilderStore = () => {
eventStore.actions.dispatchEvent("update-prop", { prop, value }) eventStore.actions.dispatchEvent("update-prop", { prop, value })
}, },
updateStyles: async (styles, id) => { updateStyles: async (styles, id) => {
await eventStore.actions.dispatchEvent("update-styles", { styles, id }) await eventStore.actions.dispatchEvent("update-styles", {
styles,
id,
})
}, },
keyDown: (key, ctrlKey) => { keyDown: (key, ctrlKey) => {
eventStore.actions.dispatchEvent("key-down", { key, ctrlKey }) eventStore.actions.dispatchEvent("key-down", { key, ctrlKey })
}, },
duplicateComponent: id => { duplicateComponent: (id, mode = "below", selectComponent = true) => {
eventStore.actions.dispatchEvent("duplicate-component", { id }) eventStore.actions.dispatchEvent("duplicate-component", {
id,
mode,
selectComponent,
})
}, },
deleteComponent: id => { deleteComponent: id => {
eventStore.actions.dispatchEvent("delete-component", { id }) eventStore.actions.dispatchEvent("delete-component", { id })

View File

@ -142,9 +142,6 @@ const createComponentStore = () => {
} }
const getComponentInstance = id => { const getComponentInstance = id => {
if (!id) {
return null
}
return derived(store, $store => $store.mountedComponents[id]) return derived(store, $store => $store.mountedComponents[id])
} }

View File

@ -129,29 +129,30 @@ const createScreenStore = () => {
// If we don't have a legacy custom layout, build a layout structure // If we don't have a legacy custom layout, build a layout structure
// from the screen navigation settings // from the screen navigation settings
if (!activeLayout) { if (!activeLayout) {
let navigationSettings = { let layoutSettings = {
navigation: "None", navigation: "None",
pageWidth: activeScreen?.width || "Large", pageWidth: activeScreen?.width || "Large",
embedded: $appStore.embedded,
} }
if (activeScreen?.showNavigation) { if (activeScreen?.showNavigation) {
navigationSettings = { layoutSettings = {
...navigationSettings, ...layoutSettings,
...($builderStore.navigation || $appStore.application?.navigation), ...($builderStore.navigation || $appStore.application?.navigation),
} }
// Default navigation to top // Default navigation to top
if (!navigationSettings.navigation) { if (!layoutSettings.navigation) {
navigationSettings.navigation = "Top" layoutSettings.navigation = "Top"
} }
// Default title to app name // Default title to app name
if (!navigationSettings.title && !navigationSettings.hideTitle) { if (!layoutSettings.title && !layoutSettings.hideTitle) {
navigationSettings.title = $appStore.application?.name layoutSettings.title = $appStore.application?.name
} }
// Default to the org logo // Default to the org logo
if (!navigationSettings.logoUrl) { if (!layoutSettings.logoUrl) {
navigationSettings.logoUrl = $orgStore?.logoUrl layoutSettings.logoUrl = $orgStore?.logoUrl
} }
} }
activeLayout = { activeLayout = {
@ -173,8 +174,7 @@ const createScreenStore = () => {
}, },
}, },
], ],
...navigationSettings, ...layoutSettings,
embedded: $appStore.embedded,
}, },
} }
} }

View File

@ -1,14 +0,0 @@
export const domDebounce = (callback, extractParams = x => x) => {
let active = false
let lastParams
return (...params) => {
lastParams = extractParams(...params)
if (!active) {
active = true
requestAnimationFrame(() => {
callback(lastParams)
active = false
})
}
}
}

View File

@ -0,0 +1,183 @@
import { GridSpacing, GridRowHeight } from "constants"
import { builderStore } from "stores"
import { buildStyleString } from "utils/styleable.js"
/**
* We use CSS variables on components to control positioning and layout of
* components inside grids.
* --grid-[mobile/desktop]-[row/col]-[start-end]: for positioning
* --grid-[mobile/desktop]-[h/v]-align: for layout of inner components within
* the components grid bounds
*
* Component definitions define their default layout preference via the
* `grid.hAlign` and `grid.vAlign` keys in the manifest.
*
* We also apply grid-[mobile/desktop]-grow CSS classes to component wrapper
* DOM nodes to use later in selectors, to control the sizing of children.
*/
// Enum representing the different CSS variables we use for grid metadata
export const GridParams = {
HAlign: "h-align",
VAlign: "v-align",
ColStart: "col-start",
ColEnd: "col-end",
RowStart: "row-start",
RowEnd: "row-end",
}
// Classes used in selectors inside grid containers to control child styles
export const GridClasses = {
DesktopFill: "grid-desktop-grow",
MobileFill: "grid-mobile-grow",
}
// Enum for device preview type, included in grid CSS variables
export const Devices = {
Desktop: "desktop",
Mobile: "mobile",
}
export const GridDragModes = {
Resize: "resize",
Move: "move",
}
// Builds a CSS variable name for a certain piece of grid metadata
export const getGridVar = (device, param) => `--grid-${device}-${param}`
// Determines whether a JS event originated from immediately within a grid
export const isGridEvent = e => {
return (
e.target.dataset?.indicator === "true" ||
e.target
.closest?.(".component")
?.parentNode.closest(".component")
?.childNodes[0]?.classList?.contains("grid")
)
}
// Svelte action to apply required class names and styles to our component
// wrappers
export const gridLayout = (node, metadata) => {
let selectComponent
// Applies the required listeners, CSS and classes to a component DOM node
const applyMetadata = metadata => {
const {
id,
styles,
interactive,
errored,
definition,
draggable,
insideGrid,
ignoresLayout,
} = metadata
if (!insideGrid) {
return
}
// If this component ignores layout, flag it as such so that we can avoid
// selecting it later
if (ignoresLayout) {
node.classList.add("ignores-layout")
return
}
// Callback to select the component when clicking on the wrapper
selectComponent = e => {
e.stopPropagation()
builderStore.actions.selectComponent(id)
}
// Determine default width and height of component
let width = errored ? 500 : definition?.size?.width || 200
let height = errored ? 60 : definition?.size?.height || 200
width += 2 * GridSpacing
height += 2 * GridSpacing
let vars = {
"--default-width": width,
"--default-height": height,
}
// Generate defaults for all grid params
const defaults = {
[GridParams.HAlign]: definition?.grid?.hAlign || "stretch",
[GridParams.VAlign]: definition?.grid?.vAlign || "center",
[GridParams.ColStart]: 1,
[GridParams.ColEnd]:
"round(up, calc((var(--grid-spacing) * 2 + var(--default-width)) / var(--col-size) + 1))",
[GridParams.RowStart]: 1,
[GridParams.RowEnd]: Math.max(2, Math.ceil(height / GridRowHeight) + 1),
}
// Specify values for all grid params for all devices, and strip these CSS
// variables from the styles being applied to the inner component, as we
// want to apply these to the wrapper instead
for (let param of Object.values(GridParams)) {
let dVar = getGridVar(Devices.Desktop, param)
let mVar = getGridVar(Devices.Mobile, param)
vars[dVar] = styles[dVar] ?? styles[mVar] ?? defaults[param]
vars[mVar] = styles[mVar] ?? styles[dVar] ?? defaults[param]
}
// Apply some overrides depending on component state
if (errored) {
vars[getGridVar(Devices.Desktop, GridParams.HAlign)] = "stretch"
vars[getGridVar(Devices.Mobile, GridParams.HAlign)] = "stretch"
vars[getGridVar(Devices.Desktop, GridParams.VAlign)] = "stretch"
vars[getGridVar(Devices.Mobile, GridParams.VAlign)] = "stretch"
}
// Apply some metadata to data attributes to speed up lookups
const addDataTag = (tagName, device, param) => {
const val = `${vars[getGridVar(device, param)]}`
if (node.dataset[tagName] !== val) {
node.dataset[tagName] = val
}
}
addDataTag("gridDesktopRowEnd", Devices.Desktop, GridParams.RowEnd)
addDataTag("gridMobileRowEnd", Devices.Mobile, GridParams.RowEnd)
addDataTag("gridDesktopHAlign", Devices.Desktop, GridParams.HAlign)
addDataTag("gridMobileHAlign", Devices.Mobile, GridParams.HAlign)
addDataTag("gridDesktopVAlign", Devices.Desktop, GridParams.VAlign)
addDataTag("gridMobileVAlign", Devices.Mobile, GridParams.VAlign)
if (node.dataset.insideGrid !== true) {
node.dataset.insideGrid = true
}
// Apply all CSS variables to the wrapper
node.style = buildStyleString(vars)
// Add a listener to select this node on click
if (interactive) {
node.addEventListener("click", selectComponent, false)
}
// Add draggable attribute
node.setAttribute("draggable", !!draggable)
}
// Removes the previously set up listeners
const removeListeners = () => {
// By checking if this is defined we can avoid trying to remove event
// listeners on every component
if (selectComponent) {
node.removeEventListener("click", selectComponent, false)
selectComponent = null
}
}
applyMetadata(metadata)
return {
update(newMetadata) {
removeListeners()
applyMetadata(newMetadata)
},
destroy() {
removeListeners()
},
}
}

View File

@ -3,13 +3,13 @@ import { builderStore } from "stores"
/** /**
* Helper to build a CSS string from a style object. * Helper to build a CSS string from a style object.
*/ */
const buildStyleString = (styleObject, customStyles) => { export const buildStyleString = (styleObject, customStyles) => {
let str = "" let str = ""
Object.entries(styleObject || {}).forEach(([style, value]) => { for (let key of Object.keys(styleObject || {})) {
if (style && value != null) { if (styleObject[key] != null) {
str += `${style}: ${value}; ` str += `${key}:${styleObject[key]};`
} }
}) }
return str + (customStyles || "") return str + (customStyles || "")
} }

View File

@ -58,7 +58,6 @@
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
border-radius: 4px;
overflow: hidden; overflow: hidden;
background-color: var(--spectrum-global-color-gray-200); background-color: var(--spectrum-global-color-gray-200);
} }

View File

@ -1,95 +1,17 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { ActionButton, Popover, Icon, notifications } from "@budibase/bbui" import { ActionButton, Popover } from "@budibase/bbui"
import { getColumnIcon } from "../lib/utils" import ColumnsSettingContent from "./ColumnsSettingContent.svelte"
import ToggleActionButtonGroup from "./ToggleActionButtonGroup.svelte"
import { helpers } from "@budibase/shared-core"
export let allowViewReadonlyColumns = false export let allowViewReadonlyColumns = false
const { columns, datasource, dispatch } = getContext("grid") const { columns } = getContext("grid")
let open = false let open = false
let anchor let anchor
$: restrictedColumns = $columns.filter(col => !col.visible || col.readonly) $: anyRestricted = $columns.filter(col => !col.visible || col.readonly).length
$: anyRestricted = restrictedColumns.length
$: text = anyRestricted ? `Columns (${anyRestricted} restricted)` : "Columns" $: text = anyRestricted ? `Columns (${anyRestricted} restricted)` : "Columns"
const toggleColumn = async (column, permission) => {
const visible = permission !== PERMISSION_OPTIONS.HIDDEN
const readonly = permission === PERMISSION_OPTIONS.READONLY
await datasource.actions.addSchemaMutation(column.name, {
visible,
readonly,
})
try {
await datasource.actions.saveSchemaMutations()
} catch (e) {
notifications.error(e.message)
} finally {
await datasource.actions.resetSchemaMutations()
await datasource.actions.refreshDefinition()
}
dispatch(visible ? "show-column" : "hide-column")
}
const PERMISSION_OPTIONS = {
WRITABLE: "writable",
READONLY: "readonly",
HIDDEN: "hidden",
}
$: displayColumns = $columns.map(c => {
const isRequired = helpers.schema.isRequired(c.schema.constraints)
const requiredTooltip = isRequired && "Required columns must be writable"
const editEnabled =
!isRequired ||
columnToPermissionOptions(c) !== PERMISSION_OPTIONS.WRITABLE
const options = [
{
icon: "Edit",
value: PERMISSION_OPTIONS.WRITABLE,
tooltip: (!editEnabled && requiredTooltip) || "Writable",
disabled: !editEnabled,
},
]
if ($datasource.type === "viewV2") {
options.push({
icon: "Visibility",
value: PERMISSION_OPTIONS.READONLY,
tooltip: allowViewReadonlyColumns
? requiredTooltip || "Read only"
: "Read only (premium feature)",
disabled: !allowViewReadonlyColumns || isRequired,
})
}
options.push({
icon: "VisibilityOff",
value: PERMISSION_OPTIONS.HIDDEN,
disabled: c.primaryDisplay || isRequired,
tooltip:
(c.primaryDisplay && "Display column cannot be hidden") ||
requiredTooltip ||
"Hidden",
})
return { ...c, options }
})
function columnToPermissionOptions(column) {
if (!column.schema.visible) {
return PERMISSION_OPTIONS.HIDDEN
}
if (column.schema.readonly) {
return PERMISSION_OPTIONS.READONLY
}
return PERMISSION_OPTIONS.WRITABLE
}
</script> </script>
<div bind:this={anchor}> <div bind:this={anchor}>
@ -106,51 +28,5 @@
</div> </div>
<Popover bind:open {anchor} align="left"> <Popover bind:open {anchor} align="left">
<div class="content"> <ColumnsSettingContent columns={$columns} {allowViewReadonlyColumns} />
<div class="columns">
{#each displayColumns as column}
<div class="column">
<Icon size="S" name={getColumnIcon(column)} />
<div class="column-label" title={column.label}>
{column.label}
</div>
</div>
<ToggleActionButtonGroup
on:click={e => toggleColumn(column, e.detail)}
value={columnToPermissionOptions(column)}
options={column.options}
/>
{/each}
</div>
</div>
</Popover> </Popover>
<style>
.content {
padding: 12px 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.columns {
display: grid;
align-items: center;
grid-template-columns: 1fr auto;
grid-row-gap: 8px;
grid-column-gap: 24px;
}
.columns :global(.spectrum-Switch) {
margin-right: 0;
}
.column {
display: flex;
gap: 8px;
}
.column-label {
min-width: 80px;
max-width: 200px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,134 @@
<script>
import { getContext } from "svelte"
import { Icon, notifications } from "@budibase/bbui"
import { getColumnIcon } from "../lib/utils"
import ToggleActionButtonGroup from "./ToggleActionButtonGroup.svelte"
import { helpers } from "@budibase/shared-core"
export let allowViewReadonlyColumns = false
const { columns, datasource, dispatch } = getContext("grid")
const toggleColumn = async (column, permission) => {
const visible = permission !== PERMISSION_OPTIONS.HIDDEN
const readonly = permission === PERMISSION_OPTIONS.READONLY
await datasource.actions.addSchemaMutation(column.name, {
visible,
readonly,
})
try {
await datasource.actions.saveSchemaMutations()
} catch (e) {
notifications.error(e.message)
} finally {
await datasource.actions.resetSchemaMutations()
await datasource.actions.refreshDefinition()
}
dispatch(visible ? "show-column" : "hide-column")
}
const PERMISSION_OPTIONS = {
WRITABLE: "writable",
READONLY: "readonly",
HIDDEN: "hidden",
}
$: displayColumns = $columns.map(c => {
const isRequired = helpers.schema.isRequired(c.schema.constraints)
const requiredTooltip = isRequired && "Required columns must be writable"
const editEnabled =
!isRequired ||
columnToPermissionOptions(c) !== PERMISSION_OPTIONS.WRITABLE
const options = [
{
icon: "Edit",
value: PERMISSION_OPTIONS.WRITABLE,
tooltip: (!editEnabled && requiredTooltip) || "Writable",
disabled: !editEnabled,
},
]
if ($datasource.type === "viewV2") {
options.push({
icon: "Visibility",
value: PERMISSION_OPTIONS.READONLY,
tooltip: allowViewReadonlyColumns
? requiredTooltip || "Read only"
: "Read only (premium feature)",
disabled: !allowViewReadonlyColumns || isRequired,
})
}
options.push({
icon: "VisibilityOff",
value: PERMISSION_OPTIONS.HIDDEN,
disabled: c.primaryDisplay || isRequired,
tooltip:
(c.primaryDisplay && "Display column cannot be hidden") ||
requiredTooltip ||
"Hidden",
})
return { ...c, options }
})
function columnToPermissionOptions(column) {
if (column.schema.visible === false) {
return PERMISSION_OPTIONS.HIDDEN
}
if (column.schema.readonly) {
return PERMISSION_OPTIONS.READONLY
}
return PERMISSION_OPTIONS.WRITABLE
}
</script>
<div class="content">
<div class="columns">
{#each displayColumns as column}
<div class="column">
<Icon size="S" name={getColumnIcon(column)} />
<div class="column-label" title={column.label}>
{column.label}
</div>
</div>
<ToggleActionButtonGroup
on:click={e => toggleColumn(column, e.detail)}
value={columnToPermissionOptions(column)}
options={column.options}
/>
{/each}
</div>
</div>
<style>
.content {
padding: 12px 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.columns {
display: grid;
align-items: center;
grid-template-columns: 1fr auto;
grid-row-gap: 8px;
grid-column-gap: 24px;
}
.columns :global(.spectrum-Switch) {
margin-right: 0;
}
.column {
display: flex;
gap: 8px;
}
.column-label {
min-width: 80px;
max-width: 200px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
</style>

View File

@ -9,3 +9,4 @@ export { memo, derivedMemo } from "./memo"
export { createWebsocket } from "./websocket" export { createWebsocket } from "./websocket"
export * from "./download" export * from "./download"
export * from "./theme" export * from "./theme"
export * from "./settings"

View File

@ -4,32 +4,23 @@ import { writable, get, derived } from "svelte/store"
// subscribed children will only fire when a new value is actually set // subscribed children will only fire when a new value is actually set
export const memo = initialValue => { export const memo = initialValue => {
const store = writable(initialValue) const store = writable(initialValue)
let currentJSON = JSON.stringify(initialValue)
const tryUpdateValue = (newValue, currentValue) => { const tryUpdateValue = newValue => {
// Sanity check for primitive equality const newJSON = JSON.stringify(newValue)
if (currentValue === newValue) { if (newJSON !== currentJSON) {
return
}
// Otherwise deep compare via JSON stringify
const currentString = JSON.stringify(currentValue)
const newString = JSON.stringify(newValue)
if (currentString !== newString) {
store.set(newValue) store.set(newValue)
currentJSON = newJSON
} }
} }
return { return {
subscribe: store.subscribe, subscribe: store.subscribe,
set: newValue => { set: tryUpdateValue,
const currentValue = get(store)
tryUpdateValue(newValue, currentValue)
},
update: updateFn => { update: updateFn => {
const currentValue = get(store) let mutableCurrentValue = JSON.parse(currentJSON)
let mutableCurrentValue = JSON.parse(JSON.stringify(currentValue))
const newValue = updateFn(mutableCurrentValue) const newValue = updateFn(mutableCurrentValue)
tryUpdateValue(newValue, currentValue) tryUpdateValue(newValue)
}, },
} }
} }

View File

@ -0,0 +1,43 @@
import { helpers } from "@budibase/shared-core"
// Util to check if a setting can be rendered for a certain instance, based on
// the "dependsOn" metadata in the manifest
export const shouldDisplaySetting = (instance, setting) => {
let dependsOn = setting.dependsOn
if (dependsOn && !Array.isArray(dependsOn)) {
dependsOn = [dependsOn]
}
if (!dependsOn?.length) {
return true
}
// Ensure all conditions are met
return dependsOn.every(condition => {
let dependantSetting = condition
let dependantValues = null
let invert = !!condition.invert
if (typeof condition === "object") {
dependantSetting = condition.setting
dependantValues = condition.value
}
if (!dependantSetting) {
return false
}
// Ensure values is an array
if (!Array.isArray(dependantValues)) {
dependantValues = [dependantValues]
}
// If inverting, we want to ensure that we don't have any matches.
// If not inverting, we want to ensure that we do have any matches.
const currentVal = helpers.deepGet(instance, dependantSetting)
const anyMatches = dependantValues.some(dependantVal => {
if (dependantVal == null) {
return currentVal != null && currentVal !== false && currentVal !== ""
}
return dependantVal === currentVal
})
return anyMatches !== invert
})
}

View File

@ -156,6 +156,7 @@ export const buildFormBlockButtonConfig = props => {
providerId: formId, providerId: formId,
tableId: resourceId, tableId: resourceId,
notificationOverride, notificationOverride,
confirm: null,
}, },
}, },
{ {

View File

@ -45,6 +45,7 @@ import { db as dbCore } from "@budibase/backend-core"
import sdk from "../../../sdk" import sdk from "../../../sdk"
import env from "../../../environment" import env from "../../../environment"
import { makeExternalQuery } from "../../../integrations/base/query" import { makeExternalQuery } from "../../../integrations/base/query"
import { dataFilters } from "@budibase/shared-core"
export interface ManyRelationship { export interface ManyRelationship {
tableId?: string tableId?: string
@ -195,29 +196,33 @@ export class ExternalRequest<T extends Operation> {
if (filters) { if (filters) {
// need to map over the filters and make sure the _id field isn't present // need to map over the filters and make sure the _id field isn't present
let prefix = 1 let prefix = 1
for (const [operatorType, operator] of Object.entries(filters)) { const checkFilters = (innerFilters: SearchFilters): SearchFilters => {
const isArrayOp = sdk.rows.utils.isArrayFilter(operatorType) for (const [operatorType, operator] of Object.entries(innerFilters)) {
for (const field of Object.keys(operator || {})) { const isArrayOp = sdk.rows.utils.isArrayFilter(operatorType)
if (dbCore.removeKeyNumbering(field) === "_id") { for (const field of Object.keys(operator || {})) {
if (primary) { if (dbCore.removeKeyNumbering(field) === "_id") {
const parts = breakRowIdField(operator[field]) if (primary) {
if (primary.length > 1 && isArrayOp) { const parts = breakRowIdField(operator[field])
operator[InternalSearchFilterOperator.COMPLEX_ID_OPERATOR] = { if (primary.length > 1 && isArrayOp) {
id: primary, operator[InternalSearchFilterOperator.COMPLEX_ID_OPERATOR] = {
values: parts[0], id: primary,
values: parts[0],
}
} else {
for (let field of primary) {
operator[`${prefix}:${field}`] = parts.shift()
}
prefix++
} }
} else {
for (let field of primary) {
operator[`${prefix}:${field}`] = parts.shift()
}
prefix++
} }
// make sure this field doesn't exist on any filter
delete operator[field]
} }
// make sure this field doesn't exist on any filter
delete operator[field]
} }
} }
return dataFilters.recurseLogicalOperators(innerFilters, checkFilters)
} }
checkFilters(filters)
} }
// there is no id, just use the user provided filters // there is no id, just use the user provided filters
if (!idCopy || !table) { if (!idCopy || !table) {

View File

@ -151,7 +151,10 @@ export function buildExternalRelationships(
return relationships return relationships
} }
export function buildInternalRelationships(table: Table): RelationshipsJson[] { export function buildInternalRelationships(
table: Table,
allTables: Table[]
): RelationshipsJson[] {
const relationships: RelationshipsJson[] = [] const relationships: RelationshipsJson[] = []
const links = Object.values(table.schema).filter( const links = Object.values(table.schema).filter(
column => column.type === FieldType.LINK column => column.type === FieldType.LINK
@ -164,6 +167,10 @@ export function buildInternalRelationships(table: Table): RelationshipsJson[] {
const linkTableId = link.tableId! const linkTableId = link.tableId!
const junctionTableId = generateJunctionTableID(tableId, linkTableId) const junctionTableId = generateJunctionTableID(tableId, linkTableId)
const isFirstTable = tableId > linkTableId const isFirstTable = tableId > linkTableId
// skip relationships with missing table definitions
if (!allTables.find(table => table._id === linkTableId)) {
continue
}
relationships.push({ relationships.push({
through: junctionTableId, through: junctionTableId,
column: link.name, column: link.name,
@ -192,10 +199,10 @@ export function buildSqlFieldList(
function extractRealFields(table: Table, existing: string[] = []) { function extractRealFields(table: Table, existing: string[] = []) {
return Object.entries(table.schema) return Object.entries(table.schema)
.filter( .filter(
column => ([columnName, column]) =>
column[1].type !== FieldType.LINK && column.type !== FieldType.LINK &&
column[1].type !== FieldType.FORMULA && column.type !== FieldType.FORMULA &&
!existing.find((field: string) => field === column[0]) !existing.find((field: string) => field === columnName)
) )
.map(column => `${table.name}.${column[0]}`) .map(column => `${table.name}.${column[0]}`)
} }

View File

@ -38,7 +38,6 @@ export async function searchView(
let query = dataFilters.buildQuery(view.query || []) let query = dataFilters.buildQuery(view.query || [])
if (body.query) { if (body.query) {
// Delete extraneous search params that cannot be overridden // Delete extraneous search params that cannot be overridden
delete body.query.allOr
delete body.query.onEmptyFilter delete body.query.onEmptyFilter
if (!isExternalTableID(view.tableId) && !db.isSqsEnabledForTenant()) { if (!isExternalTableID(view.tableId) && !db.isSqsEnabledForTenant()) {
@ -57,13 +56,12 @@ export async function searchView(
} }
}) })
}) })
} else { } else
query = { query = {
$and: { $and: {
conditions: [query, body.query], conditions: [query, body.query],
}, },
} }
}
} }
await context.ensureSnippetContext(true) await context.ensureSnippetContext(true)

View File

@ -1664,7 +1664,7 @@ describe.each([
isInternal && isInternal &&
describe("attachments and signatures", () => { describe("attachments and signatures", () => {
const coreAttachmentEnrichment = async ( const coreAttachmentEnrichment = async (
schema: any, schema: TableSchema,
field: string, field: string,
attachmentCfg: string | string[] attachmentCfg: string | string[]
) => { ) => {
@ -1691,7 +1691,7 @@ describe.each([
await withEnv({ SELF_HOSTED: "true" }, async () => { await withEnv({ SELF_HOSTED: "true" }, async () => {
return context.doInAppContext(config.getAppId(), async () => { return context.doInAppContext(config.getAppId(), async () => {
const enriched: Row[] = await outputProcessing(table, [row]) const enriched: Row[] = await outputProcessing(testTable, [row])
const [targetRow] = enriched const [targetRow] = enriched
const attachmentEntries = Array.isArray(targetRow[field]) const attachmentEntries = Array.isArray(targetRow[field])
? targetRow[field] ? targetRow[field]

View File

@ -2762,6 +2762,57 @@ describe.each([
}) })
}) })
isSql &&
describe("primaryDisplay", () => {
beforeAll(async () => {
let toRelateTable = await createTable({
name: {
name: "name",
type: FieldType.STRING,
},
})
table = await config.api.table.save(
tableForDatasource(datasource, {
schema: {
name: {
name: "name",
type: FieldType.STRING,
},
link: {
name: "link",
type: FieldType.LINK,
relationshipType: RelationshipType.MANY_TO_ONE,
tableId: toRelateTable._id!,
fieldName: "link",
},
},
})
)
toRelateTable = await config.api.table.get(toRelateTable._id!)
await config.api.table.save({
...toRelateTable,
primaryDisplay: "link",
})
const relatedRows = await Promise.all([
config.api.row.save(toRelateTable._id!, { name: "test" }),
])
await Promise.all([
config.api.row.save(table._id!, {
name: "test",
link: relatedRows.map(row => row._id),
}),
])
})
it("should be able to query, primary display on related table shouldn't be used", async () => {
// this test makes sure that if a relationship has been specified as the primary display on a table
// it is ignored and another column is used instead
await expectQuery({}).toContain([
{ name: "test", link: [{ primaryDisplay: "test" }] },
])
})
})
!isLucene && !isLucene &&
describe("$and", () => { describe("$and", () => {
beforeAll(async () => { beforeAll(async () => {

View File

@ -30,6 +30,7 @@ import {
withEnv as withCoreEnv, withEnv as withCoreEnv,
setEnv as setCoreEnv, setEnv as setCoreEnv,
} from "@budibase/backend-core" } from "@budibase/backend-core"
import sdk from "../../../sdk"
describe.each([ describe.each([
["lucene", undefined], ["lucene", undefined],
@ -120,6 +121,7 @@ describe.each([
}) })
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks()
mocks.licenses.useCloudFree() mocks.licenses.useCloudFree()
}) })
@ -1490,83 +1492,189 @@ describe.each([
) )
}) })
isLucene && it("can query on top of the view filters", async () => {
it("in lucene, cannot override a view filter", async () => { await config.api.row.save(table._id!, {
await config.api.row.save(table._id!, { one: "foo",
one: "foo", two: "bar",
two: "bar", })
}) await config.api.row.save(table._id!, {
const two = await config.api.row.save(table._id!, { one: "foo2",
one: "foo2", two: "bar2",
two: "bar2", })
}) const three = await config.api.row.save(table._id!, {
one: "foo3",
const view = await config.api.viewV2.create({ two: "bar3",
tableId: table._id!,
name: generator.guid(),
query: [
{
operator: BasicOperator.EQUAL,
field: "two",
value: "bar2",
},
],
schema: {
id: { visible: true },
one: { visible: false },
two: { visible: true },
},
})
const response = await config.api.viewV2.search(view.id, {
query: {
equal: {
two: "bar",
},
},
})
expect(response.rows).toHaveLength(1)
expect(response.rows).toEqual([
expect.objectContaining({ _id: two._id }),
])
}) })
!isLucene && const view = await config.api.viewV2.create({
it("can filter a view without a view filter", async () => { tableId: table._id!,
const one = await config.api.row.save(table._id!, { name: generator.guid(),
one: "foo", query: [
two: "bar", {
}) operator: BasicOperator.NOT_EQUAL,
await config.api.row.save(table._id!, { field: "one",
one: "foo2", value: "foo2",
two: "bar2",
})
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
schema: {
id: { visible: true },
one: { visible: false },
two: { visible: true },
}, },
}) ],
schema: {
id: { visible: true },
one: { visible: true },
two: { visible: true },
},
})
const response = await config.api.viewV2.search(view.id, { const response = await config.api.viewV2.search(view.id, {
query: { query: {
equal: { [BasicOperator.EQUAL]: {
two: "bar", two: "bar3",
},
}, },
}) [BasicOperator.NOT_EMPTY]: {
expect(response.rows).toHaveLength(1) two: null,
expect(response.rows).toEqual([ },
},
})
expect(response.rows).toHaveLength(1)
expect(response.rows).toEqual(
expect.arrayContaining([expect.objectContaining({ _id: three._id })])
)
})
it("can query on top of the view filters (using or filters)", async () => {
const one = await config.api.row.save(table._id!, {
one: "foo",
two: "bar",
})
await config.api.row.save(table._id!, {
one: "foo2",
two: "bar2",
})
const three = await config.api.row.save(table._id!, {
one: "foo3",
two: "bar3",
})
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
query: [
{
operator: BasicOperator.NOT_EQUAL,
field: "two",
value: "bar2",
},
],
schema: {
id: { visible: true },
one: { visible: false },
two: { visible: true },
},
})
const response = await config.api.viewV2.search(view.id, {
query: {
allOr: true,
[BasicOperator.NOT_EQUAL]: {
two: "bar",
},
[BasicOperator.NOT_EMPTY]: {
two: null,
},
},
})
expect(response.rows).toHaveLength(2)
expect(response.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({ _id: one._id }), expect.objectContaining({ _id: one._id }),
expect.objectContaining({ _id: three._id }),
]) ])
}) )
})
isLucene &&
it.each([true, false])(
"in lucene, cannot override a view filter",
async allOr => {
await config.api.row.save(table._id!, {
one: "foo",
two: "bar",
})
const two = await config.api.row.save(table._id!, {
one: "foo2",
two: "bar2",
})
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
query: [
{
operator: BasicOperator.EQUAL,
field: "two",
value: "bar2",
},
],
schema: {
id: { visible: true },
one: { visible: false },
two: { visible: true },
},
})
const response = await config.api.viewV2.search(view.id, {
query: {
allOr,
equal: {
two: "bar",
},
},
})
expect(response.rows).toHaveLength(1)
expect(response.rows).toEqual([
expect.objectContaining({ _id: two._id }),
])
}
)
!isLucene && !isLucene &&
it("cannot bypass a view filter", async () => { it.each([true, false])(
"can filter a view without a view filter",
async allOr => {
const one = await config.api.row.save(table._id!, {
one: "foo",
two: "bar",
})
await config.api.row.save(table._id!, {
one: "foo2",
two: "bar2",
})
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
schema: {
id: { visible: true },
one: { visible: false },
two: { visible: true },
},
})
const response = await config.api.viewV2.search(view.id, {
query: {
allOr,
equal: {
two: "bar",
},
},
})
expect(response.rows).toHaveLength(1)
expect(response.rows).toEqual([
expect.objectContaining({ _id: one._id }),
])
}
)
!isLucene &&
it.each([true, false])("cannot bypass a view filter", async allOr => {
await config.api.row.save(table._id!, { await config.api.row.save(table._id!, {
one: "foo", one: "foo",
two: "bar", two: "bar",
@ -1595,6 +1703,7 @@ describe.each([
const response = await config.api.viewV2.search(view.id, { const response = await config.api.viewV2.search(view.id, {
query: { query: {
allOr,
equal: { equal: {
two: "bar", two: "bar",
}, },
@ -1602,6 +1711,28 @@ describe.each([
}) })
expect(response.rows).toHaveLength(0) expect(response.rows).toHaveLength(0)
}) })
it("queries the row api passing the view fields only", async () => {
const searchSpy = jest.spyOn(sdk.rows, "search")
const view = await config.api.viewV2.create({
tableId: table._id!,
name: generator.guid(),
schema: {
id: { visible: true },
one: { visible: false },
},
})
await config.api.viewV2.search(view.id, { query: {} })
expect(searchSpy).toHaveBeenCalledTimes(1)
expect(searchSpy).toHaveBeenCalledWith(
expect.objectContaining({
fields: ["id"],
})
)
})
}) })
describe("permissions", () => { describe("permissions", () => {

View File

@ -1,10 +1,10 @@
import LinkController from "./LinkController" import LinkController from "./LinkController"
import { import {
getLinkDocuments, getLinkDocuments,
getUniqueByProp,
getRelatedTableForField,
getLinkedTableIDs,
getLinkedTable, getLinkedTable,
getLinkedTableIDs,
getRelatedTableForField,
getUniqueByProp,
} from "./linkUtils" } from "./linkUtils"
import flatten from "lodash/flatten" import flatten from "lodash/flatten"
import { USER_METDATA_PREFIX } from "../utils" import { USER_METDATA_PREFIX } from "../utils"
@ -13,16 +13,25 @@ import { getGlobalUsersFromMetadata } from "../../utilities/global"
import { processFormulas } from "../../utilities/rowProcessor" import { processFormulas } from "../../utilities/rowProcessor"
import { context } from "@budibase/backend-core" import { context } from "@budibase/backend-core"
import { import {
Table,
Row,
LinkDocumentValue,
FieldType,
ContextUser, ContextUser,
FieldType,
LinkDocumentValue,
Row,
Table,
} from "@budibase/types" } from "@budibase/types"
import sdk from "../../sdk" import sdk from "../../sdk"
export { IncludeDocs, getLinkDocuments, createLinkView } from "./linkUtils" export { IncludeDocs, getLinkDocuments, createLinkView } from "./linkUtils"
const INVALID_DISPLAY_COLUMN_TYPE = [
FieldType.LINK,
FieldType.ATTACHMENTS,
FieldType.ATTACHMENT_SINGLE,
FieldType.SIGNATURE_SINGLE,
FieldType.BB_REFERENCE,
FieldType.BB_REFERENCE_SINGLE,
]
/** /**
* This functionality makes sure that when rows with links are created, updated or deleted they are processed * This functionality makes sure that when rows with links are created, updated or deleted they are processed
* correctly - making sure that no stale links are left around and that all links have been made successfully. * correctly - making sure that no stale links are left around and that all links have been made successfully.
@ -206,6 +215,31 @@ export async function attachFullLinkedDocs(
return rows return rows
} }
/**
* Finds a valid value for the primary display, avoiding columns which break things
* like relationships (can be circular).
* @param row The row to lift a value from for the primary display.
* @param table The related table to attempt to work out the primary display column from.
*/
function getPrimaryDisplayValue(row: Row, table?: Table) {
const primaryDisplay = table?.primaryDisplay
let invalid = true
if (primaryDisplay) {
const primaryDisplaySchema = table?.schema[primaryDisplay]
invalid = INVALID_DISPLAY_COLUMN_TYPE.includes(primaryDisplaySchema.type)
}
if (invalid || !primaryDisplay) {
const validKey = Object.keys(table?.schema || {}).find(
key =>
table?.schema[key].type &&
!INVALID_DISPLAY_COLUMN_TYPE.includes(table?.schema[key].type)
)
return validKey ? row[validKey] : undefined
} else {
return row[primaryDisplay]
}
}
/** /**
* This function will take the given enriched rows and squash the links to only contain the primary display field. * This function will take the given enriched rows and squash the links to only contain the primary display field.
* @param table The table from which the rows originated. * @param table The table from which the rows originated.
@ -232,9 +266,7 @@ export async function squashLinksToPrimaryDisplay(
const linkTblId = link.tableId || getRelatedTableForField(table, column) const linkTblId = link.tableId || getRelatedTableForField(table, column)
const linkedTable = await getLinkedTable(linkTblId!, linkedTables) const linkedTable = await getLinkedTable(linkTblId!, linkedTables)
const obj: any = { _id: link._id } const obj: any = { _id: link._id }
if (linkedTable?.primaryDisplay && link[linkedTable.primaryDisplay]) { obj.primaryDisplay = getPrimaryDisplayValue(link, linkedTable)
obj.primaryDisplay = link[linkedTable.primaryDisplay]
}
newLinks.push(obj) newLinks.push(obj)
} }
row[column] = newLinks row[column] = newLinks

View File

@ -194,8 +194,8 @@ describe("SQL query builder", () => {
}) })
) )
expect(query).toEqual({ expect(query).toEqual({
bindings: ["john%", limit, 5000], bindings: ["john%", limit, "john%", 5000],
sql: `select * from (select * from (select * from (select * from "test" where LOWER("test"."name") LIKE :1 order by "test"."id" asc) where rownum <= :2) "test" order by "test"."id" asc) where rownum <= :3`, sql: `select * from (select * from (select * from (select * from "test" where LOWER("test"."name") LIKE :1 order by "test"."id" asc) where rownum <= :2) "test" where LOWER("test"."name") LIKE :3 order by "test"."id" asc) where rownum <= :4`,
}) })
query = new Sql(SqlClient.ORACLE, limit)._query( query = new Sql(SqlClient.ORACLE, limit)._query(
@ -208,9 +208,10 @@ describe("SQL query builder", () => {
}, },
}) })
) )
const filterSet = [`%20%`, `%25%`, `%"john"%`, `%"mary"%`]
expect(query).toEqual({ expect(query).toEqual({
bindings: ["%20%", "%25%", `%"john"%`, `%"mary"%`, limit, 5000], bindings: [...filterSet, limit, ...filterSet, 5000],
sql: `select * from (select * from (select * from (select * from "test" where COALESCE(LOWER("test"."age"), '') LIKE :1 AND COALESCE(LOWER("test"."age"), '') LIKE :2 and COALESCE(LOWER("test"."name"), '') LIKE :3 AND COALESCE(LOWER("test"."name"), '') LIKE :4 order by "test"."id" asc) where rownum <= :5) "test" order by "test"."id" asc) where rownum <= :6`, sql: `select * from (select * from (select * from (select * from "test" where COALESCE(LOWER("test"."age"), '') LIKE :1 AND COALESCE(LOWER("test"."age"), '') LIKE :2 and COALESCE(LOWER("test"."name"), '') LIKE :3 AND COALESCE(LOWER("test"."name"), '') LIKE :4 order by "test"."id" asc) where rownum <= :5) "test" where COALESCE(LOWER("test"."age"), '') LIKE :6 AND COALESCE(LOWER("test"."age"), '') LIKE :7 and COALESCE(LOWER("test"."name"), '') LIKE :8 AND COALESCE(LOWER("test"."name"), '') LIKE :9 order by "test"."id" asc) where rownum <= :10`,
}) })
query = new Sql(SqlClient.ORACLE, limit)._query( query = new Sql(SqlClient.ORACLE, limit)._query(
@ -223,8 +224,8 @@ describe("SQL query builder", () => {
}) })
) )
expect(query).toEqual({ expect(query).toEqual({
bindings: [`%jo%`, limit, 5000], bindings: [`%jo%`, limit, `%jo%`, 5000],
sql: `select * from (select * from (select * from (select * from "test" where LOWER("test"."name") LIKE :1 order by "test"."id" asc) where rownum <= :2) "test" order by "test"."id" asc) where rownum <= :3`, sql: `select * from (select * from (select * from (select * from "test" where LOWER("test"."name") LIKE :1 order by "test"."id" asc) where rownum <= :2) "test" where LOWER("test"."name") LIKE :3 order by "test"."id" asc) where rownum <= :4`,
}) })
}) })
@ -241,8 +242,8 @@ describe("SQL query builder", () => {
) )
expect(query).toEqual({ expect(query).toEqual({
bindings: ["John", limit, 5000], bindings: ["John", limit, "John", 5000],
sql: `select * from (select * from (select * from (select * from "test" where (to_char("test"."name") IS NOT NULL AND to_char("test"."name") = :1) order by "test"."id" asc) where rownum <= :2) "test" order by "test"."id" asc) where rownum <= :3`, sql: `select * from (select * from (select * from (select * from "test" where (to_char("test"."name") IS NOT NULL AND to_char("test"."name") = :1) order by "test"."id" asc) where rownum <= :2) "test" where (to_char("test"."name") IS NOT NULL AND to_char("test"."name") = :3) order by "test"."id" asc) where rownum <= :4`,
}) })
}) })
@ -259,8 +260,8 @@ describe("SQL query builder", () => {
) )
expect(query).toEqual({ expect(query).toEqual({
bindings: ["John", limit, 5000], bindings: ["John", limit, "John", 5000],
sql: `select * from (select * from (select * from (select * from "test" where (to_char("test"."name") IS NOT NULL AND to_char("test"."name") != :1) OR to_char("test"."name") IS NULL order by "test"."id" asc) where rownum <= :2) "test" order by "test"."id" asc) where rownum <= :3`, sql: `select * from (select * from (select * from (select * from "test" where (to_char("test"."name") IS NOT NULL AND to_char("test"."name") != :1) OR to_char("test"."name") IS NULL order by "test"."id" asc) where rownum <= :2) "test" where (to_char("test"."name") IS NOT NULL AND to_char("test"."name") != :3) OR to_char("test"."name") IS NULL order by "test"."id" asc) where rownum <= :4`,
}) })
}) })
}) })

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