Browse Source

Merge pull request #545 from Budibase/custom-view-backend

Custom Views
master
Martin McKeaveney 6 years ago
committed by GitHub
parent
commit
d547604178
  1. 85
      packages/builder/cypress/integration/createView.spec.js
  2. 2
      packages/builder/package.json
  3. 34
      packages/builder/src/builderStore/store/backend.js
  4. 0
      packages/builder/src/components/database/DataTable/LinkedRecord.svelte
  5. 27
      packages/builder/src/components/database/DataTable/ModelDataTable.svelte
  6. 141
      packages/builder/src/components/database/DataTable/Table.svelte
  7. 0
      packages/builder/src/components/database/DataTable/TablePagination.svelte
  8. 74
      packages/builder/src/components/database/DataTable/ViewDataTable.svelte
  9. 20
      packages/builder/src/components/database/DataTable/api.js
  10. 0
      packages/builder/src/components/database/DataTable/index.js
  11. 38
      packages/builder/src/components/database/DataTable/modals/CreateEditColumn.svelte
  12. 0
      packages/builder/src/components/database/DataTable/modals/CreateEditRecord.svelte
  13. 6
      packages/builder/src/components/database/DataTable/modals/DeleteRecord.svelte
  14. 6
      packages/builder/src/components/database/DataTable/modals/DeleteTable.svelte
  15. 62
      packages/builder/src/components/database/DataTable/modals/DeleteView.svelte
  16. 6
      packages/builder/src/components/database/DataTable/modals/RecordFieldControl.svelte
  17. 0
      packages/builder/src/components/database/DataTable/modals/index.js
  18. 95
      packages/builder/src/components/database/DataTable/popovers/Calculate.svelte
  19. 8
      packages/builder/src/components/database/DataTable/popovers/Column.svelte
  20. 0
      packages/builder/src/components/database/DataTable/popovers/ColumnHeader.svelte
  21. 0
      packages/builder/src/components/database/DataTable/popovers/EditRow.svelte
  22. 86
      packages/builder/src/components/database/DataTable/popovers/GroupBy.svelte
  23. 2
      packages/builder/src/components/database/DataTable/popovers/Row.svelte
  24. 82
      packages/builder/src/components/database/DataTable/popovers/View.svelte
  25. 0
      packages/builder/src/components/database/ModelDataTable/popovers/View.svelte
  26. 2
      packages/builder/src/components/nav/ModelNavigator/EditTable.svelte
  27. 143
      packages/builder/src/components/nav/ModelNavigator/EditView.svelte
  28. 15
      packages/builder/src/components/nav/ModelNavigator/ListItem.svelte
  29. 33
      packages/builder/src/components/nav/ModelNavigator/ModelNavigator.svelte
  30. 17
      packages/builder/src/components/userInterface/ModelSelect.svelte
  31. 10
      packages/builder/src/constants/backend/index.js
  32. 5
      packages/builder/src/pages/[application]/backend/_layout.svelte
  33. 6
      packages/builder/src/pages/[application]/backend/model/[selectedModel]/index.svelte
  34. 22
      packages/builder/src/pages/[application]/backend/view/[selectedView]/_layout.svelte
  35. 27
      packages/builder/src/pages/[application]/backend/view/[selectedView]/index.svelte
  36. 7
      packages/builder/yarn.lock
  37. 1
      packages/server/src/api/controllers/model.js
  38. 18
      packages/server/src/api/controllers/record.js
  39. 46
      packages/server/src/api/controllers/view.js
  40. 83
      packages/server/src/api/controllers/view/index.js
  41. 25
      packages/server/src/api/controllers/view/viewBuilder.js
  42. 44
      packages/server/src/api/routes/tests/__snapshots__/view.spec.js.snap
  43. 3
      packages/server/src/api/routes/tests/couchTestUtils.js
  44. 131
      packages/server/src/api/routes/tests/view.spec.js
  45. 5
      packages/server/src/api/routes/view.js
  46. 2
      packages/server/src/app.js
  47. 5
      packages/server/src/electron.js
  48. 3
      packages/standard-components/src/Chart/tests/bar.html

85
packages/builder/cypress/integration/createView.spec.js

@ -0,0 +1,85 @@
context('Create a View', () => {
before(() => {
cy.visit('localhost:4001/_builder')
cy.createApp('View App', 'View App Description')
cy.createTable('data')
cy.addColumn('data', 'group', 'Plain Text')
cy.addColumn('data', 'age', 'Number')
cy.addColumn('data', 'rating', 'Number')
cy.addRecord(["Students", 25, 1])
cy.addRecord(["Students", 20, 3])
cy.addRecord(["Students", 18, 6])
cy.addRecord(["Students", 25, 2])
cy.addRecord(["Teachers", 49, 5])
cy.addRecord(["Teachers", 36, 3])
})
it('creates a stats view based on age', () => {
cy.contains("Create New View").click()
cy.get("[placeholder='View Name']").type("Test View")
cy.contains("Save View").click()
cy.get("thead th").should(($headers) => {
expect($headers).to.have.length(7)
const headers = $headers.map((i, header) => Cypress.$(header).text())
expect(headers.get()).to.deep.eq([
"group",
"sum",
"min",
"max",
"sumsqr",
"count",
"avg",
])
})
cy.get("tbody td").should(($values) => {
const values = $values.map((i, value) => Cypress.$(value).text())
expect(values.get()).to.deep.eq([
"null",
"173",
"18",
"49",
"5671",
"6",
"28.833333333333332"
])
})
})
it('groups the stats view by group', () => {
cy.contains("Group By").click()
cy.get("select").select("group")
cy.contains("Save").click()
cy.contains("Students").should("be.visible")
cy.contains("Teachers").should("be.visible")
cy.get("tbody tr").first().find("td").should(($values) => {
const values = $values.map((i, value) => Cypress.$(value).text())
expect(values.get()).to.deep.eq([
"Students",
"88",
"18",
"25",
"1974",
"4",
"22"
])
})
})
it('renames a view', () => {
cy.contains("[data-cy=model-nav-item]", "Test View").find(".ri-more-line").click()
cy.contains("Edit").click()
cy.get("[placeholder='View Name']").type(" Updated")
cy.contains("Save").click()
cy.contains("Test View Updated").should("be.visible")
})
it('deletes a view', () => {
cy.contains("[data-cy=model-nav-item]", "Test View Updated").find(".ri-more-line").click()
cy.contains("Delete").click()
cy.get(".content").contains("button", "Delete").click()
cy.contains("TestView Updated").should("not.be.visible")
})
})

2
packages/builder/package.json

