Merge remote-tracking branch 'origin/master' into feature/signature-field-and-component

This commit is contained in:
Dean 2024-04-11 15:07:20 +01:00
commit f3e9030c3a
33 changed files with 1142 additions and 572 deletions

View File

@ -51,11 +51,11 @@ http {
proxy_buffering off;
set $csp_default "default-src 'self'";
set $csp_script "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.budibase.net https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io https://d2l5prqdbvm3op.cloudfront.net";
set $csp_script "script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.budibase.net https://cdn.budi.live https://js.intercomcdn.com https://widget.intercom.io https://d2l5prqdbvm3op.cloudfront.net https://us-assets.i.posthog.com";
set $csp_style "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://fonts.googleapis.com https://rsms.me https://maxcdn.bootstrapcdn.com";
set $csp_object "object-src 'none'";
set $csp_base_uri "base-uri 'self'";
set $csp_connect "connect-src 'self' https://*.budibase.app https://*.budibaseqa.app https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com";
set $csp_connect "connect-src 'self' https://*.budibase.app https://*.budibaseqa.app https://*.budibase.net https://api-iam.intercom.io https://api-iam.intercom.io https://api-ping.intercom.io https://app.posthog.com https://us.i.posthog.com wss://nexus-websocket-a.intercom.io wss://nexus-websocket-b.intercom.io https://nexus-websocket-a.intercom.io https://nexus-websocket-b.intercom.io https://uploads.intercomcdn.com https://uploads.intercomusercontent.com https://*.amazonaws.com https://*.s3.amazonaws.com https://*.s3.us-east-2.amazonaws.com https://*.s3.us-east-1.amazonaws.com https://*.s3.us-west-1.amazonaws.com https://*.s3.us-west-2.amazonaws.com https://*.s3.af-south-1.amazonaws.com https://*.s3.ap-east-1.amazonaws.com https://*.s3.ap-southeast-3.amazonaws.com https://*.s3.ap-south-1.amazonaws.com https://*.s3.ap-northeast-3.amazonaws.com https://*.s3.ap-northeast-2.amazonaws.com https://*.s3.ap-southeast-1.amazonaws.com https://*.s3.ap-southeast-2.amazonaws.com https://*.s3.ap-northeast-1.amazonaws.com https://*.s3.ca-central-1.amazonaws.com https://*.s3.cn-north-1.amazonaws.com https://*.s3.cn-northwest-1.amazonaws.com https://*.s3.eu-central-1.amazonaws.com https://*.s3.eu-west-1.amazonaws.com https://*.s3.eu-west-2.amazonaws.com https://*.s3.eu-south-1.amazonaws.com https://*.s3.eu-west-3.amazonaws.com https://*.s3.eu-north-1.amazonaws.com https://*.s3.sa-east-1.amazonaws.com https://*.s3.me-south-1.amazonaws.com https://*.s3.us-gov-east-1.amazonaws.com https://*.s3.us-gov-west-1.amazonaws.com https://api.github.com";
set $csp_font "font-src 'self' data: https://cdn.jsdelivr.net https://fonts.gstatic.com https://rsms.me https://maxcdn.bootstrapcdn.com https://js.intercomcdn.com https://fonts.intercomcdn.com";
set $csp_frame "frame-src 'self' https:";
set $csp_img "img-src http: https: data: blob:";

View File

@ -1,5 +1,5 @@
{
"version": "2.23.0",
"version": "2.23.3",
"npmClient": "yarn",
"packages": [
"packages/*",

View File

@ -12,6 +12,7 @@ import {
isDocument,
RowResponse,
RowValue,
SqlQueryBinding,
} from "@budibase/types"
import { getCouchInfo } from "./connections"
import { directCouchUrlCall } from "./utils"
@ -248,14 +249,20 @@ export class DatabaseImpl implements Database {
})
}
async sql<T extends Document>(sql: string): Promise<T[]> {
async sql<T extends Document>(
sql: string,
parameters?: SqlQueryBinding
): Promise<T[]> {
const dbName = this.name
const url = `/${dbName}/${SQLITE_DESIGN_DOC_ID}`
const response = await directCouchUrlCall({
url: `${this.couchInfo.sqlUrl}/${url}`,
method: "POST",
cookie: this.couchInfo.cookie,
body: sql,
body: {
query: sql,
args: parameters,
},
})
if (response.status > 300) {
throw new Error(await response.text())

View File

@ -13,6 +13,7 @@ import {
DatabaseQueryOpts,
Document,
RowValue,
SqlQueryBinding,
} from "@budibase/types"
import tracer from "dd-trace"
import { Writable } from "stream"
@ -150,10 +151,13 @@ export class DDInstrumentedDatabase implements Database {
})
}
sql<T extends Document>(sql: string): Promise<T[]> {
sql<T extends Document>(
sql: string,
parameters?: SqlQueryBinding
): Promise<T[]> {
return tracer.trace("db.sql", span => {
span?.addTags({ db_name: this.name })
return this.db.sql(sql)
return this.db.sql(sql, parameters)
})
}
}

View File

@ -4,6 +4,7 @@
import "@spectrum-css/menu/dist/index-vars.css"
import { createEventDispatcher } from "svelte"
import clickOutside from "../../Actions/click_outside"
import Popover from "../../Popover/Popover.svelte"
export let value = null
export let id = null
@ -15,8 +16,10 @@
export let getOptionValue = option => option
const dispatch = createEventDispatcher()
let open = false
let focus = false
let anchor
const selectOption = value => {
dispatch("change", value)
@ -35,11 +38,11 @@
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div
class="spectrum-InputGroup"
class:is-focused={open || focus}
class:is-disabled={disabled}
bind:this={anchor}
>
<div
class="spectrum-Textfield spectrum-InputGroup-textfield"
@ -67,7 +70,7 @@
tabindex="-1"
aria-haspopup="true"
{disabled}
on:click={() => (open = true)}
on:click={() => (open = !open)}
>
<svg
class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon spectrum-InputGroup-icon"
@ -77,42 +80,44 @@
<use xlink:href="#spectrum-css-icon-Chevron100" />
</svg>
</button>
{#if open}
<div
class="spectrum-Popover spectrum-Popover--bottom is-open"
use:clickOutside={() => {
open = false
}}
>
<ul class="spectrum-Menu" role="listbox">
{#if options && Array.isArray(options)}
{#each options as option}
<li
class="spectrum-Menu-item"
class:is-selected={getOptionValue(option) === value}
role="option"
aria-selected="true"
tabindex="0"
on:click={() => onPick(getOptionValue(option))}
>
<span class="spectrum-Menu-itemLabel"
>{getOptionLabel(option)}</span
>
<svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Checkmark100" />
</svg>
</li>
{/each}
{/if}
</ul>
</div>
{/if}
</div>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<Popover
{anchor}
{open}
align="left"
on:close={() => (open = false)}
useAnchorWidth
>
<div class="popover-content" use:clickOutside={() => (open = false)}>
<ul class="spectrum-Menu" role="listbox">
{#if options && Array.isArray(options)}
{#each options as option}
<li
class="spectrum-Menu-item"
class:is-selected={getOptionValue(option) === value}
role="option"
aria-selected="true"
tabindex="0"
on:click={() => onPick(getOptionValue(option))}
>
<span class="spectrum-Menu-itemLabel">{getOptionLabel(option)}</span
>
<svg
class="spectrum-Icon spectrum-UIIcon-Checkmark100 spectrum-Menu-checkmark spectrum-Menu-itemIcon"
focusable="false"
aria-hidden="true"
>
<use xlink:href="#spectrum-css-icon-Checkmark100" />
</svg>
</li>
{/each}
{/if}
</ul>
</div>
</Popover>
<style>
.spectrum-InputGroup {
min-width: 0;
@ -124,10 +129,13 @@
.spectrum-Textfield-input {
width: 0;
}
.spectrum-Popover {
max-height: 240px;
width: 100%;
z-index: 999;
top: 100%;
/* Popover */
.popover-content {
display: contents;
}
.popover-content:not(.auto-width) .spectrum-Menu-itemLabel {
width: 0;
flex: 1 1 auto;
}
</style>

View File

@ -45,7 +45,6 @@
const dispatch = createEventDispatcher()
let button
let popover
let component
$: sortedOptions = getSortedOptions(options, getOptionLabel, sort)
@ -146,11 +145,11 @@
<use xlink:href="#spectrum-css-icon-Chevron100" />
</svg>
</button>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<Popover
anchor={customAnchor ? customAnchor : button}
align={align || "left"}
bind:this={popover}
{open}
on:close={() => (open = false)}
useAnchorWidth={!autoWidth}
@ -266,16 +265,6 @@
width: 100%;
box-shadow: none;
}
.subtitle-text {
font-size: 12px;
line-height: 15px;
font-weight: 500;
color: var(--spectrum-global-color-gray-600);
display: block;
margin-top: var(--spacing-s);
}
.spectrum-Picker-label.auto-width {
margin-right: var(--spacing-xs);
}
@ -356,11 +345,9 @@
.option-extra.icon.field-icon {
display: flex;
}
.option-tag {
margin: 0 var(--spacing-m) 0 var(--spacing-m);
}
.option-tag :global(.spectrum-Tags-item > .spectrum-Icon) {
margin-top: 2px;
}
@ -374,4 +361,13 @@
.loading--withAutocomplete {
top: calc(34px + var(--spacing-m));
}
.subtitle-text {
font-size: 12px;
line-height: 15px;
font-weight: 500;
color: var(--spectrum-global-color-gray-600);
display: block;
margin-top: var(--spacing-s);
}
</style>

View File

@ -99,10 +99,10 @@
on:keydown={handleEscape}
class="spectrum-Popover is-open"
class:customZindex
class:hide-popover={open && !showPopover}
class:hidden={!showPopover}
role="presentation"
style="height: {customHeight}; --customZindex: {customZindex};"
transition:fly|local={{ y: -20, duration: animate ? 200 : 0 }}
transition:fly|local={{ y: -20, duration: animate ? 260 : 0 }}
on:mouseenter
on:mouseleave
>
@ -112,16 +112,17 @@
{/if}
<style>
.hide-popover {
display: contents;
}
.spectrum-Popover {
min-width: var(--spectrum-global-dimension-size-2000);
border-color: var(--spectrum-global-color-gray-300);
overflow: auto;
transition: opacity 260ms ease-out, transform 260ms ease-out;
}
.hidden {
opacity: 0;
pointer-events: none;
transform: translateY(-20px);
}
.customZindex {
z-index: var(--customZindex) !important;
}

View File

@ -131,7 +131,7 @@
if (bindings?.length) {
options.push(SidePanels.Bindings)
}
if (context) {
if (context && Object.keys(context).length > 0) {
options.push(SidePanels.Evaluation)
}
if (useSnippets && mode === Modes.JavaScript) {

View File

@ -78,7 +78,12 @@
{/if}
</div>
<Drawer bind:this={bindingDrawer} title={title ?? placeholder ?? "Bindings"}>
<Drawer
bind:this={bindingDrawer}
title={title ?? placeholder ?? "Bindings"}
on:drawerHide
on:drawerShow
>
<Button cta slot="buttons" on:click={handleClose}>Save</Button>
<svelte:component
this={panel}

View File

@ -14,7 +14,6 @@
export let key
export let nested
export let max
export let context
const dispatch = createEventDispatcher()

View File

@ -126,7 +126,7 @@
<div class="right-content">
<svelte:component
this={listType}
anchor={anchors[draggableItem.item._id]}
anchor={anchors[draggableItem.id]}
item={draggableItem.item}
{...listTypeProps}
on:change={onItemChanged}

View File

@ -0,0 +1,141 @@
<script>
import { Icon, Popover, RadioGroup } from "@budibase/bbui"
import { createEventDispatcher, getContext } from "svelte"
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
import RoleSelect from "components/common/RoleSelect.svelte"
import SubLinksDrawer from "./SubLinksDrawer.svelte"
import { screenStore } from "stores/builder"
export let anchor
export let navItem
export let bindings
const draggable = getContext("draggable")
const dispatch = createEventDispatcher()
const typeOptions = [
{ label: "Inline link", value: "link" },
{ label: "Open sub links", value: "sublinks" },
]
let popover
let open = false
let drawerCount = 0
$: urlOptions = $screenStore.screens
.map(screen => screen.routing?.route)
.filter(x => x != null)
.sort()
// Auto hide the component when another item is selected
$: if (open && $draggable.selected !== navItem.id) {
popover.hide()
}
// Open automatically if the component is marked as selected
$: if (!open && $draggable.selected === navItem.id && popover) {
popover.show()
open = true
}
const update = setting => async value => {
dispatch("change", {
...navItem,
[setting]: value,
})
}
</script>
<Icon name={navItem.type === "sublinks" ? "Dropdown" : "Link"} size="S" />
<Popover
bind:this={popover}
on:open={() => {
open = true
$draggable.actions.select(navItem.id)
}}
on:close={() => {
open = false
if ($draggable.selected === navItem.id) {
$draggable.actions.select()
}
}}
{anchor}
align="left-outside"
showPopover={drawerCount === 0}
clickOutsideOverride={drawerCount > 0}
maxHeight={600}
offset={18}
>
<div class="settings">
<PropertyControl
label="Nav item"
control={RadioGroup}
value={navItem.type}
onChange={update("type")}
props={{
options: typeOptions,
}}
/>
<PropertyControl
label="Label"
control={DrawerBindableInput}
value={navItem.text}
onChange={update("text")}
{bindings}
props={{
updateOnChange: false,
}}
on:drawerShow={() => drawerCount++}
on:drawerHide={() => drawerCount--}
/>
{#if navItem.type === "sublinks"}
<PropertyControl
label="Sub links"
control={SubLinksDrawer}
value={navItem.subLinks}
onChange={update("subLinks")}
{bindings}
props={{
navItem,
}}
on:drawerShow={() => drawerCount++}
on:drawerHide={() => drawerCount--}
/>
{:else}
<PropertyControl
label="Link"
control={DrawerBindableCombobox}
value={navItem.url}
onChange={update("url")}
{bindings}
props={{
options: urlOptions,
appendBindingsAsOptions: false,
placeholder: null,
}}
on:drawerShow={() => drawerCount++}
on:drawerHide={() => drawerCount--}
/>
{/if}
<PropertyControl
label="Access"
control={RoleSelect}
value={navItem.roleId}
onChange={update("roleId")}
/>
</div>
</Popover>
<style>
.settings {
background: var(--spectrum-alias-background-color-primary);
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: 8px;
padding: var(--spacing-xl);
}
</style>

View File

@ -1,131 +0,0 @@
<script>
import {
Button,
Icon,
DrawerContent,
Layout,
Input,
Combobox,
} from "@budibase/bbui"
import { flip } from "svelte/animate"
import { dndzone } from "svelte-dnd-action"
import { generate } from "shortid"
import { screenStore } from "stores/builder"
import RoleSelect from "components/design/settings/controls/RoleSelect.svelte"
export let links = []
const flipDurationMs = 150
let dragDisabled = true
$: links.forEach(link => {
if (!link.id) {
link.id = generate()
}
})
$: urlOptions = $screenStore.screens
.map(screen => screen.routing?.route)
.filter(x => x != null)
const addLink = () => {
links = [...links, {}]
}
const removeLink = id => {
links = links.filter(link => link.id !== id)
}
const updateLinks = e => {
links = e.detail.items
}
const handleFinalize = e => {
updateLinks(e)
dragDisabled = true
}
</script>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<DrawerContent>
<div class="container">
<Layout noPadding gap="S">
{#if links?.length}
<div
class="links"
use:dndzone={{
items: links,
flipDurationMs,
dropTargetStyle: { outline: "none" },
dragDisabled,
}}
on:finalize={handleFinalize}
on:consider={updateLinks}
>
{#each links as link (link.id)}
<div class="link" animate:flip={{ duration: flipDurationMs }}>
<div
class="handle"
aria-label="drag-handle"
style={dragDisabled ? "cursor: grab" : "cursor: grabbing"}
on:mousedown={() => (dragDisabled = false)}
>
<Icon name="DragHandle" size="XL" />
</div>
<Input bind:value={link.text} placeholder="Text" />
<Combobox
bind:value={link.url}
placeholder="URL"
options={urlOptions}
/>
<RoleSelect bind:value={link.roleId} placeholder="Minimum role" />
<Icon
name="Close"
hoverable
size="S"
on:click={() => removeLink(link.id)}
/>
</div>
{/each}
</div>
{/if}
<div>
<Button secondary icon="Add" on:click={addLink}>Add Link</Button>
</div>
</Layout>
</div>
</DrawerContent>
<style>
.container {
width: 100%;
max-width: 800px;
margin: 0 auto;
}
.links {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-m);
}
.link {
gap: var(--spacing-l);
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-radius: var(--border-radius-s);
transition: background-color ease-in-out 130ms;
}
.link:hover {
background-color: var(--spectrum-global-color-gray-100);
}
.link > :global(.spectrum-Form-item) {
flex: 1 1 auto;
width: 0;
}
.handle {
display: grid;
place-items: center;
}
</style>

View File

@ -1,30 +0,0 @@
<script>
import { Button, Drawer } from "@budibase/bbui"
import NavigationLinksDrawer from "./LinksDrawer.svelte"
import { cloneDeep } from "lodash/fp"
import { navigationStore } from "stores/builder"
let drawer
let links
const openDrawer = () => {
links = cloneDeep($navigationStore.links || [])
drawer.show()
}
const save = async () => {
let navigation = $navigationStore
navigation.links = links
await navigationStore.save(navigation)
drawer.hide()
}
</script>
<Button cta on:click={openDrawer}>Configure Links</Button>
<Drawer bind:this={drawer} title={"Navigation Links"}>
<svelte:fragment slot="description">
Configure the links in your navigation bar.
</svelte:fragment>
<Button cta slot="buttons" on:click={save}>Save</Button>
<NavigationLinksDrawer slot="body" bind:links />
</Drawer>

View File

@ -0,0 +1,55 @@
<script>
import { runtimeToReadableBinding } from "dataBinding"
import EditNavItemPopover from "./EditNavItemPopover.svelte"
import { Icon } from "@budibase/bbui"
export let item
export let removeNavItem
export let anchor
export let bindings
$: text = runtimeToReadableBinding(bindings, item.text)
</script>
<div class="list-item-body">
<div class="list-item-left">
<EditNavItemPopover {anchor} {bindings} navItem={item} on:change />
<div class="field-label">{text}</div>
</div>
<div class="list-item-right">
<Icon
size="S"
name="Close"
hoverable
on:click={e => {
e.stopPropagation()
removeNavItem(item.id)
}}
/>
</div>
</div>
<style>
.field-label {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.list-item-body,
.list-item-left {
display: flex;
align-items: center;
gap: var(--spacing-m);
min-width: 0;
}
.list-item-body {
margin-top: 8px;
margin-bottom: 8px;
}
.list-item-right :global(div.spectrum-Switch) {
margin: 0px;
}
.list-item-body {
justify-content: space-between;
}
</style>

View File

@ -0,0 +1,118 @@
<script>
import { navigationStore } from "stores/builder"
import DraggableList from "components/design/settings/controls/DraggableList/DraggableList.svelte"
import NavItem from "./NavItem.svelte"
import { generate } from "shortid"
import { getSequentialName } from "helpers/duplicate"
import { Constants } from "@budibase/frontend-core"
export let bindings
$: navItems = enrichNavItems($navigationStore.links)
$: navItemProps = {
removeNavItem,
bindings,
}
const enrichNavItems = links => {
return (links || []).map(link => ({
...link,
id: link.id || generate(),
}))
}
const save = async links => {
await navigationStore.save({ ...$navigationStore, links })
}
const handleNavItemUpdate = async e => {
const newNavItem = e.detail
const newLinks = [...navItems]
const idx = newLinks.findIndex(link => {
return link.id === newNavItem?.id
})
if (idx === -1) {
newLinks.push(newNavItem)
} else {
newLinks[idx] = newNavItem
}
await save(newLinks)
}
const handleListUpdate = async e => {
await save([...e.detail])
}
const addNavItem = async () => {
await save([
...navItems,
{
id: generate(),
text: getSequentialName(navItems, "Nav Item ", x => x.text),
url: "",
roleId: Constants.Roles.BASIC,
type: "link",
},
])
}
const removeNavItem = async id => {
await save(navItems.filter(navItem => navItem.id !== id))
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="link-configuration">
{#if navItems.length}
<DraggableList
on:change={handleListUpdate}
on:itemChange={handleNavItemUpdate}
items={navItems}
listItemKey="id"
listType={NavItem}
listTypeProps={navItemProps}
draggable={navItems.length > 1}
/>
{/if}
<div class="list-footer" on:click={addNavItem} class:empty={!navItems.length}>
<div class="add-button">Add nav item</div>
</div>
</div>
<style>
.link-configuration :global(.list-wrap > li:last-child),
.link-configuration :global(.list-wrap) {
border-bottom-left-radius: unset;
border-bottom-right-radius: unset;
border-bottom: none;
}
.list-footer {
width: 100%;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
background-color: var(
--spectrum-table-background-color,
var(--spectrum-global-color-gray-50)
);
transition: background-color ease-in-out 130ms;
display: flex;
justify-content: center;
border: 1px solid var(--spectrum-alias-border-color-mid);
cursor: pointer;
}
.list-footer.empty {
border-radius: 4px;
}
.list-footer:hover {
background-color: var(
--spectrum-table-row-background-color-hover,
var(--spectrum-alias-highlight-hover)
);
}
.add-button {
margin: var(--spacing-s);
}
</style>

View File

@ -0,0 +1,142 @@
<script>
import {
ActionButton,
Button,
Icon,
DrawerContent,
Layout,
Input,
Drawer,
} from "@budibase/bbui"
import { flip } from "svelte/animate"
import { dndzone } from "svelte-dnd-action"
import { generate } from "shortid"
import { screenStore } from "stores/builder"
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
export let value = []
export let onChange
export let navItem
export let bindings
const flipDurationMs = 150
let drawer
let subLinks = value?.slice() || []
$: count = value?.length ?? 0
$: buttonText = `${count || "No"} sub link${count === 1 ? "" : "s"}`
$: drawerTitle = navItem.text ? `${navItem.text} sub links` : "Sub links"
$: subLinks.forEach(subLink => {
if (!subLink.id) {
subLink.id = generate()
}
})
$: urlOptions = $screenStore.screens
.map(screen => screen.routing?.route)
.filter(x => x != null)
.sort()
const addSubLink = () => {
subLinks = [...subLinks, {}]
}
const removeSubLink = id => {
subLinks = subLinks.filter(link => link.id !== id)
}
const saveSubLinks = () => {
onChange(subLinks)
drawer.hide()
}
const updateSubLinks = e => {
subLinks = e.detail.items
}
</script>
<Drawer bind:this={drawer} title={drawerTitle} on:drawerShow on:drawerHide>
<Button cta slot="buttons" on:click={saveSubLinks}>Save</Button>
<DrawerContent slot="body">
<div class="container">
<Layout noPadding gap="S">
{#if subLinks?.length}
<div
class="subLinks"
use:dndzone={{
items: subLinks,
flipDurationMs,
dropTargetStyle: { outline: "none" },
}}
on:consider={updateSubLinks}
on:finalize={updateSubLinks}
>
{#each subLinks as subLink (subLink.id)}
<div class="subLink" animate:flip={{ duration: flipDurationMs }}>
<Icon name="DragHandle" size="XL" />
<Input bind:value={subLink.text} placeholder="Text" />
<DrawerBindableCombobox
value={subLink.url}
on:change={e => (subLink.url = e.detail)}
placeholder="Link"
options={urlOptions}
{bindings}
appendBindingsAsOptions={false}
/>
<Icon
name="Close"
hoverable
size="S"
on:click={() => removeSubLink(subLink.id)}
/>
</div>
{/each}
</div>
{/if}
<div>
<ActionButton quiet icon="Add" on:click={addSubLink}>
Add link
</ActionButton>
</div>
</Layout>
</div>
</DrawerContent>
</Drawer>
<div class="button">
<ActionButton on:click={drawer.show}>{buttonText}</ActionButton>
</div>
<style>
.button :global(.spectrum-ActionButton) {
width: 100%;
}
.container {
width: 100%;
max-width: 800px;
margin: 0 auto;
}
.subLinks {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: var(--spacing-m);
}
.subLink {
gap: var(--spacing-l);
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
border-radius: var(--border-radius-s);
transition: background-color ease-in-out 130ms;
}
.subLink:hover {
background-color: var(--spectrum-global-color-gray-100);
}
.subLink > :global(.spectrum-Form-item) {
flex: 1 1 auto;
width: 0;
}
</style>

View File

@ -1,62 +1,61 @@
<script>
import LinksEditor from "./LinksEditor.svelte"
import NavItemConfiguration from "./NavItemConfiguration.svelte"
import { get } from "svelte/store"
import Panel from "components/design/Panel.svelte"
import {
Detail,
Toggle,
Body,
Icon,
ColorPicker,
Input,
Label,
ActionGroup,
ActionButton,
DetailSummary,
Checkbox,
notifications,
Select,
Combobox,
} from "@budibase/bbui"
import {
themeStore,
selectedScreen,
screenStore,
navigationStore,
componentStore,
navigationStore as nav,
} from "stores/builder"
import { DefaultAppTheme } from "constants"
import BarButtonList from "/src/components/design/settings/controls/BarButtonList.svelte"
import PropertyControl from "components/design/settings/controls/PropertyControl.svelte"
import BarButtonList from "components/design/settings/controls/BarButtonList.svelte"
import ColorPicker from "components/design/settings/controls/ColorPicker.svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import DrawerBindableCombobox from "components/common/bindings/DrawerBindableCombobox.svelte"
import { getBindableProperties } from "dataBinding"
$: alignmentOptions = [
const positionOptions = [
{ value: "Top", barIcon: "PaddingTop" },
{ value: "Left", barIcon: "PaddingLeft" },
]
const alignmentOptions = [
{ value: "Left", barIcon: "TextAlignLeft" },
{ value: "Center", barIcon: "TextAlignCenter" },
{ value: "Right", barIcon: "TextAlignRight" },
]
const widthOptions = ["Max", "Large", "Medium", "Small"]
$: bindings = getBindableProperties(
$selectedScreen,
$componentStore.selectedComponentId
)
$: screenRouteOptions = $screenStore.screens
.map(screen => screen.routing?.route)
.filter(x => x != null)
const updateShowNavigation = async e => {
await screenStore.updateSetting(
get(selectedScreen),
"showNavigation",
e.detail
)
const updateShowNavigation = async show => {
await screenStore.updateSetting(get(selectedScreen), "showNavigation", show)
}
const update = async (key, value) => {
try {
let navigation = $navigationStore
let navigation = $nav
navigation[key] = value
await navigationStore.save(navigation)
await nav.save(navigation)
} catch (error) {
notifications.error("Error updating navigation settings")
}
}
const updateTextAlign = textAlignValue => {
navigationStore.syncAppNavigation({ textAlign: textAlignValue })
}
</script>
<Panel
@ -65,215 +64,142 @@
borderLeft
wide
>
<div class="generalSection">
<div class="subheading">
<Detail>General</Detail>
</div>
<div class="toggle">
<Toggle
on:change={updateShowNavigation}
value={$selectedScreen?.showNavigation}
/>
<Body size="S">Show nav on this screen</Body>
</div>
</div>
<DetailSummary name="General" initiallyShow collapsible={false}>
<PropertyControl
control={Toggle}
props={{ text: "Show nav on this screen" }}
onChange={updateShowNavigation}
value={$selectedScreen?.showNavigation}
/>
</DetailSummary>
{#if $selectedScreen?.showNavigation}
<div class="divider" />
<div class="customizeSection">
<div class="subheading">
<Detail>Customize</Detail>
</div>
<div class="info">
<Icon name="InfoOutline" size="S" />
<Body size="S">These settings apply to all screens</Body>
</div>
<div class="configureLinks">
<LinksEditor />
</div>
<div class="controls">
<div class="label">
<Label size="M">Position</Label>
</div>
<ActionGroup quiet>
<ActionButton
selected={$navigationStore.navigation === "Top"}
quiet={$navigationStore.navigation !== "Top"}
icon="PaddingTop"
on:click={() => update("navigation", "Top")}
<DetailSummary name="Customize" initiallyShow collapsible={false}>
<NavItemConfiguration {bindings} />
<div class="settings">
<PropertyControl
label="Position"
control={BarButtonList}
onChange={position => update("navigation", position)}
value={$nav.navigation}
props={{
options: positionOptions,
}}
/>
{#if $nav.navigation === "Top"}
<PropertyControl
label="Sticky header"
control={Checkbox}
value={$nav.sticky}
onChange={sticky => update("sticky", sticky)}
/>
<ActionButton
selected={$navigationStore.navigation === "Left"}
quiet={$navigationStore.navigation !== "Left"}
icon="PaddingLeft"
on:click={() => update("navigation", "Left")}
/>
</ActionGroup>
{#if $navigationStore.navigation === "Top"}
<div class="label">
<Label size="M">Sticky header</Label>
</div>
<Checkbox
value={$navigationStore.sticky}
on:change={e => update("sticky", e.detail)}
/>
<div class="label">
<Label size="M">Width</Label>
</div>
<Select
options={["Max", "Large", "Medium", "Small"]}
plaveholder={null}
value={$navigationStore.navWidth}
on:change={e => update("navWidth", e.detail)}
<PropertyControl
label="Width"
control={Select}
onChange={position => update("navWidth", position)}
value={$nav.navWidth}
props={{
placeholder: null,
options: widthOptions,
}}
/>
{/if}
<div class="label">
<Label size="M">Show title</Label>
</div>
<Checkbox
value={!$navigationStore.hideTitle}
on:change={e => update("hideTitle", !e.detail)}
<PropertyControl
label="Show title"
control={Checkbox}
value={!$nav.hideTitle}
onChange={show => update("hideTitle", !show)}
/>
{#if !$navigationStore.hideTitle}
<div class="label">
<Label size="M">Title</Label>
</div>
<Input
value={$navigationStore.title}
on:change={e => update("title", e.detail)}
updateOnChange={false}
{#if !$nav.hideTitle}
<PropertyControl
label="Title"
control={DrawerBindableInput}
value={$nav.title}
onChange={title => update("title", title)}
{bindings}
props={{
updateOnChange: false,
}}
/>
<div class="label">
<Label size="M">Text align</Label>
</div>
<BarButtonList
options={alignmentOptions}
value={$navigationStore.textAlign}
onChange={updateTextAlign}
<PropertyControl
label="Text align"
control={BarButtonList}
onChange={align => nav.syncAppNavigation({ textAlign: align })}
value={$nav.textAlign}
props={{
options: alignmentOptions,
}}
/>
{/if}
<div class="label">
<Label>Background</Label>
</div>
<ColorPicker
spectrumTheme={$themeStore.theme}
value={$navigationStore.navBackground ||
DefaultAppTheme.navBackground}
on:change={e => update("navBackground", e.detail)}
<PropertyControl
label="Background"
control={ColorPicker}
onChange={color => update("navBackground", color)}
value={$nav.navBackground || DefaultAppTheme.navBackground}
props={{
spectrumTheme: $themeStore.theme,
}}
/>
<div class="label">
<Label>Text</Label>
</div>
<ColorPicker
spectrumTheme={$themeStore.theme}
value={$navigationStore.navTextColor || DefaultAppTheme.navTextColor}
on:change={e => update("navTextColor", e.detail)}
<PropertyControl
label="Text"
control={ColorPicker}
onChange={color => update("navTextColor", color)}
value={$nav.navTextColor || DefaultAppTheme.navTextColor}
props={{
spectrumTheme: $themeStore.theme,
}}
/>
</div>
</div>
</DetailSummary>
<div class="divider" />
<div class="customizeSection">
<div class="subheading">
<Detail>Logo</Detail>
</div>
<div class="controls">
<div class="label">
<Label size="M">Show logo</Label>
</div>
<Checkbox
value={!$navigationStore.hideLogo}
on:change={e => update("hideLogo", !e.detail)}
<DetailSummary name="Logo" initiallyShow collapsible={false}>
<div class="settings">
<PropertyControl
label="Show logo"
control={Checkbox}
value={!$nav.hideLogo}
onChange={show => update("hideLogo", !show)}
/>
{#if !$navigationStore.hideLogo}
<div class="label">
<Label size="M">Logo image URL</Label>
</div>
<Input
value={$navigationStore.logoUrl}
on:change={e => update("logoUrl", e.detail)}
updateOnChange={false}
{#if !$nav.hideLogo}
<PropertyControl
label="Logo image URL"
control={DrawerBindableInput}
value={$nav.logoUrl}
onChange={url => update("logoUrl", url)}
{bindings}
props={{
updateOnChange: false,
}}
/>
<div class="label">
<Label size="M">Logo link URL</Label>
</div>
<Combobox
value={$navigationStore.logoLinkUrl}
on:change={e => update("logoLinkUrl", e.detail)}
options={screenRouteOptions}
<PropertyControl
label="Logo link URL"
control={DrawerBindableCombobox}
value={$nav.logoLinkUrl}
onChange={url => update("logoLinkUrl", url)}
{bindings}
props={{
appendBindingsAsOptions: false,
options: screenRouteOptions,
}}
/>
<div class="label">
<Label size="M">New tab</Label>
</div>
<Checkbox
value={!!$navigationStore.openLogoLinkInNewTab}
on:change={e => update("openLogoLinkInNewTab", !!e.detail)}
<PropertyControl
label="New tab"
control={Checkbox}
value={$nav.openLogoLinkInNewTab}
onChange={show => update("openLogoLinkInNewTab", show)}
/>
{/if}
</div>
</div>
</DetailSummary>
{/if}
</Panel>
<style>
.generalSection {
padding: 13px 13px 25px;
}
.customizeSection {
padding: 13px 13px 25px;
}
.subheading {
margin-bottom: 10px;
}
.subheading :global(p) {
color: var(--grey-6);
}
.toggle {
.settings {
display: flex;
align-items: center;
}
.divider {
border-top: 1px solid var(--grey-3);
}
.controls {
position: relative;
display: grid;
grid-template-columns: 90px 1fr;
align-items: start;
transition: background 130ms ease-out, border-color 130ms ease-out;
border-left: 4px solid transparent;
margin: 0 calc(-1 * var(--spacing-xl));
padding: 0 var(--spacing-xl) 0 calc(var(--spacing-xl) - 4px);
gap: 12px;
}
.label {
margin-top: 16px;
transform: translateY(-50%);
}
.info {
background-color: var(--background-alt);
padding: 12px;
display: flex;
border-radius: 4px;
gap: 4px;
margin-bottom: 16px;
}
.info :global(svg) {
margin-right: 5px;
color: var(--spectrum-global-color-gray-600);
}
.configureLinks :global(button) {
margin-bottom: 20px;
width: 100%;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: 8px;
}
</style>

View File

@ -79,7 +79,8 @@
// for autoscreens, so it's always safe to do this.
await navigationStore.saveLink(
screen.routing.route,
capitalise(screen.routing.route.split("/")[1])
capitalise(screen.routing.route.split("/")[1]),
screenAccessRole
)
}

View File

@ -42,7 +42,7 @@ export class NavigationStore extends BudiStore {
this.syncAppNavigation(app.navigation)
}
async saveLink(url, title) {
async saveLink(url, title, roleId) {
const navigation = get(this.store)
let links = [...(navigation?.links ?? [])]
@ -54,6 +54,8 @@ export class NavigationStore extends BudiStore {
links.push({
text: title,
url,
type: "link",
roleId,
})
await this.save({
...navigation,
@ -67,11 +69,20 @@ export class NavigationStore extends BudiStore {
if (!links?.length) {
return
}
// Filter out the URLs to delete
urls = Array.isArray(urls) ? urls : [urls]
// Filter out top level links pointing to these URLs
links = links.filter(link => !urls.includes(link.url))
// Filter out nested links pointing to these URLs
links.forEach(link => {
if (link.type === "sublinks" && link.subLinks?.length) {
link.subLinks = link.subLinks.filter(
subLink => !urls.includes(subLink.url)
)
}
})
await this.save({
...navigation,
links,

View File

@ -50,10 +50,18 @@ describe("Navigation store", () => {
{
url: "/home",
text: "Home",
type: "link",
},
{
url: "/test",
text: "Test",
type: "sublinks",
subLinks: [
{
text: "Foo",
url: "/bar",
},
],
},
]
@ -66,7 +74,7 @@ describe("Navigation store", () => {
.spyOn(ctx.test.navigationStore, "save")
.mockImplementation(() => {})
await ctx.test.navigationStore.saveLink("/test-url", "Testing")
await ctx.test.navigationStore.saveLink("/test-url", "Testing", "BASIC")
expect(saveSpy).toBeCalledWith({
...INITIAL_NAVIGATION_STATE,
@ -75,6 +83,8 @@ describe("Navigation store", () => {
{
url: "/test-url",
text: "Testing",
type: "link",
roleId: "BASIC",
},
],
})
@ -87,6 +97,7 @@ describe("Navigation store", () => {
{
url: "/home",
text: "Home",
type: "link",
},
],
}))
@ -94,7 +105,7 @@ describe("Navigation store", () => {
.spyOn(ctx.test.navigationStore, "save")
.mockImplementation(() => {})
await ctx.test.navigationStore.saveLink("/home", "Home")
await ctx.test.navigationStore.saveLink("/home", "Home", "BASIC")
expect(saveSpy).not.toHaveBeenCalled()
})
@ -106,14 +117,23 @@ describe("Navigation store", () => {
{
url: "/home",
text: "Home",
type: "link",
},
{
url: "/test",
text: "Test",
type: "link",
},
{
url: "/last",
text: "Last Link",
type: "sublinks",
subLinks: [
{
text: "Foo",
url: "/home",
},
],
},
],
}))
@ -130,6 +150,8 @@ describe("Navigation store", () => {
{
text: "Last Link",
url: "/last",
type: "sublinks",
subLinks: [],
},
],
})
@ -140,14 +162,17 @@ describe("Navigation store", () => {
{
url: "/home",
text: "Home",
type: "link",
},
{
url: "/test",
text: "Test",
type: "link",
},
{
url: "/last",
text: "Last Link",
type: "link",
},
]
@ -168,10 +193,12 @@ describe("Navigation store", () => {
{
url: "/home",
text: "Home",
type: "link",
},
{
url: "/last",
text: "Last Link",
type: "link",
},
],
})
@ -180,10 +207,7 @@ describe("Navigation store", () => {
it("Should ignore a request to delete if there are no links", async ctx => {
const saveSpy = vi.spyOn(ctx.test.navigationStore, "save")
await ctx.test.navigationStore.deleteLink({
url: "/some-link",
text: "Some Link",
})
await ctx.test.navigationStore.deleteLink("/some-link")
expect(saveSpy).not.toBeCalled()
})
@ -201,10 +225,18 @@ describe("Navigation store", () => {
{
url: "/home",
text: "Home",
type: "link",
},
{
url: "/last",
text: "Last Link",
type: "sublinks",
subLinks: [
{
text: "Foo",
url: "/bar",
},
],
},
],
}))
@ -217,6 +249,7 @@ describe("Navigation store", () => {
{
url: "/new-link",
text: "New Link",
type: "link",
},
],
}

View File

@ -2,9 +2,8 @@
import { getContext, setContext } from "svelte"
import { writable } from "svelte/store"
import { Heading, Icon, clickOutside } from "@budibase/bbui"
import { FieldTypes } from "constants"
import { Constants } from "@budibase/frontend-core"
import active from "svelte-spa-router/active"
import NavItem from "./NavItem.svelte"
const sdk = getContext("sdk")
const {
@ -16,6 +15,7 @@
appStore,
} = sdk
const context = getContext("context")
const navStateStore = writable({})
// Legacy props which must remain unchanged for backwards compatibility
export let title
@ -63,7 +63,7 @@
})
setContext("layout", store)
$: validLinks = getValidLinks(links, $roleStore)
$: enrichedNavItems = enrichNavItems(links, $roleStore)
$: typeClass = NavigationClasses[navigation] || NavigationClasses.None
$: navWidthClass = WidthClasses[navWidth || width] || WidthClasses.Large
$: pageWidthClass = WidthClasses[pageWidth || width] || WidthClasses.Large
@ -101,28 +101,57 @@
}
}
const getValidLinks = (allLinks, userRoleHierarchy) => {
// Strip links missing required info
let validLinks = (allLinks || []).filter(link => link.text && link.url)
// Filter to only links allowed by the current role
return validLinks.filter(link => {
const role = link.roleId || Constants.Roles.BASIC
return userRoleHierarchy?.find(roleId => roleId === role)
})
const enrichNavItem = navItem => {
const internalLink = isInternal(navItem.url)
return {
...navItem,
internalLink,
url: internalLink ? navItem.url : ensureExternal(navItem.url),
}
}
const enrichNavItems = (navItems, userRoleHierarchy) => {
if (!navItems?.length) {
return []
}
return navItems
.filter(navItem => {
// Strip nav items without text
if (!navItem.text) {
return false
}
// Strip out links without URLs
if (navItem.type !== "sublinks" && !navItem.url) {
return false
}
// Filter to only links allowed by the current role
const role = navItem.roleId || Constants.Roles.BASIC
return userRoleHierarchy?.find(roleId => roleId === role)
})
.map(navItem => {
const enrichedNavItem = enrichNavItem(navItem)
if (navItem.type === "sublinks" && navItem.subLinks?.length) {
enrichedNavItem.subLinks = navItem.subLinks
.filter(subLink => subLink.text && subLink.url)
.map(enrichNavItem)
}
return enrichedNavItem
})
}
const isInternal = url => {
return url.startsWith("/")
return url?.startsWith("/")
}
const ensureExternal = url => {
if (!url?.length) {
return url
}
return !url.startsWith("http") ? `http://${url}` : url
}
const close = () => {
mobileOpen = false
}
const navigateToPortal = () => {
if ($builderStore.inBuilder) return
window.location.href = "/builder/apps"
@ -194,7 +223,7 @@
>
<div class="nav nav--{typeClass} size--{navWidthClass}">
<div class="nav-header">
{#if validLinks?.length}
{#if enrichedNavItems.length}
<div class="burger">
<Icon
hoverable
@ -243,28 +272,20 @@
class:visible={mobileOpen}
on:click={() => (mobileOpen = false)}
/>
{#if validLinks?.length}
{#if enrichedNavItems.length}
<div class="links" class:visible={mobileOpen}>
{#each validLinks as { text, url }}
{#if isInternal(url)}
<a
class={FieldTypes.LINK}
href={url}
use:linkable
on:click={close}
use:active={url}
>
{text}
</a>
{:else}
<a
class={FieldTypes.LINK}
href={ensureExternal(url)}
on:click={close}
>
{text}
</a>
{/if}
{#each enrichedNavItems as navItem}
<NavItem
type={navItem.type}
text={navItem.text}
url={navItem.url}
subLinks={navItem.subLinks}
internalLink={navItem.internalLink}
on:clickLink={() => (mobileOpen = false)}
leftNav={navigation === "Left"}
{mobile}
{navStateStore}
/>
{/each}
<div class="close">
<Icon
@ -509,21 +530,6 @@
gap: var(--spacing-xl);
margin-top: var(--spacing-xl);
}
.link {
opacity: 0.75;
color: var(--navTextColor);
font-size: var(--spectrum-global-dimension-font-size-200);
font-weight: 600;
transition: color 130ms ease-out;
}
.link.active {
opacity: 1;
}
.link:hover {
opacity: 1;
text-decoration: underline;
text-underline-position: under;
}
.close {
display: none;
position: absolute;

View File

@ -0,0 +1,173 @@
<script>
import { createEventDispatcher, getContext } from "svelte"
import active from "svelte-spa-router/active"
import { Icon } from "@budibase/bbui"
export let type
export let url
export let text
export let subLinks
export let internalLink
export let leftNav = false
export let mobile = false
export let navStateStore
const dispatch = createEventDispatcher()
const sdk = getContext("sdk")
const { linkable } = sdk
let renderKey
$: expanded = !!$navStateStore[text]
$: renderLeftNav = leftNav || mobile
$: icon = !renderLeftNav || expanded ? "ChevronDown" : "ChevronRight"
const onClickLink = () => {
dispatch("clickLink")
renderKey = Math.random()
}
const onClickDropdown = () => {
if (!renderLeftNav) {
return
}
navStateStore.update(state => ({
...state,
[text]: !state[text],
}))
}
</script>
{#if !type || type === "link"}
{#if internalLink}
<!--
It's stupid that we have to add class:active={false} here, but if we don't
then svelte will strip out the CSS selector and active links won't be
styled
-->
<a
href={url}
on:click={onClickLink}
use:active={url}
use:linkable
class:active={false}
>
{text}
</a>
{:else}
<a href={url} on:click={onClickLink}>
{text}
</a>
{/if}
{:else}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<!-- svelte-ignore a11y-click-events-have-key-events -->
{#key renderKey}
<div class="dropdown" class:left={renderLeftNav} class:expanded>
<div class="text" on:click={onClickDropdown}>
<span>{text}</span>
<Icon name={icon} />
</div>
<div class="sublinks-wrapper">
<div class="sublinks">
{#each subLinks || [] as subLink}
{#if subLink.internalLink}
<a
href={subLink.url}
on:click={onClickLink}
use:active={subLink.url}
use:linkable
>
{subLink.text}
</a>
{:else}
<a href={subLink.url} on:click={onClickLink}>
{subLink.text}
</a>
{/if}
{/each}
</div>
</div>
</div>
{/key}
{/if}
<style>
/* Generic styles */
a,
.text span {
opacity: 0.75;
color: var(--navTextColor);
font-size: var(--spectrum-global-dimension-font-size-200);
transition: opacity 130ms ease-out;
font-weight: 600;
user-select: none;
overflow: hidden;
text-overflow: ellipsis;
}
a.active {
opacity: 1;
}
a:hover,
.dropdown:not(.left.expanded):hover .text,
.text:hover {
cursor: pointer;
opacity: 1;
}
/* Top dropdowns */
.dropdown {
position: relative;
}
.text {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
gap: var(--spacing-xs);
}
.sublinks-wrapper {
position: absolute;
top: 100%;
display: none;
padding-top: var(--spacing-s);
}
.dropdown:hover .sublinks-wrapper {
display: block;
}
.sublinks {
display: flex;
flex-direction: column;
justify-content: stretch;
align-items: flex-start;
background: var(--spectrum-global-color-gray-50);
border-radius: 6px;
border: 1px solid var(--spectrum-global-color-gray-300);
min-width: 150px;
max-width: 250px;
padding: 10px 0;
overflow: hidden;
}
.sublinks a {
padding: 6px var(--spacing-l);
font-weight: 400;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
/* Left dropdowns */
.dropdown.left .sublinks-wrapper {
display: none;
}
.dropdown.left,
.dropdown.left.expanded .sublinks-wrapper,
.dropdown.dropdown.left.expanded .sublinks {
display: contents;
}
.dropdown.left a {
padding-top: 0;
padding-bottom: 0;
}
</style>

View File

@ -853,6 +853,7 @@
"array",
"datetime",
"attachment",
"attachment_single",
"link",
"formula",
"auto",
@ -1059,6 +1060,7 @@
"array",
"datetime",
"attachment",
"attachment_single",
"link",
"formula",
"auto",
@ -1276,6 +1278,7 @@
"array",
"datetime",
"attachment",
"attachment_single",
"link",
"formula",
"auto",
@ -1752,7 +1755,7 @@
},
"fuzzy": {
"type": "object",
"description": "A fuzzy search, only supported by internal tables."
"description": "Searches for a sub-string within a string column, e.g. searching for 'dib' will match 'Budibase'."
},
"range": {
"type": "object",
@ -1786,6 +1789,36 @@
"oneOf": {
"type": "object",
"description": "Searches for rows which have a column value that is any of the specified values. The format of this must be columnName -> [value1, value2]."
},
"contains": {
"type": "object",
"description": "Searches for a value, or set of values in array column types (such as a multi-select). If an array of search options is provided then it must match all.",
"example": {
"arrayColumn": [
"a",
"b"
]
}
},
"notContains": {
"type": "object",
"description": "The logical inverse of contains. Only works on array column types. If an array of values is passed, the row must not match any of them to be returned in the response.",
"example": {
"arrayColumn": [
"a",
"b"
]
}
},
"containsAny": {
"type": "object",
"description": "As with the contains search, only works on array column types and searches for any of the provided values when given an array.",
"example": {
"arrayColumn": [
"a",
"b"
]
}
}
}
},

View File

@ -775,6 +775,7 @@ components:
- array
- datetime
- attachment
- attachment_single
- link
- formula
- auto
@ -940,6 +941,7 @@ components:
- array
- datetime
- attachment
- attachment_single
- link
- formula
- auto
@ -1112,6 +1114,7 @@ components:
- array
- datetime
- attachment
- attachment_single
- link
- formula
- auto
@ -1492,7 +1495,8 @@ components:
description: The value to search for in the column.
fuzzy:
type: object
description: A fuzzy search, only supported by internal tables.
description: Searches for a sub-string within a string column, e.g. searching
for 'dib' will match 'Budibase'.
range:
type: object
description: Searches within a range, the format of this must be in the format
@ -1524,6 +1528,32 @@ components:
description: Searches for rows which have a column value that is any of the
specified values. The format of this must be columnName ->
[value1, value2].
contains:
type: object
description: Searches for a value, or set of values in array column types (such
as a multi-select). If an array of search options is provided
then it must match all.
example:
arrayColumn:
- a
- b
notContains:
type: object
description: The logical inverse of contains. Only works on array column types.
If an array of values is passed, the row must not match any of
them to be returned in the response.
example:
arrayColumn:
- a
- b
containsAny:
type: object
description: As with the contains search, only works on array column types and
searches for any of the provided values when given an array.
example:
arrayColumn:
- a
- b
paginate:
type: boolean
description: Enables pagination, by default this is disabled.

View File

@ -27,7 +27,8 @@ export default new Resource().setSchemas({
},
fuzzy: {
type: "object",
description: "A fuzzy search, only supported by internal tables.",
description:
"Searches for a sub-string within a string column, e.g. searching for 'dib' will match 'Budibase'.",
},
range: {
type: "object",
@ -67,6 +68,30 @@ export default new Resource().setSchemas({
description:
"Searches for rows which have a column value that is any of the specified values. The format of this must be columnName -> [value1, value2].",
},
contains: {
type: "object",
description:
"Searches for a value, or set of values in array column types (such as a multi-select). If an array of search options is provided then it must match all.",
example: {
arrayColumn: ["a", "b"],
},
},
notContains: {
type: "object",
description:
"The logical inverse of contains. Only works on array column types. If an array of values is passed, the row must not match any of them to be returned in the response.",
example: {
arrayColumn: ["a", "b"],
},
},
containsAny: {
type: "object",
description:
"As with the contains search, only works on array column types and searches for any of the provided values when given an array.",
example: {
arrayColumn: ["a", "b"],
},
},
},
},
paginate: {

View File

@ -17,11 +17,9 @@ import {
Row,
Table,
UserCtx,
EmptyFilterOption,
} from "@budibase/types"
import sdk from "../../../sdk"
import * as utils from "./utils"
import { dataFilters } from "@budibase/shared-core"
import {
inputProcessing,
outputProcessing,
@ -33,17 +31,6 @@ export async function handleRequest<T extends Operation>(
tableId: string,
opts?: RunConfig
): Promise<ExternalRequestReturnType<T>> {
// make sure the filters are cleaned up, no empty strings for equals, fuzzy or string
if (opts && opts.filters) {
opts.filters = sdk.rows.removeEmptyFilters(opts.filters)
}
if (
!dataFilters.hasFilters(opts?.filters) &&
opts?.filters?.onEmptyFilter === EmptyFilterOption.RETURN_NONE
) {
return [] as any
}
return new ExternalRequest<T>(operation, tableId, opts?.datasource).run(
opts || {}
)

View File

@ -6,6 +6,7 @@ import {
Datasource,
EmptyFilterOption,
FieldType,
Row,
SearchFilters,
Table,
} from "@budibase/types"
@ -47,7 +48,7 @@ describe.each([
})
describe("strings", () => {
beforeEach(async () => {
beforeAll(async () => {
table = await config.api.table.save(
tableForDatasource(datasource, {
schema: {
@ -61,6 +62,13 @@ describe.each([
})
const rows = [{ name: "foo" }, { name: "bar" }]
let savedRows: Row[]
beforeAll(async () => {
savedRows = await Promise.all(
rows.map(r => config.api.row.save(table._id!, r))
)
})
interface StringSearchTest {
query: SearchFilters
@ -68,6 +76,8 @@ describe.each([
}
const stringSearchTests: StringSearchTest[] = [
// These three test cases are generic and don't really need
// to be repeated for all data types, so we just do them here.
{ query: {}, expected: rows },
{
query: { onEmptyFilter: EmptyFilterOption.RETURN_ALL },
@ -77,27 +87,23 @@ describe.each([
query: { onEmptyFilter: EmptyFilterOption.RETURN_NONE },
expected: [],
},
// The rest of these tests are specific to strings.
{ query: { string: { name: "foo" } }, expected: [rows[0]] },
{ query: { string: { name: "none" } }, expected: [] },
{ query: { fuzzy: { name: "oo" } }, expected: [rows[0]] },
{ query: { equal: { name: "foo" } }, expected: [rows[0]] },
{ query: { notEqual: { name: "foo" } }, expected: [rows[1]] },
{ query: { oneOf: { name: ["foo"] } }, expected: [rows[0]] },
// { query: { contains: { name: "f" } }, expected: [0] },
// { query: { notContains: { name: ["f"] } }, expected: [1] },
// { query: { containsAny: { name: ["f"] } }, expected: [0] },
]
it.each(stringSearchTests)(
`should be able to run query: $query`,
async ({ query, expected }) => {
const savedRows = await Promise.all(
rows.map(r => config.api.row.save(table._id!, r))
)
const { rows: foundRows } = await config.api.row.search(table._id!, {
tableId: table._id!,
query,
})
expect(foundRows).toHaveLength(expected.length)
expect(foundRows).toEqual(
expect.arrayContaining(
expected.map(r =>
@ -110,7 +116,7 @@ describe.each([
})
describe("number", () => {
beforeEach(async () => {
beforeAll(async () => {
table = await config.api.table.save(
tableForDatasource(datasource, {
schema: {
@ -124,6 +130,13 @@ describe.each([
})
const rows = [{ age: 1 }, { age: 10 }]
let savedRows: Row[]
beforeAll(async () => {
savedRows = await Promise.all(
rows.map(r => config.api.row.save(table._id!, r))
)
})
interface NumberSearchTest {
query: SearchFilters
@ -131,15 +144,6 @@ describe.each([
}
const numberSearchTests: NumberSearchTest[] = [
{ query: {}, expected: rows },
{
query: { onEmptyFilter: EmptyFilterOption.RETURN_ALL },
expected: rows,
},
{
query: { onEmptyFilter: EmptyFilterOption.RETURN_NONE },
expected: [],
},
{ query: { equal: { age: 1 } }, expected: [rows[0]] },
{ query: { equal: { age: 2 } }, expected: [] },
{ query: { notEqual: { age: 1 } }, expected: [rows[1]] },
@ -153,13 +157,11 @@ describe.each([
it.each(numberSearchTests)(
`should be able to run query: $query`,
async ({ query, expected }) => {
const savedRows = await Promise.all(
rows.map(r => config.api.row.save(table._id!, r))
)
const { rows: foundRows } = await config.api.row.search(table._id!, {
tableId: table._id!,
query,
})
expect(foundRows).toHaveLength(expected.length)
expect(foundRows).toEqual(
expect.arrayContaining(
expected.map(r =>
@ -186,9 +188,16 @@ describe.each([
})
const rows = [
{ dob: new Date("2020-01-01") },
{ dob: new Date("2020-01-10") },
{ dob: new Date("2020-01-01").toISOString() },
{ dob: new Date("2020-01-10").toISOString() },
]
let savedRows: Row[]
beforeEach(async () => {
savedRows = await Promise.all(
rows.map(r => config.api.row.save(table._id!, r))
)
})
interface DateSearchTest {
query: SearchFilters
@ -196,26 +205,20 @@ describe.each([
}
const dateSearchTests: DateSearchTest[] = [
{ query: {}, expected: rows },
{
query: { onEmptyFilter: EmptyFilterOption.RETURN_ALL },
expected: rows,
query: { equal: { dob: new Date("2020-01-01").toISOString() } },
expected: [rows[0]],
},
{
query: { onEmptyFilter: EmptyFilterOption.RETURN_NONE },
query: { equal: { dob: new Date("2020-01-02").toISOString() } },
expected: [],
},
{
query: { equal: { dob: new Date("2020-01-01") } },
expected: [rows[0]],
},
{ query: { equal: { dob: new Date("2020-01-02") } }, expected: [] },
{
query: { notEqual: { dob: new Date("2020-01-01") } },
query: { notEqual: { dob: new Date("2020-01-01").toISOString() } },
expected: [rows[1]],
},
{
query: { oneOf: { dob: [new Date("2020-01-01")] } },
query: { oneOf: { dob: [new Date("2020-01-01").toISOString()] } },
expected: [rows[0]],
},
{
@ -256,23 +259,15 @@ describe.each([
it.each(dateSearchTests)(
`should be able to run query: $query`,
async ({ query, expected }) => {
// TODO(samwho): most of these work for SQS, but not all. Fix 'em.
if (isSqs) {
return
}
const savedRows = await Promise.all(
rows.map(r => config.api.row.save(table._id!, r))
)
const { rows: foundRows } = await config.api.row.search(table._id!, {
tableId: table._id!,
query,
})
expect(foundRows).toHaveLength(expected.length)
expect(foundRows).toEqual(
expect.arrayContaining(
expected.map(r =>
expect.objectContaining(
savedRows.find(sr => sr.dob === r.dob.toISOString())!
)
expect.objectContaining(savedRows.find(sr => sr.dob === r.dob)!)
)
)
)

View File

@ -273,6 +273,7 @@ export interface components {
| "array"
| "datetime"
| "attachment"
| "attachment_single"
| "link"
| "formula"
| "auto"
@ -381,6 +382,7 @@ export interface components {
| "array"
| "datetime"
| "attachment"
| "attachment_single"
| "link"
| "formula"
| "auto"
@ -491,6 +493,7 @@ export interface components {
| "array"
| "datetime"
| "attachment"
| "attachment_single"
| "link"
| "formula"
| "auto"
@ -693,7 +696,7 @@ export interface components {
* @example [object Object]
*/
string?: { [key: string]: string };
/** @description A fuzzy search, only supported by internal tables. */
/** @description Searches for a sub-string within a string column, e.g. searching for 'dib' will match 'Budibase'. */
fuzzy?: { [key: string]: unknown };
/**
* @description Searches within a range, the format of this must be in the format of an object with a "low" and "high" property.
@ -713,6 +716,21 @@ export interface components {
notEmpty?: { [key: string]: unknown };
/** @description Searches for rows which have a column value that is any of the specified values. The format of this must be columnName -> [value1, value2]. */
oneOf?: { [key: string]: unknown };
/**
* @description Searches for a value, or set of values in array column types (such as a multi-select). If an array of search options is provided then it must match all.
* @example [object Object]
*/
contains?: { [key: string]: unknown };
/**
* @description The logical inverse of contains. Only works on array column types. If an array of values is passed, the row must not match any of them to be returned in the response.
* @example [object Object]
*/
notContains?: { [key: string]: unknown };
/**
* @description As with the contains search, only works on array column types and searches for any of the provided values when given an array.
* @example [object Object]
*/
containsAny?: { [key: string]: unknown };
};
/** @description Enables pagination, by default this is disabled. */
paginate?: boolean;

View File

@ -334,6 +334,7 @@ class InternalBuilder {
if (filters.containsAny) {
contains(filters.containsAny, true)
}
return query
}

View File

@ -1,4 +1,5 @@
import {
EmptyFilterOption,
Row,
RowSearchParams,
SearchFilters,
@ -11,6 +12,7 @@ import { NoEmptyFilterStrings } from "../../../constants"
import * as sqs from "./search/sqs"
import env from "../../../environment"
import { ExportRowsParams, ExportRowsResult } from "./search/types"
import { dataFilters } from "@budibase/shared-core"
export { isValidFilter } from "../../../integrations/utils"
@ -60,6 +62,16 @@ export async function search(
options: RowSearchParams
): Promise<SearchResponse<Row>> {
const isExternalTable = isExternalTableID(options.tableId)
options.query = removeEmptyFilters(options.query || {})
if (
!dataFilters.hasFilters(options.query) &&
options.query.onEmptyFilter === EmptyFilterOption.RETURN_NONE
) {
return {
rows: [],
}
}
if (isExternalTable) {
return external.search(options)
} else if (env.SQS_SEARCH_ENABLE) {

View File

@ -156,21 +156,21 @@ export async function search(
try {
const query = builder._query(request, {
disableReturning: true,
disableBindings: true,
})
if (Array.isArray(query)) {
throw new Error("SQS cannot currently handle multiple queries")
}
let sql = query.sql
let sql = query.sql,
bindings = query.bindings
// quick hack for docIds
sql = sql.replace(/`doc1`.`rowId`/g, "`doc1.rowId`")
sql = sql.replace(/`doc2`.`rowId`/g, "`doc2.rowId`")
const db = context.getAppDB()
const rows = await db.sql<Row>(sql)
const rows = await db.sql<Row>(sql, bindings)
return {
rows: await sqlOutputProcessing(

View File

@ -4,6 +4,7 @@ import {
AnyDocument,
Document,
RowValue,
SqlQueryBinding,
ViewTemplateOpts,
} from "../"
import { Writable } from "stream"
@ -143,7 +144,10 @@ export interface Database {
opts?: DatabasePutOpts
): Promise<Nano.DocumentInsertResponse>
bulkDocs(documents: AnyDocument[]): Promise<Nano.DocumentBulkResponse[]>
sql<T extends Document>(sql: string): Promise<T[]>
sql<T extends Document>(
sql: string,
parameters?: SqlQueryBinding
): Promise<T[]>
allDocs<T extends Document | RowValue>(
params: DatabaseQueryOpts
): Promise<AllDocsResponse<T>>