merge
This commit is contained in:
commit
8e40e98b6a
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
"$schema": "node_modules/lerna/schemas/lerna-schema.json",
|
||||||
"version": "2.30.6",
|
"version": "2.31.2",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
|
|
|
@ -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)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -1065,7 +1065,12 @@
|
||||||
value={inputData[key]}
|
value={inputData[key]}
|
||||||
/>
|
/>
|
||||||
{:else if value.customType === "code"}
|
{:else if value.customType === "code"}
|
||||||
<CodeEditorModal>
|
<CodeEditorModal
|
||||||
|
on:hide={() => {
|
||||||
|
// Push any pending changes when the window closes
|
||||||
|
onChange({ [key]: inputData[key] })
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div class:js-editor={editingJs}>
|
<div class:js-editor={editingJs}>
|
||||||
<div
|
<div
|
||||||
class:js-code={editingJs}
|
class:js-code={editingJs}
|
||||||
|
@ -1075,7 +1080,6 @@
|
||||||
value={inputData[key]}
|
value={inputData[key]}
|
||||||
on:change={e => {
|
on:change={e => {
|
||||||
// need to pass without the value inside
|
// need to pass without the value inside
|
||||||
onChange({ [key]: e.detail })
|
|
||||||
inputData[key] = e.detail
|
inputData[key] = e.detail
|
||||||
}}
|
}}
|
||||||
completions={stepCompletions}
|
completions={stepCompletions}
|
||||||
|
|
|
@ -11,7 +11,7 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Modal bind:this={modal}>
|
<Modal bind:this={modal} on:hide>
|
||||||
<ModalContent
|
<ModalContent
|
||||||
size="XL"
|
size="XL"
|
||||||
title="Edit Code"
|
title="Edit Code"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
export const datasourceSelect = {
|
export const datasourceSelect = {
|
||||||
table: (table, datasources) => ({
|
table: (table, datasources) => {
|
||||||
label: table.name,
|
const sourceId = table.sourceId || table.datasourceId
|
||||||
tableId: table._id,
|
const datasource = datasources.find(ds => ds._id === sourceId)
|
||||||
type: "table",
|
return {
|
||||||
datasource: datasources.find(
|
label: table.name,
|
||||||
datasource => datasource._id === table.sourceId || table.datasourceId
|
tableId: table._id,
|
||||||
),
|
type: "table",
|
||||||
}),
|
datasourceName: datasource?.name,
|
||||||
|
}
|
||||||
|
},
|
||||||
viewV2: view => ({
|
viewV2: view => ({
|
||||||
...view,
|
...view,
|
||||||
label: view.name,
|
label: view.name,
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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%;
|
||||||
|
|
|
@ -574,15 +574,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 => {
|
||||||
|
@ -601,14 +612,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) {
|
||||||
|
@ -616,6 +619,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
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -650,7 +654,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
|
||||||
|
@ -674,8 +678,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) {
|
||||||
|
@ -683,6 +689,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)
|
||||||
|
@ -725,12 +744,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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,4 +54,28 @@ export class Component extends BaseStructure {
|
||||||
getId() {
|
getId() {
|
||||||
return this._json._id
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -8,6 +8,7 @@ const blank = ({ route, screens }) => {
|
||||||
|
|
||||||
const template = new Screen()
|
const template = new Screen()
|
||||||
.instanceName("Blank screen")
|
.instanceName("Blank screen")
|
||||||
|
.customProps({ layout: "grid" })
|
||||||
.role(Roles.BASIC)
|
.role(Roles.BASIC)
|
||||||
.route(validRoute)
|
.route(validRoute)
|
||||||
.json()
|
.json()
|
||||||
|
|
|
@ -9,16 +9,21 @@ const inline = ({ tableOrView, permissions, screens }) => {
|
||||||
.customProps({
|
.customProps({
|
||||||
text: tableOrView.name,
|
text: tableOrView.name,
|
||||||
})
|
})
|
||||||
|
.gridDesktopColSpan(1, 13)
|
||||||
|
.gridDesktopRowSpan(1, 3)
|
||||||
|
|
||||||
const tableBlock = new Component("@budibase/standard-components/gridblock")
|
const tableBlock = new Component("@budibase/standard-components/gridblock")
|
||||||
.instanceName(`${tableOrView.name} - Table`)
|
.instanceName(`${tableOrView.name} - Table`)
|
||||||
.customProps({
|
.customProps({
|
||||||
table: tableOrView.datasourceSelectFormat,
|
table: tableOrView.datasourceSelectFormat,
|
||||||
})
|
})
|
||||||
|
.gridDesktopColSpan(1, 13)
|
||||||
|
.gridDesktopRowSpan(3, 21)
|
||||||
|
|
||||||
const screenTemplate = new Screen()
|
const screenTemplate = new Screen()
|
||||||
.route(getValidRoute(screens, tableOrView.name, permissions.write))
|
.route(getValidRoute(screens, tableOrView.name, permissions.write))
|
||||||
.instanceName(`${tableOrView.name} - List`)
|
.instanceName(`${tableOrView.name} - List`)
|
||||||
|
.customProps({ layout: "grid" })
|
||||||
.role(permissions.write)
|
.role(permissions.write)
|
||||||
.autoTableId(tableOrView.id)
|
.autoTableId(tableOrView.id)
|
||||||
.addChild(heading)
|
.addChild(heading)
|
||||||
|
|
|
@ -33,26 +33,22 @@ const modal = ({ tableOrView, permissions, screens }) => {
|
||||||
type: "cta",
|
type: "cta",
|
||||||
})
|
})
|
||||||
|
|
||||||
buttonGroup.instanceName(`${tableOrView.name} - Create`).customProps({
|
buttonGroup
|
||||||
hAlign: "right",
|
.instanceName(`${tableOrView.name} - Create`)
|
||||||
buttons: [createButton.json()],
|
|
||||||
})
|
|
||||||
|
|
||||||
const tableHeader = 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)
|
||||||
tableHeader.addChild(heading)
|
.gridDesktopRowSpan(1, 3)
|
||||||
tableHeader.addChild(buttonGroup)
|
|
||||||
|
|
||||||
const createFormBlock = new Component(
|
const createFormBlock = new Component(
|
||||||
"@budibase/standard-components/formblock"
|
"@budibase/standard-components/formblock"
|
||||||
|
@ -134,13 +130,17 @@ const modal = ({ tableOrView, permissions, screens }) => {
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.instanceName(`${tableOrView.name} - Table`)
|
.instanceName(`${tableOrView.name} - Table`)
|
||||||
|
.gridDesktopColSpan(1, 13)
|
||||||
|
.gridDesktopRowSpan(3, 21)
|
||||||
|
|
||||||
const template = new Screen()
|
const template = new Screen()
|
||||||
.route(getValidRoute(screens, tableOrView.name, permissions.write))
|
.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.id)
|
.autoTableId(tableOrView.id)
|
||||||
.addChild(tableHeader)
|
.addChild(heading)
|
||||||
|
.addChild(buttonGroup)
|
||||||
.addChild(tableBlock)
|
.addChild(tableBlock)
|
||||||
.addChild(createRowModal)
|
.addChild(createRowModal)
|
||||||
.addChild(detailsModal)
|
.addChild(detailsModal)
|
||||||
|
|
|
@ -11,36 +11,41 @@ const getTableScreenTemplate = ({
|
||||||
createScreenRoute,
|
createScreenRoute,
|
||||||
tableOrView,
|
tableOrView,
|
||||||
permissions,
|
permissions,
|
||||||
|
gridLayout,
|
||||||
}) => {
|
}) => {
|
||||||
const newButton = new Component("@budibase/standard-components/button")
|
const buttonGroup = new Component("@budibase/standard-components/buttongroup")
|
||||||
.instanceName("New button")
|
const createButton = new Component("@budibase/standard-components/button")
|
||||||
.customProps({
|
|
||||||
text: "Create row",
|
createButton.customProps({
|
||||||
onClick: [
|
onClick: [
|
||||||
{
|
{
|
||||||
"##eventHandlerType": "Navigate To",
|
"##eventHandlerType": "Navigate To",
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "url",
|
type: "url",
|
||||||
url: createScreenRoute,
|
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")
|
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)
|
||||||
const tableHeader = new Component("@budibase/standard-components/container")
|
.gridDesktopRowSpan(1, 3)
|
||||||
.instanceName("Heading container")
|
|
||||||
.customProps({
|
|
||||||
direction: "row",
|
|
||||||
hAlign: "stretch",
|
|
||||||
})
|
|
||||||
.addChild(heading)
|
|
||||||
.addChild(newButton)
|
|
||||||
|
|
||||||
const updateScreenRouteSegments = updateScreenRoute.split(":id")
|
const updateScreenRouteSegments = updateScreenRoute.split(":id")
|
||||||
if (updateScreenRouteSegments.length !== 2) {
|
if (updateScreenRouteSegments.length !== 2) {
|
||||||
|
@ -67,13 +72,17 @@ const getTableScreenTemplate = ({
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
.gridDesktopColSpan(1, 13)
|
||||||
|
.gridDesktopRowSpan(3, 21)
|
||||||
|
|
||||||
const template = new Screen()
|
const template = new Screen()
|
||||||
.route(route)
|
.route(route)
|
||||||
.instanceName(`${tableOrView.name} - List`)
|
.instanceName(`${tableOrView.name} - List`)
|
||||||
|
.customProps({ layout: gridLayout ? "grid" : "flex" })
|
||||||
.role(permissions.write)
|
.role(permissions.write)
|
||||||
.autoTableId(tableOrView.id)
|
.autoTableId(tableOrView.id)
|
||||||
.addChild(tableHeader)
|
.addChild(heading)
|
||||||
|
.addChild(buttonGroup)
|
||||||
.addChild(tableBlock)
|
.addChild(tableBlock)
|
||||||
.json()
|
.json()
|
||||||
|
|
||||||
|
@ -300,6 +309,7 @@ const newScreen = ({ tableOrView, permissions, screens }) => {
|
||||||
createScreenRoute,
|
createScreenRoute,
|
||||||
permissions,
|
permissions,
|
||||||
tableOrView,
|
tableOrView,
|
||||||
|
gridLayout: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const updateScreenTemplate = getUpdateScreenTemplate({
|
const updateScreenTemplate = getUpdateScreenTemplate({
|
||||||
|
@ -307,6 +317,7 @@ const newScreen = ({ tableOrView, permissions, screens }) => {
|
||||||
tableScreenRoute,
|
tableScreenRoute,
|
||||||
tableOrView,
|
tableOrView,
|
||||||
permissions,
|
permissions,
|
||||||
|
gridLayout: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const createScreenTemplate = getCreateScreenTemplate({
|
const createScreenTemplate = getCreateScreenTemplate({
|
||||||
|
@ -314,6 +325,7 @@ const newScreen = ({ tableOrView, permissions, screens }) => {
|
||||||
tableScreenRoute,
|
tableScreenRoute,
|
||||||
tableOrView,
|
tableOrView,
|
||||||
permissions,
|
permissions,
|
||||||
|
gridLayout: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
return [tableScreenTemplate, updateScreenTemplate, createScreenTemplate]
|
return [tableScreenTemplate, updateScreenTemplate, createScreenTemplate]
|
||||||
|
|
|
@ -31,26 +31,22 @@ const sidePanel = ({ tableOrView, permissions, screens }) => {
|
||||||
type: "cta",
|
type: "cta",
|
||||||
})
|
})
|
||||||
|
|
||||||
buttonGroup.instanceName(`${tableOrView.name} - Create`).customProps({
|
buttonGroup
|
||||||
hAlign: "right",
|
.instanceName(`${tableOrView.name} - Create`)
|
||||||
buttons: [createButton.json()],
|
|
||||||
})
|
|
||||||
|
|
||||||
const tableHeader = 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)
|
||||||
tableHeader.addChild(heading)
|
.gridDesktopRowSpan(1, 3)
|
||||||
tableHeader.addChild(buttonGroup)
|
|
||||||
|
|
||||||
const createFormBlock = new Component(
|
const createFormBlock = new Component(
|
||||||
"@budibase/standard-components/formblock"
|
"@budibase/standard-components/formblock"
|
||||||
|
@ -130,13 +126,17 @@ const sidePanel = ({ tableOrView, permissions, screens }) => {
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.instanceName(`${tableOrView.name} - Table`)
|
.instanceName(`${tableOrView.name} - Table`)
|
||||||
|
.gridDesktopColSpan(1, 13)
|
||||||
|
.gridDesktopRowSpan(3, 21)
|
||||||
|
|
||||||
const template = new Screen()
|
const template = new Screen()
|
||||||
.route(getValidRoute(screens, tableOrView.name, permissions.write))
|
.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.id)
|
.autoTableId(tableOrView.id)
|
||||||
.addChild(tableHeader)
|
.addChild(heading)
|
||||||
|
.addChild(buttonGroup)
|
||||||
.addChild(tableBlock)
|
.addChild(tableBlock)
|
||||||
.addChild(createRowSidePanel)
|
.addChild(createRowSidePanel)
|
||||||
.addChild(detailsSidePanel)
|
.addChild(detailsSidePanel)
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -176,87 +176,84 @@
|
||||||
{/if}
|
{/if}
|
||||||
{/key}
|
{/key}
|
||||||
|
|
||||||
<!-- Clip boundary for selection indicators -->
|
<!-- Clip boundary for selection indicators -->
|
||||||
<div
|
<div
|
||||||
id="clip-root"
|
id="clip-root"
|
||||||
class:preview={$builderStore.inBuilder}
|
class:preview={$builderStore.inBuilder}
|
||||||
class:tablet-preview={$builderStore.previewDevice ===
|
class:tablet-preview={$builderStore.previewDevice ===
|
||||||
"tablet"}
|
"tablet"}
|
||||||
class:mobile-preview={$builderStore.previewDevice ===
|
class:mobile-preview={$builderStore.previewDevice ===
|
||||||
"mobile"}
|
"mobile"}
|
||||||
>
|
>
|
||||||
<!-- Actual app -->
|
<!-- Actual app -->
|
||||||
<div id="app-root">
|
<div id="app-root">
|
||||||
{#if showDevTools}
|
{#if showDevTools}
|
||||||
<DevToolsHeader />
|
<DevToolsHeader />
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div id="app-body">
|
||||||
|
{#if permissionError}
|
||||||
|
<div class="error">
|
||||||
|
<Layout justifyItems="center" gap="S">
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
{@html ErrorSVG}
|
||||||
|
<Heading size="L">
|
||||||
|
You don't have permission to use this app
|
||||||
|
</Heading>
|
||||||
|
<Body size="S">
|
||||||
|
Ask your administrator to grant you access
|
||||||
|
</Body>
|
||||||
|
</Layout>
|
||||||
|
</div>
|
||||||
|
{:else if !$screenStore.activeLayout}
|
||||||
|
<div class="error">
|
||||||
|
<Layout justifyItems="center" gap="S">
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
{@html ErrorSVG}
|
||||||
|
<Heading size="L">
|
||||||
|
Something went wrong rendering your app
|
||||||
|
</Heading>
|
||||||
|
<Body size="S">
|
||||||
|
Get in touch with support if this issue persists
|
||||||
|
</Body>
|
||||||
|
</Layout>
|
||||||
|
</div>
|
||||||
|
{:else if embedNoScreens}
|
||||||
|
<div class="error">
|
||||||
|
<Layout justifyItems="center" gap="S">
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
||||||
|
{@html ErrorSVG}
|
||||||
|
<Heading size="L">
|
||||||
|
This Budibase app is not publicly accessible
|
||||||
|
</Heading>
|
||||||
|
</Layout>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<CustomThemeWrapper>
|
||||||
|
{#key $screenStore.activeLayout._id}
|
||||||
|
<Component
|
||||||
|
isLayout
|
||||||
|
instance={$screenStore.activeLayout.props}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
|
||||||
|
<!-- Layers on top of app -->
|
||||||
|
<NotificationDisplay />
|
||||||
|
<ConfirmationDisplay />
|
||||||
|
<PeekScreenDisplay />
|
||||||
|
</CustomThemeWrapper>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div id="app-body">
|
{#if showDevTools}
|
||||||
{#if permissionError}
|
<DevTools />
|
||||||
<div class="error">
|
|
||||||
<Layout justifyItems="center" gap="S">
|
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
||||||
{@html ErrorSVG}
|
|
||||||
<Heading size="L">
|
|
||||||
You don't have permission to use this app
|
|
||||||
</Heading>
|
|
||||||
<Body size="S">
|
|
||||||
Ask your administrator to grant you access
|
|
||||||
</Body>
|
|
||||||
</Layout>
|
|
||||||
</div>
|
|
||||||
{:else if !$screenStore.activeLayout}
|
|
||||||
<div class="error">
|
|
||||||
<Layout justifyItems="center" gap="S">
|
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
||||||
{@html ErrorSVG}
|
|
||||||
<Heading size="L">
|
|
||||||
Something went wrong rendering your app
|
|
||||||
</Heading>
|
|
||||||
<Body size="S">
|
|
||||||
Get in touch with support if this issue persists
|
|
||||||
</Body>
|
|
||||||
</Layout>
|
|
||||||
</div>
|
|
||||||
{:else if embedNoScreens}
|
|
||||||
<div class="error">
|
|
||||||
<Layout justifyItems="center" gap="S">
|
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
|
|
||||||
{@html ErrorSVG}
|
|
||||||
<Heading size="L">
|
|
||||||
This Budibase app is not publicly accessible
|
|
||||||
</Heading>
|
|
||||||
</Layout>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<CustomThemeWrapper>
|
|
||||||
{#key $screenStore.activeLayout._id}
|
|
||||||
<Component
|
|
||||||
isLayout
|
|
||||||
instance={$screenStore.activeLayout.props}
|
|
||||||
/>
|
|
||||||
{/key}
|
|
||||||
|
|
||||||
<!-- Modal container to ensure they sit on top -->
|
|
||||||
<div class="modal-container" />
|
|
||||||
|
|
||||||
<!-- Layers on top of app -->
|
|
||||||
<NotificationDisplay />
|
|
||||||
<ConfirmationDisplay />
|
|
||||||
<PeekScreenDisplay />
|
|
||||||
</CustomThemeWrapper>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if showDevTools}
|
|
||||||
<DevTools />
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if !$builderStore.inBuilder && $featuresStore.logoEnabled}
|
|
||||||
<FreeFooter />
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if !$builderStore.inBuilder && $featuresStore.logoEnabled}
|
||||||
|
<FreeFooter />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Preview and dev tools utilities -->
|
<!-- Preview and dev tools utilities -->
|
||||||
{#if $appStore.isDevApp}
|
{#if $appStore.isDevApp}
|
||||||
<SelectionIndicator />
|
<SelectionIndicator />
|
||||||
|
@ -287,7 +284,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;
|
||||||
|
@ -304,7 +301,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: clip;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -314,7 +311,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
#app-root {
|
#app-root {
|
||||||
overflow: hidden;
|
overflow: clip;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -330,6 +327,7 @@
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
|
@ -359,22 +357,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 */
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -71,4 +71,7 @@
|
||||||
div {
|
div {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
div :global(> .component > *) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>
|
|
|
@ -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>
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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 {
|
|
@ -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>
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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" />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 })
|
||||||
|
|
|
@ -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])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 || "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
})
|
||||||
|
}
|
|
@ -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) {
|
||||||
|
|
|
@ -38,7 +38,7 @@ export async function handleRequest<T extends Operation>(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
|
export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
|
|
||||||
const { _id, ...rowData } = ctx.request.body
|
const { _id, ...rowData } = ctx.request.body
|
||||||
const table = await sdk.tables.getTable(tableId)
|
const table = await sdk.tables.getTable(tableId)
|
||||||
|
@ -93,7 +93,7 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function destroy(ctx: UserCtx) {
|
export async function destroy(ctx: UserCtx) {
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
const _id = ctx.request.body._id
|
const _id = ctx.request.body._id
|
||||||
const { row } = await handleRequest(Operation.DELETE, tableId, {
|
const { row } = await handleRequest(Operation.DELETE, tableId, {
|
||||||
id: breakRowIdField(_id),
|
id: breakRowIdField(_id),
|
||||||
|
@ -104,7 +104,7 @@ export async function destroy(ctx: UserCtx) {
|
||||||
|
|
||||||
export async function bulkDestroy(ctx: UserCtx) {
|
export async function bulkDestroy(ctx: UserCtx) {
|
||||||
const { rows } = ctx.request.body
|
const { rows } = ctx.request.body
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
let promises: Promise<{ row: Row; table: Table }>[] = []
|
let promises: Promise<{ row: Row; table: Table }>[] = []
|
||||||
for (let row of rows) {
|
for (let row of rows) {
|
||||||
promises.push(
|
promises.push(
|
||||||
|
@ -123,7 +123,7 @@ export async function bulkDestroy(ctx: UserCtx) {
|
||||||
|
|
||||||
export async function fetchEnrichedRow(ctx: UserCtx) {
|
export async function fetchEnrichedRow(ctx: UserCtx) {
|
||||||
const id = ctx.params.rowId
|
const id = ctx.params.rowId
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
const { datasourceId, tableName } = breakExternalTableId(tableId)
|
const { datasourceId, tableName } = breakExternalTableId(tableId)
|
||||||
const datasource: Datasource = await sdk.datasources.get(datasourceId)
|
const datasource: Datasource = await sdk.datasources.get(datasourceId)
|
||||||
if (!datasource || !datasource.entities) {
|
if (!datasource || !datasource.entities) {
|
||||||
|
|
|
@ -47,7 +47,7 @@ export async function patch(
|
||||||
ctx: UserCtx<PatchRowRequest, PatchRowResponse>
|
ctx: UserCtx<PatchRowRequest, PatchRowResponse>
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const appId = ctx.appId
|
const appId = ctx.appId
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
const body = ctx.request.body
|
const body = ctx.request.body
|
||||||
|
|
||||||
// if it doesn't have an _id then its save
|
// if it doesn't have an _id then its save
|
||||||
|
@ -72,7 +72,7 @@ export async function patch(
|
||||||
|
|
||||||
export const save = async (ctx: UserCtx<Row, Row>) => {
|
export const save = async (ctx: UserCtx<Row, Row>) => {
|
||||||
const appId = ctx.appId
|
const appId = ctx.appId
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
const body = ctx.request.body
|
const body = ctx.request.body
|
||||||
|
|
||||||
// user metadata doesn't exist yet - don't allow creation
|
// user metadata doesn't exist yet - don't allow creation
|
||||||
|
@ -97,13 +97,12 @@ export const save = async (ctx: UserCtx<Row, Row>) => {
|
||||||
gridSocket?.emitRowUpdate(ctx, row || squashed)
|
gridSocket?.emitRowUpdate(ctx, row || squashed)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchView(ctx: any) {
|
export async function fetchLegacyView(ctx: any) {
|
||||||
const tableId = utils.getTableId(ctx)
|
|
||||||
const viewName = decodeURIComponent(ctx.params.viewName)
|
const viewName = decodeURIComponent(ctx.params.viewName)
|
||||||
|
|
||||||
const { calculation, group, field } = ctx.query
|
const { calculation, group, field } = ctx.query
|
||||||
|
|
||||||
ctx.body = await sdk.rows.fetchView(tableId, viewName, {
|
ctx.body = await sdk.rows.fetchLegacyView(viewName, {
|
||||||
calculation,
|
calculation,
|
||||||
group: calculation ? group : null,
|
group: calculation ? group : null,
|
||||||
field,
|
field,
|
||||||
|
@ -111,12 +110,12 @@ export async function fetchView(ctx: any) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetch(ctx: any) {
|
export async function fetch(ctx: any) {
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
ctx.body = await sdk.rows.fetch(tableId)
|
ctx.body = await sdk.rows.fetch(tableId)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function find(ctx: UserCtx<void, GetRowResponse>) {
|
export async function find(ctx: UserCtx<void, GetRowResponse>) {
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
const rowId = ctx.params.rowId
|
const rowId = ctx.params.rowId
|
||||||
|
|
||||||
ctx.body = await sdk.rows.find(tableId, rowId)
|
ctx.body = await sdk.rows.find(tableId, rowId)
|
||||||
|
@ -132,7 +131,7 @@ function isDeleteRow(input: any): input is DeleteRow {
|
||||||
|
|
||||||
async function processDeleteRowsRequest(ctx: UserCtx<DeleteRowRequest>) {
|
async function processDeleteRowsRequest(ctx: UserCtx<DeleteRowRequest>) {
|
||||||
let request = ctx.request.body as DeleteRows
|
let request = ctx.request.body as DeleteRows
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
|
|
||||||
const processedRows = request.rows.map(row => {
|
const processedRows = request.rows.map(row => {
|
||||||
let processedRow: Row = typeof row == "string" ? { _id: row } : row
|
let processedRow: Row = typeof row == "string" ? { _id: row } : row
|
||||||
|
@ -148,7 +147,7 @@ async function processDeleteRowsRequest(ctx: UserCtx<DeleteRowRequest>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteRows(ctx: UserCtx<DeleteRowRequest>) {
|
async function deleteRows(ctx: UserCtx<DeleteRowRequest>) {
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
const appId = ctx.appId
|
const appId = ctx.appId
|
||||||
|
|
||||||
let deleteRequest = ctx.request.body as DeleteRows
|
let deleteRequest = ctx.request.body as DeleteRows
|
||||||
|
@ -170,7 +169,7 @@ async function deleteRows(ctx: UserCtx<DeleteRowRequest>) {
|
||||||
|
|
||||||
async function deleteRow(ctx: UserCtx<DeleteRowRequest>) {
|
async function deleteRow(ctx: UserCtx<DeleteRowRequest>) {
|
||||||
const appId = ctx.appId
|
const appId = ctx.appId
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
|
|
||||||
const resp = await pickApi(tableId).destroy(ctx)
|
const resp = await pickApi(tableId).destroy(ctx)
|
||||||
if (!tableId.includes("datasource_plus")) {
|
if (!tableId.includes("datasource_plus")) {
|
||||||
|
@ -204,7 +203,7 @@ export async function destroy(ctx: UserCtx<DeleteRowRequest>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function search(ctx: Ctx<SearchRowRequest, SearchRowResponse>) {
|
export async function search(ctx: Ctx<SearchRowRequest, SearchRowResponse>) {
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
|
|
||||||
await context.ensureSnippetContext(true)
|
await context.ensureSnippetContext(true)
|
||||||
|
|
||||||
|
@ -226,7 +225,7 @@ export async function search(ctx: Ctx<SearchRowRequest, SearchRowResponse>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function validate(ctx: Ctx<Row, ValidateResponse>) {
|
export async function validate(ctx: Ctx<Row, ValidateResponse>) {
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
// external tables are hard to validate currently
|
// external tables are hard to validate currently
|
||||||
if (isExternalTableID(tableId)) {
|
if (isExternalTableID(tableId)) {
|
||||||
ctx.body = { valid: true, errors: {} }
|
ctx.body = { valid: true, errors: {} }
|
||||||
|
@ -239,14 +238,14 @@ export async function validate(ctx: Ctx<Row, ValidateResponse>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchEnrichedRow(ctx: UserCtx<void, Row>) {
|
export async function fetchEnrichedRow(ctx: UserCtx<void, Row>) {
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
ctx.body = await pickApi(tableId).fetchEnrichedRow(ctx)
|
ctx.body = await pickApi(tableId).fetchEnrichedRow(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const exportRows = async (
|
export const exportRows = async (
|
||||||
ctx: Ctx<ExportRowsRequest, ExportRowsResponse>
|
ctx: Ctx<ExportRowsRequest, ExportRowsResponse>
|
||||||
) => {
|
) => {
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
|
|
||||||
const format = ctx.query.format
|
const format = ctx.query.format
|
||||||
|
|
||||||
|
@ -279,7 +278,7 @@ export const exportRows = async (
|
||||||
export async function downloadAttachment(ctx: UserCtx) {
|
export async function downloadAttachment(ctx: UserCtx) {
|
||||||
const { columnName } = ctx.params
|
const { columnName } = ctx.params
|
||||||
|
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
const rowId = ctx.params.rowId
|
const rowId = ctx.params.rowId
|
||||||
const row = await sdk.rows.find(tableId, rowId)
|
const row = await sdk.rows.find(tableId, rowId)
|
||||||
|
|
||||||
|
|
|
@ -16,7 +16,6 @@ import {
|
||||||
PatchRowRequest,
|
PatchRowRequest,
|
||||||
PatchRowResponse,
|
PatchRowResponse,
|
||||||
Row,
|
Row,
|
||||||
Table,
|
|
||||||
UserCtx,
|
UserCtx,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import sdk from "../../../sdk"
|
import sdk from "../../../sdk"
|
||||||
|
@ -24,7 +23,7 @@ import { getLinkedTableIDs } from "../../../db/linkedRows/linkUtils"
|
||||||
import { flatten } from "lodash"
|
import { flatten } from "lodash"
|
||||||
|
|
||||||
export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
|
export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
const inputs = ctx.request.body
|
const inputs = ctx.request.body
|
||||||
const isUserTable = tableId === InternalTables.USER_METADATA
|
const isUserTable = tableId === InternalTables.USER_METADATA
|
||||||
let oldRow
|
let oldRow
|
||||||
|
@ -98,7 +97,7 @@ export async function patch(ctx: UserCtx<PatchRowRequest, PatchRowResponse>) {
|
||||||
|
|
||||||
export async function destroy(ctx: UserCtx) {
|
export async function destroy(ctx: UserCtx) {
|
||||||
const db = context.getAppDB()
|
const db = context.getAppDB()
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
const { _id } = ctx.request.body
|
const { _id } = ctx.request.body
|
||||||
let row = await db.get<Row>(_id)
|
let row = await db.get<Row>(_id)
|
||||||
let _rev = ctx.request.body._rev || row._rev
|
let _rev = ctx.request.body._rev || row._rev
|
||||||
|
@ -137,7 +136,7 @@ export async function destroy(ctx: UserCtx) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkDestroy(ctx: UserCtx) {
|
export async function bulkDestroy(ctx: UserCtx) {
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
const table = await sdk.tables.getTable(tableId)
|
const table = await sdk.tables.getTable(tableId)
|
||||||
let { rows } = ctx.request.body
|
let { rows } = ctx.request.body
|
||||||
|
|
||||||
|
@ -179,7 +178,7 @@ export async function bulkDestroy(ctx: UserCtx) {
|
||||||
export async function fetchEnrichedRow(ctx: UserCtx) {
|
export async function fetchEnrichedRow(ctx: UserCtx) {
|
||||||
const fieldName = ctx.request.query.field as string | undefined
|
const fieldName = ctx.request.query.field as string | undefined
|
||||||
const db = context.getAppDB()
|
const db = context.getAppDB()
|
||||||
const tableId = utils.getTableId(ctx)
|
const { tableId } = utils.getSourceId(ctx)
|
||||||
const rowId = ctx.params.rowId as string
|
const rowId = ctx.params.rowId as string
|
||||||
// need table to work out where links go in row, as well as the link docs
|
// need table to work out where links go in row, as well as the link docs
|
||||||
const [table, links] = await Promise.all([
|
const [table, links] = await Promise.all([
|
||||||
|
@ -197,7 +196,7 @@ export async function fetchEnrichedRow(ctx: UserCtx) {
|
||||||
)
|
)
|
||||||
|
|
||||||
// get the linked tables
|
// get the linked tables
|
||||||
const linkTableIds = getLinkedTableIDs(table as Table)
|
const linkTableIds = getLinkedTableIDs(table.schema)
|
||||||
const linkTables = await sdk.tables.getTables(linkTableIds)
|
const linkTables = await sdk.tables.getTables(linkTableIds)
|
||||||
|
|
||||||
// perform output processing
|
// perform output processing
|
||||||
|
|
|
@ -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]}`)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { InternalTables } from "../../../../db/utils"
|
import * as utils from "../../../../db/utils"
|
||||||
|
|
||||||
import { context } from "@budibase/backend-core"
|
import { context } from "@budibase/backend-core"
|
||||||
import {
|
import {
|
||||||
|
@ -67,7 +67,7 @@ export async function findRow(tableId: string, rowId: string) {
|
||||||
const db = context.getAppDB()
|
const db = context.getAppDB()
|
||||||
let row: Row
|
let row: Row
|
||||||
// TODO remove special user case in future
|
// TODO remove special user case in future
|
||||||
if (tableId === InternalTables.USER_METADATA) {
|
if (tableId === utils.InternalTables.USER_METADATA) {
|
||||||
row = await getFullUser(rowId)
|
row = await getFullUser(rowId)
|
||||||
} else {
|
} else {
|
||||||
row = await db.get(rowId)
|
row = await db.get(rowId)
|
||||||
|
@ -78,22 +78,25 @@ export async function findRow(tableId: string, rowId: string) {
|
||||||
return row
|
return row
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTableId(ctx: Ctx): string {
|
export function getSourceId(ctx: Ctx): { tableId: string; viewId?: string } {
|
||||||
// top priority, use the URL first
|
// top priority, use the URL first
|
||||||
if (ctx.params?.sourceId) {
|
if (ctx.params?.sourceId) {
|
||||||
return ctx.params.sourceId
|
const { sourceId } = ctx.params
|
||||||
|
if (utils.isViewID(sourceId)) {
|
||||||
|
return {
|
||||||
|
tableId: utils.extractViewInfoFromID(sourceId).tableId,
|
||||||
|
viewId: sourceId,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { tableId: ctx.params.sourceId }
|
||||||
}
|
}
|
||||||
// now check for old way of specifying table ID
|
// now check for old way of specifying table ID
|
||||||
if (ctx.params?.tableId) {
|
if (ctx.params?.tableId) {
|
||||||
return ctx.params.tableId
|
return { tableId: ctx.params.tableId }
|
||||||
}
|
}
|
||||||
// check body for a table ID
|
// check body for a table ID
|
||||||
if (ctx.request.body?.tableId) {
|
if (ctx.request.body?.tableId) {
|
||||||
return ctx.request.body.tableId
|
return { tableId: ctx.request.body.tableId }
|
||||||
}
|
|
||||||
// now check if a specific view name
|
|
||||||
if (ctx.params?.viewName) {
|
|
||||||
return ctx.params.viewName
|
|
||||||
}
|
}
|
||||||
throw new Error("Unable to find table ID in request")
|
throw new Error("Unable to find table ID in request")
|
||||||
}
|
}
|
||||||
|
@ -198,7 +201,7 @@ export async function sqlOutputProcessing(
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUserMetadataTable(tableId: string) {
|
export function isUserMetadataTable(tableId: string) {
|
||||||
return tableId === InternalTables.USER_METADATA
|
return tableId === utils.InternalTables.USER_METADATA
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function enrichArrayContext(
|
export async function enrichArrayContext(
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -2,7 +2,7 @@ import viewTemplate from "./viewBuilder"
|
||||||
import { apiFileReturn } from "../../../utilities/fileSystem"
|
import { apiFileReturn } from "../../../utilities/fileSystem"
|
||||||
import { csv, json, jsonWithSchema, Format, isFormat } from "./exporters"
|
import { csv, json, jsonWithSchema, Format, isFormat } from "./exporters"
|
||||||
import { deleteView, getView, getViews, saveView } from "./utils"
|
import { deleteView, getView, getViews, saveView } from "./utils"
|
||||||
import { fetchView } from "../row"
|
import { fetchLegacyView } from "../row"
|
||||||
import { context, events } from "@budibase/backend-core"
|
import { context, events } from "@budibase/backend-core"
|
||||||
import sdk from "../../../sdk"
|
import sdk from "../../../sdk"
|
||||||
import {
|
import {
|
||||||
|
@ -170,7 +170,7 @@ export async function exportView(ctx: Ctx) {
|
||||||
ctx.params.viewName = viewName
|
ctx.params.viewName = viewName
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchView(ctx)
|
await fetchLegacyView(ctx)
|
||||||
let rows = ctx.body as Row[]
|
let rows = ctx.body as Row[]
|
||||||
|
|
||||||
let schema: TableSchema = view && view.meta && view.meta.schema
|
let schema: TableSchema = view && view.meta && view.meta.schema
|
||||||
|
|
|
@ -9,7 +9,13 @@ import {
|
||||||
import tk from "timekeeper"
|
import tk from "timekeeper"
|
||||||
import emitter from "../../../../src/events"
|
import emitter from "../../../../src/events"
|
||||||
import { outputProcessing } from "../../../utilities/rowProcessor"
|
import { outputProcessing } from "../../../utilities/rowProcessor"
|
||||||
import { context, InternalTable, tenancy } from "@budibase/backend-core"
|
import {
|
||||||
|
context,
|
||||||
|
InternalTable,
|
||||||
|
tenancy,
|
||||||
|
withEnv as withCoreEnv,
|
||||||
|
setEnv as setCoreEnv,
|
||||||
|
} from "@budibase/backend-core"
|
||||||
import { quotas } from "@budibase/pro"
|
import { quotas } from "@budibase/pro"
|
||||||
import {
|
import {
|
||||||
AttachmentFieldMetadata,
|
AttachmentFieldMetadata,
|
||||||
|
@ -69,6 +75,7 @@ async function waitForEvent(
|
||||||
|
|
||||||
describe.each([
|
describe.each([
|
||||||
["internal", undefined],
|
["internal", undefined],
|
||||||
|
["sqs", undefined],
|
||||||
[DatabaseName.POSTGRES, getDatasource(DatabaseName.POSTGRES)],
|
[DatabaseName.POSTGRES, getDatasource(DatabaseName.POSTGRES)],
|
||||||
[DatabaseName.MYSQL, getDatasource(DatabaseName.MYSQL)],
|
[DatabaseName.MYSQL, getDatasource(DatabaseName.MYSQL)],
|
||||||
[DatabaseName.SQL_SERVER, getDatasource(DatabaseName.SQL_SERVER)],
|
[DatabaseName.SQL_SERVER, getDatasource(DatabaseName.SQL_SERVER)],
|
||||||
|
@ -76,6 +83,8 @@ describe.each([
|
||||||
[DatabaseName.ORACLE, getDatasource(DatabaseName.ORACLE)],
|
[DatabaseName.ORACLE, getDatasource(DatabaseName.ORACLE)],
|
||||||
])("/rows (%s)", (providerType, dsProvider) => {
|
])("/rows (%s)", (providerType, dsProvider) => {
|
||||||
const isInternal = dsProvider === undefined
|
const isInternal = dsProvider === undefined
|
||||||
|
const isLucene = providerType === "lucene"
|
||||||
|
const isSqs = providerType === "sqs"
|
||||||
const isMSSQL = providerType === DatabaseName.SQL_SERVER
|
const isMSSQL = providerType === DatabaseName.SQL_SERVER
|
||||||
const isOracle = providerType === DatabaseName.ORACLE
|
const isOracle = providerType === DatabaseName.ORACLE
|
||||||
const config = setup.getConfig()
|
const config = setup.getConfig()
|
||||||
|
@ -83,9 +92,17 @@ describe.each([
|
||||||
let table: Table
|
let table: Table
|
||||||
let datasource: Datasource | undefined
|
let datasource: Datasource | undefined
|
||||||
let client: Knex | undefined
|
let client: Knex | undefined
|
||||||
|
let envCleanup: (() => void) | undefined
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await config.init()
|
await withCoreEnv({ SQS_SEARCH_ENABLE: "true" }, () => config.init())
|
||||||
|
if (isSqs) {
|
||||||
|
envCleanup = setCoreEnv({
|
||||||
|
SQS_SEARCH_ENABLE: "true",
|
||||||
|
SQS_SEARCH_ENABLE_TENANTS: [config.getTenantId()],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (dsProvider) {
|
if (dsProvider) {
|
||||||
const rawDatasource = await dsProvider
|
const rawDatasource = await dsProvider
|
||||||
datasource = await config.createDatasource({
|
datasource = await config.createDatasource({
|
||||||
|
@ -97,6 +114,9 @@ describe.each([
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
setup.afterAll()
|
setup.afterAll()
|
||||||
|
if (envCleanup) {
|
||||||
|
envCleanup()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function saveTableRequest(
|
function saveTableRequest(
|
||||||
|
@ -346,7 +366,7 @@ describe.each([
|
||||||
expect(ids).toEqual(expect.arrayContaining(sequence))
|
expect(ids).toEqual(expect.arrayContaining(sequence))
|
||||||
})
|
})
|
||||||
|
|
||||||
isInternal &&
|
isLucene &&
|
||||||
it("row values are coerced", async () => {
|
it("row values are coerced", async () => {
|
||||||
const str: FieldSchema = {
|
const str: FieldSchema = {
|
||||||
type: FieldType.STRING,
|
type: FieldType.STRING,
|
||||||
|
@ -1664,7 +1684,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 +1711,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]
|
||||||
|
|
|
@ -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 () => {
|
||||||
|
|
|
@ -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", () => {
|
||||||
|
|
|
@ -46,7 +46,7 @@ router
|
||||||
permissions.PermissionType.TABLE,
|
permissions.PermissionType.TABLE,
|
||||||
permissions.PermissionLevel.READ
|
permissions.PermissionLevel.READ
|
||||||
),
|
),
|
||||||
rowController.fetchView
|
rowController.fetchLegacyView
|
||||||
)
|
)
|
||||||
.get("/api/views", authorized(permissions.BUILDER), viewController.v1.fetch)
|
.get("/api/views", authorized(permissions.BUILDER), viewController.v1.fetch)
|
||||||
.delete(
|
.delete(
|
||||||
|
|
|
@ -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,26 @@ 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,
|
||||||
|
TableSchema,
|
||||||
} 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.
|
||||||
|
@ -37,8 +47,8 @@ export const EventType = {
|
||||||
TABLE_DELETE: "table:delete",
|
TABLE_DELETE: "table:delete",
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearRelationshipFields(table: Table, rows: Row[]) {
|
function clearRelationshipFields(schema: TableSchema, rows: Row[]) {
|
||||||
for (let [key, field] of Object.entries(table.schema)) {
|
for (let [key, field] of Object.entries(schema)) {
|
||||||
if (field.type === FieldType.LINK) {
|
if (field.type === FieldType.LINK) {
|
||||||
rows = rows.map(row => {
|
rows = rows.map(row => {
|
||||||
delete row[key]
|
delete row[key]
|
||||||
|
@ -149,11 +159,11 @@ export async function updateLinks(args: {
|
||||||
* @return returns the rows with all of the enriched relationships on it.
|
* @return returns the rows with all of the enriched relationships on it.
|
||||||
*/
|
*/
|
||||||
export async function attachFullLinkedDocs(
|
export async function attachFullLinkedDocs(
|
||||||
table: Table,
|
schema: TableSchema,
|
||||||
rows: Row[],
|
rows: Row[],
|
||||||
opts?: { fromRow?: Row }
|
opts?: { fromRow?: Row }
|
||||||
) {
|
) {
|
||||||
const linkedTableIds = getLinkedTableIDs(table)
|
const linkedTableIds = getLinkedTableIDs(schema)
|
||||||
if (linkedTableIds.length === 0) {
|
if (linkedTableIds.length === 0) {
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
|
@ -173,7 +183,7 @@ export async function attachFullLinkedDocs(
|
||||||
}
|
}
|
||||||
const linkedTables = response[1] as Table[]
|
const linkedTables = response[1] as Table[]
|
||||||
// clear any existing links that could be dupe'd
|
// clear any existing links that could be dupe'd
|
||||||
rows = clearRelationshipFields(table, rows)
|
rows = clearRelationshipFields(schema, rows)
|
||||||
// now get the docs and combine into the rows
|
// now get the docs and combine into the rows
|
||||||
let linked: Row[] = []
|
let linked: Row[] = []
|
||||||
if (linksWithoutFromRow.length > 0) {
|
if (linksWithoutFromRow.length > 0) {
|
||||||
|
@ -192,7 +202,7 @@ export async function attachFullLinkedDocs(
|
||||||
}
|
}
|
||||||
if (linkedRow) {
|
if (linkedRow) {
|
||||||
const linkedTableId =
|
const linkedTableId =
|
||||||
linkedRow.tableId || getRelatedTableForField(table, link.fieldName)
|
linkedRow.tableId || getRelatedTableForField(schema, link.fieldName)
|
||||||
const linkedTable = linkedTables.find(
|
const linkedTable = linkedTables.find(
|
||||||
table => table._id === linkedTableId
|
table => table._id === linkedTableId
|
||||||
)
|
)
|
||||||
|
@ -206,6 +216,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.
|
||||||
|
@ -229,12 +264,11 @@ export async function squashLinksToPrimaryDisplay(
|
||||||
}
|
}
|
||||||
const newLinks = []
|
const newLinks = []
|
||||||
for (let link of row[column]) {
|
for (let link of row[column]) {
|
||||||
const linkTblId = link.tableId || getRelatedTableForField(table, column)
|
const linkTblId =
|
||||||
|
link.tableId || getRelatedTableForField(table.schema, 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
|
||||||
|
|
|
@ -7,6 +7,7 @@ import {
|
||||||
LinkDocument,
|
LinkDocument,
|
||||||
LinkDocumentValue,
|
LinkDocumentValue,
|
||||||
Table,
|
Table,
|
||||||
|
TableSchema,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import sdk from "../../sdk"
|
import sdk from "../../sdk"
|
||||||
|
|
||||||
|
@ -121,8 +122,8 @@ export function getUniqueByProp(array: any[], prop: string) {
|
||||||
return filteredArray
|
return filteredArray
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLinkedTableIDs(table: Table): string[] {
|
export function getLinkedTableIDs(schema: TableSchema): string[] {
|
||||||
return Object.values(table.schema)
|
return Object.values(schema)
|
||||||
.filter(isRelationshipColumn)
|
.filter(isRelationshipColumn)
|
||||||
.map(column => column.tableId)
|
.map(column => column.tableId)
|
||||||
}
|
}
|
||||||
|
@ -139,13 +140,16 @@ export async function getLinkedTable(id: string, tables: Table[]) {
|
||||||
return linkedTable
|
return linkedTable
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRelatedTableForField(table: Table, fieldName: string) {
|
export function getRelatedTableForField(
|
||||||
|
schema: TableSchema,
|
||||||
|
fieldName: string
|
||||||
|
) {
|
||||||
// look to see if its on the table, straight in the schema
|
// look to see if its on the table, straight in the schema
|
||||||
const field = table.schema[fieldName]
|
const field = schema[fieldName]
|
||||||
if (field?.type === FieldType.LINK) {
|
if (field?.type === FieldType.LINK) {
|
||||||
return field.tableId
|
return field.tableId
|
||||||
}
|
}
|
||||||
for (let column of Object.values(table.schema)) {
|
for (let column of Object.values(schema)) {
|
||||||
if (column.type === FieldType.LINK && column.fieldName === fieldName) {
|
if (column.type === FieldType.LINK && column.fieldName === fieldName) {
|
||||||
return column.tableId
|
return column.tableId
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,7 +34,7 @@ describe("test link functionality", () => {
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("getRelatedTableForField", () => {
|
describe("getRelatedTableForField", () => {
|
||||||
let link = basicTable()
|
const link = basicTable()
|
||||||
link.schema.link = {
|
link.schema.link = {
|
||||||
name: "link",
|
name: "link",
|
||||||
relationshipType: RelationshipType.ONE_TO_MANY,
|
relationshipType: RelationshipType.ONE_TO_MANY,
|
||||||
|
@ -44,11 +44,13 @@ describe("test link functionality", () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
it("should get the field from the table directly", () => {
|
it("should get the field from the table directly", () => {
|
||||||
expect(linkUtils.getRelatedTableForField(link, "link")).toBe("tableID")
|
expect(linkUtils.getRelatedTableForField(link.schema, "link")).toBe(
|
||||||
|
"tableID"
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should get the field from the link", () => {
|
it("should get the field from the link", () => {
|
||||||
expect(linkUtils.getRelatedTableForField(link, "otherLink")).toBe(
|
expect(linkUtils.getRelatedTableForField(link.schema, "otherLink")).toBe(
|
||||||
"tableID"
|
"tableID"
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -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`,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -97,13 +97,14 @@ describe("Captures of real examples", () => {
|
||||||
const filters = queryJson.filters?.oneOf?.taskid as number[]
|
const filters = queryJson.filters?.oneOf?.taskid as number[]
|
||||||
let query = new Sql(SqlClient.POSTGRES, limit)._query(queryJson)
|
let query = new Sql(SqlClient.POSTGRES, limit)._query(queryJson)
|
||||||
expect(query).toEqual({
|
expect(query).toEqual({
|
||||||
bindings: [...filters, limit, limit],
|
bindings: [...filters, limit, ...filters, limit],
|
||||||
sql: multiline(`select "a"."executorid" as "a.executorid", "a"."taskname" as "a.taskname",
|
sql: multiline(
|
||||||
"a"."taskid" as "a.taskid", "a"."completed" as "a.completed", "a"."qaid" as "a.qaid",
|
`select "a"."executorid" as "a.executorid", "a"."taskname" as "a.taskname", "a"."taskid" as "a.taskid",
|
||||||
"b"."productname" as "b.productname", "b"."productid" as "b.productid"
|
"a"."completed" as "a.completed", "a"."qaid" as "a.qaid", "b"."productname" as "b.productname", "b"."productid" as "b.productid"
|
||||||
from (select * from "tasks" as "a" where "a"."taskid" in ($1, $2) order by "a"."taskid" asc limit $3) as "a"
|
from (select * from "tasks" as "a" where "a"."taskid" in ($1, $2) order by "a"."taskid" asc limit $3) as "a"
|
||||||
left join "products_tasks" as "c" on "a"."taskid" = "c"."taskid"
|
left join "products_tasks" as "c" on "a"."taskid" = "c"."taskid" left join "products" as "b" on "b"."productid" = "c"."productid"
|
||||||
left join "products" as "b" on "b"."productid" = "c"."productid" order by "a"."taskid" asc limit $4`),
|
where "a"."taskid" in ($4, $5) order by "a"."taskid" asc limit $6`
|
||||||
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -123,6 +124,7 @@ describe("Captures of real examples", () => {
|
||||||
rangeValue.low,
|
rangeValue.low,
|
||||||
rangeValue.high,
|
rangeValue.high,
|
||||||
equalValue,
|
equalValue,
|
||||||
|
true,
|
||||||
limit,
|
limit,
|
||||||
],
|
],
|
||||||
sql: expect.stringContaining(
|
sql: expect.stringContaining(
|
||||||
|
@ -186,8 +188,9 @@ describe("Captures of real examples", () => {
|
||||||
}, queryJson)
|
}, queryJson)
|
||||||
expect(returningQuery).toEqual({
|
expect(returningQuery).toEqual({
|
||||||
sql: multiline(`select top (@p0) * from (select top (@p1) * from [people] where CASE WHEN [people].[name] = @p2
|
sql: multiline(`select top (@p0) * from (select top (@p1) * from [people] where CASE WHEN [people].[name] = @p2
|
||||||
THEN 1 ELSE 0 END = 1 and CASE WHEN [people].[age] = @p3 THEN 1 ELSE 0 END = 1 order by [people].[name] asc) as [people]`),
|
THEN 1 ELSE 0 END = 1 and CASE WHEN [people].[age] = @p3 THEN 1 ELSE 0 END = 1 order by [people].[name] asc) as [people]
|
||||||
bindings: [5000, 1, "Test", 22],
|
where CASE WHEN [people].[name] = @p4 THEN 1 ELSE 0 END = 1 and CASE WHEN [people].[age] = @p5 THEN 1 ELSE 0 END = 1`),
|
||||||
|
bindings: [5000, 1, "Test", 22, "Test", 22],
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,33 +1,23 @@
|
||||||
import { Ctx, Row } from "@budibase/types"
|
import { Ctx, Row } from "@budibase/types"
|
||||||
import * as utils from "../db/utils"
|
|
||||||
import sdk from "../sdk"
|
import sdk from "../sdk"
|
||||||
import { Next } from "koa"
|
import { Next } from "koa"
|
||||||
import { getTableId } from "../api/controllers/row/utils"
|
import { getSourceId } from "../api/controllers/row/utils"
|
||||||
|
|
||||||
export default async (ctx: Ctx<Row>, next: Next) => {
|
export default async (ctx: Ctx<Row>, next: Next) => {
|
||||||
const { body } = ctx.request
|
const { body } = ctx.request
|
||||||
let { _viewId: viewId } = body
|
const viewId = getSourceId(ctx).viewId ?? body._viewId
|
||||||
|
|
||||||
const possibleViewId = getTableId(ctx)
|
|
||||||
if (utils.isViewID(possibleViewId)) {
|
|
||||||
viewId = possibleViewId
|
|
||||||
}
|
|
||||||
|
|
||||||
// nothing to do, it is not a view (just a table ID)
|
// nothing to do, it is not a view (just a table ID)
|
||||||
if (!viewId) {
|
if (!viewId) {
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
const { tableId } = utils.extractViewInfoFromID(viewId)
|
|
||||||
|
|
||||||
// don't need to trim delete requests
|
// don't need to trim delete requests
|
||||||
if (ctx?.method?.toLowerCase() !== "delete") {
|
if (ctx?.method?.toLowerCase() !== "delete") {
|
||||||
await trimViewFields(ctx.request.body, viewId)
|
await trimViewFields(ctx.request.body, viewId)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.params.sourceId = tableId
|
|
||||||
ctx.params.viewId = viewId
|
|
||||||
|
|
||||||
return next()
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { db } from "@budibase/backend-core"
|
||||||
|
import {
|
||||||
|
FieldType,
|
||||||
|
isLogicalSearchOperator,
|
||||||
|
SearchFilters,
|
||||||
|
Table,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import { cloneDeep } from "lodash/fp"
|
||||||
|
import sdk from "../../../sdk"
|
||||||
|
|
||||||
|
export const removeInvalidFilters = (
|
||||||
|
filters: SearchFilters,
|
||||||
|
validFields: string[]
|
||||||
|
) => {
|
||||||
|
const result = cloneDeep(filters)
|
||||||
|
|
||||||
|
validFields = validFields.map(f => f.toLowerCase())
|
||||||
|
for (const filterKey of Object.keys(result) as (keyof SearchFilters)[]) {
|
||||||
|
const filter = result[filterKey]
|
||||||
|
if (!filter || typeof filter !== "object") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (isLogicalSearchOperator(filterKey)) {
|
||||||
|
const resultingConditions: SearchFilters[] = []
|
||||||
|
for (const condition of filter.conditions) {
|
||||||
|
const resultingCondition = removeInvalidFilters(condition, validFields)
|
||||||
|
if (Object.keys(resultingCondition).length) {
|
||||||
|
resultingConditions.push(resultingCondition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (resultingConditions.length) {
|
||||||
|
filter.conditions = resultingConditions
|
||||||
|
} else {
|
||||||
|
delete result[filterKey]
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const columnKey of Object.keys(filter)) {
|
||||||
|
const possibleKeys = [columnKey, db.removeKeyNumbering(columnKey)].map(
|
||||||
|
c => c.toLowerCase()
|
||||||
|
)
|
||||||
|
if (!validFields.some(f => possibleKeys.includes(f.toLowerCase()))) {
|
||||||
|
delete filter[columnKey as keyof typeof filter]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Object.keys(filter).length) {
|
||||||
|
delete result[filterKey]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getQueryableFields = async (
|
||||||
|
fields: string[],
|
||||||
|
table: Table
|
||||||
|
): Promise<string[]> => {
|
||||||
|
const extractTableFields = async (
|
||||||
|
table: Table,
|
||||||
|
allowedFields: string[],
|
||||||
|
fromTables: string[],
|
||||||
|
opts?: { noRelationships?: boolean }
|
||||||
|
): Promise<string[]> => {
|
||||||
|
const result = []
|
||||||
|
for (const field of Object.keys(table.schema).filter(
|
||||||
|
f => allowedFields.includes(f) && table.schema[f].visible !== false
|
||||||
|
)) {
|
||||||
|
const subSchema = table.schema[field]
|
||||||
|
const isRelationship = subSchema.type === FieldType.LINK
|
||||||
|
// avoid relationship loops
|
||||||
|
if (
|
||||||
|
isRelationship &&
|
||||||
|
(opts?.noRelationships || fromTables.includes(subSchema.tableId))
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (isRelationship) {
|
||||||
|
try {
|
||||||
|
const relatedTable = await sdk.tables.getTable(subSchema.tableId)
|
||||||
|
const relatedFields = await extractTableFields(
|
||||||
|
relatedTable,
|
||||||
|
Object.keys(relatedTable.schema),
|
||||||
|
[...fromTables, subSchema.tableId],
|
||||||
|
// don't let it recurse back and forth between relationships
|
||||||
|
{ noRelationships: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
result.push(
|
||||||
|
...relatedFields.flatMap(f => [
|
||||||
|
`${subSchema.name}.${f}`,
|
||||||
|
// should be able to filter by relationship using table name
|
||||||
|
`${relatedTable.name}.${f}`,
|
||||||
|
])
|
||||||
|
)
|
||||||
|
} catch (err: any) {
|
||||||
|
// if related table is removed, ignore
|
||||||
|
if (err.status !== 404) {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.push(field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [
|
||||||
|
"_id", // Querying by _id is always allowed, even if it's never part of the schema
|
||||||
|
]
|
||||||
|
|
||||||
|
result.push(...(await extractTableFields(table, fields, [table._id!])))
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
|
@ -14,6 +14,7 @@ import sdk from "../../index"
|
||||||
import { searchInputMapping } from "./search/utils"
|
import { searchInputMapping } from "./search/utils"
|
||||||
import { db as dbCore } from "@budibase/backend-core"
|
import { db as dbCore } from "@budibase/backend-core"
|
||||||
import tracer from "dd-trace"
|
import tracer from "dd-trace"
|
||||||
|
import { getQueryableFields, removeInvalidFilters } from "./queryUtils"
|
||||||
|
|
||||||
export { isValidFilter } from "../../../integrations/utils"
|
export { isValidFilter } from "../../../integrations/utils"
|
||||||
|
|
||||||
|
@ -73,6 +74,18 @@ export async function search(
|
||||||
const table = await sdk.tables.getTable(options.tableId)
|
const table = await sdk.tables.getTable(options.tableId)
|
||||||
options = searchInputMapping(table, options)
|
options = searchInputMapping(table, options)
|
||||||
|
|
||||||
|
if (options.query) {
|
||||||
|
const tableFields = Object.keys(table.schema).filter(
|
||||||
|
f => table.schema[f].visible !== false
|
||||||
|
)
|
||||||
|
|
||||||
|
const queriableFields = await getQueryableFields(
|
||||||
|
options.fields?.filter(f => tableFields.includes(f)) ?? tableFields,
|
||||||
|
table
|
||||||
|
)
|
||||||
|
options.query = removeInvalidFilters(options.query, queriableFields)
|
||||||
|
}
|
||||||
|
|
||||||
let result: SearchResponse<Row>
|
let result: SearchResponse<Row>
|
||||||
if (isExternalTable) {
|
if (isExternalTable) {
|
||||||
span?.addTags({ searchType: "external" })
|
span?.addTags({ searchType: "external" })
|
||||||
|
@ -108,10 +121,9 @@ export async function fetchRaw(tableId: string): Promise<Row[]> {
|
||||||
return pickApi(tableId).fetchRaw(tableId)
|
return pickApi(tableId).fetchRaw(tableId)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchView(
|
export async function fetchLegacyView(
|
||||||
tableId: string,
|
|
||||||
viewName: string,
|
viewName: string,
|
||||||
params: ViewParams
|
params: ViewParams
|
||||||
): Promise<Row[]> {
|
): Promise<Row[]> {
|
||||||
return pickApi(tableId).fetchView(viewName, params)
|
return internal.fetchLegacyView(viewName, params)
|
||||||
}
|
}
|
||||||
|
|
|
@ -272,11 +272,3 @@ export async function fetchRaw(tableId: string): Promise<Row[]> {
|
||||||
})
|
})
|
||||||
return response.rows
|
return response.rows
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchView(viewName: string) {
|
|
||||||
// there are no views in external datasources, shouldn't ever be called
|
|
||||||
// for now just fetch
|
|
||||||
const split = viewName.split("all_")
|
|
||||||
const tableId = split[1] ? split[1] : split[0]
|
|
||||||
return fetch(tableId)
|
|
||||||
}
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ import {
|
||||||
Table,
|
Table,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { isPlainObject } from "lodash"
|
import { isPlainObject } from "lodash"
|
||||||
|
import { dataFilters } from "@budibase/shared-core"
|
||||||
|
|
||||||
export function getRelationshipColumns(table: Table): {
|
export function getRelationshipColumns(table: Table): {
|
||||||
name: string
|
name: string
|
||||||
|
@ -58,5 +59,7 @@ export function updateFilterKeys(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return filters
|
return dataFilters.recurseLogicalOperators(filters, (f: SearchFilters) => {
|
||||||
|
return updateFilterKeys(f, updates)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -145,7 +145,7 @@ export async function fetchRaw(tableId: string): Promise<Row[]> {
|
||||||
return rows as Row[]
|
return rows as Row[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchView(
|
export async function fetchLegacyView(
|
||||||
viewName: string,
|
viewName: string,
|
||||||
options: { calculation: string; group: string; field: string }
|
options: { calculation: string; group: string; field: string }
|
||||||
): Promise<Row[]> {
|
): Promise<Row[]> {
|
||||||
|
|
|
@ -297,7 +297,7 @@ export async function search(
|
||||||
throw new Error("Unable to find table")
|
throw new Error("Unable to find table")
|
||||||
}
|
}
|
||||||
|
|
||||||
const relationships = buildInternalRelationships(table)
|
const relationships = buildInternalRelationships(table, allTables)
|
||||||
|
|
||||||
const searchFilters: SearchFilters = {
|
const searchFilters: SearchFilters = {
|
||||||
...cleanupFilters(query, table, allTables),
|
...cleanupFilters(query, table, allTables),
|
||||||
|
|
|
@ -1,4 +1,11 @@
|
||||||
import { Datasource, FieldType, Row, Table } from "@budibase/types"
|
import {
|
||||||
|
AutoColumnFieldMetadata,
|
||||||
|
AutoFieldSubType,
|
||||||
|
Datasource,
|
||||||
|
FieldType,
|
||||||
|
NumberFieldMetadata,
|
||||||
|
Table,
|
||||||
|
} from "@budibase/types"
|
||||||
|
|
||||||
import TestConfiguration from "../../../../../tests/utilities/TestConfiguration"
|
import TestConfiguration from "../../../../../tests/utilities/TestConfiguration"
|
||||||
import { search } from "../../../../../sdk/app/rows/search"
|
import { search } from "../../../../../sdk/app/rows/search"
|
||||||
|
@ -32,7 +39,6 @@ describe.each([
|
||||||
let envCleanup: (() => void) | undefined
|
let envCleanup: (() => void) | undefined
|
||||||
let datasource: Datasource | undefined
|
let datasource: Datasource | undefined
|
||||||
let table: Table
|
let table: Table
|
||||||
let rows: Row[]
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await withCoreEnv({ SQS_SEARCH_ENABLE: isSqs ? "true" : "false" }, () =>
|
await withCoreEnv({ SQS_SEARCH_ENABLE: isSqs ? "true" : "false" }, () =>
|
||||||
|
@ -51,16 +57,28 @@ describe.each([
|
||||||
datasource: await dsProvider,
|
datasource: await dsProvider,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const idFieldSchema: NumberFieldMetadata | AutoColumnFieldMetadata =
|
||||||
|
isInternal
|
||||||
|
? {
|
||||||
|
name: "id",
|
||||||
|
type: FieldType.AUTO,
|
||||||
|
subtype: AutoFieldSubType.AUTO_ID,
|
||||||
|
autocolumn: true,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
name: "id",
|
||||||
|
type: FieldType.NUMBER,
|
||||||
|
autocolumn: true,
|
||||||
|
}
|
||||||
|
|
||||||
table = await config.api.table.save(
|
table = await config.api.table.save(
|
||||||
tableForDatasource(datasource, {
|
tableForDatasource(datasource, {
|
||||||
primary: ["id"],
|
primary: ["id"],
|
||||||
schema: {
|
schema: {
|
||||||
id: {
|
id: idFieldSchema,
|
||||||
name: "id",
|
|
||||||
type: FieldType.NUMBER,
|
|
||||||
autocolumn: true,
|
|
||||||
},
|
|
||||||
name: {
|
name: {
|
||||||
name: "name",
|
name: "name",
|
||||||
type: FieldType.STRING,
|
type: FieldType.STRING,
|
||||||
|
@ -81,16 +99,13 @@ describe.each([
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
rows = []
|
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
rows.push(
|
await config.api.row.save(table._id!, {
|
||||||
await config.api.row.save(table._id!, {
|
name: generator.first(),
|
||||||
name: generator.first(),
|
surname: generator.last(),
|
||||||
surname: generator.last(),
|
age: generator.age(),
|
||||||
age: generator.age(),
|
address: generator.address(),
|
||||||
address: generator.address(),
|
})
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -138,4 +153,100 @@ describe.each([
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("does not allow accessing hidden fields", async () => {
|
||||||
|
await config.doInContext(config.appId, async () => {
|
||||||
|
await config.api.table.save({
|
||||||
|
...table,
|
||||||
|
schema: {
|
||||||
|
...table.schema,
|
||||||
|
name: {
|
||||||
|
...table.schema.name,
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
age: {
|
||||||
|
...table.schema.age,
|
||||||
|
visible: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const result = await search({
|
||||||
|
tableId: table._id!,
|
||||||
|
query: {},
|
||||||
|
})
|
||||||
|
expect(result.rows).toHaveLength(10)
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const keys = Object.keys(row)
|
||||||
|
expect(keys).toContain("name")
|
||||||
|
expect(keys).toContain("surname")
|
||||||
|
expect(keys).toContain("address")
|
||||||
|
expect(keys).not.toContain("age")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not allow accessing hidden fields even if requested", async () => {
|
||||||
|
await config.doInContext(config.appId, async () => {
|
||||||
|
await config.api.table.save({
|
||||||
|
...table,
|
||||||
|
schema: {
|
||||||
|
...table.schema,
|
||||||
|
name: {
|
||||||
|
...table.schema.name,
|
||||||
|
visible: true,
|
||||||
|
},
|
||||||
|
age: {
|
||||||
|
...table.schema.age,
|
||||||
|
visible: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const result = await search({
|
||||||
|
tableId: table._id!,
|
||||||
|
query: {},
|
||||||
|
fields: ["name", "age"],
|
||||||
|
})
|
||||||
|
expect(result.rows).toHaveLength(10)
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const keys = Object.keys(row)
|
||||||
|
expect(keys).toContain("name")
|
||||||
|
expect(keys).not.toContain("age")
|
||||||
|
expect(keys).not.toContain("surname")
|
||||||
|
expect(keys).not.toContain("address")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
!isLucene &&
|
||||||
|
it.each([
|
||||||
|
[["id", "name", "age"], 3],
|
||||||
|
[["name", "age"], 10],
|
||||||
|
])(
|
||||||
|
"cannot query by non search fields (fields: %s)",
|
||||||
|
async (queryFields, expectedRows) => {
|
||||||
|
await config.doInContext(config.appId, async () => {
|
||||||
|
const { rows } = await search({
|
||||||
|
tableId: table._id!,
|
||||||
|
query: {
|
||||||
|
$or: {
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
$and: {
|
||||||
|
conditions: [
|
||||||
|
{ range: { id: { low: 2, high: 4 } } },
|
||||||
|
{ range: { id: { low: 3, high: 5 } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ equal: { id: 7 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fields: queryFields,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(rows).toHaveLength(expectedRows)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
|
@ -12,6 +12,7 @@ import { getSQLClient } from "./utils"
|
||||||
import { cloneDeep } from "lodash"
|
import { cloneDeep } from "lodash"
|
||||||
import datasources from "../datasources"
|
import datasources from "../datasources"
|
||||||
import { BudibaseInternalDB } from "../../../db/utils"
|
import { BudibaseInternalDB } from "../../../db/utils"
|
||||||
|
import { dataFilters } from "@budibase/shared-core"
|
||||||
|
|
||||||
type PerformQueryFunction = (
|
type PerformQueryFunction = (
|
||||||
datasource: Datasource,
|
datasource: Datasource,
|
||||||
|
@ -199,16 +200,20 @@ export default class AliasTables {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (json.filters) {
|
if (json.filters) {
|
||||||
for (let [filterKey, filter] of Object.entries(json.filters)) {
|
const aliasFilters = (filters: SearchFilters): SearchFilters => {
|
||||||
if (typeof filter !== "object") {
|
for (let [filterKey, filter] of Object.entries(filters)) {
|
||||||
continue
|
if (typeof filter !== "object") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const aliasedFilters: typeof filter = {}
|
||||||
|
for (let key of Object.keys(filter)) {
|
||||||
|
aliasedFilters[this.aliasField(key)] = filter[key]
|
||||||
|
}
|
||||||
|
filters[filterKey as keyof SearchFilters] = aliasedFilters
|
||||||
}
|
}
|
||||||
const aliasedFilters: typeof filter = {}
|
return dataFilters.recurseLogicalOperators(filters, aliasFilters)
|
||||||
for (let key of Object.keys(filter)) {
|
|
||||||
aliasedFilters[this.aliasField(key)] = filter[key]
|
|
||||||
}
|
|
||||||
json.filters[filterKey as keyof SearchFilters] = aliasedFilters
|
|
||||||
}
|
}
|
||||||
|
json.filters = aliasFilters(json.filters)
|
||||||
}
|
}
|
||||||
if (json.meta?.table) {
|
if (json.meta?.table) {
|
||||||
this.getAlias(json.meta.table.name)
|
this.getAlias(json.meta.table.name)
|
||||||
|
|
|
@ -0,0 +1,505 @@
|
||||||
|
import {
|
||||||
|
FieldType,
|
||||||
|
RelationshipType,
|
||||||
|
SearchFilters,
|
||||||
|
Table,
|
||||||
|
} from "@budibase/types"
|
||||||
|
import { getQueryableFields, removeInvalidFilters } from "../queryUtils"
|
||||||
|
import { structures } from "../../../../api/routes/tests/utilities"
|
||||||
|
import TestConfiguration from "../../../../tests/utilities/TestConfiguration"
|
||||||
|
|
||||||
|
describe("query utils", () => {
|
||||||
|
describe("removeInvalidFilters", () => {
|
||||||
|
const fullFilters: SearchFilters = {
|
||||||
|
equal: { one: "foo" },
|
||||||
|
$or: {
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
equal: { one: "foo2", two: "bar" },
|
||||||
|
notEmpty: { one: null },
|
||||||
|
$and: {
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
equal: { three: "baz" },
|
||||||
|
notEmpty: { forth: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
$and: {
|
||||||
|
conditions: [{ equal: { one: "foo2" }, notEmpty: { one: null } }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
it("can filter empty queries", () => {
|
||||||
|
const filters: SearchFilters = {}
|
||||||
|
const result = removeInvalidFilters(filters, [])
|
||||||
|
expect(result).toEqual({})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not trim any valid field", () => {
|
||||||
|
const result = removeInvalidFilters(fullFilters, [
|
||||||
|
"one",
|
||||||
|
"two",
|
||||||
|
"three",
|
||||||
|
"forth",
|
||||||
|
])
|
||||||
|
expect(result).toEqual(fullFilters)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("trims invalid field", () => {
|
||||||
|
const result = removeInvalidFilters(fullFilters, [
|
||||||
|
"one",
|
||||||
|
"three",
|
||||||
|
"forth",
|
||||||
|
])
|
||||||
|
expect(result).toEqual({
|
||||||
|
equal: { one: "foo" },
|
||||||
|
$or: {
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
equal: { one: "foo2" },
|
||||||
|
notEmpty: { one: null },
|
||||||
|
$and: {
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
equal: { three: "baz" },
|
||||||
|
notEmpty: { forth: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
$and: {
|
||||||
|
conditions: [{ equal: { one: "foo2" }, notEmpty: { one: null } }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("trims invalid field keeping a valid fields", () => {
|
||||||
|
const result = removeInvalidFilters(fullFilters, ["three", "forth"])
|
||||||
|
const expected: SearchFilters = {
|
||||||
|
$or: {
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
$and: {
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
equal: { three: "baz" },
|
||||||
|
notEmpty: { forth: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
expect(result).toEqual(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps filter key numering", () => {
|
||||||
|
const prefixedFilters: SearchFilters = {
|
||||||
|
equal: { "1:one": "foo" },
|
||||||
|
$or: {
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
equal: { "2:one": "foo2", "3:two": "bar" },
|
||||||
|
notEmpty: { "4:one": null },
|
||||||
|
$and: {
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
equal: { "5:three": "baz", two: "bar2" },
|
||||||
|
notEmpty: { forth: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
$and: {
|
||||||
|
conditions: [{ equal: { "6:one": "foo2" }, notEmpty: { one: null } }],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = removeInvalidFilters(prefixedFilters, [
|
||||||
|
"one",
|
||||||
|
"three",
|
||||||
|
"forth",
|
||||||
|
])
|
||||||
|
expect(result).toEqual({
|
||||||
|
equal: { "1:one": "foo" },
|
||||||
|
$or: {
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
equal: { "2:one": "foo2" },
|
||||||
|
notEmpty: { "4:one": null },
|
||||||
|
$and: {
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
equal: { "5:three": "baz" },
|
||||||
|
notEmpty: { forth: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
$and: {
|
||||||
|
conditions: [{ equal: { "6:one": "foo2" }, notEmpty: { one: null } }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles relationship filters", () => {
|
||||||
|
const prefixedFilters: SearchFilters = {
|
||||||
|
$or: {
|
||||||
|
conditions: [
|
||||||
|
{ equal: { "1:other.one": "foo" } },
|
||||||
|
{
|
||||||
|
equal: {
|
||||||
|
"2:other.one": "foo2",
|
||||||
|
"3:other.two": "bar",
|
||||||
|
"4:other.three": "baz",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ equal: { "another.three": "baz2" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = removeInvalidFilters(prefixedFilters, [
|
||||||
|
"other.one",
|
||||||
|
"other.two",
|
||||||
|
"another.three",
|
||||||
|
])
|
||||||
|
expect(result).toEqual({
|
||||||
|
$or: {
|
||||||
|
conditions: [
|
||||||
|
{ equal: { "1:other.one": "foo" } },
|
||||||
|
{ equal: { "2:other.one": "foo2", "3:other.two": "bar" } },
|
||||||
|
{ equal: { "another.three": "baz2" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getQueryableFields", () => {
|
||||||
|
const config = new TestConfiguration()
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await config.init()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns table schema fields and _id", async () => {
|
||||||
|
const table: Table = await config.api.table.save({
|
||||||
|
...structures.basicTable(),
|
||||||
|
schema: {
|
||||||
|
name: { name: "name", type: FieldType.STRING },
|
||||||
|
age: { name: "age", type: FieldType.NUMBER },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await getQueryableFields(Object.keys(table.schema), table)
|
||||||
|
expect(result).toEqual(["_id", "name", "age"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("excludes hidden fields", async () => {
|
||||||
|
const table: Table = await config.api.table.save({
|
||||||
|
...structures.basicTable(),
|
||||||
|
schema: {
|
||||||
|
name: { name: "name", type: FieldType.STRING },
|
||||||
|
age: { name: "age", type: FieldType.NUMBER, visible: false },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await getQueryableFields(Object.keys(table.schema), table)
|
||||||
|
expect(result).toEqual(["_id", "name"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("includes relationship fields", async () => {
|
||||||
|
const aux: Table = await config.api.table.save({
|
||||||
|
...structures.basicTable(),
|
||||||
|
name: "auxTable",
|
||||||
|
schema: {
|
||||||
|
title: { name: "title", type: FieldType.STRING },
|
||||||
|
name: { name: "name", type: FieldType.STRING },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const table: Table = await config.api.table.save({
|
||||||
|
...structures.basicTable(),
|
||||||
|
schema: {
|
||||||
|
name: { name: "name", type: FieldType.STRING },
|
||||||
|
aux: {
|
||||||
|
name: "aux",
|
||||||
|
type: FieldType.LINK,
|
||||||
|
tableId: aux._id!,
|
||||||
|
relationshipType: RelationshipType.ONE_TO_MANY,
|
||||||
|
fieldName: "table",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await config.doInContext(config.appId, () => {
|
||||||
|
return getQueryableFields(Object.keys(table.schema), table)
|
||||||
|
})
|
||||||
|
expect(result).toEqual([
|
||||||
|
"_id",
|
||||||
|
"name",
|
||||||
|
"aux.title",
|
||||||
|
"auxTable.title",
|
||||||
|
"aux.name",
|
||||||
|
"auxTable.name",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("excludes hidden relationship fields", async () => {
|
||||||
|
const aux: Table = await config.api.table.save({
|
||||||
|
...structures.basicTable(),
|
||||||
|
name: "auxTable",
|
||||||
|
schema: {
|
||||||
|
title: { name: "title", type: FieldType.STRING, visible: false },
|
||||||
|
name: { name: "name", type: FieldType.STRING, visible: true },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const table: Table = await config.api.table.save({
|
||||||
|
...structures.basicTable(),
|
||||||
|
schema: {
|
||||||
|
name: { name: "name", type: FieldType.STRING },
|
||||||
|
aux: {
|
||||||
|
name: "aux",
|
||||||
|
type: FieldType.LINK,
|
||||||
|
tableId: aux._id!,
|
||||||
|
relationshipType: RelationshipType.ONE_TO_MANY,
|
||||||
|
fieldName: "table",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await config.doInContext(config.appId, () => {
|
||||||
|
return getQueryableFields(Object.keys(table.schema), table)
|
||||||
|
})
|
||||||
|
expect(result).toEqual(["_id", "name", "aux.name", "auxTable.name"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("excludes all relationship fields if hidden", async () => {
|
||||||
|
const aux: Table = await config.api.table.save({
|
||||||
|
...structures.basicTable(),
|
||||||
|
name: "auxTable",
|
||||||
|
schema: {
|
||||||
|
title: { name: "title", type: FieldType.STRING, visible: false },
|
||||||
|
name: { name: "name", type: FieldType.STRING, visible: true },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const table: Table = await config.api.table.save({
|
||||||
|
...structures.basicTable(),
|
||||||
|
schema: {
|
||||||
|
name: { name: "name", type: FieldType.STRING },
|
||||||
|
aux: {
|
||||||
|
name: "aux",
|
||||||
|
type: FieldType.LINK,
|
||||||
|
tableId: aux._id!,
|
||||||
|
relationshipType: RelationshipType.ONE_TO_MANY,
|
||||||
|
fieldName: "table",
|
||||||
|
visible: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await config.doInContext(config.appId, () => {
|
||||||
|
return getQueryableFields(Object.keys(table.schema), table)
|
||||||
|
})
|
||||||
|
expect(result).toEqual(["_id", "name"])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("nested relationship", () => {
|
||||||
|
describe("one-to-many", () => {
|
||||||
|
let table: Table, aux1: Table, aux2: Table
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const { _id: aux1Id } = await config.api.table.save({
|
||||||
|
...structures.basicTable(),
|
||||||
|
name: "aux1Table",
|
||||||
|
schema: {
|
||||||
|
name: { name: "name", type: FieldType.STRING },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const { _id: aux2Id } = await config.api.table.save({
|
||||||
|
...structures.basicTable(),
|
||||||
|
name: "aux2Table",
|
||||||
|
schema: {
|
||||||
|
title: { name: "title", type: FieldType.STRING },
|
||||||
|
aux1_1: {
|
||||||
|
name: "aux1_1",
|
||||||
|
type: FieldType.LINK,
|
||||||
|
tableId: aux1Id!,
|
||||||
|
relationshipType: RelationshipType.ONE_TO_MANY,
|
||||||
|
fieldName: "aux2_1",
|
||||||
|
},
|
||||||
|
aux1_2: {
|
||||||
|
name: "aux1_2",
|
||||||
|
type: FieldType.LINK,
|
||||||
|
tableId: aux1Id!,
|
||||||
|
relationshipType: RelationshipType.ONE_TO_MANY,
|
||||||
|
fieldName: "aux2_2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { _id: tableId } = await config.api.table.save({
|
||||||
|
...structures.basicTable(),
|
||||||
|
schema: {
|
||||||
|
name: { name: "name", type: FieldType.STRING },
|
||||||
|
aux1: {
|
||||||
|
name: "aux1",
|
||||||
|
type: FieldType.LINK,
|
||||||
|
tableId: aux1Id!,
|
||||||
|
relationshipType: RelationshipType.ONE_TO_MANY,
|
||||||
|
fieldName: "table",
|
||||||
|
},
|
||||||
|
aux2: {
|
||||||
|
name: "aux2",
|
||||||
|
type: FieldType.LINK,
|
||||||
|
tableId: aux2Id!,
|
||||||
|
relationshipType: RelationshipType.ONE_TO_MANY,
|
||||||
|
fieldName: "table",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// We need to refech them to get the updated foreign keys
|
||||||
|
aux1 = await config.api.table.get(aux1Id!)
|
||||||
|
aux2 = await config.api.table.get(aux2Id!)
|
||||||
|
table = await config.api.table.get(tableId!)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("includes nested relationship fields from main table", async () => {
|
||||||
|
const result = await config.doInContext(config.appId, () => {
|
||||||
|
return getQueryableFields(Object.keys(table.schema), table)
|
||||||
|
})
|
||||||
|
expect(result).toEqual([
|
||||||
|
"_id",
|
||||||
|
"name",
|
||||||
|
// aux1 primitive props
|
||||||
|
"aux1.name",
|
||||||
|
"aux1Table.name",
|
||||||
|
|
||||||
|
// aux2 primitive props
|
||||||
|
"aux2.title",
|
||||||
|
"aux2Table.title",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("includes nested relationship fields from aux 1 table", async () => {
|
||||||
|
const result = await config.doInContext(config.appId, () => {
|
||||||
|
return getQueryableFields(Object.keys(aux1.schema), aux1)
|
||||||
|
})
|
||||||
|
expect(result).toEqual([
|
||||||
|
"_id",
|
||||||
|
"name",
|
||||||
|
|
||||||
|
// aux2_1 primitive props
|
||||||
|
"aux2_1.title",
|
||||||
|
"aux2Table.title",
|
||||||
|
|
||||||
|
// aux2_2 primitive props
|
||||||
|
"aux2_2.title",
|
||||||
|
"aux2Table.title",
|
||||||
|
|
||||||
|
// table primitive props
|
||||||
|
"table.name",
|
||||||
|
"TestTable.name",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("includes nested relationship fields from aux 2 table", async () => {
|
||||||
|
const result = await config.doInContext(config.appId, () => {
|
||||||
|
return getQueryableFields(Object.keys(aux2.schema), aux2)
|
||||||
|
})
|
||||||
|
expect(result).toEqual([
|
||||||
|
"_id",
|
||||||
|
"title",
|
||||||
|
|
||||||
|
// aux1_1 primitive props
|
||||||
|
"aux1_1.name",
|
||||||
|
"aux1Table.name",
|
||||||
|
|
||||||
|
// aux1_2 primitive props
|
||||||
|
"aux1_2.name",
|
||||||
|
"aux1Table.name",
|
||||||
|
|
||||||
|
// table primitive props
|
||||||
|
"table.name",
|
||||||
|
"TestTable.name",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("many-to-many", () => {
|
||||||
|
let table: Table, aux: Table
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const { _id: auxId } = await config.api.table.save({
|
||||||
|
...structures.basicTable(),
|
||||||
|
name: "auxTable",
|
||||||
|
schema: {
|
||||||
|
title: { name: "title", type: FieldType.STRING },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { _id: tableId } = await config.api.table.save({
|
||||||
|
...structures.basicTable(),
|
||||||
|
schema: {
|
||||||
|
name: { name: "name", type: FieldType.STRING },
|
||||||
|
aux: {
|
||||||
|
name: "aux",
|
||||||
|
type: FieldType.LINK,
|
||||||
|
tableId: auxId!,
|
||||||
|
relationshipType: RelationshipType.MANY_TO_MANY,
|
||||||
|
fieldName: "table",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// We need to refech them to get the updated foreign keys
|
||||||
|
aux = await config.api.table.get(auxId!)
|
||||||
|
table = await config.api.table.get(tableId!)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("includes nested relationship fields from main table", async () => {
|
||||||
|
const result = await config.doInContext(config.appId, () => {
|
||||||
|
return getQueryableFields(Object.keys(table.schema), table)
|
||||||
|
})
|
||||||
|
expect(result).toEqual([
|
||||||
|
"_id",
|
||||||
|
"name",
|
||||||
|
|
||||||
|
// deep 1 aux primitive props
|
||||||
|
"aux.title",
|
||||||
|
"auxTable.title",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("includes nested relationship fields from aux table", async () => {
|
||||||
|
const result = await config.doInContext(config.appId, () => {
|
||||||
|
return getQueryableFields(Object.keys(aux.schema), aux)
|
||||||
|
})
|
||||||
|
expect(result).toEqual([
|
||||||
|
"_id",
|
||||||
|
"title",
|
||||||
|
|
||||||
|
// deep 1 dependency primitive props
|
||||||
|
"table.name",
|
||||||
|
"TestTable.name",
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
|
@ -3,6 +3,7 @@ import { fixAutoColumnSubType, processFormulas } from "./utils"
|
||||||
import {
|
import {
|
||||||
cache,
|
cache,
|
||||||
context,
|
context,
|
||||||
|
db,
|
||||||
HTTPError,
|
HTTPError,
|
||||||
objectStore,
|
objectStore,
|
||||||
utils,
|
utils,
|
||||||
|
@ -26,8 +27,13 @@ import {
|
||||||
processOutputBBReferences,
|
processOutputBBReferences,
|
||||||
} from "./bbReferenceProcessor"
|
} from "./bbReferenceProcessor"
|
||||||
import { isExternalTableID } from "../../integrations/utils"
|
import { isExternalTableID } from "../../integrations/utils"
|
||||||
import { helpers } from "@budibase/shared-core"
|
import {
|
||||||
|
helpers,
|
||||||
|
PROTECTED_EXTERNAL_COLUMNS,
|
||||||
|
PROTECTED_INTERNAL_COLUMNS,
|
||||||
|
} from "@budibase/shared-core"
|
||||||
import { processString } from "@budibase/string-templates"
|
import { processString } from "@budibase/string-templates"
|
||||||
|
import { isUserMetadataTable } from "../../api/controllers/row/utils"
|
||||||
|
|
||||||
export * from "./utils"
|
export * from "./utils"
|
||||||
export * from "./attachments"
|
export * from "./attachments"
|
||||||
|
@ -53,9 +59,9 @@ export async function processAutoColumn(
|
||||||
row: Row,
|
row: Row,
|
||||||
opts?: AutoColumnProcessingOpts
|
opts?: AutoColumnProcessingOpts
|
||||||
) {
|
) {
|
||||||
let noUser = !userId
|
const noUser = !userId
|
||||||
let isUserTable = table._id === InternalTables.USER_METADATA
|
const isUserTable = table._id === InternalTables.USER_METADATA
|
||||||
let now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
// if a row doesn't have a revision then it doesn't exist yet
|
// if a row doesn't have a revision then it doesn't exist yet
|
||||||
const creating = !row._rev
|
const creating = !row._rev
|
||||||
// check its not user table, or whether any of the processing options have been disabled
|
// check its not user table, or whether any of the processing options have been disabled
|
||||||
|
@ -111,7 +117,7 @@ async function processDefaultValues(table: Table, row: Row) {
|
||||||
ctx.user = user
|
ctx.user = user
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let [key, schema] of Object.entries(table.schema)) {
|
for (const [key, schema] of Object.entries(table.schema)) {
|
||||||
if ("default" in schema && schema.default != null && row[key] == null) {
|
if ("default" in schema && schema.default != null && row[key] == null) {
|
||||||
const processed = await processString(schema.default, ctx)
|
const processed = await processString(schema.default, ctx)
|
||||||
|
|
||||||
|
@ -165,10 +171,10 @@ export async function inputProcessing(
|
||||||
row: Row,
|
row: Row,
|
||||||
opts?: AutoColumnProcessingOpts
|
opts?: AutoColumnProcessingOpts
|
||||||
) {
|
) {
|
||||||
let clonedRow = cloneDeep(row)
|
const clonedRow = cloneDeep(row)
|
||||||
|
|
||||||
const dontCleanseKeys = ["type", "_id", "_rev", "tableId"]
|
const dontCleanseKeys = ["type", "_id", "_rev", "tableId"]
|
||||||
for (let [key, value] of Object.entries(clonedRow)) {
|
for (const [key, value] of Object.entries(clonedRow)) {
|
||||||
const field = table.schema[key]
|
const field = table.schema[key]
|
||||||
// cleanse fields that aren't in the schema
|
// cleanse fields that aren't in the schema
|
||||||
if (!field) {
|
if (!field) {
|
||||||
|
@ -257,7 +263,7 @@ export async function outputProcessing<T extends Row[] | Row>(
|
||||||
}
|
}
|
||||||
// attach any linked row information
|
// attach any linked row information
|
||||||
let enriched = !opts.preserveLinks
|
let enriched = !opts.preserveLinks
|
||||||
? await linkRows.attachFullLinkedDocs(table, safeRows, {
|
? await linkRows.attachFullLinkedDocs(table.schema, safeRows, {
|
||||||
fromRow: opts?.fromRow,
|
fromRow: opts?.fromRow,
|
||||||
})
|
})
|
||||||
: safeRows
|
: safeRows
|
||||||
|
@ -268,13 +274,13 @@ export async function outputProcessing<T extends Row[] | Row>(
|
||||||
}
|
}
|
||||||
|
|
||||||
// process complex types: attachments, bb references...
|
// process complex types: attachments, bb references...
|
||||||
for (let [property, column] of Object.entries(table.schema)) {
|
for (const [property, column] of Object.entries(table.schema)) {
|
||||||
if (
|
if (
|
||||||
column.type === FieldType.ATTACHMENTS ||
|
column.type === FieldType.ATTACHMENTS ||
|
||||||
column.type === FieldType.ATTACHMENT_SINGLE ||
|
column.type === FieldType.ATTACHMENT_SINGLE ||
|
||||||
column.type === FieldType.SIGNATURE_SINGLE
|
column.type === FieldType.SIGNATURE_SINGLE
|
||||||
) {
|
) {
|
||||||
for (let row of enriched) {
|
for (const row of enriched) {
|
||||||
if (row[property] == null) {
|
if (row[property] == null) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
@ -299,7 +305,7 @@ export async function outputProcessing<T extends Row[] | Row>(
|
||||||
!opts.skipBBReferences &&
|
!opts.skipBBReferences &&
|
||||||
column.type == FieldType.BB_REFERENCE
|
column.type == FieldType.BB_REFERENCE
|
||||||
) {
|
) {
|
||||||
for (let row of enriched) {
|
for (const row of enriched) {
|
||||||
row[property] = await processOutputBBReferences(
|
row[property] = await processOutputBBReferences(
|
||||||
row[property],
|
row[property],
|
||||||
column.subtype
|
column.subtype
|
||||||
|
@ -309,14 +315,14 @@ export async function outputProcessing<T extends Row[] | Row>(
|
||||||
!opts.skipBBReferences &&
|
!opts.skipBBReferences &&
|
||||||
column.type == FieldType.BB_REFERENCE_SINGLE
|
column.type == FieldType.BB_REFERENCE_SINGLE
|
||||||
) {
|
) {
|
||||||
for (let row of enriched) {
|
for (const row of enriched) {
|
||||||
row[property] = await processOutputBBReference(
|
row[property] = await processOutputBBReference(
|
||||||
row[property],
|
row[property],
|
||||||
column.subtype
|
column.subtype
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (column.type === FieldType.DATETIME && column.timeOnly) {
|
} else if (column.type === FieldType.DATETIME && column.timeOnly) {
|
||||||
for (let row of enriched) {
|
for (const row of enriched) {
|
||||||
if (row[property] instanceof Date) {
|
if (row[property] instanceof Date) {
|
||||||
const hours = row[property].getUTCHours().toString().padStart(2, "0")
|
const hours = row[property].getUTCHours().toString().padStart(2, "0")
|
||||||
const minutes = row[property]
|
const minutes = row[property]
|
||||||
|
@ -343,14 +349,44 @@ export async function outputProcessing<T extends Row[] | Row>(
|
||||||
)) as Row[]
|
)) as Row[]
|
||||||
}
|
}
|
||||||
// remove null properties to match internal API
|
// remove null properties to match internal API
|
||||||
if (isExternalTableID(table._id!)) {
|
const isExternal = isExternalTableID(table._id!)
|
||||||
for (let row of enriched) {
|
if (isExternal || db.isSqsEnabledForTenant()) {
|
||||||
for (let key of Object.keys(row)) {
|
for (const row of enriched) {
|
||||||
|
for (const key of Object.keys(row)) {
|
||||||
if (row[key] === null) {
|
if (row[key] === null) {
|
||||||
delete row[key]
|
delete row[key]
|
||||||
|
} else if (row[key] && table.schema[key]?.type === FieldType.LINK) {
|
||||||
|
for (const link of row[key] || []) {
|
||||||
|
for (const linkKey of Object.keys(link)) {
|
||||||
|
if (link[linkKey] === null) {
|
||||||
|
delete link[linkKey]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isUserMetadataTable(table._id!)) {
|
||||||
|
const protectedColumns = isExternal
|
||||||
|
? PROTECTED_EXTERNAL_COLUMNS
|
||||||
|
: PROTECTED_INTERNAL_COLUMNS
|
||||||
|
|
||||||
|
const tableFields = Object.keys(table.schema).filter(
|
||||||
|
f => table.schema[f].visible !== false
|
||||||
|
)
|
||||||
|
const fields = [...tableFields, ...protectedColumns].map(f =>
|
||||||
|
f.toLowerCase()
|
||||||
|
)
|
||||||
|
for (const row of enriched) {
|
||||||
|
for (const key of Object.keys(row)) {
|
||||||
|
if (!fields.includes(key.toLowerCase())) {
|
||||||
|
delete row[key]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (wasArray ? enriched : enriched[0]) as T
|
return (wasArray ? enriched : enriched[0]) as T
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,14 @@ import { outputProcessing } from ".."
|
||||||
import { generator, structures } from "@budibase/backend-core/tests"
|
import { generator, structures } from "@budibase/backend-core/tests"
|
||||||
import * as bbReferenceProcessor from "../bbReferenceProcessor"
|
import * as bbReferenceProcessor from "../bbReferenceProcessor"
|
||||||
|
|
||||||
|
jest.mock("@budibase/backend-core", () => ({
|
||||||
|
...jest.requireActual("@budibase/backend-core"),
|
||||||
|
db: {
|
||||||
|
...jest.requireActual("@budibase/backend-core").db,
|
||||||
|
isSqsEnabledForTenant: () => true,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
jest.mock("../bbReferenceProcessor", (): typeof bbReferenceProcessor => ({
|
jest.mock("../bbReferenceProcessor", (): typeof bbReferenceProcessor => ({
|
||||||
processInputBBReference: jest.fn(),
|
processInputBBReference: jest.fn(),
|
||||||
processInputBBReferences: jest.fn(),
|
processInputBBReferences: jest.fn(),
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { BaseSocket } from "./websocket"
|
||||||
import { auth, permissions } from "@budibase/backend-core"
|
import { auth, permissions } from "@budibase/backend-core"
|
||||||
import http from "http"
|
import http from "http"
|
||||||
import Koa from "koa"
|
import Koa from "koa"
|
||||||
import { getTableId } from "../api/controllers/row/utils"
|
import { getSourceId } from "../api/controllers/row/utils"
|
||||||
import { Row, Table, View, ViewV2 } from "@budibase/types"
|
import { Row, Table, View, ViewV2 } from "@budibase/types"
|
||||||
import { Socket } from "socket.io"
|
import { Socket } from "socket.io"
|
||||||
import { GridSocketEvent } from "@budibase/shared-core"
|
import { GridSocketEvent } from "@budibase/shared-core"
|
||||||
|
@ -80,7 +80,7 @@ export default class GridSocket extends BaseSocket {
|
||||||
}
|
}
|
||||||
|
|
||||||
emitRowUpdate(ctx: any, row: Row) {
|
emitRowUpdate(ctx: any, row: Row) {
|
||||||
const resourceId = ctx.params?.viewId || getTableId(ctx)
|
const resourceId = ctx.params?.viewId || getSourceId(ctx)
|
||||||
const room = `${ctx.appId}-${resourceId}`
|
const room = `${ctx.appId}-${resourceId}`
|
||||||
this.emitToRoom(ctx, room, GridSocketEvent.RowChange, {
|
this.emitToRoom(ctx, room, GridSocketEvent.RowChange, {
|
||||||
id: row._id,
|
id: row._id,
|
||||||
|
@ -89,7 +89,7 @@ export default class GridSocket extends BaseSocket {
|
||||||
}
|
}
|
||||||
|
|
||||||
emitRowDeletion(ctx: any, row: Row) {
|
emitRowDeletion(ctx: any, row: Row) {
|
||||||
const resourceId = ctx.params?.viewId || getTableId(ctx)
|
const resourceId = ctx.params?.viewId || getSourceId(ctx)
|
||||||
const room = `${ctx.appId}-${resourceId}`
|
const room = `${ctx.appId}-${resourceId}`
|
||||||
this.emitToRoom(ctx, room, GridSocketEvent.RowChange, {
|
this.emitToRoom(ctx, room, GridSocketEvent.RowChange, {
|
||||||
id: row._id,
|
id: row._id,
|
||||||
|
|
|
@ -113,6 +113,20 @@ export const NoEmptyFilterStrings = [
|
||||||
OperatorOptions.In.value,
|
OperatorOptions.In.value,
|
||||||
] as (keyof SearchQueryFields)[]
|
] as (keyof SearchQueryFields)[]
|
||||||
|
|
||||||
|
export function recurseLogicalOperators(
|
||||||
|
filters: SearchFilters,
|
||||||
|
fn: (f: SearchFilters) => SearchFilters
|
||||||
|
) {
|
||||||
|
for (const logical of Object.values(LogicalOperator)) {
|
||||||
|
if (filters[logical]) {
|
||||||
|
filters[logical]!.conditions = filters[logical]!.conditions.map(
|
||||||
|
condition => fn(condition)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes any fields that contain empty strings that would cause inconsistent
|
* Removes any fields that contain empty strings that would cause inconsistent
|
||||||
* behaviour with how backend tables are filtered (no value means no filter).
|
* behaviour with how backend tables are filtered (no value means no filter).
|
||||||
|
@ -145,6 +159,7 @@ export const cleanupQuery = (query: SearchFilters) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
query = recurseLogicalOperators(query, cleanupQuery)
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -410,6 +425,7 @@ export function fixupFilterArrays(filters: SearchFilters) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
recurseLogicalOperators(filters, fixupFilterArrays)
|
||||||
return filters
|
return filters
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue