mirror of https://github.com/Budibase/budibase.git
34 changed files with 676 additions and 210 deletions
@ -0,0 +1,25 @@ |
|||||
|
if (!Array.prototype.flat) { |
||||
|
Object.defineProperty(Array.prototype, "flat", { |
||||
|
configurable: true, |
||||
|
value: function flat() { |
||||
|
var depth = isNaN(arguments[0]) ? 1 : Number(arguments[0]) |
||||
|
|
||||
|
return depth |
||||
|
? Array.prototype.reduce.call( |
||||
|
this, |
||||
|
function(acc, cur) { |
||||
|
if (Array.isArray(cur)) { |
||||
|
acc.push.apply(acc, flat.call(cur, depth - 1)) |
||||
|
} else { |
||||
|
acc.push(cur) |
||||
|
} |
||||
|
|
||||
|
return acc |
||||
|
}, |
||||
|
[] |
||||
|
) |
||||
|
: Array.prototype.slice.call(this) |
||||
|
}, |
||||
|
writable: true, |
||||
|
}) |
||||
|
} |
||||
@ -0,0 +1,160 @@ |
|||||
|
<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 |
||||
|
|
||||
|
$: viewModel = $backendUiStore.models.find( |
||||
|
({ _id }) => _id === $backendUiStore.selectedView.modelId |
||||
|
) |
||||
|
$: fields = viewModel && Object.keys(viewModel.schema) |
||||
|
|
||||
|
function saveView() { |
||||
|
backendUiStore.actions.views.save(view) |
||||
|
notifier.success(`View ${view.name} saved.`) |
||||
|
dropdown.hide() |
||||
|
} |
||||
|
|
||||
|
function removeFilter(idx) { |
||||
|
view.filters.splice(idx, 1) |
||||
|
view.filters = view.filters |
||||
|
} |
||||
|
|
||||
|
function addFilter() { |
||||
|
view.filters = [...view.filters, {}] |
||||
|
} |
||||
|
</script> |
||||
|
|
||||
|
<div bind:this={anchor}> |
||||
|
<TextButton |
||||
|
text |
||||
|
small |
||||
|
on:click={dropdown.show} |
||||
|
active={view.filters && view.filters.length}> |
||||
|
<Icon name="filter" /> |
||||
|
Filter |
||||
|
</TextButton> |
||||
|
</div> |
||||
|
<Popover bind:this={dropdown} {anchor} align="left"> |
||||
|
<h5>Filter</h5> |
||||
|
<div class="input-group-row"> |
||||
|
{#each view.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 { |
return { |
||||
meta: { |
meta: { |
||||
field, |
field, |
||||
modelId, |
modelId, |
||||
groupBy, |
groupBy, |
||||
schema: { |
filters, |
||||
sum: "number", |
schema: SCHEMA_MAP[calculation], |
||||
min: "number", |
calculation, |
||||
max: "number", |
|
||||
count: "number", |
|
||||
sumsqr: "number", |
|
||||
avg: "number", |
|
||||
}, |
|
||||
}, |
}, |
||||
map: `function (doc) {
|
map: `function (doc) {
|
||||
if (doc.modelId === "${modelId}") { |
if (doc.modelId === "${modelId}" ${filterExpression}) { |
||||
emit(doc["${groupBy || "_id"}"], doc["${field}"]); |
${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