Merge pull request #1490 from Budibase/lab-day-search

Searching and pagination for internal tables
This commit is contained in:
Andrew Kingston 2021-05-17 08:20:00 +01:00 committed by GitHub
commit 982336af82
53 changed files with 1037 additions and 527 deletions

View File

@ -56,6 +56,7 @@
"@spectrum-css/link": "^3.1.1", "@spectrum-css/link": "^3.1.1",
"@spectrum-css/menu": "^3.0.1", "@spectrum-css/menu": "^3.0.1",
"@spectrum-css/modal": "^3.0.1", "@spectrum-css/modal": "^3.0.1",
"@spectrum-css/pagination": "^3.0.3",
"@spectrum-css/picker": "^1.0.1", "@spectrum-css/picker": "^1.0.1",
"@spectrum-css/popover": "^3.0.1", "@spectrum-css/popover": "^3.0.1",
"@spectrum-css/progressbar": "^1.0.2", "@spectrum-css/progressbar": "^1.0.2",

View File

@ -17,6 +17,6 @@
} }
</script> </script>
<Field {label} {labelPosition} {disabled} {error}> <Field {label} {labelPosition} {error}>
<Checkbox {error} {disabled} {text} {value} on:change={onChange} /> <Checkbox {error} {disabled} {text} {value} on:change={onChange} />
</Field> </Field>

View File

@ -8,7 +8,7 @@
export let disabled = false export let disabled = false
export let labelPosition = "above" export let labelPosition = "above"
export let error = null export let error = null
export let placeholder = "Choose an option" export let placeholder = "Choose an option or type"
export let options = [] export let options = []
export let getOptionLabel = option => extractProperty(option, "label") export let getOptionLabel = option => extractProperty(option, "label")
export let getOptionValue = option => extractProperty(option, "value") export let getOptionValue = option => extractProperty(option, "value")
@ -26,7 +26,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {disabled} {error}> <Field {label} {labelPosition} {error}>
<Combobox <Combobox
{error} {error}
{disabled} {disabled}

View File

@ -7,7 +7,7 @@
export let value = null export let value = null
export let id = null export let id = null
export let placeholder = "Choose an option" export let placeholder = "Choose an option or type"
export let disabled = false export let disabled = false
export let error = null export let error = null
export let options = [] export let options = []
@ -22,7 +22,7 @@
const getFieldText = (value, options, placeholder) => { const getFieldText = (value, options, placeholder) => {
// Always use placeholder if no value // Always use placeholder if no value
if (value == null || value === "") { if (value == null || value === "") {
return placeholder || "Choose an option" return placeholder || "Choose an option or type"
} }
// Wait for options to load if there is a value but no options // Wait for options to load if there is a value but no options
@ -45,10 +45,16 @@
} }
</script> </script>
<div class="spectrum-InputGroup" class:is-focused={open || focus}> <div
class="spectrum-InputGroup"
class:is-focused={open || focus}
class:is-invalid={!!error}
class:is-disabled={disabled}
>
<div <div
class="spectrum-Textfield spectrum-InputGroup-textfield" class="spectrum-Textfield spectrum-InputGroup-textfield"
class:is-disabled={!!error} class:is-invalid={!!error}
class:is-disabled={disabled}
class:is-focused={open || focus} class:is-focused={open || focus}
> >
<input <input
@ -57,6 +63,7 @@
on:blur={() => (focus = false)} on:blur={() => (focus = false)}
on:change={onChange} on:change={onChange}
{value} {value}
{disabled}
{placeholder} {placeholder}
class="spectrum-Textfield-input spectrum-InputGroup-input" class="spectrum-Textfield-input spectrum-InputGroup-input"
/> />
@ -65,7 +72,7 @@
class="spectrum-Picker spectrum-Picker--sizeM spectrum-InputGroup-button" class="spectrum-Picker spectrum-Picker--sizeM spectrum-InputGroup-button"
tabindex="-1" tabindex="-1"
aria-haspopup="true" aria-haspopup="true"
disabled={!!error} {disabled}
on:click={() => (open = true)} on:click={() => (open = true)}
> >
<svg <svg
@ -116,6 +123,9 @@
min-width: 0; min-width: 0;
width: 100%; width: 100%;
} }
.spectrum-Textfield {
width: 100%;
}
.spectrum-Textfield-input { .spectrum-Textfield-input {
width: 0; width: 0;
} }

View File

@ -18,7 +18,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {disabled} {error}> <Field {label} {labelPosition} {error}>
<DatePicker <DatePicker
{error} {error}
{disabled} {disabled}

View File

@ -20,7 +20,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {disabled} {error}> <Field {label} {labelPosition} {error}>
<CoreDropzone <CoreDropzone
{error} {error}
{disabled} {disabled}

View File

@ -5,7 +5,6 @@
export let id = null export let id = null
export let label = null export let label = null
export let labelPosition = "above" export let labelPosition = "above"
export let disabled = false
export let error = null export let error = null
</script> </script>

View File

@ -19,7 +19,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {disabled} {error}> <Field {label} {labelPosition} {error}>
<TextField <TextField
{error} {error}
{disabled} {disabled}

View File

@ -21,7 +21,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {disabled} {error}> <Field {label} {labelPosition} {error}>
<Multiselect <Multiselect
{error} {error}
{disabled} {disabled}

View File

@ -25,7 +25,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {disabled} {error}> <Field {label} {labelPosition} {error}>
<RadioGroup <RadioGroup
{error} {error}
{disabled} {disabled}

View File

@ -16,7 +16,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {disabled}> <Field {label} {labelPosition}>
<Search <Search
{disabled} {disabled}
{value} {value}

View File

@ -28,7 +28,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {disabled} {error}> <Field {label} {labelPosition} {error}>
<Select <Select
{quiet} {quiet}
{error} {error}

View File

@ -18,7 +18,7 @@
} }
</script> </script>
<Field {label} {labelPosition} {disabled} {error}> <Field {label} {labelPosition} {error}>
<TextArea <TextArea
bind:getCaretPosition bind:getCaretPosition
{error} {error}

View File

@ -17,6 +17,6 @@
} }
</script> </script>
<Field {label} {labelPosition} {disabled} {error}> <Field {label} {labelPosition} {error}>
<Switch {error} {disabled} {text} {value} on:change={onChange} /> <Switch {error} {disabled} {text} {value} on:change={onChange} />
</Field> </Field>

View File

@ -0,0 +1,57 @@
<script>
import "@spectrum-css/pagination/dist/index-vars.css"
import "@spectrum-css/actionbutton/dist/index-vars.css"
import "@spectrum-css/typography/dist/index-vars.css"
export let page
export let goToPrevPage
export let goToNextPage
export let hasPrevPage = true
export let hasNextPage = true
</script>
<nav class="spectrum-Pagination spectrum-Pagination--explicit">
<div
href="#"
class="spectrum-ActionButton spectrum-ActionButton--sizeM spectrum-ActionButton--quiet spectrum-Pagination-prevButton"
on:click={hasPrevPage ? goToPrevPage : null}
class:is-disabled={!hasPrevPage}
>
<svg
class="spectrum-Icon spectrum-UIIcon-ChevronLeft100"
focusable="false"
aria-hidden="true"
aria-label="ChevronLeft"
>
<use xlink:href="#spectrum-css-icon-Chevron100" />
</svg>
</div>
<span class="spectrum-Body--secondary spectrum-Pagination-counter">
Page {page}
</span>
<div
href="#"
class="spectrum-ActionButton spectrum-ActionButton--sizeM spectrum-ActionButton--quiet spectrum-Pagination-nextButton"
on:click={hasNextPage ? goToNextPage : null}
class:is-disabled={!hasNextPage}
>
<svg
class="spectrum-Icon spectrum-UIIcon-ChevronRight100"
focusable="false"
aria-hidden="true"
aria-label="ChevronLeft"
>
<use xlink:href="#spectrum-css-icon-Chevron100" />
</svg>
</div>
</nav>
<style>
.spectrum-Pagination-counter {
margin-left: 0;
user-select: none;
}
.is-disabled:hover {
cursor: initial;
}
</style>

View File

