mirror of https://github.com/Budibase/budibase.git
committed by
GitHub
21 changed files with 551 additions and 184 deletions
@ -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> |
|||
@ -0,0 +1,91 @@ |
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
|||
|
|||
exports[`viewBuilder Calculate creates a view with the calculation statistics schema 1`] = ` |
|||
Object { |
|||
"map": "function (doc) { |
|||
if (doc.modelId === \\"14f1c4e94d6a47b682ce89d35d4c78b0\\" ) { |
|||
emit(doc[\\"_id\\"], doc[\\"myField\\"]); |
|||
} |
|||
}", |
|||
"meta": Object { |
|||
"calculation": "stats", |
|||
"field": "myField", |
|||
"filters": Array [], |
|||
"groupBy": undefined, |
|||
"modelId": "14f1c4e94d6a47b682ce89d35d4c78b0", |
|||
"schema": Object { |
|||
"avg": Object { |
|||
"type": "number", |
|||
}, |
|||
"count": Object { |
|||
"type": "number", |
|||
}, |
|||
"group": Object { |
|||
"type": "string", |
|||
}, |
|||
"max": Object { |
|||
"type": "number", |
|||
}, |
|||
"min": Object { |
|||
"type": "number", |
|||
}, |
|||
"sum": Object { |
|||
"type": "number", |
|||
}, |
|||
"sumsqr": Object { |
|||
"type": "number", |
|||
}, |
|||
}, |
|||
}, |
|||
"reduce": "_stats", |
|||
} |
|||
`; |
|||
|
|||
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 { |
|||
"calculation": undefined, |
|||
"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": undefined, |
|||
}, |
|||
} |
|||
`; |
|||
|
|||
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 { |
|||
"calculation": undefined, |
|||
"field": "score", |
|||
"filters": Array [], |
|||
"groupBy": "age", |
|||
"modelId": "14f1c4e94d6a47b682ce89d35d4c78b0", |
|||
"schema": undefined, |
|||
}, |
|||
"reduce": "_stats", |
|||
} |
|||
`; |
|||
@ -0,0 +1,47 @@ |
|||
const viewTemplate = require("../viewBuilder"); |
|||
|
|||
describe("viewBuilder", () => { |
|||
|
|||
describe("Filter", () => { |
|||
it("creates a view with multiple filters and conjunctions", () => { |
|||
expect(viewTemplate({ |
|||
"name": "Test View", |
|||
"modelId": "14f1c4e94d6a47b682ce89d35d4c78b0", |
|||
"filters": [{ |
|||
"value": "Test", |
|||
"condition": "EQUALS", |
|||
"key": "Name" |
|||
}, { |
|||
"value": "Value", |
|||
"condition": "MT", |
|||
"key": "Yes", |
|||
"conjunction": "OR" |
|||
}] |
|||
})).toMatchSnapshot() |
|||
}) |
|||
}) |
|||
|
|||
describe("Calculate", () => { |
|||
it("creates a view with the calculation statistics schema", () => { |
|||
expect(viewTemplate({ |
|||
"name": "Calculate View", |
|||
"field": "myField", |
|||
"calculation": "stats", |
|||
"modelId": "14f1c4e94d6a47b682ce89d35d4c78b0", |
|||
"filters": [] |
|||
})).toMatchSnapshot() |
|||
}) |
|||
}) |
|||
|
|||
describe("Group By", () => { |
|||
it("creates a view emitting the group by field", () => { |
|||
expect(viewTemplate({ |
|||
"name": "Test Scores Grouped By Age", |
|||
"modelId": "14f1c4e94d6a47b682ce89d35d4c78b0", |
|||
"groupBy": "age", |
|||
"field": "score", |
|||
"filters": [], |
|||
})).toMatchSnapshot() |
|||
}) |
|||
}) |
|||
}); |
|||
@ -1,25 +1,112 @@ |
|||
function statsViewTemplate({ field, modelId, groupBy }) { |
|||
const TOKEN_MAP = { |
|||
EQUALS: "===", |
|||
LT: "<", |
|||
LTE: "<=", |
|||
MT: ">", |
|||
MTE: ">=", |
|||
CONTAINS: "includes", |
|||
AND: "&&", |
|||
OR: "||", |
|||
} |
|||
|
|||
const SCHEMA_MAP = { |
|||
stats: { |
|||
group: { |
|||
type: "string", |
|||
}, |
|||
sum: { |
|||
type: "number", |
|||
}, |
|||
min: { |
|||
type: "number", |
|||
}, |
|||
max: { |
|||
type: "number", |
|||
}, |
|||
count: { |
|||
type: "number", |
|||
}, |
|||
sumsqr: { |
|||
type: "number", |
|||
}, |
|||
avg: { |
|||
type: "number", |
|||
}, |
|||
}, |
|||
} |
|||
|
|||
/** |
|||
* 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}")` |
|||
) |
|||
} else { |
|||
expression.push( |
|||
`doc["${filter.key}"] ${TOKEN_MAP[filter.condition]} "${filter.value}"` |
|||
) |
|||
} |
|||
} |
|||
|
|||
return expression.join(" ") |
|||
} |
|||
|
|||
/** |
|||
* Returns a CouchDB compliant emit() expression that is used to emit the |
|||
* correct key/value pairs for custom views. |
|||
* @param {String?} field - field to use for calculations, if any |
|||
* @param {String?} groupBy - field to group calculation results on, if any |
|||
*/ |
|||
function parseEmitExpression(field, groupBy) { |
|||
if (field) return `emit(doc["${groupBy || "_id"}"], doc["${field}"]);` |
|||
return `emit(doc._id);` |
|||
} |
|||
|
|||
/** |
|||
* Return a fully parsed CouchDB compliant view definition |
|||
* that will be stored in the design document in the database. |
|||
* |
|||
* @param {Object} viewDefinition - the JSON definition for a custom view. |
|||
* field: field that calculations will be performed on |
|||
* modelId: modelId of the model this view was created from |
|||
* groupBy: field that calculations will be grouped by. Field must be present for this to be useful |
|||
* filters: Array of filter objects containing predicates that are parsed into a JS expression |
|||
* calculation: an optional calculation to be performed over the view data. |
|||
*/ |
|||
function viewTemplate({ field, modelId, groupBy, filters = [], calculation }) { |
|||
const parsedFilters = parseFilterExpression(filters) |
|||
const filterExpression = parsedFilters ? `&& ${parsedFilters}` : "" |
|||
|
|||
const emitExpression = parseEmitExpression(field, groupBy) |
|||
|
|||
const reduction = field ? { reduce: "_stats" } : {} |
|||
|
|||
return { |
|||
meta: { |
|||
field, |
|||
modelId, |
|||
groupBy, |
|||
schema: { |
|||
sum: "number", |
|||
min: "number", |
|||
max: "number", |
|||
count: "number", |
|||
sumsqr: "number", |
|||
avg: "number", |
|||
}, |
|||
filters, |
|||
schema: SCHEMA_MAP[calculation], |
|||
calculation, |
|||
}, |
|||
map: `function (doc) {
|
|||
if (doc.modelId === "${modelId}") { |
|||
emit(doc["${groupBy || "_id"}"], doc["${field}"]); |
|||
if (doc.modelId === "${modelId}" ${filterExpression}) { |
|||
${emitExpression} |
|||
} |
|||
}`,
|
|||
reduce: "_stats", |
|||
...reduction, |
|||
} |
|||
} |
|||
|
|||
module.exports = statsViewTemplate |
|||
module.exports = viewTemplate |
|||
|
|||
File diff suppressed because one or more lines are too long
Loading…
Reference in new issue