custom filtering working, needs more test coverage
This commit is contained in:
parent
af6451c33c
commit
6ba109222b
|
@ -17,6 +17,7 @@
|
|||
import EditRowPopover from "./popovers/EditRow.svelte"
|
||||
import CalculationPopover from "./popovers/Calculate.svelte"
|
||||
import GroupByPopover from "./popovers/GroupBy.svelte"
|
||||
import FilterPopover from "./popovers/Filter.svelte"
|
||||
|
||||
let COLUMNS = [
|
||||
{
|
||||
|
@ -54,8 +55,8 @@
|
|||
|
||||
let data = []
|
||||
|
||||
$: ({ name, groupBy } = view)
|
||||
$: !name.startsWith("all_") && fetchViewData(name, groupBy)
|
||||
$: ({ name, groupBy, filters } = view)
|
||||
$: !name.startsWith("all_") && filters && fetchViewData(name, groupBy)
|
||||
|
||||
async function fetchViewData(name, groupBy) {
|
||||
let QUERY_VIEW_URL = `/api/views/${name}?stats=true`
|
||||
|
@ -69,6 +70,7 @@
|
|||
</script>
|
||||
|
||||
<Table title={decodeURI(view.name)} columns={COLUMNS} {data}>
|
||||
<FilterPopover {view} />
|
||||
<CalculationPopover {view} />
|
||||
<GroupByPopover {view} />
|
||||
</Table>
|
||||
|
|
|
@ -19,25 +19,6 @@
|
|||
? Object.entries($backendUiStore.selectedModel.schema)
|
||||
: []
|
||||
|
||||
const isSelect = meta =>
|
||||
meta.type === "string" &&
|
||||
meta.constraints &&
|
||||
meta.constraints.inclusion &&
|
||||
meta.constraints.inclusion.length > 0
|
||||
|
||||
function determineInputType(meta) {
|
||||
if (meta.type === "datetime") return "date"
|
||||
if (meta.type === "number") return "number"
|
||||
if (meta.type === "boolean") return "checkbox"
|
||||
if (isSelect(meta)) return "select"
|
||||
|
||||
return "text"
|
||||
}
|
||||
|
||||
function determineOptions(meta) {
|
||||
return isSelect(meta) ? meta.constraints.inclusion : []
|
||||
}
|
||||
|
||||
async function saveRecord() {
|
||||
const recordResponse = await api.saveRecord(
|
||||
{
|
||||
|
@ -73,11 +54,7 @@
|
|||
linkName={meta.name}
|
||||
modelId={meta.modelId} />
|
||||
{:else}
|
||||
<RecordFieldControl
|
||||
type={determineInputType(meta)}
|
||||
options={determineOptions(meta)}
|
||||
label={meta.name}
|
||||
bind:value={record[key]} />
|
||||
<RecordFieldControl {meta} bind:value={record[key]} />
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
|
|
@ -1,10 +1,32 @@
|
|||
<script>
|
||||
import { Input, Select } from "@budibase/bbui"
|
||||
|
||||
export let type = "text"
|
||||
export let value = type === "checkbox" ? false : ""
|
||||
export let label
|
||||
export let options = []
|
||||
export let value
|
||||
export let meta
|
||||
|
||||
const isSelect = meta =>
|
||||
meta.type === "string" &&
|
||||
meta.constraints &&
|
||||
meta.constraints.inclusion &&
|
||||
meta.constraints.inclusion.length > 0
|
||||
|
||||
let type = determineInputType(meta)
|
||||
let options = determineOptions(meta)
|
||||
|
||||
value = value || type === "checkbox" ? false : ""
|
||||
|
||||
function determineInputType(meta) {
|
||||
if (meta.type === "datetime") return "date"
|
||||
if (meta.type === "number") return "number"
|
||||
if (meta.type === "boolean") return "checkbox"
|
||||
if (isSelect(meta)) return "select"
|
||||
|
||||
return "text"
|
||||
}
|
||||
|
||||
function determineOptions(meta) {
|
||||
return isSelect(meta) ? meta.constraints.inclusion : []
|
||||
}
|
||||
|
||||
const handleInput = event => {
|
||||
if (event.target.type === "checkbox") {
|
||||
|
@ -22,7 +44,7 @@
|
|||
</script>
|
||||
|
||||
{#if type === 'select'}
|
||||
<Select thin secondary data-cy="{label}-select" bind:value>
|
||||
<Select thin secondary data-cy="{meta.name}-select" bind:value>
|
||||
<option />
|
||||
{#each options as opt}
|
||||
<option value={opt}>{opt}</option>
|
||||
|
@ -30,12 +52,12 @@
|
|||
</Select>
|
||||
{:else}
|
||||
{#if type === 'checkbox'}
|
||||
<label>{label}</label>
|
||||
<label>{meta.name}</label>
|
||||
{/if}
|
||||
<Input
|
||||
thin
|
||||
placeholder={label}
|
||||
data-cy="{label}-input"
|
||||
placeholder={meta.name}
|
||||
data-cy="{meta.name}-input"
|
||||
checked={value}
|
||||
{type}
|
||||
{value}
|
||||
|
|
|
@ -0,0 +1,162 @@
|
|||
<script>
|
||||
import {
|
||||
Popover,
|
||||
TextButton,
|
||||
Button,
|
||||
Icon,
|
||||
Input,
|
||||
Select,
|
||||
} from "@budibase/bbui"
|
||||
import { backendUiStore } from "builderStore"
|
||||
import { notifier } from "builderStore/store/notifications"
|
||||
import CreateEditRecord from "../modals/CreateEditRecord.svelte"
|
||||
|
||||
const CONDITIONS = [
|
||||
{
|
||||
name: "Equals",
|
||||
key: "EQUALS",
|
||||
},
|
||||
{
|
||||
name: "Less Than",
|
||||
key: "LT",
|
||||
},
|
||||
{
|
||||
name: "Less Than Or Equal",
|
||||
key: "LTE",
|
||||
},
|
||||
{
|
||||
name: "More Than",
|
||||
key: "MT",
|
||||
},
|
||||
{
|
||||
name: "More Than Or Equal",
|
||||
key: "MTE",
|
||||
},
|
||||
{
|
||||
name: "Contains",
|
||||
key: "CONTAINS",
|
||||
},
|
||||
]
|
||||
|
||||
const CONJUNCTIONS = [
|
||||
{
|
||||
name: "Or",
|
||||
key: "OR",
|
||||
},
|
||||
{
|
||||
name: "And",
|
||||
key: "AND",
|
||||
},
|
||||
]
|
||||
|
||||
export let view = {}
|
||||
|
||||
let anchor
|
||||
let dropdown
|
||||
let filters = view.filters
|
||||
|
||||
$: viewModel = $backendUiStore.models.find(
|
||||
({ _id }) => _id === $backendUiStore.selectedView.modelId
|
||||
)
|
||||
$: fields = viewModel && Object.keys(viewModel.schema)
|
||||
|
||||
function saveView() {
|
||||
view.filters = filters
|
||||
backendUiStore.actions.views.save(view)
|
||||
notifier.success(`View ${view.name} saved.`)
|
||||
dropdown.hide()
|
||||
}
|
||||
|
||||
function removeFilter(idx) {
|
||||
filters.splice(idx, 1)
|
||||
filters = filters
|
||||
}
|
||||
|
||||
function addFilter() {
|
||||
filters = [...filters, {}]
|
||||
}
|
||||
</script>
|
||||
|
||||
<div bind:this={anchor}>
|
||||
<TextButton
|
||||
text
|
||||
small
|
||||
on:click={dropdown.show}
|
||||
active={filters && filters.length}>
|
||||
<Icon name="filter" />
|
||||
Filter
|
||||
</TextButton>
|
||||
</div>
|
||||
<Popover bind:this={dropdown} {anchor} align="left">
|
||||
<h5>Filter</h5>
|
||||
<div class="input-group-row">
|
||||
{#each filters as filter, idx}
|
||||
{#if idx === 0}
|
||||
<p>Where</p>
|
||||
{:else}
|
||||
<Select secondary thin bind:value={filter.conjunction}>
|
||||
{#each CONJUNCTIONS as conjunction}
|
||||
<option value={conjunction.key}>{conjunction.name}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
{/if}
|
||||
<Select secondary thin bind:value={filter.key}>
|
||||
{#each fields as field}
|
||||
<option value={field}>{field}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
<Select secondary thin bind:value={filter.condition}>
|
||||
{#each CONDITIONS as condition}
|
||||
<option value={condition.key}>{condition.name}</option>
|
||||
{/each}
|
||||
</Select>
|
||||
<Input
|
||||
thin
|
||||
placeholder={filter.key || fields[0]}
|
||||
bind:value={filter.value} />
|
||||
<i class="ri-close-circle-fill" on:click={() => removeFilter(idx)} />
|
||||
{/each}
|
||||
</div>
|
||||
<div class="button-group">
|
||||
<Button text on:click={addFilter}>Add Filter</Button>
|
||||
<div>
|
||||
<Button secondary on:click={dropdown.hide}>Cancel</Button>
|
||||
<Button primary on:click={saveView}>Save</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
<style>
|
||||
h5 {
|
||||
margin-bottom: var(--spacing-l);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
margin-top: var(--spacing-l);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global(.button-group > div > button) {
|
||||
margin-left: var(--spacing-m);
|
||||
}
|
||||
|
||||
.ri-close-circle-fill {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.input-group-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(50px, auto) 1fr 1fr 1fr 15px;
|
||||
gap: var(--spacing-s);
|
||||
margin-bottom: var(--spacing-l);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
</style>
|
|
@ -23,8 +23,8 @@
|
|||
}
|
||||
},
|
||||
"scripts": {
|
||||
"test": "jest routes --runInBand",
|
||||
"test:integration": "jest workflow --runInBand",
|
||||
"test": "jest --testPathIgnorePatterns=routes && npm run test:integration",
|
||||
"test:integration": "jest routes --runInBand",
|
||||
"test:watch": "jest --watch",
|
||||
"initialise": "node ../cli/bin/budi init -q",
|
||||
"run:docker": "node src/index",
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`viewBuilder Filter creates a view with multiple filters and conjunctions 1`] = `
|
||||
Object {
|
||||
"map": "function (doc) {
|
||||
if (doc.modelId === \\"14f1c4e94d6a47b682ce89d35d4c78b0\\" && doc[\\"Name\\"] === \\"Test\\" || doc[\\"Yes\\"] > \\"Value\\") {
|
||||
emit(doc._id);
|
||||
}
|
||||
}",
|
||||
"meta": Object {
|
||||
"field": undefined,
|
||||
"filters": Array [
|
||||
Object {
|
||||
"condition": "EQUALS",
|
||||
"key": "Name",
|
||||
"value": "Test",
|
||||
},
|
||||
Object {
|
||||
"condition": "MT",
|
||||
"conjunction": "OR",
|
||||
"key": "Yes",
|
||||
"value": "Value",
|
||||
},
|
||||
],
|
||||
"groupBy": undefined,
|
||||
"modelId": "14f1c4e94d6a47b682ce89d35d4c78b0",
|
||||
"schema": Object {
|
||||
"avg": "number",
|
||||
"count": "number",
|
||||
"max": "number",
|
||||
"min": "number",
|
||||
"sum": "number",
|
||||
"sumsqr": "number",
|
||||
},
|
||||
},
|
||||
"reduce": "_stats",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`viewBuilder Group By creates a view emitting the group by field 1`] = `
|
||||
Object {
|
||||
"map": "function (doc) {
|
||||
if (doc.modelId === \\"14f1c4e94d6a47b682ce89d35d4c78b0\\" ) {
|
||||
emit(doc[\\"age\\"], doc[\\"score\\"]);
|
||||
}
|
||||
}",
|
||||
"meta": Object {
|
||||
"field": "score",
|
||||
"filters": Array [],
|
||||
"groupBy": "age",
|
||||
"modelId": "14f1c4e94d6a47b682ce89d35d4c78b0",
|
||||
"schema": Object {
|
||||
"avg": "number",
|
||||
"count": "number",
|
||||
"max": "number",
|
||||
"min": "number",
|
||||
"sum": "number",
|
||||
"sumsqr": "number",
|
||||
},
|
||||
},
|
||||
"reduce": "_stats",
|
||||
}
|
||||
`;
|
|
@ -0,0 +1,39 @@
|
|||
const statsViewTemplate = require("../viewBuilder");
|
||||
|
||||
describe("viewBuilder", () => {
|
||||
|
||||
describe("Filter", () => {
|
||||
it("creates a view with multiple filters and conjunctions", () => {
|
||||
expect(statsViewTemplate({
|
||||
"name": "yeety",
|
||||
"modelId": "14f1c4e94d6a47b682ce89d35d4c78b0",
|
||||
"filters": [{
|
||||
"value": "Test",
|
||||
"condition": "EQUALS",
|
||||
"key": "Name"
|
||||
}, {
|
||||
"value": "Value",
|
||||
"condition": "MT",
|
||||
"key": "Yes",
|
||||
"conjunction": "OR"
|
||||
}]
|
||||
})).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Calculate", () => {
|
||||
|
||||
})
|
||||
|
||||
describe("Group By", () => {
|
||||
it("creates a view emitting the group by field", () => {
|
||||
expect(statsViewTemplate({
|
||||
"name": "Test Scores Grouped By Age",
|
||||
"modelId": "14f1c4e94d6a47b682ce89d35d4c78b0",
|
||||
"groupBy": "age",
|
||||
"field": "score",
|
||||
"filters": [],
|
||||
})).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
});
|
|
@ -4,42 +4,55 @@ const TOKEN_MAP = {
|
|||
LTE: "<=",
|
||||
MT: ">",
|
||||
MTE: ">=",
|
||||
CONTAINS: "includes()",
|
||||
CONTAINS: "includes",
|
||||
AND: "&&",
|
||||
OR: "||"
|
||||
OR: "||",
|
||||
}
|
||||
|
||||
function parseFilters(filters) {
|
||||
const expression = filters.map(filter => {
|
||||
if (filter.conjunction) return TOKEN_MAP[filter.conjunction];
|
||||
|
||||
return `doc["${filter.key}"] ${TOKEN_MAP[filter.condition]} "${filter.value}"`
|
||||
})
|
||||
|
||||
/**
|
||||
* Iterates through the array of filters to create a JS
|
||||
* expression that gets used in a CouchDB view.
|
||||
* @param {Array} filters - an array of filter objects
|
||||
* @returns {String} JS Expression
|
||||
*/
|
||||
function parseFilterExpression(filters) {
|
||||
const expression = []
|
||||
|
||||
for (let filter of filters) {
|
||||
if (filter.conjunction) expression.push(TOKEN_MAP[filter.conjunction]);
|
||||
|
||||
if (filter.condition === "CONTAINS") {
|
||||
expression.push(
|
||||
`doc["${filter.key}"].${TOKEN_MAP[filter.condition]}("${
|
||||
filter.value
|
||||
}")`)
|
||||
return
|
||||
}
|
||||
|
||||
expression.push(`doc["${filter.key}"] ${TOKEN_MAP[filter.condition]} "${
|
||||
filter.value
|
||||
}"`)
|
||||
}
|
||||
|
||||
return expression.join(" ")
|
||||
}
|
||||
|
||||
function statsViewTemplate({ field, modelId, groupBy }) {
|
||||
function parseEmitExpression(field, groupBy) {
|
||||
if (field) return `emit(doc["${groupBy || "_id"}"], doc["${field}"]);`
|
||||
return `emit(doc._id);`
|
||||
}
|
||||
|
||||
function statsViewTemplate({ field, modelId, groupBy, filters = [] }) {
|
||||
const filterExpression = parseFilterExpression(filters)
|
||||
|
||||
const emitExpression = parseEmitExpression(field, groupBy)
|
||||
|
||||
return {
|
||||
meta: {
|
||||
field,
|
||||
modelId,
|
||||
groupBy,
|
||||
filter: [
|
||||
{
|
||||
key: "Status",
|
||||
condition: "Equals",
|
||||
value: "VIP",
|
||||
},
|
||||
{
|
||||
conjunction: "AND"
|
||||
},
|
||||
{
|
||||
key: "Status",
|
||||
condition: "Equals",
|
||||
value: "VIP",
|
||||
}
|
||||
],
|
||||
filters,
|
||||
schema: {
|
||||
sum: "number",
|
||||
min: "number",
|
||||
|
@ -50,8 +63,10 @@ function statsViewTemplate({ field, modelId, groupBy }) {
|
|||
},
|
||||
},
|
||||
map: `function (doc) {
|
||||
if (doc.modelId === "${modelId}") {
|
||||
emit(doc["${groupBy || "_id"}"], doc["${field}"]);
|
||||
if (doc.modelId === "${modelId}" ${
|
||||
filterExpression ? `&& ${filterExpression}` : ""
|
||||
}) {
|
||||
${emitExpression}
|
||||
}
|
||||
}`,
|
||||
reduce: "_stats",
|
||||
|
|
Loading…
Reference in New Issue