@ -42,7 +42,7 @@
<div <div
on:click on:click
class:spectrum-ProgressBar--indeterminate={!value} class:spectrum-ProgressCircle--indeterminate={!value}
class:spectrum-ProgressCircle--overBackground={overBackground} class:spectrum-ProgressCircle--overBackground={overBackground}
class="spectrum-ProgressCircle spectrum-ProgressCircle--{convertSize(size)}" class="spectrum-ProgressCircle spectrum-ProgressCircle--{convertSize(size)}"
> >

View File

@ -4,6 +4,16 @@
import CellRenderer from "./CellRenderer.svelte" import CellRenderer from "./CellRenderer.svelte"
import SelectEditRenderer from "./SelectEditRenderer.svelte" import SelectEditRenderer from "./SelectEditRenderer.svelte"
/**
* The expected schema is our normal couch schemas for our tables.
* Each field schema can be enriched with a few extra properties to customise
* the behaviour.
* All of these are optional and do not need to be added.
* displayName: Overrides the field name displayed as the column title
* sortable: Set to false to disable sorting data by a certain column
* editable: Set to false to disable editing a certain column if the
* allowEditColumns prop is true
*/
export let data = [] export let data = []
export let schema = {} export let schema = {}
export let showAutoColumns = false export let showAutoColumns = false
@ -462,10 +472,6 @@
tbody tr.hidden { tbody tr.hidden {
height: calc(var(--row-height) + 1px); height: calc(var(--row-height) + 1px);
} }
tbody tr.offset {
background-color: red;
display: block;
}
td { td {
padding-top: 0; padding-top: 0;
padding-bottom: 0; padding-bottom: 0;

View File

@ -51,6 +51,7 @@ export { default as TreeView } from "./TreeView/Tree.svelte"
export { default as TreeItem } from "./TreeView/Item.svelte" export { default as TreeItem } from "./TreeView/Item.svelte"
export { default as Divider } from "./Divider/Divider.svelte" export { default as Divider } from "./Divider/Divider.svelte"
export { default as Search } from "./Form/Search.svelte" export { default as Search } from "./Form/Search.svelte"
export { default as Pagination } from "./Pagination/Pagination.svelte"
// Typography // Typography
export { default as Body } from "./Typography/Body.svelte" export { default as Body } from "./Typography/Body.svelte"

View File

@ -161,6 +161,11 @@
resolved "https://registry.yarnpkg.com/@spectrum-css/modal/-/modal-3.0.2.tgz#58b6621cab65f90788d310374f40df1f7090473f" resolved "https://registry.yarnpkg.com/@spectrum-css/modal/-/modal-3.0.2.tgz#58b6621cab65f90788d310374f40df1f7090473f"
integrity sha512-YnIivJhoaao7Otu+HV7sgebPyFbO6sd/oMvTN/Rb2wwgnaMnIIuIRdGandSrcgotN2uNgs+P0knG6mv/xA1/dg== integrity sha512-YnIivJhoaao7Otu+HV7sgebPyFbO6sd/oMvTN/Rb2wwgnaMnIIuIRdGandSrcgotN2uNgs+P0knG6mv/xA1/dg==
"@spectrum-css/pagination@^3.0.3":
version "3.0.3"
resolved "https://registry.yarnpkg.com/@spectrum-css/pagination/-/pagination-3.0.3.tgz#b204c3ada384c4af751a354bc428346d82eeea65"
integrity sha512-OJ/v9GeNXJOZ9Yr9LDBYPrR2NCiLOWP9wANT/a5sqFuugRnQbn/HYMnRp9TBxwpDY6ihaPo0T/wi7kLiAJFdDw==
"@spectrum-css/picker@^1.0.1": "@spectrum-css/picker@^1.0.1":
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/@spectrum-css/picker/-/picker-1.0.2.tgz#b49429ae3c89f9c5f2c0530787ce45392c9612ff" resolved "https://registry.yarnpkg.com/@spectrum-css/picker/-/picker-1.0.2.tgz#b49429ae3c89f9c5f2c0530787ce45392c9612ff"

View File

@ -5,6 +5,7 @@ export const gradient = (node, config = {}) => {
lightness: 0.7, lightness: 0.7,
softness: 0.9, softness: 0.9,
seed: null, seed: null,
version: null,
} }
// Applies a gradient background // Applies a gradient background
@ -15,6 +16,7 @@ export const gradient = (node, config = {}) => {
} }
const { saturation, lightness, softness, points } = config const { saturation, lightness, softness, points } = config
const seed = config.seed || Math.random().toString(32).substring(2) const seed = config.seed || Math.random().toString(32).substring(2)
const version = config.version ?? 0
// Hash function which returns a fixed hash between specified limits // Hash function which returns a fixed hash between specified limits
// for a given seed and a given version // for a given seed and a given version
@ -69,10 +71,10 @@ export const gradient = (node, config = {}) => {
) )
} }
let css = `opacity:0.9;background:${randomHSL(seed, 0, 0.7)};` let css = `opacity:0.9;background:${randomHSL(seed, version, 0.7)};`
css += "background-image:" css += "background-image:"
for (let i = 0; i < points - 1; i++) { for (let i = 0; i < points - 1; i++) {
css += `${randomGradientPoint(seed, i)},` css += `${randomGradientPoint(seed, version + i)},`
} }
css += `${randomGradientPoint(seed, points)};` css += `${randomGradientPoint(seed, points)};`
node.style = css node.style = css

View File

@ -90,10 +90,17 @@ const createScreen = table => {
tableId: table._id, tableId: table._id,
type: "table", type: "table",
}, },
filter: { filter: [
_id: `{{ ${makePropSafe("url")}.${makePropSafe("id")} }}`, {
}, field: "_id",
operator: "equal",
type: "string",
value: `{{ ${makePropSafe("url")}.${makePropSafe("id")} }}`,
valueType: "Binding",
},
],
limit: 1, limit: 1,
paginate: false,
}) })
const repeater = new Component("@budibase/standard-components/repeater") const repeater = new Component("@budibase/standard-components/repeater")

View File

@ -80,6 +80,7 @@ const createScreen = table => {
tableId: table._id, tableId: table._id,
type: "table", type: "table",
}, },
paginate: false,
}) })
const spectrumTable = new Component("@budibase/standard-components/table") const spectrumTable = new Component("@budibase/standard-components/table")

View File

