forked from tsai/budibase
46 changed files with 672 additions and 407 deletions
@ -1,41 +1,44 @@ |
|||||
context("Add Multi-Option Datatype", () => { |
context("Add Multi-Option Datatype", () => { |
||||
before(() => { |
before(() => { |
||||
cy.login() |
cy.login() |
||||
cy.createTestApp() |
cy.createTestApp() |
||||
}) |
}) |
||||
|
|
||||
it("should create a new table, with data", () => { |
it("should create a new table, with data", () => { |
||||
cy.createTable("Multi Data") |
cy.createTable("Multi Data") |
||||
cy.addColumn("Multi Data", "Test Data", "Multi-select", "1\n2\n3\n4\n5") |
cy.addColumn("Multi Data", "Test Data", "Multi-select", "1\n2\n3\n4\n5") |
||||
cy.addRowMultiValue(["1", "2", "3", "4", "5"]) |
cy.addRowMultiValue(["1", "2", "3", "4", "5"]) |
||||
}) |
}) |
||||
|
|
||||
it ("should add form with multi select picker, containing 5 options", () => { |
it("should add form with multi select picker, containing 5 options", () => { |
||||
cy.navigateToFrontend() |
cy.navigateToFrontend() |
||||
cy.wait(500) |
cy.wait(500) |
||||
// Add data provider
|
// Add data provider
|
||||
cy.get(`[data-cy="category-Data Provider"]`).click() |
cy.get(`[data-cy="category-Data"]`).click() |
||||
cy.get('[data-cy="dataSource-prop-control"]').click() |
cy.get(`[data-cy="component-Data Provider"]`).click() |
||||
cy.get(".dropdown").contains("Multi Data").click() |
cy.get('[data-cy="dataSource-prop-control"]').click() |
||||
cy.wait(500) |
cy.get(".dropdown").contains("Multi Data").click() |
||||
// Add Form with schema to match table
|
cy.wait(500) |
||||
cy.addComponent("Form", "Form") |
// Add Form with schema to match table
|
||||
cy.get('[data-cy="dataSource-prop-control"').click() |
cy.addComponent("Form", "Form") |
||||
cy.get(".dropdown").contains("Multi Data").click() |
cy.get('[data-cy="dataSource-prop-control"').click() |
||||
cy.wait(500) |
cy.get(".dropdown").contains("Multi Data").click() |
||||
// Add multi-select picker to form
|
cy.wait(500) |
||||
cy.addComponent("Form", "Multi-select Picker").then((componentId) => { |
// Add multi-select picker to form
|
||||
cy.get('[data-cy="field-prop-control"]').type("Test Data").type('{enter}') |
cy.addComponent("Form", "Multi-select Picker").then(componentId => { |
||||
cy.wait(1000) |
cy.get('[data-cy="field-prop-control"]').type("Test Data").type("{enter}") |
||||
cy.getComponent(componentId).contains("Choose some options").click() |
cy.wait(1000) |
||||
// Check picker has 5 items
|
cy.getComponent(componentId).contains("Choose some options").click() |
||||
cy.getComponent(componentId).find('li').should('have.length', 5) |
// Check picker has 5 items
|
||||
// Select all items
|
cy.getComponent(componentId).find("li").should("have.length", 5) |
||||
for (let i = 1; i < 6; i++) { |
// Select all items
|
||||
cy.getComponent(componentId).find('li').contains(i).click() |
for (let i = 1; i < 6; i++) { |
||||
} |
cy.getComponent(componentId).find("li").contains(i).click() |
||||
// Check items have been selected
|
} |
||||
cy.getComponent(componentId).find('.spectrum-Picker-label').contains("(5)") |
// Check items have been selected
|
||||
}) |
cy.getComponent(componentId) |
||||
|
.find(".spectrum-Picker-label") |
||||
|
.contains("(5)") |
||||
}) |
}) |
||||
|
}) |
||||
}) |
}) |
||||
|
|||||
@ -0,0 +1,30 @@ |
|||||
|
<script> |
||||
|
import BindingPanel from "./BindingPanel.svelte" |
||||
|
|
||||
|
export let bindings = [] |
||||
|
export let valid |
||||
|
export let value = "" |
||||
|
export let allowJS = false |
||||
|
|
||||
|
$: enrichedBindings = enrichBindings(bindings) |
||||
|
|
||||
|
// Ensure bindings have the correct categories |
||||
|
const enrichBindings = bindings => { |
||||
|
if (!bindings?.length) { |
||||
|
return bindings |
||||
|
} |
||||
|
return bindings?.map(binding => ({ |
||||
|
...binding, |
||||
|
category: "Bindable Values", |
||||
|
type: null, |
||||
|
})) |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<BindingPanel |
||||
|
bind:valid |
||||
|
bindings={enrichedBindings} |
||||
|
{value} |
||||
|
{allowJS} |
||||
|
on:change |
||||
|
/> |
||||
@ -1,209 +1,27 @@ |
|||||
<script> |
<script> |
||||
import groupBy from "lodash/fp/groupBy" |
import BindingPanel from "./BindingPanel.svelte" |
||||
import { Search, TextArea, DrawerContent } from "@budibase/bbui" |
|
||||
import { createEventDispatcher } from "svelte" |
|
||||
import { isValid } from "@budibase/string-templates" |
|
||||
import { handlebarsCompletions } from "constants/completions" |
|
||||
import { readableToRuntimeBinding } from "builderStore/dataBinding" |
|
||||
import { addHBSBinding } from "./utils" |
|
||||
|
|
||||
const dispatch = createEventDispatcher() |
export let bindings = [] |
||||
|
export let valid |
||||
export let bindableProperties = [] |
|
||||
export let valid = true |
|
||||
export let value = "" |
export let value = "" |
||||
|
export let allowJS = false |
||||
|
|
||||
let helpers = handlebarsCompletions() |
$: enrichedBindings = enrichBindings(bindings) |
||||
let getCaretPosition |
|
||||
let search = "" |
|
||||
|
|
||||
$: categories = Object.entries(groupBy("category", bindableProperties)) |
|
||||
$: valid = isValid(readableToRuntimeBinding(bindableProperties, value)) |
|
||||
$: dispatch("change", value) |
|
||||
$: searchRgx = new RegExp(search, "ig") |
|
||||
$: filteredCategories = categories.map(([categoryName, bindings]) => { |
|
||||
const filteredBindings = bindings.filter(binding => { |
|
||||
return binding.label.match(searchRgx) |
|
||||
}) |
|
||||
return [categoryName, filteredBindings] |
|
||||
}) |
|
||||
$: filteredHelpers = helpers?.filter(helper => { |
|
||||
return helper.label.match(searchRgx) || helper.description.match(searchRgx) |
|
||||
}) |
|
||||
</script> |
|
||||
|
|
||||
<DrawerContent> |
|
||||
<svelte:fragment slot="sidebar"> |
|
||||
<div class="container"> |
|
||||
<section> |
|
||||
<div class="heading">Search</div> |
|
||||
<Search placeholder="Search" bind:value={search} /> |
|
||||
</section> |
|
||||
{#each filteredCategories as [categoryName, bindings]} |
|
||||
{#if bindings.length} |
|
||||
<section> |
|
||||
<div class="heading">{categoryName}</div> |
|
||||
<ul> |
|
||||
{#each bindings as binding} |
|
||||
<li |
|
||||
on:click={() => { |
|
||||
value = addHBSBinding(value, getCaretPosition(), binding) |
|
||||
}} |
|
||||
> |
|
||||
<span class="binding__label">{binding.label}</span> |
|
||||
<span class="binding__type">{binding.type}</span> |
|
||||
{#if binding.description} |
|
||||
<br /> |
|
||||
<div class="binding__description"> |
|
||||
{binding.description || ""} |
|
||||
</div> |
|
||||
{/if} |
|
||||
</li> |
|
||||
{/each} |
|
||||
</ul> |
|
||||
</section> |
|
||||
{/if} |
|
||||
{/each} |
|
||||
{#if filteredHelpers?.length} |
|
||||
<section> |
|
||||
<div class="heading">Helpers</div> |
|
||||
<ul> |
|
||||
{#each filteredHelpers as helper} |
|
||||
<li |
|
||||
on:click={() => { |
|
||||
value = addHBSBinding(value, getCaretPosition(), helper.text) |
|
||||
}} |
|
||||
> |
|
||||
<div class="helper"> |
|
||||
<div class="helper__name">{helper.displayText}</div> |
|
||||
<div class="helper__description"> |
|
||||
{@html helper.description} |
|
||||
</div> |
|
||||
<pre class="helper__example">{helper.example || ''}</pre> |
|
||||
</div> |
|
||||
</li> |
|
||||
{/each} |
|
||||
</ul> |
|
||||
</section> |
|
||||
{/if} |
|
||||
</div> |
|
||||
</svelte:fragment> |
|
||||
<div class="main"> |
|
||||
<TextArea |
|
||||
bind:getCaretPosition |
|
||||
bind:value |
|
||||
placeholder="Add text, or click the objects on the left to add them to the textbox." |
|
||||
/> |
|
||||
{#if !valid} |
|
||||
<p class="syntax-error"> |
|
||||
Current Handlebars syntax is invalid, please check the guide |
|
||||
<a href="https://handlebarsjs.com/guide/">here</a> |
|
||||
for more details. |
|
||||
</p> |
|
||||
{/if} |
|
||||
</div> |
|
||||
</DrawerContent> |
|
||||
|
|
||||
<style> |
|
||||
.main :global(textarea) { |
|
||||
min-height: 150px !important; |
|
||||
} |
|
||||
|
|
||||
.container { |
|
||||
margin: calc(-1 * var(--spacing-xl)); |
|
||||
} |
|
||||
.heading { |
|
||||
font-size: var(--font-size-s); |
|
||||
font-weight: 600; |
|
||||
text-transform: uppercase; |
|
||||
color: var(--spectrum-global-color-gray-600); |
|
||||
padding: var(--spacing-xl) 0 var(--spacing-m) 0; |
|
||||
} |
|
||||
|
|
||||
section { |
|
||||
padding: 0 var(--spacing-xl) var(--spacing-xl) var(--spacing-xl); |
|
||||
} |
|
||||
section:not(:first-child) { |
|
||||
border-top: var(--border-light); |
|
||||
} |
|
||||
ul { |
|
||||
list-style: none; |
|
||||
padding: 0; |
|
||||
margin: 0; |
|
||||
} |
|
||||
|
|
||||
li { |
|
||||
font-size: var(--font-size-s); |
|
||||
padding: var(--spacing-m); |
|
||||
border-radius: 4px; |
|
||||
border: var(--border-light); |
|
||||
transition: background-color 130ms ease-in-out, color 130ms ease-in-out, |
|
||||
border-color 130ms ease-in-out; |
|
||||
} |
|
||||
li:not(:last-of-type) { |
|
||||
margin-bottom: var(--spacing-s); |
|
||||
} |
|
||||
li :global(*) { |
|
||||
transition: color 130ms ease-in-out; |
|
||||
} |
|
||||
li:hover { |
|
||||
color: var(--spectrum-global-color-gray-900); |
|
||||
background-color: var(--spectrum-global-color-gray-50); |
|
||||
border-color: var(--spectrum-global-color-gray-500); |
|
||||
cursor: pointer; |
|
||||
} |
|
||||
li:hover :global(*) { |
|
||||
color: var(--spectrum-global-color-gray-900) !important; |
|
||||
} |
|
||||
|
|
||||
.helper { |
|
||||
display: flex; |
|
||||
flex-direction: column; |
|
||||
justify-content: flex-start; |
|
||||
align-items: flex-start; |
|
||||
gap: var(--spacing-xs); |
|
||||
} |
|
||||
.helper__name { |
|
||||
font-weight: bold; |
|
||||
} |
|
||||
.helper__description, |
|
||||
.helper__description :global(*) { |
|
||||
color: var(--spectrum-global-color-gray-700); |
|
||||
} |
|
||||
.helper__example { |
|
||||
white-space: normal; |
|
||||
margin: 0.5rem 0 0 0; |
|
||||
font-weight: 700; |
|
||||
} |
|
||||
.helper__description :global(p) { |
|
||||
margin: 0; |
|
||||
} |
|
||||
|
|
||||
.syntax-error { |
// Ensure bindings have the correct properties |
||||
padding-top: var(--spacing-m); |
const enrichBindings = bindings => { |
||||
color: var(--red); |
return bindings?.map(binding => ({ |
||||
font-size: 12px; |
...binding, |
||||
} |
readableBinding: binding.label || binding.readableBinding, |
||||
.syntax-error a { |
runtimeBinding: binding.path || binding.runtimeBinding, |
||||
color: var(--red); |
})) |
||||
text-decoration: underline; |
|
||||
} |
} |
||||
|
</script> |
||||
|
|
||||
.binding__label { |
<BindingPanel |
||||
font-weight: 600; |
bind:valid |
||||
text-transform: capitalize; |
bindings={enrichedBindings} |
||||
} |
{value} |
||||
.binding__description { |
{allowJS} |
||||
color: var(--spectrum-global-color-gray-700); |
on:change |
||||
margin: 0.5rem 0 0 0; |
/> |
||||
white-space: normal; |
|
||||
} |
|
||||
.binding__type { |
|
||||
font-family: monospace; |
|
||||
background-color: var(--spectrum-global-color-gray-200); |
|
||||
border-radius: var(--border-radius-s); |
|
||||
padding: 2px 4px; |
|
||||
margin-left: 2px; |
|
||||
font-weight: 600; |
|
||||
} |
|
||||
</style> |
|
||||
|
|||||
@ -0,0 +1,85 @@ |
|||||
|
<script> |
||||
|
import { get } from "svelte/store" |
||||
|
import { getContext, onDestroy } from "svelte" |
||||
|
import { ModalContent, Modal } from "@budibase/bbui" |
||||
|
import FilterModal from "./FilterModal.svelte" |
||||
|
import { buildLuceneQuery } from "builder/src/helpers/lucene" |
||||
|
import Button from "../Button.svelte" |
||||
|
|
||||
|
export let dataProvider |
||||
|
export let allowedFields |
||||
|
export let size = "M" |
||||
|
|
||||
|
const component = getContext("component") |
||||
|
const { builderStore, ActionTypes, getAction } = getContext("sdk") |
||||
|
|
||||
|
let modal |
||||
|
let tmpFilters = [] |
||||
|
let filters = [] |
||||
|
|
||||
|
$: dataProviderId = dataProvider?.id |
||||
|
$: addExtension = getAction( |
||||
|
dataProviderId, |
||||
|
ActionTypes.AddDataProviderQueryExtension |
||||
|
) |
||||
|
$: removeExtension = getAction( |
||||
|
dataProviderId, |
||||
|
ActionTypes.RemoveDataProviderQueryExtension |
||||
|
) |
||||
|
$: schema = dataProvider?.schema |
||||
|
$: schemaFields = getSchemaFields(schema, allowedFields) |
||||
|
|
||||
|
// Add query extension to data provider |
||||
|
$: { |
||||
|
if (filters?.length) { |
||||
|
const queryExtension = buildLuceneQuery(filters) |
||||
|
addExtension?.($component.id, queryExtension) |
||||
|
} else { |
||||
|
removeExtension?.($component.id) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const getSchemaFields = (schema, allowedFields) => { |
||||
|
let clonedSchema = {} |
||||
|
if (!allowedFields?.length) { |
||||
|
clonedSchema = schema |
||||
|
} else { |
||||
|
allowedFields?.forEach(field => { |
||||
|
clonedSchema[field] = schema[field] |
||||
|
}) |
||||
|
} |
||||
|
return Object.values(clonedSchema || {}) |
||||
|
} |
||||
|
|
||||
|
const openEditor = () => { |
||||
|
if (get(builderStore).inBuilder) { |
||||
|
return |
||||
|
} |
||||
|
tmpFilters = [...filters] |
||||
|
modal.show() |
||||
|
} |
||||
|
|
||||
|
const updateQuery = () => { |
||||
|
filters = [...tmpFilters] |
||||
|
} |
||||
|
|
||||
|
onDestroy(() => { |
||||
|
removeExtension?.($component.id) |
||||
|
}) |
||||
|
</script> |
||||
|
|
||||
|
<Button |
||||
|
onClick={openEditor} |
||||
|
icon="Properties" |
||||
|
text="Filter" |
||||
|
{size} |
||||
|
type="secondary" |
||||
|
quiet |
||||
|
active={filters?.length > 0} |
||||
|
/> |
||||
|
|
||||
|
<Modal bind:this={modal}> |
||||
|
<ModalContent title="Edit filters" size="XL" onConfirm={updateQuery}> |
||||
|
<FilterModal bind:filters={tmpFilters} {schemaFields} /> |
||||
|
</ModalContent> |
||||
|
</Modal> |
||||
@ -0,0 +1,176 @@ |
|||||
|
<script> |
||||
|
import { |
||||
|
Body, |
||||
|
Button, |
||||
|
Combobox, |
||||
|
DatePicker, |
||||
|
DrawerContent, |
||||
|
Icon, |
||||
|
Input, |
||||
|
Layout, |
||||
|
Select, |
||||
|
} from "@budibase/bbui" |
||||
|
import { generate } from "shortid" |
||||
|
import { |
||||
|
getValidOperatorsForType, |
||||
|
OperatorOptions, |
||||
|
} from "builder/src/constants/lucene" |
||||
|
|
||||
|
export let schemaFields |
||||
|
export let filters = [] |
||||
|
|
||||
|
const BannedTypes = ["link", "attachment", "formula"] |
||||
|
|
||||
|
$: fieldOptions = (schemaFields ?? []) |
||||
|
.filter(field => !BannedTypes.includes(field.type)) |
||||
|
.map(field => field.name) |
||||
|
|
||||
|
const addFilter = () => { |
||||
|
filters = [ |
||||
|
...filters, |
||||
|
{ |
||||
|
id: generate(), |
||||
|
field: null, |
||||
|
operator: OperatorOptions.Equals.value, |
||||
|
value: null, |
||||
|
valueType: "Value", |
||||
|
}, |
||||
|
] |
||||
|
} |
||||
|
|
||||
|
const removeFilter = id => { |
||||
|
filters = filters.filter(field => field.id !== id) |
||||
|
} |
||||
|
|
||||
|
const duplicateFilter = id => { |
||||
|
const existingFilter = filters.find(filter => filter.id === id) |
||||
|
const duplicate = { ...existingFilter, id: generate() } |
||||
|
filters = [...filters, duplicate] |
||||
|
} |
||||
|
|
||||
|
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).map( |
||||
|
x => x.value |
||||
|
) |
||||
|
if (!validOperators.includes(expression.operator)) { |
||||
|
expression.operator = validOperators[0] ?? OperatorOptions.Equals.value |
||||
|
onOperatorChange(expression, expression.operator) |
||||
|
} |
||||
|
|
||||
|
// if changed to an array, change default value to empty array |
||||
|
const idx = filters.findIndex(x => x.field === field) |
||||
|
if (expression.type === "array") { |
||||
|
filters[idx].value = [] |
||||
|
} else { |
||||
|
filters[idx].value = null |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
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> |
||||
|
|
||||
|
<DrawerContent> |
||||
|
<div class="container"> |
||||
|
<Layout noPadding> |
||||
|
<Body size="S"> |
||||
|
{#if !filters?.length} |
||||
|
Add your first filter expression. |
||||
|
{:else} |
||||
|
Results are filtered to only those which match all of the following |
||||
|
constraints. |
||||
|
{/if} |
||||
|
</Body> |
||||
|
{#if filters?.length} |
||||
|
<div class="fields"> |
||||
|
{#each filters as filter, idx} |
||||
|
<Select |
||||
|
bind:value={filter.field} |
||||
|
options={fieldOptions} |
||||
|
on:change={e => onFieldChange(filter, e.detail)} |
||||
|
placeholder="Column" |
||||
|
/> |
||||
|
<Select |
||||
|
disabled={!filter.field} |
||||
|
options={getValidOperatorsForType(filter.type)} |
||||
|
bind:value={filter.operator} |
||||
|
on:change={e => onOperatorChange(filter, e.detail)} |
||||
|
placeholder={null} |
||||
|
/> |
||||
|
{#if ["string", "longform", "number"].includes(filter.type)} |
||||
|
<Input disabled={filter.noValue} bind:value={filter.value} /> |
||||
|
{:else if ["options", "array"].includes(filter.type)} |
||||
|
<Combobox |
||||
|
disabled={filter.noValue} |
||||
|
options={getFieldOptions(filter.field)} |
||||
|
bind:value={filter.value} |
||||
|
/> |
||||
|
{:else if filter.type === "boolean"} |
||||
|
<Combobox |
||||
|
disabled={filter.noValue} |
||||
|
options={[ |
||||
|
{ label: "True", value: "true" }, |
||||
|
{ label: "False", value: "false" }, |
||||
|
]} |
||||
|
bind:value={filter.value} |
||||
|
/> |
||||
|
{:else if filter.type === "datetime"} |
||||
|
<DatePicker disabled={filter.noValue} bind:value={filter.value} /> |
||||
|
{:else} |
||||
|
<Input disabled /> |
||||
|
{/if} |
||||
|
<Icon |
||||
|
name="Duplicate" |
||||
|
hoverable |
||||
|
size="S" |
||||
|
on:click={() => duplicateFilter(filter.id)} |
||||
|
/> |
||||
|
<Icon |
||||
|
name="Close" |
||||
|
hoverable |
||||
|
size="S" |
||||
|
on:click={() => removeFilter(filter.id)} |
||||
|
/> |
||||
|
{/each} |
||||
|
</div> |
||||
|
{/if} |
||||
|
<div> |
||||
|
<Button icon="AddCircle" size="M" secondary on:click={addFilter}> |
||||
|
Add filter |
||||
|
</Button> |
||||
|
</div> |
||||
|
</Layout> |
||||
|
</div> |
||||
|
</DrawerContent> |
||||
|
|
||||
|
<style> |
||||
|
.container { |
||||
|
width: 100%; |
||||
|
max-width: 1000px; |
||||
|
margin: 0 auto; |
||||
|
} |
||||
|
.fields { |
||||
|
display: grid; |
||||
|
column-gap: var(--spacing-l); |
||||
|
row-gap: var(--spacing-s); |
||||
|
align-items: center; |
||||
|
grid-template-columns: 1fr 120px 1fr auto auto; |
||||
|
} |
||||
|
</style> |
||||
@ -0,0 +1 @@ |
|||||
|
export { default as dynamicfilter } from "./DynamicFilter.svelte" |
||||
Loading…
Reference in new issue