@ -57,7 +57,7 @@
] ]
}, },
"dependencies": { "dependencies": {
"@budibase/bbui": "^1.23.1", "@budibase/bbui": "^1.24.1",
"@budibase/client": "^0.1.17", "@budibase/client": "^0.1.17",
"@budibase/colorpicker": "^1.0.1", "@budibase/colorpicker": "^1.0.1",
"@sentry/browser": "5.19.1", "@sentry/browser": "5.19.1",

34
packages/builder/src/builderStore/store/backend.js

@ -23,13 +23,10 @@ export const getBackendUiStore = () => {
database: { database: {
select: async db => { select: async db => {
const modelsResponse = await api.get(`/api/models`) const modelsResponse = await api.get(`/api/models`)
const viewsResponse = await api.get(`/api/views`)
const models = await modelsResponse.json() const models = await modelsResponse.json()
const views = await viewsResponse.json()
store.update(state => { store.update(state => {
state.selectedDatabase = db state.selectedDatabase = db
state.models = models state.models = models
state.views = views
return state return state
}) })
}, },
@ -59,8 +56,7 @@ export const getBackendUiStore = () => {
store.update(state => { store.update(state => {
state.selectedModel = model state.selectedModel = model
state.draftModel = cloneDeep(model) state.draftModel = cloneDeep(model)
state.selectedField = "" state.selectedView = { name: `all_${model._id}` }
state.selectedView = `all_${model._id}`
return state return state
}), }),
save: async model => { save: async model => {
@ -99,10 +95,8 @@ export const getBackendUiStore = () => {
// delete the original if renaming // delete the original if renaming
delete state.draftModel.schema[originalName] delete state.draftModel.schema[originalName]
state.draftModel.schema = { state.draftModel.schema[field.name] = cloneDeep(field)
...state.draftModel.schema,
[field.name]: cloneDeep(field),
}
store.actions.models.save(state.draftModel) store.actions.models.save(state.draftModel)
return state return state
}) })
@ -119,8 +113,30 @@ export const getBackendUiStore = () => {
select: view => select: view =>
store.update(state => { store.update(state => {
state.selectedView = view state.selectedView = view
state.selectedModel = {}
return state return state
}), }),
delete: async view => {
await api.delete(`/api/views/${view}`)
await store.actions.models.fetch()
},
save: async view => {
await api.post(`/api/views`, view)
store.update(state => {
const viewModel = state.models.find(
model => model._id === view.modelId
)
// TODO: Cleaner?
if (!viewModel.views) viewModel.views = {}
if (view.originalName) delete viewModel.views[view.originalName]
viewModel.views[view.name] = view
state.models = state.models
state.selectedView = view
return state
})
},
}, },
users: { users: {
create: user => create: user =>

0
packages/builder/src/components/database/ModelDataTable/LinkedRecord.svelte → packages/builder/src/components/database/DataTable/LinkedRecord.svelte

27
packages/builder/src/components/database/ModelDataTable/ModelDataTable.svelte → packages/builder/src/components/database/DataTable/ModelDataTable.svelte

@ -1,6 +1,7 @@
<script> <script>
import { onMount } from "svelte" import { onMount } from "svelte"
import fsort from "fast-sort" import fsort from "fast-sort"
import getOr from "lodash/fp/getOr"
import { store, backendUiStore } from "builderStore" import { store, backendUiStore } from "builderStore"
import { Button, Icon } from "@budibase/bbui" import { Button, Icon } from "@budibase/bbui"
import ActionButton from "components/common/ActionButton.svelte" import ActionButton from "components/common/ActionButton.svelte"
@ -9,6 +10,7 @@
import { DeleteRecordModal, CreateEditRecordModal } from "./modals" import { DeleteRecordModal, CreateEditRecordModal } from "./modals"
import RowPopover from "./popovers/Row.svelte" import RowPopover from "./popovers/Row.svelte"
import ColumnPopover from "./popovers/Column.svelte" import ColumnPopover from "./popovers/Column.svelte"
import ViewPopover from "./popovers/View.svelte"
import ColumnHeaderPopover from "./popovers/ColumnHeader.svelte" import ColumnHeaderPopover from "./popovers/ColumnHeader.svelte"
import EditRowPopover from "./popovers/EditRow.svelte" import EditRowPopover from "./popovers/EditRow.svelte"
import * as api from "./api" import * as api from "./api"
@ -20,12 +22,14 @@
let modalOpen = false let modalOpen = false
let data = [] let data = []
let headers = [] let headers = []
let views = []
let currentPage = 0 let currentPage = 0
let search let search
$: { $: {
if ($backendUiStore.selectedView) { if (
$backendUiStore.selectedView &&
$backendUiStore.selectedView.name.startsWith("all_")
) {
api.fetchDataForView($backendUiStore.selectedView).then(records => { api.fetchDataForView($backendUiStore.selectedView).then(records => {
data = records || [] data = records || []
}) })
@ -46,22 +50,6 @@
) )
$: schema = $backendUiStore.selectedModel.schema $: schema = $backendUiStore.selectedModel.schema
const createNewRecord = () => {
open(
CreateEditRecordModal,
{
onClosed: close,
},
{ styleContent: { padding: "0" } }
)
}
onMount(() => {
if (views.length) {
backendUiStore.actions.views.select(views[0])
}
})
</script> </script>
<section> <section>
@ -71,6 +59,7 @@
<ColumnPopover /> <ColumnPopover />
{#if Object.keys($backendUiStore.selectedModel.schema).length > 0} {#if Object.keys($backendUiStore.selectedModel.schema).length > 0}
<RowPopover /> <RowPopover />
<ViewPopover />
{/if} {/if}
</div> </div>
</div> </div>
@ -101,7 +90,7 @@
<td> <td>
{#if schema[header].type === 'link'} {#if schema[header].type === 'link'}
<LinkedRecord field={schema[header]} ids={row[header]} /> <LinkedRecord field={schema[header]} ids={row[header]} />
{:else}{row[header] || ''}{/if} {:else}{getOr('', header, row)}{/if}
</td> </td>
{/each} {/each}
</tr> </tr>

141
packages/builder/src/components/database/DataTable/Table.svelte

@ -0,0 +1,141 @@
<script>
import { onMount } from "svelte"
import fsort from "fast-sort"
import getOr from "lodash/fp/getOr"
import { store, backendUiStore } from "builderStore"
import api from "builderStore/api"
import { Button, Icon } from "@budibase/bbui"
import ActionButton from "components/common/ActionButton.svelte"
import LinkedRecord from "./LinkedRecord.svelte"
import TablePagination from "./TablePagination.svelte"
import { DeleteRecordModal, CreateEditRecordModal } from "./modals"
import RowPopover from "./popovers/Row.svelte"
import ColumnPopover from "./popovers/Column.svelte"
import ViewPopover from "./popovers/View.svelte"
import ColumnHeaderPopover from "./popovers/ColumnHeader.svelte"
import EditRowPopover from "./popovers/EditRow.svelte"
import CalculationPopover from "./popovers/Calculate.svelte"
export let columns = []
export let data = []
export let title
const ITEMS_PER_PAGE = 10
let currentPage = 0
$: paginatedData =
data && data.length
? data.slice(
currentPage * ITEMS_PER_PAGE,
currentPage * ITEMS_PER_PAGE + ITEMS_PER_PAGE
)
: []
$: sort = $backendUiStore.sort
$: sorted = sort ? fsort(data)[sort.direction](sort.column) : data
</script>
<section>
<div class="table-controls">
<h2 class="title">{title}</h2>
<div class="popovers">
<slot />
</div>
</div>
<table class="uk-table">
<thead>
<tr>
{#each columns as header}
<th>{header.name}</th>
{/each}
</tr>
</thead>
<tbody>
{#if paginatedData.length === 0}
<div class="no-data">No Data.</div>
{/if}
{#each paginatedData as row}
<tr>
{#each columns as header}
<td>{getOr(row.default || '', header.key, row)}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
<TablePagination
{data}
bind:currentPage
pageItemCount={data.length}
{ITEMS_PER_PAGE} />
</section>
<style>
section {
margin-bottom: 20px;
}
.title {
font-size: 24px;
font-weight: 600;
text-rendering: optimizeLegibility;
text-transform: capitalize;
}
table {
border: 1px solid var(--grey-4);
background: #fff;
border-radius: 3px;
border-collapse: collapse;
}
thead {
height: 40px;
background: var(--grey-3);
border: 1px solid var(--grey-4);
}
thead th {
color: var(--ink);
text-transform: capitalize;
font-weight: 500;
font-size: 14px;
text-rendering: optimizeLegibility;
transition: 0.5s all;
vertical-align: middle;
}
th:hover {
color: var(--blue);
cursor: pointer;
}
td {
max-width: 200px;
text-overflow: ellipsis;
border: 1px solid var(--grey-4);
}
tbody tr {
border-bottom: 1px solid var(--grey-4);
transition: 0.3s background-color;
color: var(--ink);
font-size: 12px;
}
tbody tr:hover {
background: var(--grey-1);
}
.table-controls {
width: 100%;
}
.popovers {
display: flex;
gap: var(--spacing-l);
}
.no-data {
padding: 14px;
}
</style>

0
packages/builder/src/components/database/ModelDataTable/TablePagination.svelte → packages/builder/src/components/database/DataTable/TablePagination.svelte

74
packages/builder/src/components/database/DataTable/ViewDataTable.svelte

@ -0,0 +1,74 @@
<script>
import { onMount } from "svelte"
import fsort from "fast-sort"
import getOr from "lodash/fp/getOr"
import { store, backendUiStore } from "builderStore"
import api from "builderStore/api"
import { Button, Icon } from "@budibase/bbui"
import Table from "./Table.svelte"
import ActionButton from "components/common/ActionButton.svelte"
import LinkedRecord from "./LinkedRecord.svelte"
import TablePagination from "./TablePagination.svelte"
import { DeleteRecordModal, CreateEditRecordModal } from "./modals"
import RowPopover from "./popovers/Row.svelte"
import ColumnPopover from "./popovers/Column.svelte"
import ViewPopover from "./popovers/View.svelte"
import ColumnHeaderPopover from "./popovers/ColumnHeader.svelte"
import EditRowPopover from "./popovers/EditRow.svelte"
import CalculationPopover from "./popovers/Calculate.svelte"
import GroupByPopover from "./popovers/GroupBy.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 = {}
let data = []
$: ({ name, groupBy } = view)
$: !name.startsWith("all_") && fetchViewData(name, groupBy)
async function fetchViewData(name, groupBy) {
let QUERY_VIEW_URL = `/api/views/${name}?stats=true`
if (groupBy) {
QUERY_VIEW_URL += `&group=${groupBy}`
}
const response = await api.get(QUERY_VIEW_URL)
data = await response.json()
}
</script>
<Table title={decodeURI(view.name)} columns={COLUMNS} {data}>
<CalculationPopover {view} />
<GroupByPopover {view} />
</Table>

20
packages/builder/src/components/database/ModelDataTable/api.js → packages/builder/src/components/database/DataTable/api.js

@ -6,11 +6,10 @@ export async function createUser(user) {
return await response.json() return await response.json()
} }
export async function createDatabase(appname, instanceName) { export async function saveRecord(record, modelId) {
const CREATE_DATABASE_URL = `/api/${appname}/instances` const SAVE_RECORDS_URL = `/api/${modelId}/records`
const response = await api.post(CREATE_DATABASE_URL, { const response = await api.post(SAVE_RECORDS_URL, record)
name: instanceName,
})
return await response.json() return await response.json()
} }
@ -20,15 +19,8 @@ export async function deleteRecord(record) {
return response return response
} }
export async function saveRecord(record, modelId) { export async function fetchDataForView(view) {
const SAVE_RECORDS_URL = `/api/${modelId}/records` const FETCH_RECORDS_URL = `/api/views/${view.name}`
const response = await api.post(SAVE_RECORDS_URL, record)
return await response.json()
}
export async function fetchDataForView(viewName) {
const FETCH_RECORDS_URL = `/api/views/${viewName}`
const response = await api.get(FETCH_RECORDS_URL) const response = await api.get(FETCH_RECORDS_URL)
return await response.json() return await response.json()

0
packages/builder/src/components/database/ModelDataTable/index.js → packages/builder/src/components/database/DataTable/index.js

38
packages/builder/src/components/database/ModelDataTable/modals/CreateEditColumn.svelte → packages/builder/src/components/database/DataTable/modals/CreateEditColumn.svelte

@ -15,22 +15,20 @@
import LinkedRecordSelector from "components/common/LinkedRecordSelector.svelte" import LinkedRecordSelector from "components/common/LinkedRecordSelector.svelte"
import * as api from "../api" import * as api from "../api"
let fieldDefinitions = cloneDeep(FIELDS)
export let onClosed export let onClosed
export let field = {} export let field = {
type: "string",
constraints: fieldDefinitions.STRING.constraints,
}
let fieldDefinitions = cloneDeep(FIELDS)
let originalName = field.name let originalName = field.name
$: required = $: required =
field.constraints && field.constraints &&
field.constraints.presence && field.constraints.presence &&
!field.constraints.presence.allowEmpty !field.constraints.presence.allowEmpty
$: if (field.type) {
field.constraints = merge(
fieldDefinitions[field.type.toUpperCase()].constraints,
field.constraints
)
}
async function saveColumn() { async function saveColumn() {
backendUiStore.update(state => { backendUiStore.update(state => {
@ -43,14 +41,26 @@
}) })
onClosed() onClosed()
} }
function handleFieldConstraints(event) {
const { type, constraints } = fieldDefinitions[
event.target.value.toUpperCase()
]
field.type = type
field.constraints = constraints
}
</script> </script>
<div class="actions"> <div class="actions">
<Input placeholder="Name" thin bind:value={field.name} /> <Input placeholder="Name" thin bind:value={field.name} />
<Select secondary thin bind:value={field.type}> <Select
secondary
thin
on:change={handleFieldConstraints}
bind:value={field.value}>
{#each Object.values(fieldDefinitions) as field} {#each Object.values(fieldDefinitions) as field}
<option value={field.type}>{field.name}</option> <option value={field.value}>{field.name}</option>
{/each} {/each}
</Select> </Select>
@ -63,28 +73,28 @@
on:change={() => (field.constraints.presence.allowEmpty = required)} /> on:change={() => (field.constraints.presence.allowEmpty = required)} />
</div> </div>
{#if field.type === 'string'} {#if field.value === 'string' && field.constraints}
<NumberBox <NumberBox
label="Max Length" label="Max Length"
bind:value={field.constraints.length.maximum} /> bind:value={field.constraints.length.maximum} />
<ValuesList <ValuesList
label="Categories" label="Categories"
bind:values={field.constraints.inclusion} /> bind:values={field.constraints.inclusion} />
{:else if field.type === 'datetime'} {:else if field.value === 'datetime' && field.constraints}
<DatePicker <DatePicker
label="Min Value" label="Min Value"
bind:value={field.constraints.datetime.earliest} /> bind:value={field.constraints.datetime.earliest} />
<DatePicker <DatePicker
label="Max Value" label="Max Value"
bind:value={field.constraints.datetime.latest} /> bind:value={field.constraints.datetime.latest} />
{:else if field.type === 'number'} {:else if field.value === 'number' && field.constraints}
<NumberBox <NumberBox
label="Min Value" label="Min Value"
bind:value={field.constraints.numericality.greaterThanOrEqualTo} /> bind:value={field.constraints.numericality.greaterThanOrEqualTo} />
<NumberBox <NumberBox
label="Max Value" label="Max Value"
bind:value={field.constraints.numericality.lessThanOrEqualTo} /> bind:value={field.constraints.numericality.lessThanOrEqualTo} />
{:else if field.type === 'link'} {:else if field.value === 'link'}
<div class="field"> <div class="field">
<label>Link</label> <label>Link</label>
<select class="budibase__input" bind:value={field.modelId}> <select class="budibase__input" bind:value={field.modelId}>

0
packages/builder/src/components/database/ModelDataTable/modals/CreateEditRecord.svelte → packages/builder/src/components/database/DataTable/modals/CreateEditRecord.svelte

6
packages/builder/src/components/database/ModelDataTable/modals/DeleteRecord.svelte → packages/builder/src/components/database/DataTable/modals/DeleteRecord.svelte

@ -10,10 +10,10 @@
<section> <section>
<div class="content"> <div class="content">
<heading> <header>
<i class="ri-information-line alert" /> <i class="ri-information-line alert" />
<h4 class="budibase__title--4">Delete Record</h4> <h4 class="budibase__title--4">Delete Record</h4>
</heading> </header>
<p> <p>
Are you sure you want to delete this record? All of your data will be Are you sure you want to delete this record? All of your data will be
permanently removed. This action cannot be undone. permanently removed. This action cannot be undone.
@ -47,7 +47,7 @@
border-top: 1px solid #ccc; border-top: 1px solid #ccc;
} }
heading { header {
display: flex; display: flex;
align-items: center; align-items: center;
} }

6
packages/builder/src/components/database/ModelDataTable/modals/DeleteTable.svelte → packages/builder/src/components/database/DataTable/modals/DeleteTable.svelte

@ -14,10 +14,10 @@
<section> <section>
<div class="content"> <div class="content">
<heading> <header>
<i class="ri-information-line alert" /> <i class="ri-information-line alert" />
<h4 class="budibase__title--4">Delete Table</h4> <h4 class="budibase__title--4">Delete Table</h4>
</heading> </header>
<p> <p>
Are you sure you want to delete this table? All of your data will be Are you sure you want to delete this table? All of your data will be
permanently removed. This action cannot be undone. permanently removed. This action cannot be undone.
@ -50,7 +50,7 @@
border-top: 1px solid #ccc; border-top: 1px solid #ccc;
} }
heading { header {
display: flex; display: flex;
align-items: center; align-items: center;
} }

62
packages/builder/src/components/database/DataTable/modals/DeleteView.svelte

@ -0,0 +1,62 @@
<script>
import { goto } from "@sveltech/routify"
import ActionButton from "components/common/ActionButton.svelte"
import { notifier } from "builderStore/store/notifications"
import { store, backendUiStore } from "builderStore"
import * as api from "../api"
export let viewName
export let onClosed
</script>
<section>
<div class="content">
<header>
<i class="ri-information-line alert" />
<h4 class="budibase__title--4">Delete View</h4>
</header>
<p>
Are you sure you want to delete this view? All of your data will be
permanently removed. This action cannot be undone.
</p>
</div>
<div class="modal-actions">
<ActionButton on:click={onClosed}>Cancel</ActionButton>
<ActionButton
alert
on:click={async () => {
await backendUiStore.actions.views.delete(viewName)
notifier.danger(`View ${viewName} deleted.`)
$goto(`./backend`)
onClosed()
}}>
Delete
</ActionButton>
</div>
</section>
<style>
.alert {
color: rgba(255, 0, 31, 1);
background: var(--grey-1);
padding: 5px;
}
.modal-actions {
padding: 10px;
background: var(--grey-1);
border-top: 1px solid #ccc;
}
header {
display: flex;
align-items: center;
}
.content {
padding: 30px;
}
h4 {
margin: 0 0 0 10px;
}
</style>

6
packages/builder/src/components/database/ModelDataTable/modals/RecordFieldControl.svelte → packages/builder/src/components/database/DataTable/modals/RecordFieldControl.svelte

@ -2,12 +2,10 @@
import { Input, Select } from "@budibase/bbui" import { Input, Select } from "@budibase/bbui"
export let type = "text" export let type = "text"
export let value = "" export let value = type === "checkbox" ? false : ""
export let label export let label
export let options = [] export let options = []
let checked = type === "checkbox" ? value : false
const handleInput = event => { const handleInput = event => {
if (event.target.type === "checkbox") { if (event.target.type === "checkbox") {
value = event.target.checked value = event.target.checked
@ -38,7 +36,7 @@
thin thin
placeholder={label} placeholder={label}
data-cy="{label}-input" data-cy="{label}-input"
{checked} checked={value}
{type} {type}
{value} {value}
on:input={handleInput} on:input={handleInput}

0
packages/builder/src/components/database/ModelDataTable/modals/index.js → packages/builder/src/components/database/DataTable/modals/index.js

95
packages/builder/src/components/database/DataTable/popovers/Calculate.svelte

@ -0,0 +1,95 @@
<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 CALCULATIONS = [
{
name: "Statistics",
key: "stats",
},
]
export let view = {}
let anchor
let dropdown
$: viewModel = $backendUiStore.models.find(
({ _id }) => _id === $backendUiStore.selectedView.modelId
)
$: fields =
viewModel &&
Object.keys(viewModel.schema).filter(
field => viewModel.schema[field].type === "number"
)
function saveView() {
backendUiStore.actions.views.save(view)
notifier.success(`View ${view.name} saved.`)
dropdown.hide()
}
</script>
<div bind:this={anchor}>
<TextButton text small on:click={dropdown.show} active={!!view.field}>
<Icon name="calculate" />
Calculate
</TextButton>
</div>
<Popover bind:this={dropdown} {anchor} align="left">
<h5>Calculate</h5>
<div class="input-group-row">
<p>The</p>
<Select secondary thin bind:value={view.calculation}>
{#each CALCULATIONS as calculation}
<option value={calculation.key}>{calculation.name}</option>
{/each}
</Select>
<p>of</p>
<Select secondary thin bind:value={view.field}>
{#each fields as field}
<option value={field}>{field}</option>
{/each}
</Select>
</div>
<div class="button-group">
<Button secondary on:click={dropdown.hide}>Cancel</Button>
<Button primary on:click={saveView}>Save</Button>
</div>
</Popover>
<style>
h5 {
margin-bottom: var(--spacing-l);
font-weight: 500;
}
.button-group {
margin-top: var(--spacing-l);
display: flex;
justify-content: flex-end;
gap: var(--spacing-s);
}
.input-group-row {
display: grid;
grid-template-columns: 50px 1fr 20px 1fr;
gap: var(--spacing-s);
margin-bottom: var(--spacing-l);
align-items: center;
}
p {
margin: 0;
font-size: var(--font-size-xs);
}
</style>

8
packages/builder/src/components/database/ModelDataTable/popovers/Column.svelte → packages/builder/src/components/database/DataTable/popovers/Column.svelte

@ -1,6 +1,12 @@
<script> <script>
import { backendUiStore } from "builderStore" import { backendUiStore } from "builderStore"
import { DropdownMenu, Button, Icon, Input, Select } from "@budibase/bbui" import {
DropdownMenu,
TextButton as Button,
Icon,
Input,
Select,
} from "@budibase/bbui"
import { FIELDS } from "constants/backend" import { FIELDS } from "constants/backend"
import CreateEditColumn from "../modals/CreateEditColumn.svelte" import CreateEditColumn from "../modals/CreateEditColumn.svelte"

0
packages/builder/src/components/database/ModelDataTable/popovers/ColumnHeader.svelte → packages/builder/src/components/database/DataTable/popovers/ColumnHeader.svelte

0
packages/builder/src/components/database/ModelDataTable/popovers/EditRow.svelte → packages/builder/src/components/database/DataTable/popovers/EditRow.svelte

86
packages/builder/src/components/database/DataTable/popovers/GroupBy.svelte

@ -0,0 +1,86 @@
<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 CALCULATIONS = [
{
name: "Statistics",
key: "stats",
},
]
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()
}
</script>
<div bind:this={anchor}>
<TextButton text small active={!!view.groupBy} on:click={dropdown.show}>
<Icon name="group" />
Group By
</TextButton>
</div>
<Popover bind:this={dropdown} {anchor} align="left">
<h5>Group By</h5>
<div class="input-group-row">
<p>Group By</p>
<Select secondary thin bind:value={view.groupBy}>
<option value={false} />
{#each fields as field}
<option value={field}>{field}</option>
{/each}
</Select>
</div>
<div class="button-group">
<Button secondary on:click={dropdown.hide}>Cancel</Button>
<Button primary on:click={saveView}>Save</Button>
</div>
</Popover>
<style>
h5 {
margin-bottom: var(--spacing-l);
font-weight: 500;
}
.button-group {
margin-top: var(--spacing-l);
display: flex;
justify-content: flex-end;
gap: var(--spacing-s);
}
.input-group-row {
display: grid;
grid-template-columns: 50px 1fr 20px 1fr;
gap: var(--spacing-s);
margin-bottom: var(--spacing-l);
align-items: center;
}
p {
margin: 0;
font-size: var(--font-size-xs);
}
</style>

2
packages/builder/src/components/database/ModelDataTable/popovers/Row.svelte → packages/builder/src/components/database/DataTable/popovers/Row.svelte

@ -1,5 +1,5 @@
<script> <script>
import { DropdownMenu, Button, Icon } from "@budibase/bbui" import { DropdownMenu, TextButton as Button, Icon } from "@budibase/bbui"
import CreateEditRecord from "../modals/CreateEditRecord.svelte" import CreateEditRecord from "../modals/CreateEditRecord.svelte"
let anchor let anchor

82
packages/builder/src/components/database/DataTable/popovers/View.svelte

@ -0,0 +1,82 @@
<script>
import {
Popover,
TextButton,
Button,
Icon,
Input,
Select,
} from "@budibase/bbui"
import { goto } from "@sveltech/routify"
import { backendUiStore } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import CreateEditRecord from "../modals/CreateEditRecord.svelte"
let anchor
let dropdown
let name
let field
$: fields = Object.keys($backendUiStore.selectedModel.schema).filter(key => {
return $backendUiStore.selectedModel.schema[key].type === "number"
})
$: views = $backendUiStore.models.flatMap(model => Object.keys(model.views))
function saveView() {
if (views.includes(name)) {
notifier.danger(`View exists with name ${name}.`)
return
}
backendUiStore.actions.views.save({
name,
modelId: $backendUiStore.selectedModel._id,
field,
})
notifier.success(`View ${name} created`)
dropdown.hide()
$goto(`../../../view/${name}`)
}
</script>
<div bind:this={anchor}>
<TextButton text small on:click={dropdown.show}>
<Icon name="addrow" />
Create New View
</TextButton>
</div>
<Popover bind:this={dropdown} {anchor} align="left">
<h5>Create View</h5>
<div class="input-group-column">
<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 class="button-group">
<Button secondary on:click={dropdown.hide}>Cancel</Button>
<Button primary on:click={saveView}>Save View</Button>
</div>
</Popover>
<style>
h5 {
margin-bottom: var(--spacing-l);
font-weight: 500;
}
.button-group {
margin-top: var(--spacing-l);
display: flex;
justify-content: flex-end;
gap: var(--spacing-s);
}
.input-group-column {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
}
</style>

0
packages/builder/src/components/database/ModelDataTable/popovers/View.svelte

2
packages/builder/src/components/nav/ModelNavigator/EditTable.svelte

@ -3,7 +3,7 @@
import { backendUiStore } from "builderStore" import { backendUiStore } from "builderStore"
import { DropdownMenu, Button, Icon, Input, Select } from "@budibase/bbui" import { DropdownMenu, Button, Icon, Input, Select } from "@budibase/bbui"
import { FIELDS } from "constants/backend" import { FIELDS } from "constants/backend"
import DeleteTableModal from "components/database/ModelDataTable/modals/DeleteTable.svelte" import DeleteTableModal from "components/database/DataTable/modals/DeleteTable.svelte"
const { open, close } = getContext("simple-modal") const { open, close } = getContext("simple-modal")

143
packages/builder/src/components/nav/ModelNavigator/EditView.svelte

@ -0,0 +1,143 @@
<script>
import { getContext } from "svelte"
import { backendUiStore } from "builderStore"
import { notifier } from "builderStore/store/notifications"
import { DropdownMenu, Button, Icon, Input, Select } from "@budibase/bbui"
import { FIELDS } from "constants/backend"
import DeleteViewModal from "components/database/DataTable/modals/DeleteView.svelte"
const { open, close } = getContext("simple-modal")
export let view
let anchor
let dropdown
let editing
let originalName = view.name
function showEditor() {
editing = true
}
function hideEditor() {
dropdown.hide()
editing = false
close()
}
const deleteView = () => {
open(
DeleteViewModal,
{
onClosed: close,
viewName: view.name,
},
{ styleContent: { padding: "0" } }
)
}
function save() {
backendUiStore.actions.views.save({
originalName,
...view,
})
notifier.success("Renamed View Successfully.")
hideEditor()
}
</script>
<div bind:this={anchor} on:click={dropdown.show}>
<i class="ri-more-line" />
</div>
<DropdownMenu bind:this={dropdown} {anchor} align="left">
{#if editing}
<h5>Edit View</h5>
<div class="container">
<Input placeholder="View Name" thin bind:value={view.name} />
</div>
<footer>
<div class="button-margin-3">
<Button secondary on:click={hideEditor}>Cancel</Button>
</div>
<div class="button-margin-4">
<Button primary on:click={save}>Save</Button>
</div>
</footer>
{:else}
<ul>
<li on:click={showEditor}>
<Icon name="edit" />
Edit
</li>
<li data-cy="delete-view" on:click={deleteView}>
<Icon name="delete" />
Delete
</li>
</ul>
{/if}
</DropdownMenu>
<style>
h5 {
padding: var(--spacing-xl) 0 0 var(--spacing-xl);
margin: 0;
font-weight: 500;
}
.container {
padding: var(--spacing-xl);
}
ul {
padding: var(--spacing-xl) 0 0 var(--spacing-xl);
list-style: none;
padding-left: 0;
margin: 0;
padding: var(--spacing-s) 0;
}
li {
display: flex;
font-family: var(--font-sans);
font-size: var(--font-size-xs);
color: var(--ink);
padding: var(--spacing-s) var(--spacing-m);
margin: auto 0px;
align-items: center;
cursor: pointer;
}
li:hover {
background-color: var(--grey-2);
}
li:active {
color: var(--blue);
}
footer {
padding: 20px;
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
gap: 20px;
background: var(--grey-1);
border-bottom-left-radius: 0.5rem;
border-bottom-left-radius: 0.5rem;
}
.button-margin-1 {
grid-column-start: 1;
display: grid;
}
.button-margin-3 {
grid-column-start: 3;
display: grid;
}
.button-margin-4 {
grid-column-start: 4;
display: grid;
}
</style>

15
packages/builder/src/components/nav/ModelNavigator/ListItem.svelte

@ -6,15 +6,24 @@
export let indented export let indented
</script> </script>
<div class:selected on:click class={className}> <div
<i class:indented class={icon} /> data-cy="model-nav-item"
class:indented
class:selected
on:click
class={className}>
<i class={icon} />
<span>{title}</span> <span>{title}</span>
<slot /> <slot />
</div> </div>
<style> <style>
.indented { .indented {
margin-left: 10px; grid-template-columns: 50px 1fr 20px;
}
.indented i {
justify-self: end;
} }
div { div {

33
packages/builder/src/components/nav/ModelNavigator/ModelNavigator.svelte

@ -8,20 +8,21 @@
import { Button } from "@budibase/bbui" import { Button } from "@budibase/bbui"
import CreateTablePopover from "./CreateTable.svelte" import CreateTablePopover from "./CreateTable.svelte"
import EditTablePopover from "./EditTable.svelte" import EditTablePopover from "./EditTable.svelte"
import EditViewPopover from "./EditView.svelte"
const { open, close } = getContext("simple-modal") const { open, close } = getContext("simple-modal")
$: selectedTab = $backendUiStore.tabs.NAVIGATION_PANEL $: selectedView =
$backendUiStore.selectedView && $backendUiStore.selectedView.name
function selectModel(model, fieldId) { function selectModel(model) {
backendUiStore.actions.models.select(model) backendUiStore.actions.models.select(model)
$goto(`./model/${model._id}`) $goto(`./model/${model._id}`)
if (fieldId) { }
backendUiStore.update(state => {
state.selectedField = fieldId function selectView(view) {
return state backendUiStore.actions.views.select(view)
}) $goto(`./view/${view.name}`)
}
} }
</script> </script>
@ -34,12 +35,26 @@
<div class="hierarchy-items-container"> <div class="hierarchy-items-container">
{#each $backendUiStore.models as model} {#each $backendUiStore.models as model}
<ListItem <ListItem
selected={!$backendUiStore.selectedField && model._id === $backendUiStore.selectedModel._id} selected={selectedView === `all_${model._id}`}
title={model.name} title={model.name}
icon="ri-table-fill" icon="ri-table-fill"
on:click={() => selectModel(model)}> on:click={() => selectModel(model)}>
<EditTablePopover table={model} /> <EditTablePopover table={model} />
</ListItem> </ListItem>
{#each Object.keys(model.views || {}) as viewName}
<ListItem
indented
selected={selectedView === viewName}
title={viewName}
icon="ri-eye-line"
on:click={() => selectView({
name: viewName,
...model.views[viewName],
})}>
<EditViewPopover
view={{ name: viewName, ...model.views[viewName] }} />
</ListItem>
{/each}
{/each} {/each}
</div> </div>
</div> </div>

17
packages/builder/src/components/userInterface/ModelSelect.svelte

@ -1,16 +1,13 @@
<script> <script>
import { Select } from "@budibase/bbui"
import { backendUiStore } from "builderStore" import { backendUiStore } from "builderStore"
export let value export let value
</script> </script>
<div class="uk-margin block-field"> <Select thin secondary wide on:change {value}>
<div class="uk-form-controls"> <option value="" />
<select class="budibase__input" on:change {value}> {#each $backendUiStore.models as model}
<option value="" /> <option value={model._id}>{model.name}</option>
{#each $backendUiStore.models as model} {/each}
<option value={model._id}>{model.name}</option> </Select>
{/each}
</select>
</div>
</div>

10
packages/builder/src/constants/backend/index.js

@ -3,6 +3,7 @@ export const FIELDS = {
name: "Plain Text", name: "Plain Text",
icon: "ri-text", icon: "ri-text",
type: "string", type: "string",
value: "string",
constraints: { constraints: {
type: "string", type: "string",
length: {}, length: {},
@ -13,16 +14,18 @@ export const FIELDS = {
name: "Number", name: "Number",
icon: "ri-number-1", icon: "ri-number-1",
type: "number", type: "number",
value: "number",
constraints: { constraints: {
type: "number", type: "number",
presence: { allowEmpty: true }, presence: { allowEmpty: true },
numericality: {}, numericality: { greaterThanOrEqualTo: "", lessThanOrEqualTo: "" },
}, },
}, },
BOOLEAN: { BOOLEAN: {
name: "True/False", name: "True/False",
icon: "ri-toggle-line", icon: "ri-toggle-line",
type: "boolean", type: "boolean",
value: "boolean",
constraints: { constraints: {
type: "boolean", type: "boolean",
presence: { allowEmpty: true }, presence: { allowEmpty: true },
@ -41,10 +44,15 @@ export const FIELDS = {
name: "Date/Time", name: "Date/Time",
icon: "ri-calendar-event-fill", icon: "ri-calendar-event-fill",
type: "string", type: "string",
value: "datetime",
constraints: { constraints: {
type: "string", type: "string",
length: {}, length: {},
presence: { allowEmpty: true }, presence: { allowEmpty: true },
datetime: {
latest: "",
earliest: "",
},
}, },
}, },
// IMAGE: { // IMAGE: {

5
packages/builder/src/pages/[application]/backend/_layout.svelte

@ -1,8 +1,7 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { store, backendUiStore } from "builderStore" import { store, backendUiStore } from "builderStore"
import * as api from "components/database/ModelDataTable/api" import * as api from "components/database/DataTable/api"
import ModelNavigator from "components/nav/ModelNavigator/ModelNavigator.svelte" import ModelNavigator from "components/nav/ModelNavigator/ModelNavigator.svelte"
</script> </script>
@ -23,12 +22,10 @@
background: var(--grey-1); background: var(--grey-1);
line-height: 1; line-height: 1;
} }
.content { .content {
flex: 1 1 auto; flex: 1 1 auto;
margin: 20px 40px; margin: 20px 40px;
} }
.nav { .nav {
flex: 0 1 auto; flex: 0 1 auto;
width: 300px; width: 300px;

6
packages/builder/src/pages/[application]/backend/model/[selectedModel]/index.svelte

@ -1,11 +1,11 @@
<script> <script>
import { getContext } from "svelte" import { getContext } from "svelte"
import { Button } from "@budibase/bbui" import { Button } from "@budibase/bbui"
import ModelDataTable from "components/database/ModelDataTable" import ModelDataTable from "components/database/DataTable"
import { backendUiStore } from "builderStore" import { backendUiStore } from "builderStore"
import ActionButton from "components/common/ActionButton.svelte" import ActionButton from "components/common/ActionButton.svelte"
import * as api from "components/database/ModelDataTable/api" import * as api from "components/database/DataTable/api"
import { CreateEditRecordModal } from "components/database/ModelDataTable/modals" import { CreateEditRecordModal } from "components/database/DataTable/modals"
const { open, close } = getContext("simple-modal") const { open, close } = getContext("simple-modal")

22
packages/builder/src/pages/[application]/backend/view/[selectedView]/_layout.svelte

@ -0,0 +1,22 @@
<script>
import { params } from "@sveltech/routify"
import { backendUiStore } from "builderStore"
if ($params.selectedView) {
let view
const viewName = decodeURI($params.selectedView)
for (let model of $backendUiStore.models) {
if (model.views && model.views[viewName]) {
view = model.views[viewName]
}
}
if (view) {
backendUiStore.actions.views.select({
name: viewName,
...view,
})
}
}
</script>
<slot />

27
packages/builder/src/pages/[application]/backend/view/[selectedView]/index.svelte

@ -0,0 +1,27 @@
<script>
import { getContext } from "svelte"
import { Button } from "@budibase/bbui"
import ViewDataTable from "components/database/DataTable/ViewDataTable"
import { backendUiStore } from "builderStore"
import ActionButton from "components/common/ActionButton.svelte"
import * as api from "components/database/DataTable/api"
import { CreateEditRecordModal } from "components/database/DataTable/modals"
const { open, close } = getContext("simple-modal")
$: selectedView = $backendUiStore.selectedView
</script>
{#if $backendUiStore.selectedDatabase._id && selectedView}
<ViewDataTable view={selectedView} />
{:else}
<i>create your first table to start building</i>
{/if}
<style>
i {
font-size: 20px;
margin-right: 10px;
color: var(--grey-4);
}
</style>

7
packages/builder/yarn.lock

@ -688,9 +688,10 @@
lodash "^4.17.13" lodash "^4.17.13"
to-fast-properties "^2.0.0" to-fast-properties "^2.0.0"
"@budibase/bbui@^1.23.1": "@budibase/bbui@^1.24.1":
version "1.23.1" version "1.24.1"
resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.23.1.tgz#232b0d31379ef64afe5e76f477d5489e67defc22" resolved "https://registry.yarnpkg.com/@budibase/bbui/-/bbui-1.24.1.tgz#d1c527990c3dcdef78080abfe6aaeef6e8fb2d66"
integrity sha512-yZE4Uk6EB3MoIUd2oNN50u8KOXRX65yRFv49gN0T6iCjTjEAdEk8JtsQR9AL3VWn3EdPz5xOkrGsIvNtxqaAFw==
dependencies: dependencies:
sirv-cli "^0.4.6" sirv-cli "^0.4.6"

1
packages/server/src/api/controllers/model.js

@ -21,6 +21,7 @@ exports.save = async function(ctx) {
const modelToSave = { const modelToSave = {
type: "model", type: "model",
_id: newid(), _id: newid(),
views: {},
...ctx.request.body, ...ctx.request.body,
} }

18
packages/server/src/api/controllers/record.js

@ -80,10 +80,24 @@ exports.save = async function(ctx) {
exports.fetchView = async function(ctx) { exports.fetchView = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId) const db = new CouchDB(ctx.user.instanceId)
const { stats, group } = ctx.query
const response = await db.query(`database/${ctx.params.viewName}`, { const response = await db.query(`database/${ctx.params.viewName}`, {
include_docs: true, include_docs: !stats,
group,
}) })
ctx.body = response.rows.map(row => row.doc)
if (stats) {
for (let row of response.rows) {
row.value = {
...row.value,
avg: row.value.sum / row.value.count,
}
}
} else {
response.rows = response.rows.map(row => row.doc)
}
ctx.body = response.rows
} }
exports.fetchModelRecords = async function(ctx) { exports.fetchModelRecords = async function(ctx) {

46
packages/server/src/api/controllers/view.js

@ -1,46 +0,0 @@
const CouchDB = require("../../db")
const controller = {
query: async () => {},
fetch: async ctx => {
const db = new CouchDB(ctx.user.instanceId)
const designDoc = await db.get("_design/database")
const response = []
for (let name in designDoc.views) {
if (
!name.startsWith("all") &&
name !== "by_type" &&
name !== "by_username" &&
name !== "by_workflow_trigger"
) {
response.push({
name,
...designDoc.views[name],
})
}
}
ctx.body = response
},
create: async ctx => {
const db = new CouchDB(ctx.user.instanceId)
const newView = ctx.request.body
const designDoc = await db.get("_design/database")
designDoc.views = {
...designDoc.views,
[newView.name]: newView,
}
await db.put(designDoc)
ctx.body = newView
ctx.message = `View ${newView.name} created successfully.`
},
destroy: async ctx => {
const db = new CouchDB(ctx.user.instanceId)
ctx.body = await db.destroy(ctx.params.userId)
},
}
module.exports = controller

83
packages/server/src/api/controllers/view/index.js

@ -0,0 +1,83 @@
const CouchDB = require("../../../db")
const statsViewTemplate = require("./viewBuilder")
const controller = {
fetch: async ctx => {
const db = new CouchDB(ctx.user.instanceId)
const designDoc = await db.get("_design/database")
const response = []
for (let name in designDoc.views) {
if (
!name.startsWith("all") &&
name !== "by_type" &&
name !== "by_username" &&
name !== "by_workflow_trigger"
) {
response.push({
name,
...designDoc.views[name],
})
}
}
ctx.body = response
},
save: async ctx => {
const db = new CouchDB(ctx.user.instanceId)
const { originalName, ...newView } = ctx.request.body
const designDoc = await db.get("_design/database")
const view = statsViewTemplate(newView)
designDoc.views = {
...designDoc.views,
[newView.name]: view,
}
// view has been renamed
if (originalName) {
delete designDoc.views[originalName]
}
await db.put(designDoc)
// add views to model document
const model = await db.get(ctx.request.body.modelId)
model.views = {
...(model.views ? model.views : {}),
[newView.name]: view.meta,
}
if (originalName) {
delete model.views[originalName]
}
await db.put(model)
ctx.body = view
ctx.message = `View ${newView.name} saved successfully.`
},
destroy: async ctx => {
const db = new CouchDB(ctx.user.instanceId)
const designDoc = await db.get("_design/database")
const viewName = decodeURI(ctx.params.viewName)
const view = designDoc.views[viewName]
delete designDoc.views[viewName]
await db.put(designDoc)
const model = await db.get(view.meta.modelId)
delete model.views[viewName]
await db.put(model)
ctx.body = view
ctx.message = `View ${ctx.params.viewName} saved successfully.`
},
}
module.exports = controller

25
packages/server/src/api/controllers/view/viewBuilder.js

@ -0,0 +1,25 @@
function statsViewTemplate({ field, modelId, groupBy }) {
return {
meta: {
field,
modelId,
groupBy,
schema: {
sum: "number",
min: "number",
max: "number",
count: "number",
sumsqr: "number",
avg: "number",
},
},
map: `function (doc) {
if (doc.modelId === "${modelId}") {
emit(doc["${groupBy || "_id"}"], doc["${field}"]);
}
}`,
reduce: "_stats",
}
}
module.exports = statsViewTemplate

44
packages/server/src/api/routes/tests/__snapshots__/view.spec.js.snap

@ -0,0 +1,44 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`/views query returns data for the created view 1`] = `
Array [
Object {
"key": null,
"value": Object {
"avg": 2333.3333333333335,
"count": 3,
"max": 4000,
"min": 1000,
"sum": 7000,
"sumsqr": 21000000,
},
},
]
`;
exports[`/views query returns data for the created view using a group by 1`] = `
Array [
Object {
"key": "One",
"value": Object {
"avg": 1500,
"count": 2,
"max": 2000,
"min": 1000,
"sum": 3000,
"sumsqr": 5000000,
},
},
Object {
"key": "Two",
"value": Object {
"avg": 4000,
"count": 1,
"max": 4000,
"min": 4000,
"sum": 4000,
"sumsqr": 16000000,
},
},
]
`;

3
packages/server/src/api/routes/tests/couchTestUtils.js

@ -193,9 +193,6 @@ const createUserWithPermissions = async (
accessLevelId: accessRes.body._id, accessLevelId: accessRes.body._id,
}) })
//const db = new CouchDB(instanceId)
//const designDoc = await db.get("_design/database")
const anonUser = { const anonUser = {
userId: "ANON", userId: "ANON",
accessLevelId: ANON_LEVEL_ID, accessLevelId: ANON_LEVEL_ID,

131
packages/server/src/api/routes/tests/view.spec.js

@ -4,7 +4,8 @@ const {
createInstance, createInstance,
createModel, createModel,
supertest, supertest,
defaultHeaders defaultHeaders,
getDocument
} = require("./couchTestUtils") } = require("./couchTestUtils")
describe("/views", () => { describe("/views", () => {
@ -14,6 +15,25 @@ describe("/views", () => {
let instance let instance
let model let model
const createView = async (config = {
name: "TestView",
field: "Price",
modelId: model._id
}) =>
await request
.post(`/api/views`)
.send(config)
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
const createRecord = async record => request
.post(`/api/${model._id}/records`)
.send(record)
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
beforeAll(async () => { beforeAll(async () => {
({ request, server } = await supertest()) ({ request, server } = await supertest())
await createClientDatabase(request) await createClientDatabase(request)
@ -28,46 +48,111 @@ describe("/views", () => {
server.close() server.close()
}) })
const createView = async () =>
await request
.post(`/api/views`)
.send({
name: "TestView",
map: `function(doc) {
if (doc.id) {
emit(doc.name, doc._id);
}
}`,
reduce: `function(keys, values) { }`
})
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
describe("create", () => { describe("create", () => {
beforeEach(async () => {
model = await createModel(request, app._id, instance._id);
})
it("returns a success message when the view is successfully created", async () => { it("returns a success message when the view is successfully created", async () => {
const res = await createView() const res = await createView()
expect(res.res.statusMessage).toEqual("View TestView created successfully."); expect(res.res.statusMessage).toEqual("View TestView saved successfully.");
expect(res.body.name).toEqual("TestView"); })
it("updates the model record with the new view metadata", async () => {
const res = await createView()
expect(res.res.statusMessage).toEqual("View TestView saved successfully.");
const updatedModel = await getDocument(instance._id, model._id)
expect(updatedModel.views).toEqual({
TestView: {
field: "Price",
modelId: model._id,
schema: {
sum: "number",
min: "number",
max: "number",
count: "number",
sumsqr: "number",
avg: "number"
}
}
});
}) })
}); });
describe("fetch", () => { describe("fetch", () => {
beforeEach(async () => { beforeEach(async () => {
model = await createModel(request, app._id, instance._id); model = await createModel(request, app._id, instance._id);
}); });
it("should only return custom views", async () => { it("returns only custom views", async () => {
const view = await createView() await createView()
const res = await request const res = await request
.get(`/api/views`) .get(`/api/views`)
.set(defaultHeaders(app._id, instance._id)) .set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200) .expect(200)
expect(res.body.length).toBe(1) expect(res.body.length).toBe(1)
expect(res.body.find(v => v.name === view.body.name)).toBeDefined() expect(res.body.find(({ name }) => name === "TestView")).toBeDefined()
})
});
describe("query", () => {
beforeEach(async () => {
model = await createModel(request, app._id, instance._id);
});
it("returns data for the created view", async () => {
await createView()
await createRecord({
modelId: model._id,
Price: 1000
})
await createRecord({
modelId: model._id,
Price: 2000
})
await createRecord({
modelId: model._id,
Price: 4000
})
const res = await request
.get(`/api/views/TestView?stats=true`)
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.length).toBe(1)
expect(res.body).toMatchSnapshot()
})
it("returns data for the created view using a group by", async () => {
await createView({
name: "TestView",
field: "Price",
groupBy: "Category",
modelId: model._id
})
await createRecord({
modelId: model._id,
Price: 1000,
Category: "One"
})
await createRecord({
modelId: model._id,
Price: 2000,
Category: "One"
})
await createRecord({
modelId: model._id,
Price: 4000,
Category: "Two"
})
const res = await request
.get(`/api/views/TestView?stats=true&group=Category`)
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
expect(res.body.length).toBe(2)
expect(res.body).toMatchSnapshot()
}) })
}); });
}); });

5
packages/server/src/api/routes/view.js

@ -13,8 +13,7 @@ router
recordController.fetchView recordController.fetchView
) )
.get("/api/views", authorized(BUILDER), viewController.fetch) .get("/api/views", authorized(BUILDER), viewController.fetch)
// .patch("/api/:databaseId/views", controller.update); .delete("/api/views/:viewName", authorized(BUILDER), viewController.destroy)
// .delete("/api/:instanceId/views/:viewId/:revId", controller.destroy); .post("/api/views", authorized(BUILDER), viewController.save)
.post("/api/views", authorized(BUILDER), viewController.create)
module.exports = router module.exports = router

2
packages/server/src/app.js

@ -45,6 +45,8 @@ const server = http.createServer(app.callback())
server.on("close", () => console.log("Server Closed")) server.on("close", () => console.log("Server Closed"))
process.on("SIGINT", () => process.exit(1))
module.exports = server.listen(env.PORT || 4001, () => { module.exports = server.listen(env.PORT || 4001, () => {
console.log(`Budibase running on ${JSON.stringify(server.address())}`) console.log(`Budibase running on ${JSON.stringify(server.address())}`)
}) })

5
packages/server/src/electron.js

@ -36,7 +36,10 @@ async function startApp() {
async function createWindow() { async function createWindow() {
app.server = require("./app") app.server = require("./app")
win = new BrowserWindow({ width: 1920, height: 1080 }) win = new BrowserWindow({
width: 1920,
height: 1080,
})
win.setTitle(APP_TITLE) win.setTitle(APP_TITLE)
win.loadURL(APP_URL) win.loadURL(APP_URL)
if (isDev) { if (isDev) {

3
packages/standard-components/src/Chart/tests/bar.html

@ -8,8 +8,7 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3-selection/1.2.0/d3-selection.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/d3-selection/1.2.0/d3-selection.js"></script>
<script src="../../../node_modules/britecharts/dist/umd/bar.min.js" type="text/javascript"></script> <script src="../../../node_modules/britecharts/dist/umd/bar.min.js" type="text/javascript"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/britecharts/dist/css/britecharts.min.css" type="text/css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/britecharts/dist/css/britecharts.min.css" type="text/css" />
</head> </head>
<body> <body>

Loading…
Cancel
Save