@ -40,13 +40,11 @@
if (wasSelectedTable._id === table._id) { if (wasSelectedTable._id === table._id) {
$goto("./table") $goto("./table")
} }
editorModal.hide()
} }
async function save() { async function save() {
await tables.save(table) await tables.save(table)
notifications.success("Table renamed successfully") notifications.success("Table renamed successfully")
editorModal.hide()
} }
function checkValid(evt) { function checkValid(evt) {

View File

@ -7,7 +7,7 @@
const inputChanged = ev => { const inputChanged = ev => {
try { try {
values = ev.target.value.split("\n") values = ev.detail.split("\n")
} catch (_) { } catch (_) {
values = [] values = []
} }

View File

@ -13,10 +13,11 @@
export let title = "Bindings" export let title = "Bindings"
export let placeholder export let placeholder
export let label export let label
export let disabled = false
const dispatch = createEventDispatcher() const dispatch = createEventDispatcher()
let bindingDrawer let bindingDrawer
$: tempValue = value $: tempValue = Array.isArray(value) ? value : []
$: readableValue = runtimeToReadableBinding(bindings, value) $: readableValue = runtimeToReadableBinding(bindings, value)
const handleClose = () => { const handleClose = () => {
@ -32,13 +33,16 @@
<div class="control"> <div class="control">
<Input <Input
{label} {label}
{disabled}
value={readableValue} value={readableValue}
on:change={event => onChange(event.detail)} on:change={event => onChange(event.detail)}
{placeholder} {placeholder}
/> />
<div class="icon" on:click={bindingDrawer.show}> {#if !disabled}
<Icon size="S" name="FlashOn" /> <div class="icon" on:click={bindingDrawer.show}>
</div> <Icon size="S" name="FlashOn" />
</div>
{/if}
</div> </div>
<Drawer bind:this={bindingDrawer} {title}> <Drawer bind:this={bindingDrawer} {title}>
<svelte:fragment slot="description"> <svelte:fragment slot="description">

View File

@ -4,7 +4,6 @@
"table", "table",
"repeater", "repeater",
"button", "button",
"search",
{ {
"name": "Form", "name": "Form",
"icon": "Form", "icon": "Form",

View File

@ -3,20 +3,34 @@
import { makePropSafe } from "@budibase/string-templates" import { makePropSafe } from "@budibase/string-templates"
import { currentAsset, store } from "builderStore" import { currentAsset, store } from "builderStore"
import { findComponentPath } from "builderStore/storeUtils" import { findComponentPath } from "builderStore/storeUtils"
import { createEventDispatcher, onMount } from "svelte"
export let value export let value
export let onChange
const dispatch = createEventDispatcher()
const getValue = component => `{{ literal ${makePropSafe(component._id)} }}`
$: path = findComponentPath($currentAsset.props, $store.selectedComponentId) $: path = findComponentPath($currentAsset.props, $store.selectedComponentId)
$: providers = path.filter( $: providers = path.filter(
component => component =>
component._component === "@budibase/standard-components/dataprovider" component._component === "@budibase/standard-components/dataprovider"
) )
// Set initial value to closest data provider
onMount(() => {
const valid = value && providers.find(x => getValue(x) === value) != null
if (!valid && providers.length) {
dispatch("change", getValue(providers[providers.length - 1]))
}
})
</script> </script>
<Select <Select
{value} {value}
placeholder={null}
on:change on:change
options={providers} options={providers}
getOptionLabel={component => component._instanceName} getOptionLabel={component => component._instanceName}
getOptionValue={component => `{{ literal ${makePropSafe(component._id)} }}`} getOptionValue={getValue}
/> />

View File

@ -80,7 +80,7 @@
/> />
{/each} {/each}
<div> <div>
<Button icon="AddCircle" size="S" cta on:click={addField}> <Button icon="AddCircle" secondary on:click={addField}>
Add Add
{fieldLabel} {fieldLabel}
</Button> </Button>

View File

@ -1,71 +0,0 @@
<script>
import { Button, Drawer, Body, DrawerContent, Layout } from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import { notifications } from "@budibase/bbui"
import {
getDatasourceForProvider,
getSchemaForDatasource,
} from "builderStore/dataBinding"
import SaveFields from "./EventsEditor/actions/SaveFields.svelte"
import { currentAsset } from "builderStore"
const dispatch = createEventDispatcher()
export let value = {}
export let componentInstance
let drawer
let tempValue = value
$: schemaFields = getSchemaFields(componentInstance)
const getSchemaFields = component => {
const datasource = getDatasourceForProvider($currentAsset, component)
const { schema } = getSchemaForDatasource(datasource)
return Object.values(schema || {})
}
const saveFilter = async () => {
dispatch("change", tempValue)
notifications.success("Filters saved.")
drawer.hide()
}
const onFieldsChanged = event => {
tempValue = event.detail
}
</script>
<Button secondary on:click={drawer.show}>Define Filters</Button>
<Drawer bind:this={drawer} title="Filtering">
<Button cta slot="buttons" on:click={saveFilter}>Save</Button>
<DrawerContent slot="body">
<Layout>
<Body size="S">
{#if !Object.keys(tempValue || {}).length}
Add your first filter column.
{:else}
Results are filtered to only those which match all of the following
constaints.
{/if}
</Body>
<div class="fields">
<SaveFields
parameterFields={value}
{schemaFields}
valueLabel="Equals"
on:change={onFieldsChanged}
/>
</div>
</Layout>
</DrawerContent>
</Drawer>
<style>
.fields {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
align-items: center;
grid-template-columns: auto 1fr auto 1fr auto;
}
</style>

View File

@ -0,0 +1,90 @@
<script>
import {
notifications,
Button,
Drawer,
Body,
DrawerContent,
Layout,
} from "@budibase/bbui"
import { createEventDispatcher } from "svelte"
import {
getDatasourceForProvider,
getSchemaForDatasource,
} from "builderStore/dataBinding"
import LuceneFilterBuilder from "./LuceneFilterBuilder.svelte"
import { currentAsset } from "builderStore"
import SaveFields from "../EventsEditor/actions/SaveFields.svelte"
const dispatch = createEventDispatcher()
export let value = []
export let componentInstance
let drawer
let tempValue = value
$: numFilters = Array.isArray(tempValue)
? tempValue.length
: Object.keys(tempValue || {}).length
$: dataSource = getDatasourceForProvider($currentAsset, componentInstance)
$: schema = getSchemaForDatasource(dataSource)?.schema
$: schemaFields = Object.values(schema || {})
$: internalTable = dataSource?.type === "table"
// Reset value if value is wrong type for the datasource.
// Lucene editor needs an array, and simple editor needs an object.
$: {
if (internalTable && !Array.isArray(value)) {
tempValue = []
dispatch("change", [])
} else if (!internalTable && Array.isArray(value)) {
tempValue = {}
dispatch("change", {})
}
}
const saveFilter = async () => {
dispatch("change", tempValue)
notifications.success("Filters saved.")
drawer.hide()
}
</script>
<Button secondary on:click={drawer.show}>Define Filters</Button>
<Drawer bind:this={drawer} title="Filtering">
<Button cta slot="buttons" on:click={saveFilter}>Save</Button>
<DrawerContent slot="body">
<Layout>
<Body size="S">
{#if !numFilters}
Add your first filter column.
{:else}
Results are filtered to only those which match all of the following
constaints.
{/if}
</Body>
{#if internalTable}
<LuceneFilterBuilder bind:value={tempValue} {schemaFields} />
{:else}
<div class="fields">
<SaveFields
parameterFields={Array.isArray(value) ? {} : value}
{schemaFields}
valueLabel="Equals"
on:change={e => (tempValue = e.detail)}
/>
</div>
{/if}
</Layout>
</DrawerContent>
</Drawer>
<style>
.fields {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
align-items: center;
grid-template-columns: auto 1fr auto 1fr auto;
}
</style>

View File

@ -0,0 +1,237 @@
<script>
import {
DatePicker,
ActionButton,
Button,
Select,
Combobox,
Input,
} from "@budibase/bbui"
import { store, currentAsset } from "builderStore"
import { getBindableProperties } from "builderStore/dataBinding"
import { createEventDispatcher } from "svelte"
import DrawerBindableInput from "components/common/bindings/DrawerBindableInput.svelte"
import { generate } from "shortid"
const dispatch = createEventDispatcher()
export let schemaFields
export let value
const OperatorOptions = {
Equals: {
value: "equal",
label: "Equals",
},
NotEquals: {
value: "notEqual",
label: "Not equals",
},
Empty: {
value: "empty",
label: "Is empty",
},
NotEmpty: {
value: "notEmpty",
label: "Is not empty",
},
StartsWith: {
value: "string",
label: "Starts with",
},
Like: {
value: "fuzzy",
label: "Like",
},
MoreThan: {
value: "rangeLow",
label: "More than",
},
LessThan: {
value: "rangeHigh",
label: "Less than",
},
}
const BannedTypes = ["link", "attachment"]
$: bindableProperties = getBindableProperties(
$currentAsset,
$store.selectedComponentId
)
$: fieldOptions = (schemaFields ?? [])
.filter(field => !BannedTypes.includes(field.type))
.map(field => field.name)
const addField = () => {
value = [
...value,
{
id: generate(),
field: null,
operator: OperatorOptions.Equals.value,
value: null,
valueType: "Value",
},
]
}
const removeField = id => {
value = value.filter(field => field.id !== id)
}
const getValidOperatorsForType = type => {
const Op = OperatorOptions
if (type === "string") {
return [
Op.Equals,
Op.NotEquals,
Op.StartsWith,
Op.Like,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "number") {
return [
Op.Equals,
Op.NotEquals,
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "options") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "boolean") {
return [Op.Equals, Op.NotEquals, Op.Empty, Op.NotEmpty]
} else if (type === "longform") {
return [
Op.Equals,
Op.NotEquals,
Op.StartsWith,
Op.Like,
Op.Empty,
Op.NotEmpty,
]
} else if (type === "datetime") {
return [
Op.Equals,
Op.NotEquals,
Op.MoreThan,
Op.LessThan,
Op.Empty,
Op.NotEmpty,
]
}
return []
}
const onFieldChange = (expression, field) => {
// Update the field type
expression.type = schemaFields.find(x => x.name === field)?.type
// Ensure a valid operator is set
const validOperators = getValidOperatorsForType(expression.type)
if (!validOperators.includes(expression.operator)) {
expression.operator =
validOperators[0]?.value ?? OperatorOptions.Equals.value
onOperatorChange(expression, expression.operator)
}
}
const onOperatorChange = (expression, operator) => {
const noValueOptions = [
OperatorOptions.Empty.value,
OperatorOptions.NotEmpty.value,
]
expression.noValue = noValueOptions.includes(operator)
if (expression.noValue) {
expression.value = null
}
}
const getFieldOptions = field => {
const schema = schemaFields.find(x => x.name === field)
return schema?.constraints?.inclusion || []
}
</script>
{#if value?.length}
<div class="fields">
{#each value as expression, idx}
<Select
bind:value={expression.field}
options={fieldOptions}
on:change={e => onFieldChange(expression, e.detail)}
placeholder="Column"
/>
<Select
disabled={!expression.field}
options={getValidOperatorsForType(expression.type)}
bind:value={expression.operator}
on:change={e => onOperatorChange(expression, e.detail)}
placeholder={null}
/>
<Select
disabled={expression.noValue || !expression.field}
options={["Value", "Binding"]}
bind:value={expression.valueType}
placeholder={null}
/>
{#if expression.valueType === "Binding"}
<DrawerBindableInput
disabled={expression.noValue}
title={`Value for "${expression.field}"`}
value={expression.value}
placeholder="Value"
bindings={bindableProperties}
on:change={event => (expression.value = event.detail)}
/>
{:else if ["string", "longform", "number"].includes(expression.type)}
<Input disabled={expression.noValue} bind:value={expression.value} />
{:else if expression.type === "options"}
<Combobox
disabled={expression.noValue}
options={getFieldOptions(expression.field)}
bind:value={expression.value}
/>
{:else if expression.type === "boolean"}
<Combobox
disabled
options={[
{ label: "True", value: true },
{ label: "False", value: false },
]}
bind:value={expression.value}
/>
{:else if expression.type === "datetime"}
<DatePicker
disabled={expression.noValue}
bind:value={expression.value}
/>
{:else}
<DrawerBindableInput disabled />
{/if}
<ActionButton
size="S"
quiet
icon="Close"
on:click={() => removeField(expression.id)}
/>
{/each}
</div>
{/if}
<div>
<Button icon="AddCircle" size="M" secondary on:click={addField}>
Add expression
</Button>
</div>
<style>
.fields {
display: grid;
column-gap: var(--spacing-l);
row-gap: var(--spacing-s);
align-items: center;
grid-template-columns: 1fr 120px 120px 1fr auto;
}
</style>

View File

@ -16,7 +16,7 @@
import MultiFieldSelect from "./PropertyControls/MultiFieldSelect.svelte" import MultiFieldSelect from "./PropertyControls/MultiFieldSelect.svelte"
import SchemaSelect from "./PropertyControls/SchemaSelect.svelte" import SchemaSelect from "./PropertyControls/SchemaSelect.svelte"
import EventsEditor from "./PropertyControls/EventsEditor" import EventsEditor from "./PropertyControls/EventsEditor"
import FilterEditor from "./PropertyControls/FilterEditor.svelte" import FilterEditor from "./PropertyControls/FilterEditor/FilterEditor.svelte"
import { IconSelect } from "./PropertyControls/IconSelect" import { IconSelect } from "./PropertyControls/IconSelect"
import ColorPicker from "./PropertyControls/ColorPicker.svelte" import ColorPicker from "./PropertyControls/ColorPicker.svelte"
import StringFieldSelect from "./PropertyControls/StringFieldSelect.svelte" import StringFieldSelect from "./PropertyControls/StringFieldSelect.svelte"
@ -156,8 +156,11 @@
{/if} {/if}
{/each} {/each}
{:else} {:else}
<div class="empty"> <div class="text">This component doesn't have any additional settings.</div>
This component doesn't have any additional settings. {/if}
{#if componentDefinition?.info}
<div class="text">
{@html componentDefinition?.info}
</div> </div>
{/if} {/if}
@ -185,7 +188,7 @@
height: 100%; height: 100%;
gap: var(--spacing-s); gap: var(--spacing-s);
} }
.empty { .text {
font-size: var(--spectrum-global-dimension-font-size-75); font-size: var(--spectrum-global-dimension-font-size-75);
margin-top: var(--spacing-m); margin-top: var(--spacing-m);
color: var(--grey-6); color: var(--grey-6);

View File

@ -6,10 +6,6 @@ html, body {
min-height: 100%; min-height: 100%;
} }
.spectrum--light {
--spectrum-alias-background-color-primary: var(--spectrum-global-color-gray-75);
}
body { body {
--background: var(--spectrum-alias-background-color-primary); --background: var(--spectrum-alias-background-color-primary);
--background-alt: var(--spectrum-alias-background-color-secondary); --background-alt: var(--spectrum-alias-background-color-secondary);

View File

@ -18,19 +18,37 @@ export const fetchTableData = async tableId => {
} }
/** /**
* Perform a mango query against an internal table * Searches a table using Lucene.
* @param {String} tableId - id of the table to search
* @param {Object} search - Mango Compliant search object
* @param {Object} pagination - the pagination controls
*/ */
export const searchTableData = async ({ tableId, search, pagination }) => { export const searchTable = async ({
const output = await API.post({ tableId,
url: `/api/${tableId}/rows/search`, query,
bookmark,
limit,
sort,
sortOrder,
sortType,
paginate,
}) => {
if (!tableId || !query) {
return {
rows: [],
}
}
const res = await API.post({
url: `/api/search/${tableId}/rows`,
body: { body: {
query: search, query,
pagination, bookmark,
limit,
sort,
sortOrder,
sortType,
paginate,
}, },
}) })
output.rows = await enrichRows(output.rows, tableId) return {
return output ...res,
rows: await enrichRows(res?.rows, tableId),
}
} }

View File

@ -39,8 +39,18 @@
</script> </script>
{#if loaded && $screenStore.activeLayout} {#if loaded && $screenStore.activeLayout}
<Provider key="user" data={$authStore} {actions}> <div lang="en" dir="ltr" class="spectrum spectrum--medium spectrum--light">
<Component definition={$screenStore.activeLayout.props} /> <Provider key="user" data={$authStore} {actions}>
<NotificationDisplay /> <Component definition={$screenStore.activeLayout.props} />
</Provider> <NotificationDisplay />
</Provider>
</div>
{/if} {/if}
<style>
div {
background: transparent;
height: 100%;
position: relative;
}
</style>

View File

@ -16,7 +16,6 @@ const {
const { FieldTypes } = require("../../constants") const { FieldTypes } = require("../../constants")
const { isEqual } = require("lodash") const { isEqual } = require("lodash")
const { cloneDeep } = require("lodash/fp") const { cloneDeep } = require("lodash/fp")
const { QueryBuilder, search } = require("./search/utils")
const TABLE_VIEW_BEGINS_WITH = `all${SEPARATOR}${DocumentTypes.TABLE}${SEPARATOR}` const TABLE_VIEW_BEGINS_WITH = `all${SEPARATOR}${DocumentTypes.TABLE}${SEPARATOR}`
@ -248,45 +247,6 @@ exports.fetchView = async function (ctx) {
} }
} }
exports.search = async function (ctx) {
const appId = ctx.appId
const db = new CouchDB(appId)
const {
query,
pagination: { pageSize = 10, bookmark },
} = ctx.request.body
const tableId = ctx.params.tableId
const queryBuilder = new QueryBuilder(appId)
.setLimit(pageSize)
.addTable(tableId)
if (bookmark) {
queryBuilder.setBookmark(bookmark)
}
let searchString
if (ctx.query && ctx.query.raw && ctx.query.raw !== "") {
searchString = queryBuilder.complete(query["RAW"])
} else {
// make all strings a starts with operation rather than pure equality
for (const [key, queryVal] of Object.entries(query)) {
if (typeof queryVal === "string") {
queryBuilder.addString(key, queryVal)
} else {
queryBuilder.addEqual(key, queryVal)
}
}
searchString = queryBuilder.complete()
}
const response = await search(searchString)
const table = await db.get(tableId)
ctx.body = {
rows: await outputProcessing(appId, table, response.rows),
bookmark: response.bookmark,
}
}
exports.fetchTableRows = async function (ctx) { exports.fetchTableRows = async function (ctx) {
const appId = ctx.appId const appId = ctx.appId
const db = new CouchDB(appId) const db = new CouchDB(appId)

View File

@ -1,18 +1,26 @@
const { QueryBuilder, buildSearchUrl, search } = require("./utils") const { fullSearch, paginatedSearch } = require("./utils")
const CouchDB = require("../../../db")
const { outputProcessing } = require("../../../utilities/rowProcessor")
exports.rowSearch = async ctx => { exports.rowSearch = async ctx => {
const appId = ctx.appId const appId = ctx.appId
const { tableId } = ctx.params const { tableId } = ctx.params
const { bookmark, query, raw } = ctx.request.body const db = new CouchDB(appId)
let url const { paginate, query, ...params } = ctx.request.body
if (query) { params.tableId = tableId
url = new QueryBuilder(appId, query, bookmark).addTable(tableId).complete()
} else if (raw) { let response
url = buildSearchUrl({ if (paginate) {
appId, response = await paginatedSearch(appId, query, params)
query: raw, } else {
bookmark, response = await fullSearch(appId, query, params)
})
} }
ctx.body = await search(url)
// Enrich search results with relationships
if (response.rows && response.rows.length) {
const table = await db.get(tableId)
response.rows = await outputProcessing(appId, table, response.rows)
}
ctx.body = response
} }

View File

@ -4,28 +4,19 @@ const env = require("../../../environment")
const fetch = require("node-fetch") const fetch = require("node-fetch")
/** /**
* Given a set of inputs this will generate the URL which is to be sent to the search proxy in CouchDB. * Escapes any characters in a string which lucene searches require to be
* @param {string} appId The ID of the app which we will be searching within. * escaped.
* @param {string} query The lucene query string which is to be used for searching. * @param value The value to escape
* @param {string|null} bookmark If there were more than the limit specified can send the bookmark that was * @returns {string}
* returned with query for next set of search results.
* @param {number} limit The number of entries to return per query.
* @param {boolean} excludeDocs By default full rows are returned, if required this can be disabled.
* @return {string} The URL which a GET can be performed on to receive results.
*/ */
function buildSearchUrl({ appId, query, bookmark, excludeDocs, limit = 50 }) { const luceneEscape = value => {
let url = `${env.COUCH_DB_URL}/${appId}/_design/database/_search` return `${value}`.replace(/[ #+\-&|!(){}\[\]^"~*?:\\]/g, "\\$&")
url += `/${SearchIndexes.ROWS}?q=${query}`
url += `&limit=${limit}`
if (!excludeDocs) {
url += "&include_docs=true"
}
if (bookmark) {
url += `&bookmark=${bookmark}`
}
return checkSlashesInUrl(url)
} }
/**
* Class to build lucene query URLs.
* Optionally takes a base lucene query object.
*/
class QueryBuilder { class QueryBuilder {
constructor(appId, base) { constructor(appId, base) {
this.appId = appId this.appId = appId
@ -34,10 +25,20 @@ class QueryBuilder {
fuzzy: {}, fuzzy: {},
range: {}, range: {},
equal: {}, equal: {},
notEqual: {},
empty: {},
notEmpty: {},
...base, ...base,
} }
this.limit = 50 this.limit = 50
this.bookmark = null this.sortOrder = "ascending"
this.sortType = "string"
this.includeDocs = true
}
setTable(tableId) {
this.query.equal.tableId = tableId
return this
} }
setLimit(limit) { setLimit(limit) {
@ -45,11 +46,31 @@ class QueryBuilder {
return this return this
} }
setSort(sort) {
this.sort = sort
return this
}
setSortOrder(sortOrder) {
this.sortOrder = sortOrder
return this
}
setSortType(sortType) {
this.sortType = sortType
return this
}
setBookmark(bookmark) { setBookmark(bookmark) {
this.bookmark = bookmark this.bookmark = bookmark
return this return this
} }
excludeDocs() {
this.includeDocs = false
return this
}
addString(key, partial) { addString(key, partial) {
this.query.string[key] = partial this.query.string[key] = partial
return this return this
@ -73,52 +94,113 @@ class QueryBuilder {
return this return this
} }
addTable(tableId) { addNotEqual(key, value) {
this.query.equal.tableId = tableId this.query.notEqual[key] = value
return this return this
} }
complete(rawQuery = null) { addEmpty(key, value) {
let output = "" this.query.empty[key] = value
return this
}
addNotEmpty(key, value) {
this.query.notEmpty[key] = value
return this
}
buildSearchQuery() {
let query = "*:*"
function build(structure, queryFn) { function build(structure, queryFn) {
for (let [key, value] of Object.entries(structure)) { for (let [key, value] of Object.entries(structure)) {
if (output.length !== 0) { const expression = queryFn(luceneEscape(key.replace(/ /, "_")), value)
output += " AND " if (expression == null) {
continue
} }
output += queryFn(key, value) query += ` AND ${expression}`
} }
} }
// Construct the actual lucene search query string from JSON structure
if (this.query.string) { if (this.query.string) {
build(this.query.string, (key, value) => `${key}:${value}*`) build(this.query.string, (key, value) => {
return value ? `${key}:${luceneEscape(value.toLowerCase())}*` : null
})
} }
if (this.query.range) { if (this.query.range) {
build( build(this.query.range, (key, value) => {
this.query.range, if (!value) {
(key, value) => `${key}:[${value.low} TO ${value.high}]` return null
) }
if (value.low == null || value.low === "") {
return null
}
if (value.high == null || value.high === "") {
return null
}
return `${key}:[${value.low} TO ${value.high}]`
})
} }
if (this.query.fuzzy) { if (this.query.fuzzy) {
build(this.query.fuzzy, (key, value) => `${key}:${value}~`) build(this.query.fuzzy, (key, value) => {
return value ? `${key}:${luceneEscape(value.toLowerCase())}~` : null
})
} }
if (this.query.equal) { if (this.query.equal) {
build(this.query.equal, (key, value) => `${key}:${value}`) build(this.query.equal, (key, value) => {
return value ? `${key}:${luceneEscape(value.toLowerCase())}` : null
})
} }
if (rawQuery) { if (this.query.notEqual) {
output = output.length === 0 ? rawQuery : `&${rawQuery}` build(this.query.notEqual, (key, value) => {
return value ? `!${key}:${luceneEscape(value.toLowerCase())}` : null
})
} }
return buildSearchUrl({ if (this.query.empty) {
appId: this.appId, build(this.query.empty, key => `!${key}:["" TO *]`)
query: output, }
bookmark: this.bookmark, if (this.query.notEmpty) {
limit: this.limit, build(this.query.notEmpty, key => `${key}:["" TO *]`)
}) }
return query
}
buildSearchBody() {
let body = {
q: this.buildSearchQuery(),
limit: Math.min(this.limit, 200),
include_docs: this.includeDocs,
}
if (this.bookmark) {
body.bookmark = this.bookmark
}
if (this.sort) {
const order = this.sortOrder === "descending" ? "-" : ""
const type = `<${this.sortType}>`
body.sort = `${order}${this.sort.replace(/ /, "_")}${type}`
}
return body
}
async run() {
const url = `${env.COUCH_DB_URL}/${this.appId}/_design/database/_search/${SearchIndexes.ROWS}`
const body = this.buildSearchBody()
return await runQuery(url, body)
} }
} }
exports.search = async query => { /**
const response = await fetch(query, { * Executes a lucene search query.
method: "GET", * @param url The query URL
* @param body The request body defining search criteria
* @returns {Promise<{rows: []}>}
*/
const runQuery = async (url, body) => {
const response = await fetch(url, {
body: JSON.stringify(body),
method: "POST",
}) })
const json = await response.json() const json = await response.json()
let output = { let output = {
@ -133,5 +215,122 @@ exports.search = async query => {
return output return output
} }
exports.QueryBuilder = QueryBuilder /**
exports.buildSearchUrl = buildSearchUrl * Gets round the fixed limit of 200 results from a query by fetching as many
* pages as required and concatenating the results. This recursively operates
* until enough results have been found.
* @param appId {string} The app ID to search
* @param query {object} The JSON query structure
* @param params {object} The search params including:
* tableId {string} The table ID to search
* sort {string} The sort column
* sortOrder {string} The sort order ("ascending" or "descending")
* sortType {string} Whether to treat sortable values as strings or
* numbers. ("string" or "number")
* limit {number} The number of results to fetch
* bookmark {string|null} Current bookmark in the recursive search
* rows {array|null} Current results in the recursive search
* @returns {Promise<*[]|*>}
*/
const recursiveSearch = async (appId, query, params) => {
const bookmark = params.bookmark
const rows = params.rows || []
if (rows.length >= params.limit) {
return rows
}
let pageSize = 200
if (rows.length > params.limit - 200) {
pageSize = params.limit - rows.length
}
const page = await new QueryBuilder(appId, query)
.setTable(params.tableId)
.setBookmark(bookmark)
.setLimit(pageSize)
.setSort(params.sort)
.setSortOrder(params.sortOrder)
.setSortType(params.sortType)
.run()
if (!page.rows.length) {
return rows
}
if (page.rows.length < 200) {
return [...rows, ...page.rows]
}
const newParams = {
...params,
bookmark: page.bookmark,
rows: [...rows, ...page.rows],
}
return await recursiveSearch(appId, query, newParams)
}
/**
* Performs a paginated search. A bookmark will be returned to allow the next
* page to be fetched. There is a max limit off 200 results per page in a
* paginated search.
* @param appId {string} The app ID to search
* @param query {object} The JSON query structure
* @param params {object} The search params including:
* tableId {string} The table ID to search
* sort {string} The sort column
* sortOrder {string} The sort order ("ascending" or "descending")
* sortType {string} Whether to treat sortable values as strings or
* numbers. ("string" or "number")
* limit {number} The desired page size
* bookmark {string} The bookmark to resume from
* @returns {Promise<{hasNextPage: boolean, rows: *[]}>}
*/
exports.paginatedSearch = async (appId, query, params) => {
let limit = params.limit
if (limit == null || isNaN(limit) || limit < 0) {
limit = 50
}
limit = Math.min(limit, 200)
const search = new QueryBuilder(appId, query)
.setTable(params.tableId)
.setSort(params.sort)
.setSortOrder(params.sortOrder)
.setSortType(params.sortType)
const searchResults = await search
.setBookmark(params.bookmark)
.setLimit(limit)
.run()
// Try fetching 1 row in the next page to see if another page of results
// exists or not
const nextResults = await search
.setBookmark(searchResults.bookmark)
.setLimit(1)
.run()
return {
...searchResults,
hasNextPage: nextResults.rows && nextResults.rows.length > 0,
}
}
/**
* Performs a full search, fetching multiple pages if required to return the
* desired amount of results. There is a limit of 1000 results to avoid
* heavy performance hits, and to avoid client components breaking from
* handling too much data.
* @param appId {string} The app ID to search
* @param query {object} The JSON query structure
* @param params {object} The search params including:
* tableId {string} The table ID to search
* sort {string} The sort column
* sortOrder {string} The sort order ("ascending" or "descending")
* sortType {string} Whether to treat sortable values as strings or
* numbers. ("string" or "number")
* limit {number} The desired number of results
* @returns {Promise<{rows: *}>}
*/
exports.fullSearch = async (appId, query, params) => {
let limit = params.limit
if (limit == null || isNaN(limit) || limit < 0) {
limit = 1000
}
params.limit = Math.min(limit, 1000)
const rows = await recursiveSearch(appId, query, params)
return { rows }
}

View File

@ -23,6 +23,7 @@ const queryRoutes = require("./query")
const hostingRoutes = require("./hosting") const hostingRoutes = require("./hosting")
const backupRoutes = require("./backup") const backupRoutes = require("./backup")
const devRoutes = require("./dev") const devRoutes = require("./dev")
const searchRoutes = require("./search")
exports.mainRoutes = [ exports.mainRoutes = [
authRoutes, authRoutes,
@ -51,6 +52,7 @@ exports.mainRoutes = [
// this could be breaking as koa may recognise other routes as this // this could be breaking as koa may recognise other routes as this
tableRoutes, tableRoutes,
rowRoutes, rowRoutes,
searchRoutes,
] ]
exports.staticRoutes = staticRoutes exports.staticRoutes = staticRoutes

View File

@ -39,12 +39,6 @@ router
usage, usage,
rowController.save rowController.save
) )
.post(
"/api/:tableId/rows/search",
paramResource("tableId"),
authorized(PermissionTypes.TABLE, PermissionLevels.READ),
rowController.search
)
.patch( .patch(
"/api/:tableId/rows/:rowId", "/api/:tableId/rows/:rowId",
paramSubResource("tableId", "rowId"), paramSubResource("tableId", "rowId"),

View File

@ -84,6 +84,7 @@ async function searchIndex(appId, indexName, fnString) {
designDoc.indexes = { designDoc.indexes = {
[indexName]: { [indexName]: {
index: fnString, index: fnString,
analyzer: "keyword",
}, },
} }
await db.put(designDoc) await db.put(designDoc)
@ -96,11 +97,15 @@ exports.createAllSearchIndex = async appId => {
function (doc) { function (doc) {
function idx(input, prev) { function idx(input, prev) {
for (let key of Object.keys(input)) { for (let key of Object.keys(input)) {
const idxKey = prev != null ? `${prev}.${key}` : key let idxKey = prev != null ? `${prev}.${key}` : key
if (key === "_id" || key === "_rev") { idxKey = idxKey.replace(/ /, "_")
if (key === "_id" || key === "_rev" || input[key] == null) {
continue continue
} }
if (typeof input[key] !== "object") { if (typeof input[key] === "string") {
// eslint-disable-next-line no-undef
index(idxKey, input[key].toLowerCase(), { store: true })
} else if (typeof input[key] !== "object") {
// eslint-disable-next-line no-undef // eslint-disable-next-line no-undef
index(idxKey, input[key], { store: true }) index(idxKey, input[key], { store: true })
} else { } else {

View File

@ -123,24 +123,6 @@ function processAutoColumn(user, table, row) {
return { table, row } return { table, row }
} }
/**
* Given a set of rows and the table they came from this function will sort by auto ID or a custom
* method if provided (not implemented yet).
*/
function sortRows(table, rows) {
// sort based on auto ID (if found)
let autoIDColumn = Object.entries(table.schema).find(
schema => schema[1].subtype === AutoFieldSubTypes.AUTO_ID
)
// get the column name, this is the first element in the array (Object.entries)
autoIDColumn = autoIDColumn && autoIDColumn.length ? autoIDColumn[0] : null
if (autoIDColumn) {
// sort in ascending order
rows.sort((a, b) => a[autoIDColumn] - b[autoIDColumn])
}
return rows
}
/** /**
* Looks through the rows provided and finds formulas - which it then processes. * Looks through the rows provided and finds formulas - which it then processes.
*/ */
@ -213,8 +195,6 @@ exports.outputProcessing = async (appId, table, rows) => {
rows = [rows] rows = [rows]
wasArray = false wasArray = false
} }
// sort by auto ID
rows = sortRows(table, rows)
// attach any linked row information // attach any linked row information
let enriched = await linkRows.attachFullLinkedDocs(appId, table, rows) let enriched = await linkRows.attachFullLinkedDocs(appId, table, rows)

View File

@ -65,41 +65,6 @@
"type": "schema" "type": "schema"
} }
}, },
"search": {
"name": "Search",
"description": "A searchable list of items.",
"icon": "Search",
"styleable": true,
"hasChildren": true,
"settings": [
{
"type": "table",
"label": "Table",
"key": "table"
},
{
"type": "multifield",
"label": "Columns",
"key": "columns",
"dependsOn": "table"
},
{
"type": "number",
"label": "Rows/Page",
"defaultValue": 25,
"key": "pageSize"
},
{
"type": "text",
"label": "Empty Text",
"key": "noRowsMessage",
"defaultValue": "No rows found."
}
],
"context": {
"type": "schema"
}
},
"stackedlist": { "stackedlist": {
"name": "Stacked List", "name": "Stacked List",
"icon": "TaskList", "icon": "TaskList",
@ -1416,6 +1381,7 @@
}, },
"dataprovider": { "dataprovider": {
"name": "Data Provider", "name": "Data Provider",
"info": "Pagination is only available for data stored in internal tables.",
"icon": "Data", "icon": "Data",
"styleable": false, "styleable": false,
"hasChildren": true, "hasChildren": true,
@ -1445,7 +1411,14 @@
{ {
"type": "number", "type": "number",
"label": "Limit", "label": "Limit",
"key": "limit" "key": "limit",
"defaultValue": 50
},
{
"type": "boolean",
"label": "Paginate",
"key": "paginate",
"defaultValue": true
} }
], ],
"context": { "context": {
@ -1464,12 +1437,8 @@
"key": "schema" "key": "schema"
}, },
{ {
"label": "Loading", "label": "Page Number",
"key": "loading" "key": "pageNumber"
},
{
"label": "Loaded",
"key": "loaded"
} }
] ]
} }

View File

@ -1,11 +1,13 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { ProgressCircle, Pagination } from "@budibase/bbui"
export let dataSource export let dataSource
export let filter export let filter
export let sortColumn export let sortColumn
export let sortOrder export let sortOrder
export let limit export let limit
export let paginate
const { API, styleable, Provider, ActionTypes } = getContext("sdk") const { API, styleable, Provider, ActionTypes } = getContext("sdk")
const component = getContext("component") const component = getContext("component")
@ -15,15 +17,45 @@
// Loading flag for the initial load // Loading flag for the initial load
let loaded = false let loaded = false
let schemaLoaded = false
// Provider state
let rows = []
let allRows = [] let allRows = []
let schema = {} let schema = {}
let bookmarks = [null]
let pageNumber = 0
$: fetchData(dataSource) $: internalTable = dataSource?.type === "table"
$: filteredRows = filterRows(allRows, filter) $: query = internalTable ? buildLuceneQuery(filter) : null
$: sortedRows = sortRows(filteredRows, sortColumn, sortOrder) $: hasNextPage = bookmarks[pageNumber + 1] != null
$: rows = limitRows(sortedRows, limit) $: hasPrevPage = pageNumber > 0
$: getSchema(dataSource) $: getSchema(dataSource)
$: sortType = getSortType(schema, sortColumn)
$: {
// Wait until schema loads before loading data, so that we can determine
// the correct sort type first time
if (schemaLoaded) {
fetchData(
dataSource,
query,
limit,
sortColumn,
sortOrder,
sortType,
paginate
)
}
}
$: {
// Sort and limit rows in memory when we aren't searching internal tables
if (internalTable) {
rows = allRows
} else {
const sortedRows = sortRows(allRows, sortColumn, sortOrder)
rows = limitRows(sortedRows, limit)
}
}
$: actions = [ $: actions = [
{ {
type: ActionTypes.RefreshDatasource, type: ActionTypes.RefreshDatasource,
@ -31,27 +63,86 @@
metadata: { dataSource }, metadata: { dataSource },
}, },
] ]
$: dataContext = { $: dataContext = { rows, schema, rowsLength: rows.length }
rows,
schema, const getSortType = (schema, sortColumn) => {
rowsLength: rows.length, if (!schema || !sortColumn || !schema[sortColumn]) {
loading, return "string"
loaded, }
const type = schema?.[sortColumn]?.type
return type === "number" ? "number" : "string"
} }
const fetchData = async dataSource => { const buildLuceneQuery = filter => {
let query = {
string: {},
fuzzy: {},
range: {},
equal: {},
notEqual: {},
empty: {},
notEmpty: {},
}
if (Array.isArray(filter)) {
filter.forEach(({ operator, field, type, value }) => {
if (operator.startsWith("range")) {
if (!query.range[field]) {
query.range[field] = {
low: type === "number" ? Number.MIN_SAFE_INTEGER : "0000",
high: type === "number" ? Number.MAX_SAFE_INTEGER : "9999",
}
}
if (operator === "rangeLow") {
query.range[field].low = value
} else if (operator === "rangeHigh") {
query.range[field].high = value
}
} else if (query[operator]) {
query[operator][field] = value
}
})
}
return query
}
const fetchData = async (
dataSource,
query,
limit,
sortColumn,
sortOrder,
sortType,
paginate
) => {
loading = true loading = true
allRows = await API.fetchDatasource(dataSource) if (dataSource?.type === "table") {
const res = await API.searchTable({
tableId: dataSource.tableId,
query,
limit,
sort: sortColumn,
sortOrder: sortOrder?.toLowerCase() ?? "ascending",
sortType,
paginate,
})
pageNumber = 0
allRows = res.rows
if (res.hasNextPage) {
bookmarks = [null, res.bookmark]
} else {
bookmarks = [null]
}
} else {
const rows = await API.fetchDatasource(dataSource)
allRows = inMemoryFilterRows(rows, filter)
}
loading = false loading = false
loaded = true loaded = true
} }
const filterRows = (rows, filter) => { const inMemoryFilterRows = (rows, filter) => {
if (!Object.keys(filter || {}).length) {
return rows
}
let filteredData = [...rows] let filteredData = [...rows]
Object.entries(filter).forEach(([field, value]) => { Object.entries(filter || {}).forEach(([field, value]) => {
if (value != null && value !== "") { if (value != null && value !== "") {
filteredData = filteredData.filter(row => { filteredData = filteredData.filter(row => {
return row[field] === value return row[field] === value
@ -111,11 +202,91 @@
} }
}) })
schema = fixedSchema schema = fixedSchema
schemaLoaded = true
}
const nextPage = async () => {
if (!hasNextPage || !internalTable) {
return
}
const res = await API.searchTable({
tableId: dataSource?.tableId,
query,
bookmark: bookmarks[pageNumber + 1],
limit,
sort: sortColumn,
sortOrder: sortOrder?.toLowerCase() ?? "ascending",
sortType,
paginate: true,
})
pageNumber++
allRows = res.rows
if (res.hasNextPage) {
bookmarks[pageNumber + 1] = res.bookmark
}
}
const prevPage = async () => {
if (!hasPrevPage || !internalTable) {
return
}
const res = await API.searchTable({
tableId: dataSource?.tableId,
query,
bookmark: bookmarks[pageNumber - 1],
limit,
sort: sortColumn,
sortOrder: sortOrder?.toLowerCase() ?? "ascending",
sortType,
paginate: true,
})
pageNumber--
allRows = res.rows
} }
</script> </script>
<div use:styleable={$component.styles}> <div use:styleable={$component.styles} class="container">
<Provider {actions} data={dataContext}> <Provider {actions} data={dataContext}>
<slot /> {#if !loaded}
<div class="loading">
<ProgressCircle />
</div>
{:else}
<slot />
{#if paginate && internalTable}
<div class="pagination">
<Pagination
page={pageNumber + 1}
{hasPrevPage}
{hasNextPage}
goToPrevPage={prevPage}
goToNextPage={nextPage}
/>
</div>
{/if}
{/if}
</Provider> </Provider>
</div> </div>
<style>
.container {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
}
.loading {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
height: 100px;
}
.pagination {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
margin-top: var(--spacing-xl);
}
</style>

View File

@ -1,195 +0,0 @@
<script>
import { getContext } from "svelte"
import {
Button,
DatePicker,
Label,
Select,
Toggle,
Input,
} from "@budibase/bbui"
const { API, styleable, Provider, builderStore, ActionTypes } = getContext(
"sdk"
)
const component = getContext("component")
export let table
export let columns = []
export let pageSize
export let noRowsMessage
let rows = []
let loaded = false
let search = {}
let tableDefinition
let schema
let nextBookmark = null
let bookmark = null
let lastBookmark = null
$: fetchData(table, bookmark)
// omit empty strings
$: parsedSearch = Object.keys(search).reduce(
(acc, next) =>
search[next] === "" ? acc : { ...acc, [next]: search[next] },
{}
)
$: actions = [
{
type: ActionTypes.RefreshDatasource,
callback: () => fetchData(table, bookmark),
metadata: { datasource: { type: "table", tableId: table } },
},
]
async function fetchData(table, mark) {
if (table) {
const tableDef = await API.fetchTableDefinition(table)
schema = tableDef.schema
const output = await API.searchTableData({
tableId: table,
search: parsedSearch,
pagination: {
pageSize,
bookmark: mark,
},
})
rows = output.rows
nextBookmark = output.bookmark
}
loaded = true
}
function nextPage() {
lastBookmark = bookmark
bookmark = nextBookmark
}
function previousPage() {
nextBookmark = bookmark
if (lastBookmark !== bookmark) {
bookmark = lastBookmark
} else {
// special case for going back to beginning
bookmark = null
lastBookmark = null
}
}
</script>
<Provider {actions}>
<div use:styleable={$component.styles}>
<div class="query-builder">
{#if schema}
{#each columns as field}
<div class="form-field">
<Label extraSmall grey>{schema[field].name}</Label>
{#if schema[field].type === "options"}
<Select secondary bind:value={search[field]}>
<option value="">Choose an option</option>
{#each schema[field].constraints.inclusion as opt}
<option>{opt}</option>
{/each}
</Select>
{:else if schema[field].type === "datetime"}
<DatePicker bind:value={search[field]} />
{:else if schema[field].type === "boolean"}
<Toggle text={schema[field].name} bind:checked={search[field]} />
{:else if schema[field].type === "number"}
<Input type="number" bind:value={search[field]} />
{:else if schema[field].type === "string"}
<Input bind:value={search[field]} />
{/if}
</div>
{/each}
{/if}
<div class="actions">
<Button
secondary
on:click={() => {
search = {}
bookmark = null
}}
>
Reset
</Button>
<Button
primary
on:click={() => {
bookmark = null
fetchData(table, bookmark)
}}
>
Search
</Button>
</div>
</div>
{#if loaded}
{#if rows.length > 0}
{#if $component.children === 0 && $builderStore.inBuilder}
<p><i class="ri-image-line" />Add some components to display.</p>
{:else}
{#each rows as row}
<Provider data={row}>
<slot />
</Provider>
{/each}
{/if}
{:else if noRowsMessage}
<p><i class="ri-search-2-line" />{noRowsMessage}</p>
{/if}
{/if}
<div class="pagination">
{#if lastBookmark != null || bookmark != null}
<Button primary on:click={previousPage}>Back</Button>
{/if}
{#if nextBookmark != null && rows.length !== 0}
<Button primary on:click={nextPage}>Next</Button>
{/if}
</div>
</div>
</Provider>
<style>
p {
margin: 0 var(--spacing-m);
background-color: var(--grey-2);
color: var(--grey-6);
font-size: var(--font-size-s);
padding: var(--spacing-l);
border-radius: var(--border-radius-s);
display: grid;
place-items: center;
}
p i {
margin-bottom: var(--spacing-m);
font-size: 1.5rem;
color: var(--grey-5);
}
.query-builder {
padding: var(--spacing-m);
border-radius: var(--border-radius-s);
}
.actions {
display: grid;
grid-gap: var(--spacing-s);
justify-content: flex-end;
grid-auto-flow: column;
}
.form-field {
margin-bottom: var(--spacing-m);
}
.pagination {
display: grid;
grid-gap: var(--spacing-s);
justify-content: flex-end;
margin-top: var(--spacing-m);
grid-auto-flow: column;
}
</style>

View File

@ -10,9 +10,9 @@
{#if options} {#if options}
<div use:chart={options} use:styleable={$component.styles} /> <div use:chart={options} use:styleable={$component.styles} />
{:else if builderStore.inBuilder} {:else if $builderStore.inBuilder}
<div use:styleable={$component.styles}> <div class="placeholder" use:styleable={$component.styles}>
Use the settings panel to build your chart --> Use the settings panel to build your chart.
</div> </div>
{/if} {/if}
@ -21,4 +21,10 @@
display: flex !important; display: flex !important;
text-transform: capitalize; text-transform: capitalize;
} }
div :global(.apexcharts-yaxis-label, .apexcharts-xaxis-label) {
fill: #aaa;
}
div.placeholder {
padding: 10px;
}
</style> </style>

View File

@ -187,5 +187,6 @@
div { div {
padding: 20px; padding: 20px;
position: relative; position: relative;
background-color: var(--spectrum-alias-background-color-secondary);
} }
</style> </style>

View File

@ -27,7 +27,6 @@ export { default as embed } from "./Embed.svelte"
export { default as cardhorizontal } from "./CardHorizontal.svelte" export { default as cardhorizontal } from "./CardHorizontal.svelte"
export { default as cardstat } from "./CardStat.svelte" export { default as cardstat } from "./CardStat.svelte"
export { default as icon } from "./Icon.svelte" export { default as icon } from "./Icon.svelte"
export { default as search } from "./Search.svelte"
export { default as backgroundimage } from "./BackgroundImage.svelte" export { default as backgroundimage } from "./BackgroundImage.svelte"
export * from "./charts" export * from "./charts"
export * from "./forms" export * from "./forms"

View File

@ -94,3 +94,9 @@
<slot /> <slot />
</Table> </Table>
</div> </div>
<style>
div {
background-color: var(--spectrum-alias-background-color-secondary);
}
</style>

View File

@ -4,7 +4,7 @@ const processors = require("./processors")
const { cloneDeep } = require("lodash/fp") const { cloneDeep } = require("lodash/fp")
const { const {
removeNull, removeNull,
addConstants, updateContext,
removeHandlebarsStatements, removeHandlebarsStatements,
} = require("./utilities") } = require("./utilities")
const manifest = require("../manifest.json") const manifest = require("../manifest.json")
@ -92,8 +92,7 @@ module.exports.processStringSync = (string, context) => {
} }
// take a copy of input incase error // take a copy of input incase error
const input = string const input = string
let clonedContext = removeNull(cloneDeep(context)) const clonedContext = removeNull(updateContext(cloneDeep(context)))
clonedContext = addConstants(clonedContext)
// remove any null/undefined properties // remove any null/undefined properties
if (typeof string !== "string") { if (typeof string !== "string") {
throw "Cannot process non-string types." throw "Cannot process non-string types."

View File

@ -23,11 +23,24 @@ module.exports.removeNull = obj => {
return obj return obj
} }
module.exports.addConstants = obj => { module.exports.updateContext = obj => {
if (obj.now == null) { if (obj.now == null) {
obj.now = new Date() obj.now = new Date().toISOString()
} }
return obj function recurse(obj) {
for (let key of Object.keys(obj)) {
if (!obj[key]) {
continue
}
if (obj[key] instanceof Date) {
obj[key] = obj[key].toISOString()
} else if (typeof obj[key] === "object") {
obj[key] = recurse(obj[key])
}
}
return obj
}
return recurse(obj)
} }
module.exports.removeHandlebarsStatements = string => { module.exports.removeHandlebarsStatements = string => {

View File

@ -107,6 +107,12 @@ describe("check the utility functions", () => {
const property = makePropSafe("thing") const property = makePropSafe("thing")
expect(property).toEqual("[thing]") expect(property).toEqual("[thing]")
}) })
it("should be able to handle an input date object", async () => {
const date = new Date()
const output = await processString("{{ dateObj }}", { dateObj: date })
expect(date.toISOString()).toEqual(output)
})
}) })
describe("check manifest", () => { describe("check manifest", () => {

View File

@ -4559,7 +4559,7 @@ supports-color@^7.1.0:
dependencies: dependencies:
has-flag "^4.0.0" has-flag "^4.0.0"
svelte@^3.37.0: svelte@^3.38.2:
version "3.38.2" version "3.38.2"
resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.38.2.tgz#55e5c681f793ae349b5cc2fe58e5782af4275ef5" resolved "https://registry.yarnpkg.com/svelte/-/svelte-3.38.2.tgz#55e5c681f793ae349b5cc2fe58e5782af4275ef5"
integrity sha512-q5Dq0/QHh4BLJyEVWGe7Cej5NWs040LWjMbicBGZ+3qpFWJ1YObRmUDZKbbovddLC9WW7THTj3kYbTOFmU9fbg== integrity sha512-q5Dq0/QHh4BLJyEVWGe7Cej5NWs040LWjMbicBGZ+3qpFWJ1YObRmUDZKbbovddLC9WW7THTj3kYbTOFmU9fbg==