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

Custom Views
This commit is contained in:
Martin McKeaveney 2020-08-20 14:33:48 +01:00 committed by GitHub
commit 2cea4be763
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 1247 additions and 186 deletions

View File

@ -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")
})
})

View File

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

View File

@ -23,13 +23,10 @@ export const getBackendUiStore = () => {
database: {
select: async db => {
const modelsResponse = await api.get(`/api/models`)
const viewsResponse = await api.get(`/api/views`)
const models = await modelsResponse.json()
const views = await viewsResponse.json()
store.update(state => {
state.selectedDatabase = db
state.models = models
state.views = views
return state
})
},
@ -59,8 +56,7 @@ export const getBackendUiStore = () => {
store.update(state => {
state.selectedModel = model
state.draftModel = cloneDeep(model)
state.selectedField = ""
state.selectedView = `all_${model._id}`
state.selectedView = { name: `all_${model._id}` }
return state
}),
save: async model => {
@ -99,10 +95,8 @@ export const getBackendUiStore = () => {
// delete the original if renaming
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)
return state
})
@ -119,8 +113,30 @@ export const getBackendUiStore = () => {
select: view =>
store.update(state => {
state.selectedView = view
state.selectedModel = {}
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: {
create: user =>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,12 @@
<script>
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 CreateEditColumn from "../modals/CreateEditColumn.svelte"

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@
import { backendUiStore } from "builderStore"
import { DropdownMenu, Button, Icon, Input, Select } from "@budibase/bbui"
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")

View File

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

View File

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

View File

@ -8,20 +8,21 @@
import { Button } from "@budibase/bbui"
import CreateTablePopover from "./CreateTable.svelte"
import EditTablePopover from "./EditTable.svelte"
import EditViewPopover from "./EditView.svelte"
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)
$goto(`./model/${model._id}`)
if (fieldId) {
backendUiStore.update(state => {
state.selectedField = fieldId
return state
})
}
}
function selectView(view) {
backendUiStore.actions.views.select(view)
$goto(`./view/${view.name}`)
}
</script>
@ -34,12 +35,26 @@
<div class="hierarchy-items-container">
{#each $backendUiStore.models as model}
<ListItem
selected={!$backendUiStore.selectedField && model._id === $backendUiStore.selectedModel._id}
selected={selectedView === `all_${model._id}`}
title={model.name}
icon="ri-table-fill"
on:click={() => selectModel(model)}>
<EditTablePopover table={model} />
</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}
</div>
</div>

View File

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

View File

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

View File

@ -1,8 +1,7 @@
<script>
import { getContext } from "svelte"
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"
</script>
@ -23,12 +22,10 @@
background: var(--grey-1);
line-height: 1;
}
.content {
flex: 1 1 auto;
margin: 20px 40px;
}
.nav {
flex: 0 1 auto;
width: 300px;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -80,10 +80,24 @@ exports.save = async function(ctx) {
exports.fetchView = async function(ctx) {
const db = new CouchDB(ctx.user.instanceId)
const { stats, group } = ctx.query
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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,8 @@ const {
createInstance,
createModel,
supertest,
defaultHeaders
defaultHeaders,
getDocument
} = require("./couchTestUtils")
describe("/views", () => {
@ -14,6 +15,25 @@ describe("/views", () => {
let instance
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 () => {
({ request, server } = await supertest())
await createClientDatabase(request)
@ -28,46 +48,111 @@ describe("/views", () => {
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", () => {
beforeEach(async () => {
model = await createModel(request, app._id, instance._id);
})
it("returns a success message when the view is successfully created", async () => {
const res = await createView()
expect(res.res.statusMessage).toEqual("View TestView created successfully.");
expect(res.body.name).toEqual("TestView");
expect(res.res.statusMessage).toEqual("View TestView saved successfully.");
})
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", () => {
beforeEach(async () => {
model = await createModel(request, app._id, instance._id);
});
it("should only return custom views", async () => {
const view = await createView()
it("returns only custom views", async () => {
await createView()
const res = await request
.get(`/api/views`)
.set(defaultHeaders(app._id, instance._id))
.expect('Content-Type', /json/)
.expect(200)
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()
})
});
});

View File

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

View File

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

View File

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

View File

@ -8,8 +8,7 @@
<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>
<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>
<body>