Merge branch 'master' into feature/binding-interface

This commit is contained in:
Michael Shanks 2020-08-27 09:39:11 +01:00
commit d7f70d0424
28 changed files with 626 additions and 194 deletions

View File

@ -1,3 +1,4 @@
context('Create a View', () => { context('Create a View', () => {
before(() => { before(() => {
cy.visit('localhost:4001/_builder') cy.visit('localhost:4001/_builder')
@ -7,6 +8,7 @@ context('Create a View', () => {
cy.addColumn('data', 'age', 'Number') cy.addColumn('data', 'age', 'Number')
cy.addColumn('data', 'rating', 'Number') cy.addColumn('data', 'rating', 'Number')
// 6 Records
cy.addRecord(["Students", 25, 1]) cy.addRecord(["Students", 25, 1])
cy.addRecord(["Students", 20, 3]) cy.addRecord(["Students", 20, 3])
cy.addRecord(["Students", 18, 6]) cy.addRecord(["Students", 18, 6])
@ -15,11 +17,39 @@ context('Create a View', () => {
cy.addRecord(["Teachers", 36, 3]) cy.addRecord(["Teachers", 36, 3])
}) })
it('creates a view', () => {
it('creates a stats view based on age', () => {
cy.contains("Create New View").click() cy.contains("Create New View").click()
cy.get("[placeholder='View Name']").type("Test View") cy.get("[placeholder='View Name']").type("Test View")
cy.contains("Save View").click() cy.contains("Save View").click()
cy.get(".title").contains("Test View")
cy.get("thead th").should(($headers) => {
expect($headers).to.have.length(3)
const headers = $headers.map((i, header) => Cypress.$(header).text())
expect(headers.get()).to.deep.eq([
"group",
"age",
"rating"
])
})
});
it('filters the view by age over 10', () => {
cy.contains("Filter").click()
cy.contains("Add Filter").click()
cy.get(".menu-container").find("select").first().select("age")
cy.get(".menu-container").find("select").eq(1).select("More Than")
cy.get("input[placeholder='age']").type(18)
cy.contains("Save").click()
cy.get("tbody tr").should(($values) => {
expect($values).to.have.length(5)
})
});
it('creates a stats calculation view based on age', () => {
cy.contains("Calculate").click()
cy.get(".menu-container").find("select").first().select("Statistics")
cy.get(".menu-container").find("select").eq(1).select("age")
cy.contains("Save").click()
cy.get("thead th").should(($headers) => { cy.get("thead th").should(($headers) => {
expect($headers).to.have.length(7) expect($headers).to.have.length(7)
const headers = $headers.map((i, header) => Cypress.$(header).text()) const headers = $headers.map((i, header) => Cypress.$(header).text())
@ -28,8 +58,8 @@ context('Create a View', () => {
"sum", "sum",
"min", "min",
"max", "max",
"sumsqr",
"count", "count",
"sumsqr",
"avg", "avg",
]) ])
}) })
@ -37,17 +67,17 @@ context('Create a View', () => {
const values = $values.map((i, value) => Cypress.$(value).text()) const values = $values.map((i, value) => Cypress.$(value).text())
expect(values.get()).to.deep.eq([ expect(values.get()).to.deep.eq([
"null", "null",
"173", "155",
"18", "20",
"49", "49",
"5671", "5",
"6", "5347",
"28.833333333333332" "31"
]) ])
}) })
}) })
it('groups the stats view by group', () => { it('groups the view by group', () => {
cy.contains("Group By").click() cy.contains("Group By").click()
cy.get("select").select("group") cy.get("select").select("group")
cy.contains("Save").click() cy.contains("Save").click()
@ -58,12 +88,12 @@ context('Create a View', () => {
const values = $values.map((i, value) => Cypress.$(value).text()) const values = $values.map((i, value) => Cypress.$(value).text())
expect(values.get()).to.deep.eq([ expect(values.get()).to.deep.eq([
"Students", "Students",
"88", "70",
"18", "20",
"25", "25",
"1974", "3",
"4", "1650",
"22" "23.333333333333332"
]) ])
}) })
}) })
@ -77,6 +107,7 @@ context('Create a View', () => {
}) })
it('deletes a view', () => { it('deletes a view', () => {
cy.contains("[data-cy=model-nav-item]", "Test View Updated").click()
cy.contains("[data-cy=model-nav-item]", "Test View Updated").find(".ri-more-line").click() cy.contains("[data-cy=model-nav-item]", "Test View Updated").find(".ri-more-line").click()
cy.contains("Delete").click() cy.contains("Delete").click()
cy.get(".content").contains("button", "Delete").click() cy.get(".content").contains("button", "Delete").click()

View File

@ -128,3 +128,29 @@
justify-content: center; justify-content: center;
font-size: 20px; font-size: 20px;
} }
/* Table styles */
.bb-table {
border-collapse: collapse;
border-spacing: 0;
width: 100%;
margin-bottom: var(--spacing-xl);
}
* + .bb-table {
margin-top: var(--spacing-xl)
}
.bb-table th {
padding: var(--spacing-l) var(--spacing-m);
text-align: left;
vertical-align: bottom;
}
.bb-table td {
padding: var(--spacing-l) var(--spacing-m);
vertical-align: top;
}
.bb-table td > :last-child { margin-bottom: 0; }

View File

@ -9,10 +9,6 @@ const INITIAL_BACKEND_UI_STATE = {
selectedDatabase: {}, selectedDatabase: {},
selectedModel: {}, selectedModel: {},
draftModel: {}, draftModel: {},
tabs: {
SETUP_PANEL: "SETUP",
NAVIGATION_PANEL: "NAVIGATE",
},
} }
export const getBackendUiStore = () => { export const getBackendUiStore = () => {
@ -127,19 +123,24 @@ export const getBackendUiStore = () => {
await store.actions.models.fetch() await store.actions.models.fetch()
}, },
save: async view => { save: async view => {
await api.post(`/api/views`, view) const response = await api.post(`/api/views`, view)
const json = await response.json()
const viewMeta = {
name: view.name,
...json,
}
store.update(state => { store.update(state => {
const viewModel = state.models.find( const viewModel = state.models.find(
model => model._id === view.modelId model => model._id === view.modelId
) )
// TODO: Cleaner?
if (!viewModel.views) viewModel.views = {}
if (view.originalName) delete viewModel.views[view.originalName] if (view.originalName) delete viewModel.views[view.originalName]
viewModel.views[view.name] = view viewModel.views[view.name] = viewMeta
state.models = state.models state.models = state.models
state.selectedView = view state.selectedView = viewMeta
return state return state
}) })
}, },
@ -156,11 +157,3 @@ export const getBackendUiStore = () => {
return store return store
} }
// Store Actions
export const createDatabaseForApp = store => appInstance => {
store.update(state => {
state.appInstances.push(appInstance)
return state
})
}

View File

@ -1,7 +1,6 @@
import { values, cloneDeep } from "lodash/fp" import { values, cloneDeep } from "lodash/fp"
import getNewComponentName from "../getNewComponentName" import getNewComponentName from "../getNewComponentName"
import { backendUiStore } from "builderStore" import { backendUiStore } from "builderStore"
import * as backendStoreActions from "./backend"
import { writable, get } from "svelte/store" import { writable, get } from "svelte/store"
import api from "../api" import api from "../api"
import { DEFAULT_PAGES_OBJECT } from "../../constants" import { DEFAULT_PAGES_OBJECT } from "../../constants"
@ -25,6 +24,7 @@ import {
saveScreenApi as _saveScreenApi, saveScreenApi as _saveScreenApi,
regenerateCssForCurrentScreen, regenerateCssForCurrentScreen,
generateNewIdsForComponent, generateNewIdsForComponent,
getComponentDefinition,
} from "../storeUtils" } from "../storeUtils"
export const getStore = () => { export const getStore = () => {
const initial = { const initial = {
@ -50,8 +50,6 @@ export const getStore = () => {
store.setPackage = setPackage(store, initial) store.setPackage = setPackage(store, initial)
store.createDatabaseForApp = backendStoreActions.createDatabaseForApp(store)
store.saveScreen = saveScreen(store) store.saveScreen = saveScreen(store)
store.setCurrentScreen = setCurrentScreen(store) store.setCurrentScreen = setCurrentScreen(store)
store.setCurrentPage = setCurrentPage(store) store.setCurrentPage = setCurrentPage(store)
@ -77,9 +75,6 @@ export const getStore = () => {
export default getStore export default getStore
export const getComponentDefinition = (state, name) =>
name.startsWith("##") ? getBuiltin(name) : state.components[name]
const setPackage = (store, initial) => async pkg => { const setPackage = (store, initial) => async pkg => {
const [main_screens, unauth_screens] = await Promise.all([ const [main_screens, unauth_screens] = await Promise.all([
api api

View File

@ -1,4 +1,7 @@
import { makePropsSafe } from "components/userInterface/pagesParsing/createProps" import {
makePropsSafe,
getBuiltin,
} from "components/userInterface/pagesParsing/createProps"
import api from "./api" import api from "./api"
import { generate_screen_css } from "./generate_css" import { generate_screen_css } from "./generate_css"
import { uuid } from "./uuid" import { uuid } from "./uuid"
@ -87,3 +90,6 @@ export const generateNewIdsForComponent = (c, state) =>
p._id = uuid() p._id = uuid()
p._instanceName = getNewComponentName(p._component, state) p._instanceName = getNewComponentName(p._component, state)
}) })
export const getComponentDefinition = (state, name) =>
name.startsWith("##") ? getBuiltin(name) : state.components[name]

View File

@ -63,7 +63,7 @@
{/if} {/if}
</div> </div>
</div> </div>
<table class="uk-table"> <table class="bb-table">
<thead> <thead>
<tr> <tr>
<th class="edit-header"> <th class="edit-header">

View File

@ -16,7 +16,7 @@
import EditRowPopover from "./popovers/EditRow.svelte" import EditRowPopover from "./popovers/EditRow.svelte"
import CalculationPopover from "./popovers/Calculate.svelte" import CalculationPopover from "./popovers/Calculate.svelte"
export let columns = [] export let schema = []
export let data = [] export let data = []
export let title export let title
@ -24,6 +24,7 @@
let currentPage = 0 let currentPage = 0
$: columns = schema ? Object.keys(schema) : []
$: paginatedData = $: paginatedData =
data && data.length data && data.length
? data.slice( ? data.slice(
@ -42,11 +43,11 @@
<slot /> <slot />
</div> </div>
</div> </div>
<table class="uk-table"> <table class="bb-table">
<thead> <thead>
<tr> <tr>
{#each columns as header} {#each columns as header}
<th>{header.name}</th> <th>{header}</th>
{/each} {/each}
</tr> </tr>
</thead> </thead>
@ -57,7 +58,7 @@
{#each paginatedData as row} {#each paginatedData as row}
<tr> <tr>
{#each columns as header} {#each columns as header}
<td>{getOr(row.default || '', header.key, row)}</td> <td>{getOr('', header, row)}</td>
{/each} {/each}
</tr> </tr>
{/each} {/each}

View File

@ -17,58 +17,35 @@
import EditRowPopover from "./popovers/EditRow.svelte" import EditRowPopover from "./popovers/EditRow.svelte"
import CalculationPopover from "./popovers/Calculate.svelte" import CalculationPopover from "./popovers/Calculate.svelte"
import GroupByPopover from "./popovers/GroupBy.svelte" import GroupByPopover from "./popovers/GroupBy.svelte"
import FilterPopover from "./popovers/Filter.svelte"
let COLUMNS = [
{
name: "group",
key: "key",
default: "All Records",
},
{
name: "sum",
key: "value.sum",
},
{
name: "min",
key: "value.min",
},
{
name: "max",
key: "value.max",
},
{
name: "sumsqr",
key: "value.sumsqr",
},
{
name: "count",
key: "value.count",
},
{
name: "avg",
key: "value.avg",
},
]
export let view = {} export let view = {}
let data = [] let data = []
$: ({ name, groupBy } = view) $: name = view.name
$: !name.startsWith("all_") && fetchViewData(name, groupBy) $: filters = view.filters
$: field = view.field
$: groupBy = view.groupBy
$: !name.startsWith("all_") && filters && fetchViewData(name, field, groupBy)
async function fetchViewData(name, groupBy) { async function fetchViewData(name, field, groupBy) {
let QUERY_VIEW_URL = `/api/views/${name}?stats=true` const params = new URLSearchParams()
if (groupBy) {
QUERY_VIEW_URL += `&group=${groupBy}` if (field) params.set("stats", true)
} if (groupBy) params.set("group", groupBy)
let QUERY_VIEW_URL = `/api/views/${name}?${params}`
const response = await api.get(QUERY_VIEW_URL) const response = await api.get(QUERY_VIEW_URL)
data = await response.json() data = await response.json()
} }
</script> </script>
<Table title={decodeURI(view.name)} columns={COLUMNS} {data}> <Table title={decodeURI(name)} schema={view.schema} {data}>
<FilterPopover {view} />
<CalculationPopover {view} /> <CalculationPopover {view} />
<GroupByPopover {view} /> {#if view.calculation}
<GroupByPopover {view} />
{/if}
</Table> </Table>

View File

@ -19,25 +19,6 @@
? Object.entries($backendUiStore.selectedModel.schema) ? 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() { async function saveRecord() {
const recordResponse = await api.saveRecord( const recordResponse = await api.saveRecord(
{ {
@ -73,11 +54,7 @@
linkName={meta.name} linkName={meta.name}
modelId={meta.modelId} /> modelId={meta.modelId} />
{:else} {:else}
<RecordFieldControl <RecordFieldControl {meta} bind:value={record[key]} />
type={determineInputType(meta)}
options={determineOptions(meta)}
label={meta.name}
bind:value={record[key]} />
{/if} {/if}
</div> </div>
{/each} {/each}

View File

@ -1,10 +1,24 @@
<script> <script>
import { Input, Select } from "@budibase/bbui" import { Input, Select } from "@budibase/bbui"
export let type = "text" export let meta
export let value = type === "checkbox" ? false : "" export let value = meta.type === "boolean" ? false : ""
export let label
export let options = [] let isSelect =
meta.type === "string" &&
meta.constraints &&
meta.constraints.inclusion &&
meta.constraints.inclusion.length > 0
let type = determineInputType(meta)
function determineInputType(meta) {
if (meta.type === "datetime") return "date"
if (meta.type === "number") return "number"
if (meta.type === "boolean") return "checkbox"
if (isSelect) return "select"
return "text"
}
const handleInput = event => { const handleInput = event => {
if (event.target.type === "checkbox") { if (event.target.type === "checkbox") {
@ -22,20 +36,20 @@
</script> </script>
{#if type === 'select'} {#if type === 'select'}
<Select thin secondary data-cy="{label}-select" bind:value> <Select thin secondary data-cy="{meta.name}-select" bind:value>
<option /> <option />
{#each options as opt} {#each meta.constraints.inclusion as opt}
<option value={opt}>{opt}</option> <option value={opt}>{opt}</option>
{/each} {/each}
</Select> </Select>
{:else} {:else}
{#if type === 'checkbox'} {#if type === 'checkbox'}
<label>{label}</label> <label>{meta.name}</label>
{/if} {/if}
<Input <Input
thin thin
placeholder={label} placeholder={meta.name}
data-cy="{label}-input" data-cy="{meta.name}-input"
checked={value} checked={value}
{type} {type}
{value} {value}

View File

@ -50,12 +50,14 @@
<div class="input-group-row"> <div class="input-group-row">
<p>The</p> <p>The</p>
<Select secondary thin bind:value={view.calculation}> <Select secondary thin bind:value={view.calculation}>
<option value={null} />
{#each CALCULATIONS as calculation} {#each CALCULATIONS as calculation}
<option value={calculation.key}>{calculation.name}</option> <option value={calculation.key}>{calculation.name}</option>
{/each} {/each}
</Select> </Select>
<p>of</p> <p>of</p>
<Select secondary thin bind:value={view.field}> <Select secondary thin bind:value={view.field}>
<option value={null} />
{#each fields as field} {#each fields as field}
<option value={field}>{field}</option> <option value={field}>{field}</option>
{/each} {/each}

View File

@ -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>

View File

@ -73,7 +73,7 @@
.input-group-row { .input-group-row {
display: grid; display: grid;
grid-template-columns: 50px 1fr 20px 1fr; grid-template-columns: 75px 1fr 20px 1fr;
gap: var(--spacing-s); gap: var(--spacing-s);
margin-bottom: var(--spacing-l); margin-bottom: var(--spacing-l);
align-items: center; align-items: center;

View File

@ -51,11 +51,6 @@
<h5>Create View</h5> <h5>Create View</h5>
<div class="input-group-column"> <div class="input-group-column">
<Input placeholder="View Name" thin bind:value={name} /> <Input placeholder="View Name" thin bind:value={name} />
<Select thin secondary bind:value={field}>
{#each fields as field}
<option value={field}>{field}</option>
{/each}
</Select>
</div> </div>
<div class="button-group"> <div class="button-group">
<Button secondary on:click={dropdown.hide}>Cancel</Button> <Button secondary on:click={dropdown.hide}>Cancel</Button>

View File

@ -47,10 +47,10 @@
selected={selectedView === viewName} selected={selectedView === viewName}
title={viewName} title={viewName}
icon="ri-eye-line" icon="ri-eye-line"
on:click={() => selectView({ on:click={() => (selectedView === viewName ? {} : selectView({
name: viewName, name: viewName,
...model.views[viewName], ...model.views[viewName],
})}> }))}>
<EditViewPopover <EditViewPopover
view={{ name: viewName, ...model.views[viewName] }} /> view={{ name: viewName, ...model.views[viewName] }} />
</ListItem> </ListItem>

View File

@ -1,7 +1,7 @@
<script> <script>
import { MoreIcon } from "components/common/Icons" import { MoreIcon } from "components/common/Icons"
import { store } from "builderStore" import { store } from "builderStore"
import { getComponentDefinition } from "builderStore/store" import { getComponentDefinition } from "builderStore/storeUtils"
import ConfirmDialog from "components/common/ConfirmDialog.svelte" import ConfirmDialog from "components/common/ConfirmDialog.svelte"
import { last, cloneDeep } from "lodash/fp" import { last, cloneDeep } from "lodash/fp"
import UIkit from "uikit" import UIkit from "uikit"

View File

@ -11,6 +11,11 @@
export let screens = [] export let screens = []
/*
Using a store here seems odd....
have a look in the <ComponentsHierarchyChildren /> code file to find out why.
I have commented the dragDropStore parameter
*/
const dragDropStore = writable({}) const dragDropStore = writable({})
let confirmDeleteDialog let confirmDeleteDialog

View File

@ -10,11 +10,29 @@
ChevronDownIcon, ChevronDownIcon,
CopyIcon, CopyIcon,
} from "../common/Icons" } from "../common/Icons"
import { getComponentDefinition } from "builderStore/storeUtils"
export let components = [] export let components = []
export let currentComponent export let currentComponent
export let onSelect = () => {} export let onSelect = () => {}
export let level = 0 export let level = 0
/*
"dragDropStore" is a svelte store.
This component is recursive... a tree.
Using a single, shared store, all the nodes in the tree can subscribe to state that is changed by other nodes, in the same tree.
e.g. Say i have the structure
- Heading 1
- Container
- Heading 2
- Heading 3
- Heading 4
1. When I dragover "Heading 1", a placeholder drop-slot appears below it
2. I drag down a bit so the cursor is INSIDE the container (i.e. now in a child <ComponentsHierarchyChildren />)
3. Using store subscribes... the original drop-slot now knows that it should disappear, and a new one is created inside the container.
*/
export let dragDropStore export let dragDropStore
let dropUnderComponent let dropUnderComponent
@ -47,7 +65,7 @@
const dragover = (component, index) => e => { const dragover = (component, index) => e => {
const canHaveChildrenButIsEmpty = const canHaveChildrenButIsEmpty =
$store.components[component._component].children && getComponentDefinition($store, component._component).children &&
component._children.length === 0 component._children.length === 0
e.dataTransfer.dropEffect = "copy" e.dataTransfer.dropEffect = "copy"
@ -97,6 +115,15 @@
return s return s
}) })
} }
const dragend = () => {
dragDropStore.update(s => {
s.dropPosition = ""
s.targetComponent = null
s.componentToDrop = null
return s
})
}
</script> </script>
<ul> <ul>
@ -117,6 +144,7 @@
class:selected={currentComponent === component} class:selected={currentComponent === component}
style="padding-left: {level * 20 + 40}px" style="padding-left: {level * 20 + 40}px"
draggable={true} draggable={true}
on:dragend={dragend}
on:dragstart={dragstart(component)} on:dragstart={dragstart(component)}
on:dragover={dragover(component, index)} on:dragover={dragover(component, index)}
on:drop={drop} on:drop={drop}

View File

@ -96,6 +96,8 @@ textarea {
cursor: pointer; cursor: pointer;
} }
/* Top bottom spacing */
.bb-margin-m { .bb-margin-m {
margin-bottom: var(--spacing-m); margin-bottom: var(--spacing-m);
} }

View File

@ -23,8 +23,8 @@
} }
}, },
"scripts": { "scripts": {
"test": "jest routes --runInBand", "test": "jest --testPathIgnorePatterns=routes && npm run test:integration",
"test:integration": "jest workflow --runInBand", "test:integration": "jest routes --runInBand",
"test:watch": "jest --watch", "test:watch": "jest --watch",
"initialise": "node ../cli/bin/budi init -q", "initialise": "node ../cli/bin/budi init -q",
"run:docker": "node src/index", "run:docker": "node src/index",

View File

@ -87,12 +87,11 @@ exports.fetchView = async function(ctx) {
}) })
if (stats) { if (stats) {
for (let row of response.rows) { response.rows = response.rows.map(row => ({
row.value = { group: row.key,
...row.value, ...row.value,
avg: row.value.sum / row.value.count, avg: row.value.sum / row.value.count,
} }))
}
} else { } else {
response.rows = response.rows.map(row => row.doc) response.rows = response.rows.map(row => row.doc)
} }

View File

@ -1,5 +1,5 @@
const CouchDB = require("../../../db") const CouchDB = require("../../../db")
const statsViewTemplate = require("./viewBuilder") const viewTemplate = require("./viewBuilder")
const controller = { const controller = {
fetch: async ctx => { fetch: async ctx => {
@ -25,15 +25,15 @@ const controller = {
}, },
save: async ctx => { save: async ctx => {
const db = new CouchDB(ctx.user.instanceId) const db = new CouchDB(ctx.user.instanceId)
const { originalName, ...newView } = ctx.request.body const { originalName, ...viewToSave } = ctx.request.body
const designDoc = await db.get("_design/database") const designDoc = await db.get("_design/database")
const view = statsViewTemplate(newView) const view = viewTemplate(viewToSave)
designDoc.views = { designDoc.views = {
...designDoc.views, ...designDoc.views,
[newView.name]: view, [viewToSave.name]: view,
} }
// view has been renamed // view has been renamed
@ -45,10 +45,11 @@ const controller = {
// add views to model document // add views to model document
const model = await db.get(ctx.request.body.modelId) const model = await db.get(ctx.request.body.modelId)
model.views = { if (!model.views) model.views = {}
...(model.views ? model.views : {}), if (!view.meta.schema) {
[newView.name]: view.meta, view.meta.schema = model.schema
} }
model.views[viewToSave.name] = view.meta
if (originalName) { if (originalName) {
delete model.views[originalName] delete model.views[originalName]
@ -56,8 +57,8 @@ const controller = {
await db.put(model) await db.put(model)
ctx.body = view ctx.body = model.views[viewToSave.name]
ctx.message = `View ${newView.name} saved successfully.` ctx.message = `View ${viewToSave.name} saved successfully.`
}, },
destroy: async ctx => { destroy: async ctx => {
const db = new CouchDB(ctx.user.instanceId) const db = new CouchDB(ctx.user.instanceId)

View File

@ -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",
}
`;

View File

@ -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()
})
})
});

View File

@ -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

View File

@ -3,15 +3,13 @@
exports[`/views query returns data for the created view 1`] = ` exports[`/views query returns data for the created view 1`] = `
Array [ Array [
Object { Object {
"key": null, "avg": 2333.3333333333335,
"value": Object { "count": 3,
"avg": 2333.3333333333335, "group": null,
"count": 3, "max": 4000,
"max": 4000, "min": 1000,
"min": 1000, "sum": 7000,
"sum": 7000, "sumsqr": 21000000,
"sumsqr": 21000000,
},
}, },
] ]
`; `;
@ -19,26 +17,22 @@ Array [
exports[`/views query returns data for the created view using a group by 1`] = ` exports[`/views query returns data for the created view using a group by 1`] = `
Array [ Array [
Object { Object {
"key": "One", "avg": 1500,
"value": Object { "count": 2,
"avg": 1500, "group": "One",
"count": 2, "max": 2000,
"max": 2000, "min": 1000,
"min": 1000, "sum": 3000,
"sum": 3000, "sumsqr": 5000000,
"sumsqr": 5000000,
},
}, },
Object { Object {
"key": "Two", "avg": 4000,
"value": Object { "count": 1,
"avg": 4000, "group": "Two",
"count": 1, "max": 4000,
"max": 4000, "min": 4000,
"min": 4000, "sum": 4000,
"sum": 4000, "sumsqr": 16000000,
"sumsqr": 16000000,
},
}, },
] ]
`; `;

View File

@ -66,13 +66,14 @@ describe("/views", () => {
TestView: { TestView: {
field: "Price", field: "Price",
modelId: model._id, modelId: model._id,
filters: [],
schema: { schema: {
sum: "number", name: {
min: "number", type: "text",
max: "number", constraints: {
count: "number", type: "string"
sumsqr: "number", }
avg: "number" }
} }
} }
}); });

File diff suppressed because one or more lines are too long