Merge branch 'feature/binding-interface' of github.com:Budibase/budibase into feature/binding-interface
This commit is contained in:
commit
91adebe93d
|
@ -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()
|
||||||
|
|
|
@ -49,6 +49,9 @@
|
||||||
],
|
],
|
||||||
"setupFilesAfterEnv": [
|
"setupFilesAfterEnv": [
|
||||||
"@testing-library/jest-dom/extend-expect"
|
"@testing-library/jest-dom/extend-expect"
|
||||||
|
],
|
||||||
|
"setupFiles": [
|
||||||
|
"./scripts/jestSetup.js"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
|
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
|
@ -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; }
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ import { cloneDeep, difference } from "lodash/fp"
|
||||||
/**
|
/**
|
||||||
* parameter for fetchBindableProperties function
|
* parameter for fetchBindableProperties function
|
||||||
* @typedef {Object} fetchBindablePropertiesParameter
|
* @typedef {Object} fetchBindablePropertiesParameter
|
||||||
* @property {string} componentInstanceId - an _id of a component that has been added to a screen, whihc you want to fetch bindable props for
|
* @property {string} componentInstanceId - an _id of a component that has been added to a screen, which you want to fetch bindable props for
|
||||||
* @propperty {Object} screen - current screen - where componentInstanceId lives
|
* @propperty {Object} screen - current screen - where componentInstanceId lives
|
||||||
* @property {Object} components - dictionary of component definitions
|
* @property {Object} components - dictionary of component definitions
|
||||||
* @property {Array} models - array of all models
|
* @property {Array} models - array of all models
|
||||||
|
@ -23,7 +23,7 @@ import { cloneDeep, difference } from "lodash/fp"
|
||||||
* @param {fetchBindablePropertiesParameter} param
|
* @param {fetchBindablePropertiesParameter} param
|
||||||
* @returns {Array.<BindableProperty>}
|
* @returns {Array.<BindableProperty>}
|
||||||
*/
|
*/
|
||||||
export default function ({ componentInstanceId, screen, components, models }) {
|
export default function({ componentInstanceId, screen, components, models }) {
|
||||||
const walkResult = walk({
|
const walkResult = walk({
|
||||||
// cloning so we are free to mutate props (e.g. by adding _contexts)
|
// cloning so we are free to mutate props (e.g. by adding _contexts)
|
||||||
instance: cloneDeep(screen.props),
|
instance: cloneDeep(screen.props),
|
||||||
|
@ -69,16 +69,16 @@ const componentInstanceToBindable = walkResult => i => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const contextToBindables = walkResult => c => {
|
const contextToBindables = walkResult => context => {
|
||||||
const contextParentPath = getParentPath(walkResult, c)
|
const contextParentPath = getParentPath(walkResult, context)
|
||||||
|
|
||||||
return Object.keys(c.model.schema).map(k => ({
|
return Object.keys(context.model.schema).map(k => ({
|
||||||
type: "context",
|
type: "context",
|
||||||
instance: c.instance,
|
instance: context.instance,
|
||||||
// how the binding expression persists, and is used in the app at runtime
|
// how the binding expression persists, and is used in the app at runtime
|
||||||
runtimeBinding: `${contextParentPath}data.${k}`,
|
runtimeBinding: `${contextParentPath}data.${k}`,
|
||||||
// how the binding exressions looks to the user of the builder
|
// how the binding exressions looks to the user of the builder
|
||||||
readableBinding: `${c.instance._instanceName}.${c.model.name}.${k}`,
|
readableBinding: `${context.instance._instanceName}.${context.model.name}.${k}`,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -120,7 +120,7 @@ const walk = ({ instance, targetId, components, models, result }) => {
|
||||||
// but this will not be correct, as some of
|
// but this will not be correct, as some of
|
||||||
// these components will be in another context
|
// these components will be in another context
|
||||||
// but we dont know this until the end of the walk
|
// but we dont know this until the end of the walk
|
||||||
// so we will filter in another metod
|
// so we will filter in another method
|
||||||
result.bindableInstances.push({
|
result.bindableInstances.push({
|
||||||
instance,
|
instance,
|
||||||
prop: component.bindable,
|
prop: component.bindable,
|
||||||
|
@ -129,7 +129,8 @@ const walk = ({ instance, targetId, components, models, result }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// a component that provides context to it's children
|
// a component that provides context to it's children
|
||||||
const contextualInstance = component && component.context && instance[component.context]
|
const contextualInstance =
|
||||||
|
component && component.context && instance[component.context]
|
||||||
|
|
||||||
if (contextualInstance) {
|
if (contextualInstance) {
|
||||||
// add to currentContexts (ancestory of context)
|
// add to currentContexts (ancestory of context)
|
||||||
|
|
|
@ -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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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} />
|
||||||
|
{#if view.calculation}
|
||||||
<GroupByPopover {view} />
|
<GroupByPopover {view} />
|
||||||
|
{/if}
|
||||||
</Table>
|
</Table>
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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>
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -35,22 +35,26 @@
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replaceBindings(val) {
|
async function replaceBindings(textWithBindings) {
|
||||||
getBindableProperties()
|
getBindableProperties()
|
||||||
// Find all instances of mustasche
|
// Find all instances of mustasche
|
||||||
const boundValues = val.match(/{{([^}]+)}}/g)
|
const CAPTURE_VAR_INSIDE_MUSTACHE = /{{([^}]+)}}/g
|
||||||
|
const boundValues = textWithBindings.match(CAPTURE_VAR_INSIDE_MUSTACHE)
|
||||||
|
|
||||||
// Replace with names:
|
// Replace with names:
|
||||||
boundValues &&
|
boundValues &&
|
||||||
boundValues.forEach(v => {
|
boundValues.forEach(boundValue => {
|
||||||
const binding = bindableProperties.find(({ readableBinding }) => {
|
const binding = bindableProperties.find(({ readableBinding }) => {
|
||||||
return v === `{{ ${readableBinding} }}`
|
return boundValue === `{{ ${readableBinding} }}`
|
||||||
})
|
})
|
||||||
if (binding) {
|
if (binding) {
|
||||||
val = val.replace(v, `{{ ${binding.runtimeBinding} }}`)
|
textWithBindings = textWithBindings.replace(
|
||||||
|
boundValue,
|
||||||
|
`{{ ${binding.runtimeBinding} }}`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
onChange(key, val)
|
onChange(key, textWithBindings)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChange(key, v) {
|
function handleChange(key, v) {
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import fetchBindableProperties from "../src/builderStore/fetchBindableProperties"
|
import fetchBindableProperties from "../src/builderStore/fetchBindableProperties"
|
||||||
|
|
||||||
describe("fetch bindable properties", () => {
|
describe("fetch bindable properties", () => {
|
||||||
|
|
||||||
it("should return bindable properties from screen components", () => {
|
it("should return bindable properties from screen components", () => {
|
||||||
|
|
|
@ -40,7 +40,7 @@ const subscribe = (subscription, storeKey) => {
|
||||||
const contextStore = contextStores[storeKey]
|
const contextStore = contextStores[storeKey]
|
||||||
|
|
||||||
// we are subscribing to multiple stores,
|
// we are subscribing to multiple stores,
|
||||||
// we dont want to each subscription the first time
|
// we dont want to run our listener for every subscription, the first time
|
||||||
// as this could repeatedly run $set on the same component
|
// as this could repeatedly run $set on the same component
|
||||||
// ... which already has its initial properties set properly
|
// ... which already has its initial properties set properly
|
||||||
const ignoreFirstSubscription = () => {
|
const ignoreFirstSubscription = () => {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -3,42 +3,36 @@
|
||||||
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,
|
|
||||||
"value": Object {
|
|
||||||
"avg": 2333.3333333333335,
|
"avg": 2333.3333333333335,
|
||||||
"count": 3,
|
"count": 3,
|
||||||
|
"group": null,
|
||||||
"max": 4000,
|
"max": 4000,
|
||||||
"min": 1000,
|
"min": 1000,
|
||||||
"sum": 7000,
|
"sum": 7000,
|
||||||
"sumsqr": 21000000,
|
"sumsqr": 21000000,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
|
||||||
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",
|
|
||||||
"value": Object {
|
|
||||||
"avg": 1500,
|
"avg": 1500,
|
||||||
"count": 2,
|
"count": 2,
|
||||||
|
"group": "One",
|
||||||
"max": 2000,
|
"max": 2000,
|
||||||
"min": 1000,
|
"min": 1000,
|
||||||
"sum": 3000,
|
"sum": 3000,
|
||||||
"sumsqr": 5000000,
|
"sumsqr": 5000000,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
Object {
|
Object {
|
||||||
"key": "Two",
|
|
||||||
"value": Object {
|
|
||||||
"avg": 4000,
|
"avg": 4000,
|
||||||
"count": 1,
|
"count": 1,
|
||||||
|
"group": "Two",
|
||||||
"max": 4000,
|
"max": 4000,
|
||||||
"min": 4000,
|
"min": 4000,
|
||||||
"sum": 4000,
|
"sum": 4000,
|
||||||
"sumsqr": 16000000,
|
"sumsqr": 16000000,
|
||||||
},
|
},
|
||||||
},
|
|
||||||
]
|
]
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -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
Loading…
Reference in New Issue