Merge remote-tracking branch 'origin/master' into feature/signature-field-and-component
This commit is contained in:
commit
f3e9030c3a
|
@ -51,11 +51,11 @@ http {
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
|
|
||||||
set $csp_default "default-src 'self'";
|
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_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_object "object-src 'none'";
|
||||||
set $csp_base_uri "base-uri 'self'";
|
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_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_frame "frame-src 'self' https:";
|
||||||
set $csp_img "img-src http: https: data: blob:";
|
set $csp_img "img-src http: https: data: blob:";
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"version": "2.23.0",
|
"version": "2.23.3",
|
||||||
"npmClient": "yarn",
|
"npmClient": "yarn",
|
||||||
"packages": [
|
"packages": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
isDocument,
|
isDocument,
|
||||||
RowResponse,
|
RowResponse,
|
||||||
RowValue,
|
RowValue,
|
||||||
|
SqlQueryBinding,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import { getCouchInfo } from "./connections"
|
import { getCouchInfo } from "./connections"
|
||||||
import { directCouchUrlCall } from "./utils"
|
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 dbName = this.name
|
||||||
const url = `/${dbName}/${SQLITE_DESIGN_DOC_ID}`
|
const url = `/${dbName}/${SQLITE_DESIGN_DOC_ID}`
|
||||||
const response = await directCouchUrlCall({
|
const response = await directCouchUrlCall({
|
||||||
url: `${this.couchInfo.sqlUrl}/${url}`,
|
url: `${this.couchInfo.sqlUrl}/${url}`,
|
||||||
method: "POST",
|
method: "POST",
|
||||||
cookie: this.couchInfo.cookie,
|
cookie: this.couchInfo.cookie,
|
||||||
body: sql,
|
body: {
|
||||||
|
query: sql,
|
||||||
|
args: parameters,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if (response.status > 300) {
|
if (response.status > 300) {
|
||||||
throw new Error(await response.text())
|
throw new Error(await response.text())
|
||||||
|
|
|
@ -13,6 +13,7 @@ import {
|
||||||
DatabaseQueryOpts,
|
DatabaseQueryOpts,
|
||||||
Document,
|
Document,
|
||||||
RowValue,
|
RowValue,
|
||||||
|
SqlQueryBinding,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import tracer from "dd-trace"
|
import tracer from "dd-trace"
|
||||||
import { Writable } from "stream"
|
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 => {
|
return tracer.trace("db.sql", span => {
|
||||||
span?.addTags({ db_name: this.name })
|
span?.addTags({ db_name: this.name })
|
||||||
return this.db.sql(sql)
|
return this.db.sql(sql, parameters)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
import "@spectrum-css/menu/dist/index-vars.css"
|
import "@spectrum-css/menu/dist/index-vars.css"
|
||||||
import { createEventDispatcher } from "svelte"
|
import { createEventDispatcher } from "svelte"
|
||||||
import clickOutside from "../../Actions/click_outside"
|
import clickOutside from "../../Actions/click_outside"
|
||||||
|
import Popover from "../../Popover/Popover.svelte"
|
||||||
|
|
||||||
export let value = null
|
export let value = null
|
||||||
export let id = null
|
export let id = null
|
||||||
|
@ -15,8 +16,10 @@
|
||||||
export let getOptionValue = option => option
|
export let getOptionValue = option => option
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
let open = false
|
let open = false
|
||||||
let focus = false
|
let focus = false
|
||||||
|
let anchor
|
||||||
|
|
||||||
const selectOption = value => {
|
const selectOption = value => {
|
||||||
dispatch("change", value)
|
dispatch("change", value)
|
||||||
|
@ -35,11 +38,11 @@
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
||||||
<div
|
<div
|
||||||
class="spectrum-InputGroup"
|
class="spectrum-InputGroup"
|
||||||
class:is-focused={open || focus}
|
class:is-focused={open || focus}
|
||||||
class:is-disabled={disabled}
|
class:is-disabled={disabled}
|
||||||
|
bind:this={anchor}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="spectrum-Textfield spectrum-InputGroup-textfield"
|
class="spectrum-Textfield spectrum-InputGroup-textfield"
|
||||||
|
@ -67,7 +70,7 @@
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
{disabled}
|
{disabled}
|
||||||
on:click={() => (open = true)}
|
on:click={() => (open = !open)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon spectrum-InputGroup-icon"
|
class="spectrum-Icon spectrum-UIIcon-ChevronDown100 spectrum-Picker-menuIcon spectrum-InputGroup-icon"
|
||||||
|
@ -77,42 +80,44 @@
|
||||||
<use xlink:href="#spectrum-css-icon-Chevron100" />
|
<use xlink:href="#spectrum-css-icon-Chevron100" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
</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>
|
<style>
|
||||||
.spectrum-InputGroup {
|
.spectrum-InputGroup {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
@ -124,10 +129,13 @@
|
||||||
.spectrum-Textfield-input {
|
.spectrum-Textfield-input {
|
||||||
width: 0;
|
width: 0;
|
||||||
}
|
}
|
||||||
.spectrum-Popover {
|
|
||||||
max-height: 240px;
|
/* Popover */
|
||||||
width: 100%;
|
.popover-content {
|
||||||
z-index: 999;
|
display: contents;
|
||||||
top: 100%;
|
}
|
||||||
|
.popover-content:not(.auto-width) .spectrum-Menu-itemLabel {
|
||||||
|
width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -45,7 +45,6 @@
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
let button
|
let button
|
||||||
let popover
|
|
||||||
let component
|
let component
|
||||||
|
|
||||||
$: sortedOptions = getSortedOptions(options, getOptionLabel, sort)
|
$: sortedOptions = getSortedOptions(options, getOptionLabel, sort)
|
||||||
|
@ -146,11 +145,11 @@
|
||||||
<use xlink:href="#spectrum-css-icon-Chevron100" />
|
<use xlink:href="#spectrum-css-icon-Chevron100" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
<Popover
|
<Popover
|
||||||
anchor={customAnchor ? customAnchor : button}
|
anchor={customAnchor ? customAnchor : button}
|
||||||
align={align || "left"}
|
align={align || "left"}
|
||||||
bind:this={popover}
|
|
||||||
{open}
|
{open}
|
||||||
on:close={() => (open = false)}
|
on:close={() => (open = false)}
|
||||||
useAnchorWidth={!autoWidth}
|
useAnchorWidth={!autoWidth}
|
||||||
|
@ -266,16 +265,6 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-shadow: none;
|
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 {
|
.spectrum-Picker-label.auto-width {
|
||||||
margin-right: var(--spacing-xs);
|
margin-right: var(--spacing-xs);
|
||||||
}
|
}
|
||||||
|
@ -356,11 +345,9 @@
|
||||||
.option-extra.icon.field-icon {
|
.option-extra.icon.field-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-tag {
|
.option-tag {
|
||||||
margin: 0 var(--spacing-m) 0 var(--spacing-m);
|
margin: 0 var(--spacing-m) 0 var(--spacing-m);
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-tag :global(.spectrum-Tags-item > .spectrum-Icon) {
|
.option-tag :global(.spectrum-Tags-item > .spectrum-Icon) {
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
@ -374,4 +361,13 @@
|
||||||
.loading--withAutocomplete {
|
.loading--withAutocomplete {
|
||||||
top: calc(34px + var(--spacing-m));
|
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>
|
</style>
|
||||||
|
|
|
@ -99,10 +99,10 @@
|
||||||
on:keydown={handleEscape}
|
on:keydown={handleEscape}
|
||||||
class="spectrum-Popover is-open"
|
class="spectrum-Popover is-open"
|
||||||
class:customZindex
|
class:customZindex
|
||||||
class:hide-popover={open && !showPopover}
|
class:hidden={!showPopover}
|
||||||
role="presentation"
|
role="presentation"
|
||||||
style="height: {customHeight}; --customZindex: {customZindex};"
|
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:mouseenter
|
||||||
on:mouseleave
|
on:mouseleave
|
||||||
>
|
>
|
||||||
|
@ -112,16 +112,17 @@
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.hide-popover {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spectrum-Popover {
|
.spectrum-Popover {
|
||||||
min-width: var(--spectrum-global-dimension-size-2000);
|
min-width: var(--spectrum-global-dimension-size-2000);
|
||||||
border-color: var(--spectrum-global-color-gray-300);
|
border-color: var(--spectrum-global-color-gray-300);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
transition: opacity 260ms ease-out, transform 260ms ease-out;
|
||||||
|
}
|
||||||
|
.hidden {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(-20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.customZindex {
|
.customZindex {
|
||||||
z-index: var(--customZindex) !important;
|
z-index: var(--customZindex) !important;
|
||||||
}
|
}
|
||||||
|
|
|
@ -131,7 +131,7 @@
|
||||||
if (bindings?.length) {
|
if (bindings?.length) {
|
||||||
options.push(SidePanels.Bindings)
|
options.push(SidePanels.Bindings)
|
||||||
}
|
}
|
||||||
if (context) {
|
if (context && Object.keys(context).length > 0) {
|
||||||
options.push(SidePanels.Evaluation)
|
options.push(SidePanels.Evaluation)
|
||||||
}
|
}
|
||||||
if (useSnippets && mode === Modes.JavaScript) {
|
if (useSnippets && mode === Modes.JavaScript) {
|
||||||
|
|
|
@ -78,7 +78,12 @@
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
<Button cta slot="buttons" on:click={handleClose}>Save</Button>
|
||||||
<svelte:component
|
<svelte:component
|
||||||
this={panel}
|
this={panel}
|
||||||
|
|
|
@ -14,7 +14,6 @@
|
||||||
export let key
|
export let key
|
||||||
export let nested
|
export let nested
|
||||||
export let max
|
export let max
|
||||||
export let context
|
|
||||||
|
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
|
|
|
@ -126,7 +126,7 @@
|
||||||
<div class="right-content">
|
<div class="right-content">
|
||||||
<svelte:component
|
<svelte:component
|
||||||
this={listType}
|
this={listType}
|
||||||
anchor={anchors[draggableItem.item._id]}
|
anchor={anchors[draggableItem.id]}
|
||||||
item={draggableItem.item}
|
item={draggableItem.item}
|
||||||
{...listTypeProps}
|
{...listTypeProps}
|
||||||
on:change={onItemChanged}
|
on:change={onItemChanged}
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
|
@ -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>
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -1,62 +1,61 @@
|
||||||
<script>
|
<script>
|
||||||
import LinksEditor from "./LinksEditor.svelte"
|
import NavItemConfiguration from "./NavItemConfiguration.svelte"
|
||||||
import { get } from "svelte/store"
|
import { get } from "svelte/store"
|
||||||
import Panel from "components/design/Panel.svelte"
|
import Panel from "components/design/Panel.svelte"
|
||||||
import {
|
import {
|
||||||
Detail,
|
|
||||||
Toggle,
|
Toggle,
|
||||||
Body,
|
DetailSummary,
|
||||||
Icon,
|
|
||||||
ColorPicker,
|
|
||||||
Input,
|
|
||||||
Label,
|
|
||||||
ActionGroup,
|
|
||||||
ActionButton,
|
|
||||||
Checkbox,
|
Checkbox,
|
||||||
notifications,
|
notifications,
|
||||||
Select,
|
Select,
|
||||||
Combobox,
|
|
||||||
} from "@budibase/bbui"
|
} from "@budibase/bbui"
|
||||||
import {
|
import {
|
||||||
themeStore,
|
themeStore,
|
||||||
selectedScreen,
|
selectedScreen,
|
||||||
screenStore,
|
screenStore,
|
||||||
navigationStore,
|
componentStore,
|
||||||
|
navigationStore as nav,
|
||||||
} from "stores/builder"
|
} from "stores/builder"
|
||||||
import { DefaultAppTheme } from "constants"
|
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: "Left", barIcon: "TextAlignLeft" },
|
||||||
{ value: "Center", barIcon: "TextAlignCenter" },
|
{ value: "Center", barIcon: "TextAlignCenter" },
|
||||||
{ value: "Right", barIcon: "TextAlignRight" },
|
{ value: "Right", barIcon: "TextAlignRight" },
|
||||||
]
|
]
|
||||||
|
const widthOptions = ["Max", "Large", "Medium", "Small"]
|
||||||
|
|
||||||
|
$: bindings = getBindableProperties(
|
||||||
|
$selectedScreen,
|
||||||
|
$componentStore.selectedComponentId
|
||||||
|
)
|
||||||
$: screenRouteOptions = $screenStore.screens
|
$: screenRouteOptions = $screenStore.screens
|
||||||
.map(screen => screen.routing?.route)
|
.map(screen => screen.routing?.route)
|
||||||
.filter(x => x != null)
|
.filter(x => x != null)
|
||||||
|
|
||||||
const updateShowNavigation = async e => {
|
const updateShowNavigation = async show => {
|
||||||
await screenStore.updateSetting(
|
await screenStore.updateSetting(get(selectedScreen), "showNavigation", show)
|
||||||
get(selectedScreen),
|
|
||||||
"showNavigation",
|
|
||||||
e.detail
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const update = async (key, value) => {
|
const update = async (key, value) => {
|
||||||
try {
|
try {
|
||||||
let navigation = $navigationStore
|
let navigation = $nav
|
||||||
navigation[key] = value
|
navigation[key] = value
|
||||||
await navigationStore.save(navigation)
|
await nav.save(navigation)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
notifications.error("Error updating navigation settings")
|
notifications.error("Error updating navigation settings")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateTextAlign = textAlignValue => {
|
|
||||||
navigationStore.syncAppNavigation({ textAlign: textAlignValue })
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Panel
|
<Panel
|
||||||
|
@ -65,215 +64,142 @@
|
||||||
borderLeft
|
borderLeft
|
||||||
wide
|
wide
|
||||||
>
|
>
|
||||||
<div class="generalSection">
|
<DetailSummary name="General" initiallyShow collapsible={false}>
|
||||||
<div class="subheading">
|
<PropertyControl
|
||||||
<Detail>General</Detail>
|
control={Toggle}
|
||||||
</div>
|
props={{ text: "Show nav on this screen" }}
|
||||||
<div class="toggle">
|
onChange={updateShowNavigation}
|
||||||
<Toggle
|
value={$selectedScreen?.showNavigation}
|
||||||
on:change={updateShowNavigation}
|
/>
|
||||||
value={$selectedScreen?.showNavigation}
|
</DetailSummary>
|
||||||
/>
|
|
||||||
<Body size="S">Show nav on this screen</Body>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if $selectedScreen?.showNavigation}
|
{#if $selectedScreen?.showNavigation}
|
||||||
<div class="divider" />
|
<DetailSummary name="Customize" initiallyShow collapsible={false}>
|
||||||
<div class="customizeSection">
|
<NavItemConfiguration {bindings} />
|
||||||
<div class="subheading">
|
<div class="settings">
|
||||||
<Detail>Customize</Detail>
|
<PropertyControl
|
||||||
</div>
|
label="Position"
|
||||||
<div class="info">
|
control={BarButtonList}
|
||||||
<Icon name="InfoOutline" size="S" />
|
onChange={position => update("navigation", position)}
|
||||||
<Body size="S">These settings apply to all screens</Body>
|
value={$nav.navigation}
|
||||||
</div>
|
props={{
|
||||||
<div class="configureLinks">
|
options: positionOptions,
|
||||||
<LinksEditor />
|
}}
|
||||||
</div>
|
/>
|
||||||
<div class="controls">
|
{#if $nav.navigation === "Top"}
|
||||||
<div class="label">
|
<PropertyControl
|
||||||
<Label size="M">Position</Label>
|
label="Sticky header"
|
||||||
</div>
|
control={Checkbox}
|
||||||
<ActionGroup quiet>
|
value={$nav.sticky}
|
||||||
<ActionButton
|
onChange={sticky => update("sticky", sticky)}
|
||||||
selected={$navigationStore.navigation === "Top"}
|
|
||||||
quiet={$navigationStore.navigation !== "Top"}
|
|
||||||
icon="PaddingTop"
|
|
||||||
on:click={() => update("navigation", "Top")}
|
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<PropertyControl
|
||||||
selected={$navigationStore.navigation === "Left"}
|
label="Width"
|
||||||
quiet={$navigationStore.navigation !== "Left"}
|
control={Select}
|
||||||
icon="PaddingLeft"
|
onChange={position => update("navWidth", position)}
|
||||||
on:click={() => update("navigation", "Left")}
|
value={$nav.navWidth}
|
||||||
/>
|
props={{
|
||||||
</ActionGroup>
|
placeholder: null,
|
||||||
|
options: widthOptions,
|
||||||
{#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)}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="label">
|
<PropertyControl
|
||||||
<Label size="M">Show title</Label>
|
label="Show title"
|
||||||
</div>
|
control={Checkbox}
|
||||||
<Checkbox
|
value={!$nav.hideTitle}
|
||||||
value={!$navigationStore.hideTitle}
|
onChange={show => update("hideTitle", !show)}
|
||||||
on:change={e => update("hideTitle", !e.detail)}
|
|
||||||
/>
|
/>
|
||||||
{#if !$navigationStore.hideTitle}
|
{#if !$nav.hideTitle}
|
||||||
<div class="label">
|
<PropertyControl
|
||||||
<Label size="M">Title</Label>
|
label="Title"
|
||||||
</div>
|
control={DrawerBindableInput}
|
||||||
<Input
|
value={$nav.title}
|
||||||
value={$navigationStore.title}
|
onChange={title => update("title", title)}
|
||||||
on:change={e => update("title", e.detail)}
|
{bindings}
|
||||||
updateOnChange={false}
|
props={{
|
||||||
|
updateOnChange: false,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
<PropertyControl
|
||||||
<div class="label">
|
label="Text align"
|
||||||
<Label size="M">Text align</Label>
|
control={BarButtonList}
|
||||||
</div>
|
onChange={align => nav.syncAppNavigation({ textAlign: align })}
|
||||||
<BarButtonList
|
value={$nav.textAlign}
|
||||||
options={alignmentOptions}
|
props={{
|
||||||
value={$navigationStore.textAlign}
|
options: alignmentOptions,
|
||||||
onChange={updateTextAlign}
|
}}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="label">
|
<PropertyControl
|
||||||
<Label>Background</Label>
|
label="Background"
|
||||||
</div>
|
control={ColorPicker}
|
||||||
<ColorPicker
|
onChange={color => update("navBackground", color)}
|
||||||
spectrumTheme={$themeStore.theme}
|
value={$nav.navBackground || DefaultAppTheme.navBackground}
|
||||||
value={$navigationStore.navBackground ||
|
props={{
|
||||||
DefaultAppTheme.navBackground}
|
spectrumTheme: $themeStore.theme,
|
||||||
on:change={e => update("navBackground", e.detail)}
|
}}
|
||||||
/>
|
/>
|
||||||
<div class="label">
|
<PropertyControl
|
||||||
<Label>Text</Label>
|
label="Text"
|
||||||
</div>
|
control={ColorPicker}
|
||||||
<ColorPicker
|
onChange={color => update("navTextColor", color)}
|
||||||
spectrumTheme={$themeStore.theme}
|
value={$nav.navTextColor || DefaultAppTheme.navTextColor}
|
||||||
value={$navigationStore.navTextColor || DefaultAppTheme.navTextColor}
|
props={{
|
||||||
on:change={e => update("navTextColor", e.detail)}
|
spectrumTheme: $themeStore.theme,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DetailSummary>
|
||||||
|
|
||||||
<div class="divider" />
|
<DetailSummary name="Logo" initiallyShow collapsible={false}>
|
||||||
<div class="customizeSection">
|
<div class="settings">
|
||||||
<div class="subheading">
|
<PropertyControl
|
||||||
<Detail>Logo</Detail>
|
label="Show logo"
|
||||||
</div>
|
control={Checkbox}
|
||||||
<div class="controls">
|
value={!$nav.hideLogo}
|
||||||
<div class="label">
|
onChange={show => update("hideLogo", !show)}
|
||||||
<Label size="M">Show logo</Label>
|
|
||||||
</div>
|
|
||||||
<Checkbox
|
|
||||||
value={!$navigationStore.hideLogo}
|
|
||||||
on:change={e => update("hideLogo", !e.detail)}
|
|
||||||
/>
|
/>
|
||||||
{#if !$navigationStore.hideLogo}
|
{#if !$nav.hideLogo}
|
||||||
<div class="label">
|
<PropertyControl
|
||||||
<Label size="M">Logo image URL</Label>
|
label="Logo image URL"
|
||||||
</div>
|
control={DrawerBindableInput}
|
||||||
<Input
|
value={$nav.logoUrl}
|
||||||
value={$navigationStore.logoUrl}
|
onChange={url => update("logoUrl", url)}
|
||||||
on:change={e => update("logoUrl", e.detail)}
|
{bindings}
|
||||||
updateOnChange={false}
|
props={{
|
||||||
|
updateOnChange: false,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div class="label">
|
<PropertyControl
|
||||||
<Label size="M">Logo link URL</Label>
|
label="Logo link URL"
|
||||||
</div>
|
control={DrawerBindableCombobox}
|
||||||
<Combobox
|
value={$nav.logoLinkUrl}
|
||||||
value={$navigationStore.logoLinkUrl}
|
onChange={url => update("logoLinkUrl", url)}
|
||||||
on:change={e => update("logoLinkUrl", e.detail)}
|
{bindings}
|
||||||
options={screenRouteOptions}
|
props={{
|
||||||
|
appendBindingsAsOptions: false,
|
||||||
|
options: screenRouteOptions,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div class="label">
|
<PropertyControl
|
||||||
<Label size="M">New tab</Label>
|
label="New tab"
|
||||||
</div>
|
control={Checkbox}
|
||||||
<Checkbox
|
value={$nav.openLogoLinkInNewTab}
|
||||||
value={!!$navigationStore.openLogoLinkInNewTab}
|
onChange={show => update("openLogoLinkInNewTab", show)}
|
||||||
on:change={e => update("openLogoLinkInNewTab", !!e.detail)}
|
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DetailSummary>
|
||||||
{/if}
|
{/if}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.generalSection {
|
.settings {
|
||||||
padding: 13px 13px 25px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.customizeSection {
|
|
||||||
padding: 13px 13px 25px;
|
|
||||||
}
|
|
||||||
.subheading {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subheading :global(p) {
|
|
||||||
color: var(--grey-6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
}
|
justify-content: flex-start;
|
||||||
|
align-items: stretch;
|
||||||
.divider {
|
gap: 8px;
|
||||||
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%;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -79,7 +79,8 @@
|
||||||
// for autoscreens, so it's always safe to do this.
|
// for autoscreens, so it's always safe to do this.
|
||||||
await navigationStore.saveLink(
|
await navigationStore.saveLink(
|
||||||
screen.routing.route,
|
screen.routing.route,
|
||||||
capitalise(screen.routing.route.split("/")[1])
|
capitalise(screen.routing.route.split("/")[1]),
|
||||||
|
screenAccessRole
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -42,7 +42,7 @@ export class NavigationStore extends BudiStore {
|
||||||
this.syncAppNavigation(app.navigation)
|
this.syncAppNavigation(app.navigation)
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveLink(url, title) {
|
async saveLink(url, title, roleId) {
|
||||||
const navigation = get(this.store)
|
const navigation = get(this.store)
|
||||||
let links = [...(navigation?.links ?? [])]
|
let links = [...(navigation?.links ?? [])]
|
||||||
|
|
||||||
|
@ -54,6 +54,8 @@ export class NavigationStore extends BudiStore {
|
||||||
links.push({
|
links.push({
|
||||||
text: title,
|
text: title,
|
||||||
url,
|
url,
|
||||||
|
type: "link",
|
||||||
|
roleId,
|
||||||
})
|
})
|
||||||
await this.save({
|
await this.save({
|
||||||
...navigation,
|
...navigation,
|
||||||
|
@ -67,11 +69,20 @@ export class NavigationStore extends BudiStore {
|
||||||
if (!links?.length) {
|
if (!links?.length) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out the URLs to delete
|
|
||||||
urls = Array.isArray(urls) ? urls : [urls]
|
urls = Array.isArray(urls) ? urls : [urls]
|
||||||
|
|
||||||
|
// Filter out top level links pointing to these URLs
|
||||||
links = links.filter(link => !urls.includes(link.url))
|
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({
|
await this.save({
|
||||||
...navigation,
|
...navigation,
|
||||||
links,
|
links,
|
||||||
|
|
|
@ -50,10 +50,18 @@ describe("Navigation store", () => {
|
||||||
{
|
{
|
||||||
url: "/home",
|
url: "/home",
|
||||||
text: "Home",
|
text: "Home",
|
||||||
|
type: "link",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: "/test",
|
url: "/test",
|
||||||
text: "Test",
|
text: "Test",
|
||||||
|
type: "sublinks",
|
||||||
|
subLinks: [
|
||||||
|
{
|
||||||
|
text: "Foo",
|
||||||
|
url: "/bar",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -66,7 +74,7 @@ describe("Navigation store", () => {
|
||||||
.spyOn(ctx.test.navigationStore, "save")
|
.spyOn(ctx.test.navigationStore, "save")
|
||||||
.mockImplementation(() => {})
|
.mockImplementation(() => {})
|
||||||
|
|
||||||
await ctx.test.navigationStore.saveLink("/test-url", "Testing")
|
await ctx.test.navigationStore.saveLink("/test-url", "Testing", "BASIC")
|
||||||
|
|
||||||
expect(saveSpy).toBeCalledWith({
|
expect(saveSpy).toBeCalledWith({
|
||||||
...INITIAL_NAVIGATION_STATE,
|
...INITIAL_NAVIGATION_STATE,
|
||||||
|
@ -75,6 +83,8 @@ describe("Navigation store", () => {
|
||||||
{
|
{
|
||||||
url: "/test-url",
|
url: "/test-url",
|
||||||
text: "Testing",
|
text: "Testing",
|
||||||
|
type: "link",
|
||||||
|
roleId: "BASIC",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
@ -87,6 +97,7 @@ describe("Navigation store", () => {
|
||||||
{
|
{
|
||||||
url: "/home",
|
url: "/home",
|
||||||
text: "Home",
|
text: "Home",
|
||||||
|
type: "link",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
@ -94,7 +105,7 @@ describe("Navigation store", () => {
|
||||||
.spyOn(ctx.test.navigationStore, "save")
|
.spyOn(ctx.test.navigationStore, "save")
|
||||||
.mockImplementation(() => {})
|
.mockImplementation(() => {})
|
||||||
|
|
||||||
await ctx.test.navigationStore.saveLink("/home", "Home")
|
await ctx.test.navigationStore.saveLink("/home", "Home", "BASIC")
|
||||||
|
|
||||||
expect(saveSpy).not.toHaveBeenCalled()
|
expect(saveSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
@ -106,14 +117,23 @@ describe("Navigation store", () => {
|
||||||
{
|
{
|
||||||
url: "/home",
|
url: "/home",
|
||||||
text: "Home",
|
text: "Home",
|
||||||
|
type: "link",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: "/test",
|
url: "/test",
|
||||||
text: "Test",
|
text: "Test",
|
||||||
|
type: "link",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: "/last",
|
url: "/last",
|
||||||
text: "Last Link",
|
text: "Last Link",
|
||||||
|
type: "sublinks",
|
||||||
|
subLinks: [
|
||||||
|
{
|
||||||
|
text: "Foo",
|
||||||
|
url: "/home",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
@ -130,6 +150,8 @@ describe("Navigation store", () => {
|
||||||
{
|
{
|
||||||
text: "Last Link",
|
text: "Last Link",
|
||||||
url: "/last",
|
url: "/last",
|
||||||
|
type: "sublinks",
|
||||||
|
subLinks: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
@ -140,14 +162,17 @@ describe("Navigation store", () => {
|
||||||
{
|
{
|
||||||
url: "/home",
|
url: "/home",
|
||||||
text: "Home",
|
text: "Home",
|
||||||
|
type: "link",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: "/test",
|
url: "/test",
|
||||||
text: "Test",
|
text: "Test",
|
||||||
|
type: "link",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: "/last",
|
url: "/last",
|
||||||
text: "Last Link",
|
text: "Last Link",
|
||||||
|
type: "link",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -168,10 +193,12 @@ describe("Navigation store", () => {
|
||||||
{
|
{
|
||||||
url: "/home",
|
url: "/home",
|
||||||
text: "Home",
|
text: "Home",
|
||||||
|
type: "link",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: "/last",
|
url: "/last",
|
||||||
text: "Last Link",
|
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 => {
|
it("Should ignore a request to delete if there are no links", async ctx => {
|
||||||
const saveSpy = vi.spyOn(ctx.test.navigationStore, "save")
|
const saveSpy = vi.spyOn(ctx.test.navigationStore, "save")
|
||||||
|
|
||||||
await ctx.test.navigationStore.deleteLink({
|
await ctx.test.navigationStore.deleteLink("/some-link")
|
||||||
url: "/some-link",
|
|
||||||
text: "Some Link",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(saveSpy).not.toBeCalled()
|
expect(saveSpy).not.toBeCalled()
|
||||||
})
|
})
|
||||||
|
@ -201,10 +225,18 @@ describe("Navigation store", () => {
|
||||||
{
|
{
|
||||||
url: "/home",
|
url: "/home",
|
||||||
text: "Home",
|
text: "Home",
|
||||||
|
type: "link",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
url: "/last",
|
url: "/last",
|
||||||
text: "Last Link",
|
text: "Last Link",
|
||||||
|
type: "sublinks",
|
||||||
|
subLinks: [
|
||||||
|
{
|
||||||
|
text: "Foo",
|
||||||
|
url: "/bar",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
|
@ -217,6 +249,7 @@ describe("Navigation store", () => {
|
||||||
{
|
{
|
||||||
url: "/new-link",
|
url: "/new-link",
|
||||||
text: "New Link",
|
text: "New Link",
|
||||||
|
type: "link",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,9 +2,8 @@
|
||||||
import { getContext, setContext } from "svelte"
|
import { getContext, setContext } from "svelte"
|
||||||
import { writable } from "svelte/store"
|
import { writable } from "svelte/store"
|
||||||
import { Heading, Icon, clickOutside } from "@budibase/bbui"
|
import { Heading, Icon, clickOutside } from "@budibase/bbui"
|
||||||
import { FieldTypes } from "constants"
|
|
||||||
import { Constants } from "@budibase/frontend-core"
|
import { Constants } from "@budibase/frontend-core"
|
||||||
import active from "svelte-spa-router/active"
|
import NavItem from "./NavItem.svelte"
|
||||||
|
|
||||||
const sdk = getContext("sdk")
|
const sdk = getContext("sdk")
|
||||||
const {
|
const {
|
||||||
|
@ -16,6 +15,7 @@
|
||||||
appStore,
|
appStore,
|
||||||
} = sdk
|
} = sdk
|
||||||
const context = getContext("context")
|
const context = getContext("context")
|
||||||
|
const navStateStore = writable({})
|
||||||
|
|
||||||
// Legacy props which must remain unchanged for backwards compatibility
|
// Legacy props which must remain unchanged for backwards compatibility
|
||||||
export let title
|
export let title
|
||||||
|
@ -63,7 +63,7 @@
|
||||||
})
|
})
|
||||||
setContext("layout", store)
|
setContext("layout", store)
|
||||||
|
|
||||||
$: validLinks = getValidLinks(links, $roleStore)
|
$: enrichedNavItems = enrichNavItems(links, $roleStore)
|
||||||
$: typeClass = NavigationClasses[navigation] || NavigationClasses.None
|
$: typeClass = NavigationClasses[navigation] || NavigationClasses.None
|
||||||
$: navWidthClass = WidthClasses[navWidth || width] || WidthClasses.Large
|
$: navWidthClass = WidthClasses[navWidth || width] || WidthClasses.Large
|
||||||
$: pageWidthClass = WidthClasses[pageWidth || width] || WidthClasses.Large
|
$: pageWidthClass = WidthClasses[pageWidth || width] || WidthClasses.Large
|
||||||
|
@ -101,28 +101,57 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getValidLinks = (allLinks, userRoleHierarchy) => {
|
const enrichNavItem = navItem => {
|
||||||
// Strip links missing required info
|
const internalLink = isInternal(navItem.url)
|
||||||
let validLinks = (allLinks || []).filter(link => link.text && link.url)
|
return {
|
||||||
// Filter to only links allowed by the current role
|
...navItem,
|
||||||
return validLinks.filter(link => {
|
internalLink,
|
||||||
const role = link.roleId || Constants.Roles.BASIC
|
url: internalLink ? navItem.url : ensureExternal(navItem.url),
|
||||||
return userRoleHierarchy?.find(roleId => roleId === role)
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
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 => {
|
const isInternal = url => {
|
||||||
return url.startsWith("/")
|
return url?.startsWith("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureExternal = url => {
|
const ensureExternal = url => {
|
||||||
|
if (!url?.length) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
return !url.startsWith("http") ? `http://${url}` : url
|
return !url.startsWith("http") ? `http://${url}` : url
|
||||||
}
|
}
|
||||||
|
|
||||||
const close = () => {
|
|
||||||
mobileOpen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const navigateToPortal = () => {
|
const navigateToPortal = () => {
|
||||||
if ($builderStore.inBuilder) return
|
if ($builderStore.inBuilder) return
|
||||||
window.location.href = "/builder/apps"
|
window.location.href = "/builder/apps"
|
||||||
|
@ -194,7 +223,7 @@
|
||||||
>
|
>
|
||||||
<div class="nav nav--{typeClass} size--{navWidthClass}">
|
<div class="nav nav--{typeClass} size--{navWidthClass}">
|
||||||
<div class="nav-header">
|
<div class="nav-header">
|
||||||
{#if validLinks?.length}
|
{#if enrichedNavItems.length}
|
||||||
<div class="burger">
|
<div class="burger">
|
||||||
<Icon
|
<Icon
|
||||||
hoverable
|
hoverable
|
||||||
|
@ -243,28 +272,20 @@
|
||||||
class:visible={mobileOpen}
|
class:visible={mobileOpen}
|
||||||
on:click={() => (mobileOpen = false)}
|
on:click={() => (mobileOpen = false)}
|
||||||
/>
|
/>
|
||||||
{#if validLinks?.length}
|
{#if enrichedNavItems.length}
|
||||||
<div class="links" class:visible={mobileOpen}>
|
<div class="links" class:visible={mobileOpen}>
|
||||||
{#each validLinks as { text, url }}
|
{#each enrichedNavItems as navItem}
|
||||||
{#if isInternal(url)}
|
<NavItem
|
||||||
<a
|
type={navItem.type}
|
||||||
class={FieldTypes.LINK}
|
text={navItem.text}
|
||||||
href={url}
|
url={navItem.url}
|
||||||
use:linkable
|
subLinks={navItem.subLinks}
|
||||||
on:click={close}
|
internalLink={navItem.internalLink}
|
||||||
use:active={url}
|
on:clickLink={() => (mobileOpen = false)}
|
||||||
>
|
leftNav={navigation === "Left"}
|
||||||
{text}
|
{mobile}
|
||||||
</a>
|
{navStateStore}
|
||||||
{:else}
|
/>
|
||||||
<a
|
|
||||||
class={FieldTypes.LINK}
|
|
||||||
href={ensureExternal(url)}
|
|
||||||
on:click={close}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
{/each}
|
||||||
<div class="close">
|
<div class="close">
|
||||||
<Icon
|
<Icon
|
||||||
|
@ -509,21 +530,6 @@
|
||||||
gap: var(--spacing-xl);
|
gap: var(--spacing-xl);
|
||||||
margin-top: 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 {
|
.close {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
@ -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>
|
|
@ -853,6 +853,7 @@
|
||||||
"array",
|
"array",
|
||||||
"datetime",
|
"datetime",
|
||||||
"attachment",
|
"attachment",
|
||||||
|
"attachment_single",
|
||||||
"link",
|
"link",
|
||||||
"formula",
|
"formula",
|
||||||
"auto",
|
"auto",
|
||||||
|
@ -1059,6 +1060,7 @@
|
||||||
"array",
|
"array",
|
||||||
"datetime",
|
"datetime",
|
||||||
"attachment",
|
"attachment",
|
||||||
|
"attachment_single",
|
||||||
"link",
|
"link",
|
||||||
"formula",
|
"formula",
|
||||||
"auto",
|
"auto",
|
||||||
|
@ -1276,6 +1278,7 @@
|
||||||
"array",
|
"array",
|
||||||
"datetime",
|
"datetime",
|
||||||
"attachment",
|
"attachment",
|
||||||
|
"attachment_single",
|
||||||
"link",
|
"link",
|
||||||
"formula",
|
"formula",
|
||||||
"auto",
|
"auto",
|
||||||
|
@ -1752,7 +1755,7 @@
|
||||||
},
|
},
|
||||||
"fuzzy": {
|
"fuzzy": {
|
||||||
"type": "object",
|
"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": {
|
"range": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
@ -1786,6 +1789,36 @@
|
||||||
"oneOf": {
|
"oneOf": {
|
||||||
"type": "object",
|
"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]."
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -775,6 +775,7 @@ components:
|
||||||
- array
|
- array
|
||||||
- datetime
|
- datetime
|
||||||
- attachment
|
- attachment
|
||||||
|
- attachment_single
|
||||||
- link
|
- link
|
||||||
- formula
|
- formula
|
||||||
- auto
|
- auto
|
||||||
|
@ -940,6 +941,7 @@ components:
|
||||||
- array
|
- array
|
||||||
- datetime
|
- datetime
|
||||||
- attachment
|
- attachment
|
||||||
|
- attachment_single
|
||||||
- link
|
- link
|
||||||
- formula
|
- formula
|
||||||
- auto
|
- auto
|
||||||
|
@ -1112,6 +1114,7 @@ components:
|
||||||
- array
|
- array
|
||||||
- datetime
|
- datetime
|
||||||
- attachment
|
- attachment
|
||||||
|
- attachment_single
|
||||||
- link
|
- link
|
||||||
- formula
|
- formula
|
||||||
- auto
|
- auto
|
||||||
|
@ -1492,7 +1495,8 @@ components:
|
||||||
description: The value to search for in the column.
|
description: The value to search for in the column.
|
||||||
fuzzy:
|
fuzzy:
|
||||||
type: object
|
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:
|
range:
|
||||||
type: object
|
type: object
|
||||||
description: Searches within a range, the format of this must be in the format
|
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
|
description: Searches for rows which have a column value that is any of the
|
||||||
specified values. The format of this must be columnName ->
|
specified values. The format of this must be columnName ->
|
||||||
[value1, value2].
|
[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:
|
paginate:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Enables pagination, by default this is disabled.
|
description: Enables pagination, by default this is disabled.
|
||||||
|
|
|
@ -27,7 +27,8 @@ export default new Resource().setSchemas({
|
||||||
},
|
},
|
||||||
fuzzy: {
|
fuzzy: {
|
||||||
type: "object",
|
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: {
|
range: {
|
||||||
type: "object",
|
type: "object",
|
||||||
|
@ -67,6 +68,30 @@ export default new Resource().setSchemas({
|
||||||
description:
|
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].",
|
"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: {
|
paginate: {
|
||||||
|
|
|
@ -17,11 +17,9 @@ import {
|
||||||
Row,
|
Row,
|
||||||
Table,
|
Table,
|
||||||
UserCtx,
|
UserCtx,
|
||||||
EmptyFilterOption,
|
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
import sdk from "../../../sdk"
|
import sdk from "../../../sdk"
|
||||||
import * as utils from "./utils"
|
import * as utils from "./utils"
|
||||||
import { dataFilters } from "@budibase/shared-core"
|
|
||||||
import {
|
import {
|
||||||
inputProcessing,
|
inputProcessing,
|
||||||
outputProcessing,
|
outputProcessing,
|
||||||
|
@ -33,17 +31,6 @@ export async function handleRequest<T extends Operation>(
|
||||||
tableId: string,
|
tableId: string,
|
||||||
opts?: RunConfig
|
opts?: RunConfig
|
||||||
): Promise<ExternalRequestReturnType<T>> {
|
): 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(
|
return new ExternalRequest<T>(operation, tableId, opts?.datasource).run(
|
||||||
opts || {}
|
opts || {}
|
||||||
)
|
)
|
||||||
|
|
|
@ -6,6 +6,7 @@ import {
|
||||||
Datasource,
|
Datasource,
|
||||||
EmptyFilterOption,
|
EmptyFilterOption,
|
||||||
FieldType,
|
FieldType,
|
||||||
|
Row,
|
||||||
SearchFilters,
|
SearchFilters,
|
||||||
Table,
|
Table,
|
||||||
} from "@budibase/types"
|
} from "@budibase/types"
|
||||||
|
@ -47,7 +48,7 @@ describe.each([
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("strings", () => {
|
describe("strings", () => {
|
||||||
beforeEach(async () => {
|
beforeAll(async () => {
|
||||||
table = await config.api.table.save(
|
table = await config.api.table.save(
|
||||||
tableForDatasource(datasource, {
|
tableForDatasource(datasource, {
|
||||||
schema: {
|
schema: {
|
||||||
|
@ -61,6 +62,13 @@ describe.each([
|
||||||
})
|
})
|
||||||
|
|
||||||
const rows = [{ name: "foo" }, { name: "bar" }]
|
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 {
|
interface StringSearchTest {
|
||||||
query: SearchFilters
|
query: SearchFilters
|
||||||
|
@ -68,6 +76,8 @@ describe.each([
|
||||||
}
|
}
|
||||||
|
|
||||||
const stringSearchTests: StringSearchTest[] = [
|
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: {}, expected: rows },
|
||||||
{
|
{
|
||||||
query: { onEmptyFilter: EmptyFilterOption.RETURN_ALL },
|
query: { onEmptyFilter: EmptyFilterOption.RETURN_ALL },
|
||||||
|
@ -77,27 +87,23 @@ describe.each([
|
||||||
query: { onEmptyFilter: EmptyFilterOption.RETURN_NONE },
|
query: { onEmptyFilter: EmptyFilterOption.RETURN_NONE },
|
||||||
expected: [],
|
expected: [],
|
||||||
},
|
},
|
||||||
|
// The rest of these tests are specific to strings.
|
||||||
{ query: { string: { name: "foo" } }, expected: [rows[0]] },
|
{ query: { string: { name: "foo" } }, expected: [rows[0]] },
|
||||||
{ query: { string: { name: "none" } }, expected: [] },
|
{ query: { string: { name: "none" } }, expected: [] },
|
||||||
{ query: { fuzzy: { name: "oo" } }, expected: [rows[0]] },
|
{ query: { fuzzy: { name: "oo" } }, expected: [rows[0]] },
|
||||||
{ query: { equal: { name: "foo" } }, expected: [rows[0]] },
|
{ query: { equal: { name: "foo" } }, expected: [rows[0]] },
|
||||||
{ query: { notEqual: { name: "foo" } }, expected: [rows[1]] },
|
{ query: { notEqual: { name: "foo" } }, expected: [rows[1]] },
|
||||||
{ query: { oneOf: { name: ["foo"] } }, expected: [rows[0]] },
|
{ 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)(
|
it.each(stringSearchTests)(
|
||||||
`should be able to run query: $query`,
|
`should be able to run query: $query`,
|
||||||
async ({ query, expected }) => {
|
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!, {
|
const { rows: foundRows } = await config.api.row.search(table._id!, {
|
||||||
tableId: table._id!,
|
tableId: table._id!,
|
||||||
query,
|
query,
|
||||||
})
|
})
|
||||||
|
expect(foundRows).toHaveLength(expected.length)
|
||||||
expect(foundRows).toEqual(
|
expect(foundRows).toEqual(
|
||||||
expect.arrayContaining(
|
expect.arrayContaining(
|
||||||
expected.map(r =>
|
expected.map(r =>
|
||||||
|
@ -110,7 +116,7 @@ describe.each([
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("number", () => {
|
describe("number", () => {
|
||||||
beforeEach(async () => {
|
beforeAll(async () => {
|
||||||
table = await config.api.table.save(
|
table = await config.api.table.save(
|
||||||
tableForDatasource(datasource, {
|
tableForDatasource(datasource, {
|
||||||
schema: {
|
schema: {
|
||||||
|
@ -124,6 +130,13 @@ describe.each([
|
||||||
})
|
})
|
||||||
|
|
||||||
const rows = [{ age: 1 }, { age: 10 }]
|
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 {
|
interface NumberSearchTest {
|
||||||
query: SearchFilters
|
query: SearchFilters
|
||||||
|
@ -131,15 +144,6 @@ describe.each([
|
||||||
}
|
}
|
||||||
|
|
||||||
const numberSearchTests: NumberSearchTest[] = [
|
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: 1 } }, expected: [rows[0]] },
|
||||||
{ query: { equal: { age: 2 } }, expected: [] },
|
{ query: { equal: { age: 2 } }, expected: [] },
|
||||||
{ query: { notEqual: { age: 1 } }, expected: [rows[1]] },
|
{ query: { notEqual: { age: 1 } }, expected: [rows[1]] },
|
||||||
|
@ -153,13 +157,11 @@ describe.each([
|
||||||
it.each(numberSearchTests)(
|
it.each(numberSearchTests)(
|
||||||
`should be able to run query: $query`,
|
`should be able to run query: $query`,
|
||||||
async ({ query, expected }) => {
|
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!, {
|
const { rows: foundRows } = await config.api.row.search(table._id!, {
|
||||||
tableId: table._id!,
|
tableId: table._id!,
|
||||||
query,
|
query,
|
||||||
})
|
})
|
||||||
|
expect(foundRows).toHaveLength(expected.length)
|
||||||
expect(foundRows).toEqual(
|
expect(foundRows).toEqual(
|
||||||
expect.arrayContaining(
|
expect.arrayContaining(
|
||||||
expected.map(r =>
|
expected.map(r =>
|
||||||
|
@ -186,9 +188,16 @@ describe.each([
|
||||||
})
|
})
|
||||||
|
|
||||||
const rows = [
|
const rows = [
|
||||||
{ dob: new Date("2020-01-01") },
|
{ dob: new Date("2020-01-01").toISOString() },
|
||||||
{ dob: new Date("2020-01-10") },
|
{ 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 {
|
interface DateSearchTest {
|
||||||
query: SearchFilters
|
query: SearchFilters
|
||||||
|
@ -196,26 +205,20 @@ describe.each([
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateSearchTests: DateSearchTest[] = [
|
const dateSearchTests: DateSearchTest[] = [
|
||||||
{ query: {}, expected: rows },
|
|
||||||
{
|
{
|
||||||
query: { onEmptyFilter: EmptyFilterOption.RETURN_ALL },
|
query: { equal: { dob: new Date("2020-01-01").toISOString() } },
|
||||||
expected: rows,
|
expected: [rows[0]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
query: { onEmptyFilter: EmptyFilterOption.RETURN_NONE },
|
query: { equal: { dob: new Date("2020-01-02").toISOString() } },
|
||||||
expected: [],
|
expected: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
query: { equal: { dob: new Date("2020-01-01") } },
|
query: { notEqual: { dob: new Date("2020-01-01").toISOString() } },
|
||||||
expected: [rows[0]],
|
|
||||||
},
|
|
||||||
{ query: { equal: { dob: new Date("2020-01-02") } }, expected: [] },
|
|
||||||
{
|
|
||||||
query: { notEqual: { dob: new Date("2020-01-01") } },
|
|
||||||
expected: [rows[1]],
|
expected: [rows[1]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
query: { oneOf: { dob: [new Date("2020-01-01")] } },
|
query: { oneOf: { dob: [new Date("2020-01-01").toISOString()] } },
|
||||||
expected: [rows[0]],
|
expected: [rows[0]],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -256,23 +259,15 @@ describe.each([
|
||||||
it.each(dateSearchTests)(
|
it.each(dateSearchTests)(
|
||||||
`should be able to run query: $query`,
|
`should be able to run query: $query`,
|
||||||
async ({ query, expected }) => {
|
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!, {
|
const { rows: foundRows } = await config.api.row.search(table._id!, {
|
||||||
tableId: table._id!,
|
tableId: table._id!,
|
||||||
query,
|
query,
|
||||||
})
|
})
|
||||||
|
expect(foundRows).toHaveLength(expected.length)
|
||||||
expect(foundRows).toEqual(
|
expect(foundRows).toEqual(
|
||||||
expect.arrayContaining(
|
expect.arrayContaining(
|
||||||
expected.map(r =>
|
expected.map(r =>
|
||||||
expect.objectContaining(
|
expect.objectContaining(savedRows.find(sr => sr.dob === r.dob)!)
|
||||||
savedRows.find(sr => sr.dob === r.dob.toISOString())!
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
@ -273,6 +273,7 @@ export interface components {
|
||||||
| "array"
|
| "array"
|
||||||
| "datetime"
|
| "datetime"
|
||||||
| "attachment"
|
| "attachment"
|
||||||
|
| "attachment_single"
|
||||||
| "link"
|
| "link"
|
||||||
| "formula"
|
| "formula"
|
||||||
| "auto"
|
| "auto"
|
||||||
|
@ -381,6 +382,7 @@ export interface components {
|
||||||
| "array"
|
| "array"
|
||||||
| "datetime"
|
| "datetime"
|
||||||
| "attachment"
|
| "attachment"
|
||||||
|
| "attachment_single"
|
||||||
| "link"
|
| "link"
|
||||||
| "formula"
|
| "formula"
|
||||||
| "auto"
|
| "auto"
|
||||||
|
@ -491,6 +493,7 @@ export interface components {
|
||||||
| "array"
|
| "array"
|
||||||
| "datetime"
|
| "datetime"
|
||||||
| "attachment"
|
| "attachment"
|
||||||
|
| "attachment_single"
|
||||||
| "link"
|
| "link"
|
||||||
| "formula"
|
| "formula"
|
||||||
| "auto"
|
| "auto"
|
||||||
|
@ -693,7 +696,7 @@ export interface components {
|
||||||
* @example [object Object]
|
* @example [object Object]
|
||||||
*/
|
*/
|
||||||
string?: { [key: string]: string };
|
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 };
|
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.
|
* @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 };
|
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]. */
|
/** @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 };
|
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. */
|
/** @description Enables pagination, by default this is disabled. */
|
||||||
paginate?: boolean;
|
paginate?: boolean;
|
||||||
|
|
|
@ -334,6 +334,7 @@ class InternalBuilder {
|
||||||
if (filters.containsAny) {
|
if (filters.containsAny) {
|
||||||
contains(filters.containsAny, true)
|
contains(filters.containsAny, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import {
|
import {
|
||||||
|
EmptyFilterOption,
|
||||||
Row,
|
Row,
|
||||||
RowSearchParams,
|
RowSearchParams,
|
||||||
SearchFilters,
|
SearchFilters,
|
||||||
|
@ -11,6 +12,7 @@ import { NoEmptyFilterStrings } from "../../../constants"
|
||||||
import * as sqs from "./search/sqs"
|
import * as sqs from "./search/sqs"
|
||||||
import env from "../../../environment"
|
import env from "../../../environment"
|
||||||
import { ExportRowsParams, ExportRowsResult } from "./search/types"
|
import { ExportRowsParams, ExportRowsResult } from "./search/types"
|
||||||
|
import { dataFilters } from "@budibase/shared-core"
|
||||||
|
|
||||||
export { isValidFilter } from "../../../integrations/utils"
|
export { isValidFilter } from "../../../integrations/utils"
|
||||||
|
|
||||||
|
@ -60,6 +62,16 @@ export async function search(
|
||||||
options: RowSearchParams
|
options: RowSearchParams
|
||||||
): Promise<SearchResponse<Row>> {
|
): Promise<SearchResponse<Row>> {
|
||||||
const isExternalTable = isExternalTableID(options.tableId)
|
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) {
|
if (isExternalTable) {
|
||||||
return external.search(options)
|
return external.search(options)
|
||||||
} else if (env.SQS_SEARCH_ENABLE) {
|
} else if (env.SQS_SEARCH_ENABLE) {
|
||||||
|
|
|
@ -156,21 +156,21 @@ export async function search(
|
||||||
try {
|
try {
|
||||||
const query = builder._query(request, {
|
const query = builder._query(request, {
|
||||||
disableReturning: true,
|
disableReturning: true,
|
||||||
disableBindings: true,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (Array.isArray(query)) {
|
if (Array.isArray(query)) {
|
||||||
throw new Error("SQS cannot currently handle multiple queries")
|
throw new Error("SQS cannot currently handle multiple queries")
|
||||||
}
|
}
|
||||||
|
|
||||||
let sql = query.sql
|
let sql = query.sql,
|
||||||
|
bindings = query.bindings
|
||||||
|
|
||||||
// quick hack for docIds
|
// quick hack for docIds
|
||||||
sql = sql.replace(/`doc1`.`rowId`/g, "`doc1.rowId`")
|
sql = sql.replace(/`doc1`.`rowId`/g, "`doc1.rowId`")
|
||||||
sql = sql.replace(/`doc2`.`rowId`/g, "`doc2.rowId`")
|
sql = sql.replace(/`doc2`.`rowId`/g, "`doc2.rowId`")
|
||||||
|
|
||||||
const db = context.getAppDB()
|
const db = context.getAppDB()
|
||||||
const rows = await db.sql<Row>(sql)
|
const rows = await db.sql<Row>(sql, bindings)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: await sqlOutputProcessing(
|
rows: await sqlOutputProcessing(
|
||||||
|
|
|
@ -4,6 +4,7 @@ import {
|
||||||
AnyDocument,
|
AnyDocument,
|
||||||
Document,
|
Document,
|
||||||
RowValue,
|
RowValue,
|
||||||
|
SqlQueryBinding,
|
||||||
ViewTemplateOpts,
|
ViewTemplateOpts,
|
||||||
} from "../"
|
} from "../"
|
||||||
import { Writable } from "stream"
|
import { Writable } from "stream"
|
||||||
|
@ -143,7 +144,10 @@ export interface Database {
|
||||||
opts?: DatabasePutOpts
|
opts?: DatabasePutOpts
|
||||||
): Promise<Nano.DocumentInsertResponse>
|
): Promise<Nano.DocumentInsertResponse>
|
||||||
bulkDocs(documents: AnyDocument[]): Promise<Nano.DocumentBulkResponse[]>
|
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>(
|
allDocs<T extends Document | RowValue>(
|
||||||
params: DatabaseQueryOpts
|
params: DatabaseQueryOpts
|
||||||
): Promise<AllDocsResponse<T>>
|
): Promise<AllDocsResponse<T>>
|
||||||
|
|
Loading…
Reference in New Issue