From 6ad5ebbe06fde37bc0134756625a1f39fb465d09 Mon Sep 17 00:00:00 2001 From: Martin McKeaveney Date: Fri, 14 Aug 2020 16:31:53 +0100 Subject: [PATCH] began UI for custom views --- .../builder/src/builderStore/store/backend.js | 19 ++- .../ModelDataTable/ModelDataTable.svelte | 5 +- .../modals/RecordFieldControl.svelte | 6 +- .../ModelDataTable/popovers/Column.svelte | 2 - .../popovers/ColumnHeader.svelte | 2 - .../ModelDataTable/popovers/View.svelte | 41 +++++ .../nav/ModelNavigator/EditView.svelte | 137 +++++++++++++++ .../nav/ModelNavigator/ListItem.svelte | 10 +- .../nav/ModelNavigator/ModelNavigator.svelte | 26 ++- .../nav/ModelSetupNav/ModelFieldEditor.svelte | 120 -------------- .../nav/ModelSetupNav/ModelSetupNav.svelte | 156 ------------------ .../src/components/nav/ModelSetupNav/index.js | 1 - .../[application]/backend/_layout.svelte | 38 ----- .../src/api/controllers/view/customViews.js | 27 --- .../server/src/api/controllers/view/index.js | 42 +++-- .../src/api/controllers/view/viewBuilder.js | 21 +++ .../tests/__snapshots__/view.spec.js.snap | 44 +++++ .../src/api/routes/tests/couchTestUtils.js | 3 - .../server/src/api/routes/tests/view.spec.js | 131 ++++++++++++--- packages/server/src/api/routes/view.js | 6 +- .../server/src/middleware/authenticated.js | 4 + 21 files changed, 427 insertions(+), 414 deletions(-) create mode 100644 packages/builder/src/components/nav/ModelNavigator/EditView.svelte delete mode 100644 packages/builder/src/components/nav/ModelSetupNav/ModelFieldEditor.svelte delete mode 100644 packages/builder/src/components/nav/ModelSetupNav/ModelSetupNav.svelte delete mode 100644 packages/builder/src/components/nav/ModelSetupNav/index.js delete mode 100644 packages/builder/src/pages/[application]/backend/_layout.svelte delete mode 100644 packages/server/src/api/controllers/view/customViews.js create mode 100644 packages/server/src/api/controllers/view/viewBuilder.js create mode 100644 packages/server/src/api/routes/tests/__snapshots__/view.spec.js.snap diff --git a/packages/builder/src/builderStore/store/backend.js b/packages/builder/src/builderStore/store/backend.js index 28272d9893..7c80f21353 100644 --- a/packages/builder/src/builderStore/store/backend.js +++ b/packages/builder/src/builderStore/store/backend.js @@ -59,7 +59,6 @@ export const getBackendUiStore = () => { store.update(state => { state.selectedModel = model state.draftModel = cloneDeep(model) - state.selectedField = "" state.selectedView = `all_${model._id}` return state }), @@ -87,10 +86,8 @@ export const getBackendUiStore = () => { delete: async model => { await api.delete(`/api/models/${model._id}/${model._rev}`) store.update(state => { - state.models = state.models.filter( - existing => existing._id !== model._id - ) - state.selectedModel = state.models[0] || {} + state.models = state.models.filter(existing => existing._id !== model._id) + state.selectedModel = state.models[0] || {} return state }) }, @@ -113,14 +110,24 @@ export const getBackendUiStore = () => { store.actions.models.save(state.draftModel) return state }) - }, + } }, views: { select: view => store.update(state => { state.selectedView = view + state.selectedModel = {} return state }), + save: async view => { + const response = await api.post(`/api/views`, view) + const savedView = await response.json() + await store.actions.models.fetch() + store.update(state => { + state.selectedView = view.name + return state + }) + } }, users: { create: user => diff --git a/packages/builder/src/components/database/ModelDataTable/ModelDataTable.svelte b/packages/builder/src/components/database/ModelDataTable/ModelDataTable.svelte index fa36018ae8..3caa7e4678 100644 --- a/packages/builder/src/components/database/ModelDataTable/ModelDataTable.svelte +++ b/packages/builder/src/components/database/ModelDataTable/ModelDataTable.svelte @@ -1,6 +1,7 @@ + +
+ +
+ +
Create View
+
+ +
+
+ + +
+
+ + diff --git a/packages/builder/src/components/nav/ModelNavigator/EditView.svelte b/packages/builder/src/components/nav/ModelNavigator/EditView.svelte new file mode 100644 index 0000000000..cc6ce2036d --- /dev/null +++ b/packages/builder/src/components/nav/ModelNavigator/EditView.svelte @@ -0,0 +1,137 @@ + + +
+ +
+ + {#if editing} +
Edit View
+
+ +
+
+
+ +
+
+ +
+
+ {:else} +
    +
  • + + Edit +
  • +
  • + + Delete +
  • +
+ {/if} +
+ + diff --git a/packages/builder/src/components/nav/ModelNavigator/ListItem.svelte b/packages/builder/src/components/nav/ModelNavigator/ListItem.svelte index 5fb2832162..85582f0345 100644 --- a/packages/builder/src/components/nav/ModelNavigator/ListItem.svelte +++ b/packages/builder/src/components/nav/ModelNavigator/ListItem.svelte @@ -6,15 +6,19 @@ export let indented -
- +
+ {title}
diff --git a/packages/builder/src/components/nav/ModelSetupNav/ModelSetupNav.svelte b/packages/builder/src/components/nav/ModelSetupNav/ModelSetupNav.svelte deleted file mode 100644 index cdbe784421..0000000000 --- a/packages/builder/src/components/nav/ModelSetupNav/ModelSetupNav.svelte +++ /dev/null @@ -1,156 +0,0 @@ - - -
- - {#if selectedTab === 'SETUP'} - {#if $backendUiStore.selectedField} - - {:else if $backendUiStore.draftModel.schema} -
-
Name
- -
- - {/if} -
- -
- {:else if selectedTab === 'DELETE'} -
-
Danger Zone
- -
- {/if} -
-
- - diff --git a/packages/builder/src/components/nav/ModelSetupNav/index.js b/packages/builder/src/components/nav/ModelSetupNav/index.js deleted file mode 100644 index e772d21707..0000000000 --- a/packages/builder/src/components/nav/ModelSetupNav/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as ModelSetupNav } from "./ModelSetupNav.svelte" diff --git a/packages/builder/src/pages/[application]/backend/_layout.svelte b/packages/builder/src/pages/[application]/backend/_layout.svelte deleted file mode 100644 index 36fc25c869..0000000000 --- a/packages/builder/src/pages/[application]/backend/_layout.svelte +++ /dev/null @@ -1,38 +0,0 @@ - - -
- -
- -
-
- - diff --git a/packages/server/src/api/controllers/view/customViews.js b/packages/server/src/api/controllers/view/customViews.js deleted file mode 100644 index 66e643f809..0000000000 --- a/packages/server/src/api/controllers/view/customViews.js +++ /dev/null @@ -1,27 +0,0 @@ -const FORMULA_MAP = { - SUM: "_sum", - COUNT: "_count", - STATS: "_stats" -}; - -function customViewTemplate({ - field, - formula, - modelId -}) { - return { - meta: { - field, - formula, - modelId - }, - map: `function (doc) { - if (doc.modelId === "${modelId}") { - emit(doc._id, doc["${field}"]); - } - }`, - reduce: "_stats" - } -} - -module.exports = customViewTemplate \ No newline at end of file diff --git a/packages/server/src/api/controllers/view/index.js b/packages/server/src/api/controllers/view/index.js index e65cfd97c3..a58bcf7ead 100644 --- a/packages/server/src/api/controllers/view/index.js +++ b/packages/server/src/api/controllers/view/index.js @@ -1,23 +1,25 @@ const CouchDB = require("../../../db") -const customViewTemplate = require("./customViews"); +const statsViewTemplate = require("./viewBuilder"); const controller = { query: async ctx => { - // const db = new CouchDB(ctx.user.instanceId) - const db = new CouchDB("inst_4e6f424_970ca7f2b9e24ec8896eb10862d7f22b") + const db = new CouchDB(ctx.user.instanceId) + const { meta } = ctx.request.body const response = await db.query(`database/${ctx.params.viewName}`, { - group: false + group: !!meta.groupBy }) + for (row of response.rows) { + row.value = { + ...row.value, + avg: row.value.sum / row.value.count + } + } + ctx.body = response.rows - // ctx.body = { - // ...data, - // avg: data.sum / data.count - // } }, fetch: async ctx => { - // const db = new CouchDB(ctx.user.instanceId) - const db = new CouchDB("inst_4e6f424_970ca7f2b9e24ec8896eb10862d7f22b") + const db = new CouchDB(ctx.user.instanceId) const designDoc = await db.get("_design/database") const response = [] @@ -37,14 +39,13 @@ const controller = { ctx.body = response }, - create: async ctx => { - // const db = new CouchDB(ctx.user.instanceId) - const db = new CouchDB("inst_4e6f424_970ca7f2b9e24ec8896eb10862d7f22b") + save: async ctx => { + const db = new CouchDB(ctx.user.instanceId) const newView = ctx.request.body const designDoc = await db.get("_design/database") - const view = customViewTemplate(ctx.request.body) + const view = statsViewTemplate(newView) designDoc.views = { ...designDoc.views, @@ -53,8 +54,17 @@ const controller = { await db.put(designDoc) - ctx.body = newView - ctx.message = `View ${newView.name} created successfully.` + + // add views to model document + const model = await db.get(ctx.request.body.modelId) + model.views = { + ...(model.views ? model.views : {}), + [newView.name]: view.meta + } + await db.put(model) + + ctx.body = view + ctx.message = `View ${newView.name} saved successfully.` }, destroy: async ctx => { const db = new CouchDB(ctx.user.instanceId) diff --git a/packages/server/src/api/controllers/view/viewBuilder.js b/packages/server/src/api/controllers/view/viewBuilder.js new file mode 100644 index 0000000000..76ebd2c437 --- /dev/null +++ b/packages/server/src/api/controllers/view/viewBuilder.js @@ -0,0 +1,21 @@ +function statsViewTemplate({ + field, + modelId, + groupBy +}) { + return { + meta: { + field, + modelId, + groupBy + }, + map: `function (doc) { + if (doc.modelId === "${modelId}") { + emit(doc["${groupBy || "_id"}"], doc["${field}"]); + } + }`, + reduce: "_stats" + } +} + +module.exports = statsViewTemplate \ No newline at end of file diff --git a/packages/server/src/api/routes/tests/__snapshots__/view.spec.js.snap b/packages/server/src/api/routes/tests/__snapshots__/view.spec.js.snap new file mode 100644 index 0000000000..96ce281ee1 --- /dev/null +++ b/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, + }, + }, +] +`; diff --git a/packages/server/src/api/routes/tests/couchTestUtils.js b/packages/server/src/api/routes/tests/couchTestUtils.js index e87233d458..594c4a3121 100644 --- a/packages/server/src/api/routes/tests/couchTestUtils.js +++ b/packages/server/src/api/routes/tests/couchTestUtils.js @@ -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, diff --git a/packages/server/src/api/routes/tests/view.spec.js b/packages/server/src/api/routes/tests/view.spec.js index 5a889bed67..ef173a224a 100644 --- a/packages/server/src/api/routes/tests/view.spec.js +++ b/packages/server/src/api/routes/tests/view.spec.js @@ -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 + } + }); }) }); 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 + .post(`/api/views/query/TestView`) + .send({ + meta: {} + }) + .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 + .post(`/api/views/query/TestView`) + .send({ + meta: { + groupBy: "Category" + } + }) + .set(defaultHeaders(app._id, instance._id)) + .expect('Content-Type', /json/) + .expect(200) + expect(res.body.length).toBe(2) + expect(res.body).toMatchSnapshot() }) }); }); \ No newline at end of file diff --git a/packages/server/src/api/routes/view.js b/packages/server/src/api/routes/view.js index aa839c0d96..549fc1659e 100644 --- a/packages/server/src/api/routes/view.js +++ b/packages/server/src/api/routes/view.js @@ -13,9 +13,7 @@ router recordController.fetchView ) .get("/api/views", viewController.fetch) - .get("/api/views/query/:viewName", viewController.query) - // .patch("/api/:databaseId/views", controller.update); - // .delete("/api/:instanceId/views/:viewId/:revId", controller.destroy); - .post("/api/views", viewController.create) + .post("/api/views/query/:viewName", viewController.query) + .post("/api/views", viewController.save) module.exports = router diff --git a/packages/server/src/middleware/authenticated.js b/packages/server/src/middleware/authenticated.js index 53cb0b2c13..e1ad397d7e 100644 --- a/packages/server/src/middleware/authenticated.js +++ b/packages/server/src/middleware/authenticated.js @@ -14,6 +14,10 @@ module.exports = async (ctx, next) => { return } + // ctx.user = { + // instanceId: "inst_4e6f424_970ca7f2b9e24ec8896eb10862d7f22b" + // } + const appToken = ctx.cookies.get("budibase:token") const builderToken = ctx.cookies.get("builder:token")