diff --git a/packages/backend-core/src/events/constants.js b/packages/backend-core/src/events/constants.js index 354f92f56b..6be537b8de 100644 --- a/packages/backend-core/src/events/constants.js +++ b/packages/backend-core/src/events/constants.js @@ -97,9 +97,11 @@ exports.Events = { VIEW_DELETED: "view:deleted", VIEW_EXPORTED: "view:exported", VIEW_FILTER_CREATED: "view:filter:created", - VIEW_FILTER_DELETED: "view:filter:created", + VIEW_FILTER_UPDATED: "view:filter:updated", + VIEW_FILTER_DELETED: "view:filter:deleted", VIEW_CALCULATION_CREATED: "view:calculation:created", - VIEW_CALCULATION_DELETED: "view:calculation:created", + VIEW_CALCULATION_UPDATED: "view:calculation:updated", + VIEW_CALCULATION_DELETED: "view:calculation:deleted", // ROW // ROW_CREATED: "row:created", diff --git a/packages/backend-core/src/events/handlers/view.js b/packages/backend-core/src/events/handlers/view.js index 785eaa0434..9c1c77ad63 100644 --- a/packages/backend-core/src/events/handlers/view.js +++ b/packages/backend-core/src/events/handlers/view.js @@ -8,13 +8,11 @@ exports.created = () => { events.processEvent(Events.VIEW_CREATED, properties) } -// TODO exports.updated = () => { const properties = {} events.processEvent(Events.VIEW_UPDATED, properties) } -// TODO exports.deleted = () => { const properties = {} events.processEvent(Events.VIEW_DELETED, properties) @@ -25,25 +23,31 @@ exports.exported = (table, format) => { events.processEvent(Events.VIEW_EXPORTED, properties) } -// TODO exports.filterCreated = () => { const properties = {} events.processEvent(Events.VIEW_FILTER_CREATED, properties) } -// TODO +exports.filterUpdated = () => { + const properties = {} + events.processEvent(Events.VIEW_FILTER_UPDATED, properties) +} + exports.filterDeleted = () => { const properties = {} events.processEvent(Events.VIEW_FILTER_DELETED, properties) } -// TODO exports.calculationCreated = () => { const properties = {} events.processEvent(Events.VIEW_CALCULATION_CREATED, properties) } -// TODO +exports.calculationUpdated = () => { + const properties = {} + events.processEvent(Events.VIEW_CALCULATION_UPDATED, properties) +} + exports.calculationDeleted = () => { const properties = {} events.processEvent(Events.VIEW_CALCULATION_DELETED, properties) diff --git a/packages/backend-core/src/tests/utilities/mocks/events.js b/packages/backend-core/src/tests/utilities/mocks/events.js index 1d0fdc3f8c..5c5738adab 100644 --- a/packages/backend-core/src/tests/utilities/mocks/events.js +++ b/packages/backend-core/src/tests/utilities/mocks/events.js @@ -106,7 +106,16 @@ jest.mock("../../../events", () => { permissionUpdated: jest.fn(), }, view: { + created: jest.fn(), + updated: jest.fn(), + deleted: jest.fn(), exported: jest.fn(), + filterCreated: jest.fn(), + filterUpdated: jest.fn(), + filterDeleted: jest.fn(), + calculationCreated: jest.fn(), + calculationUpdated: jest.fn(), + calculationDeleted: jest.fn(), }, } }) diff --git a/packages/server/src/api/controllers/view/index.js b/packages/server/src/api/controllers/view/index.js index 1174abc904..16d30d350d 100644 --- a/packages/server/src/api/controllers/view/index.js +++ b/packages/server/src/api/controllers/view/index.js @@ -8,6 +8,7 @@ const { FieldTypes } = require("../../../constants") const { getAppDB } = require("@budibase/backend-core/context") const { events } = require("@budibase/backend-core") const { DocumentTypes } = require("../../../db/utils") +const { cloneDeep, isEqual } = require("lodash") exports.fetch = async ctx => { ctx.body = await getViews() @@ -17,24 +18,28 @@ exports.save = async ctx => { const db = getAppDB() const { originalName, ...viewToSave } = ctx.request.body const view = viewTemplate(viewToSave) + const viewName = viewToSave.name - if (!viewToSave.name) { + if (!viewName) { ctx.throw(400, "Cannot create view without a name") } - await saveView(originalName, viewToSave.name, view) + await saveView(originalName, viewName, view) // add views to table document - const table = await db.get(ctx.request.body.tableId) + const existingTable = await db.get(ctx.request.body.tableId) + const table = cloneDeep(existingTable) if (!table.views) table.views = {} if (!view.meta.schema) { view.meta.schema = table.schema } - table.views[viewToSave.name] = view.meta + table.views[viewName] = view.meta if (originalName) { delete table.views[originalName] + existingTable.views[viewName] = existingTable.views[originalName] } await db.put(table) + handleViewEvents(existingTable.views[viewName], table.views[viewName]) ctx.body = { ...table.views[viewToSave.name], @@ -42,6 +47,62 @@ exports.save = async ctx => { } } +const calculationEvents = (existingView, newView) => { + const existingCalculation = existingView && existingView.calculation + const newCalculation = newView && newView.calculation + + if (existingCalculation && !newCalculation) { + events.view.calculationDeleted() + } + + if (!existingCalculation && newCalculation) { + events.view.calculationCreated() + } + + if ( + existingCalculation && + newCalculation && + existingCalculation !== newCalculation + ) { + events.view.calculationUpdated() + } +} + +const filterEvents = (existingView, newView) => { + const hasExistingFilters = !!( + existingView && + existingView.filters && + existingView.filters.length + ) + const hasNewFilters = !!(newView && newView.filters && newView.filters.length) + + if (hasExistingFilters && !hasNewFilters) { + events.view.filterDeleted() + } + + if (!hasExistingFilters && hasNewFilters) { + events.view.filterCreated() + } + + if ( + hasExistingFilters && + hasNewFilters && + !isEqual(existingView.filters, newView.filters) + ) { + events.view.filterUpdated() + } +} + +const handleViewEvents = (existingView, newView) => { + if (!existingView) { + events.view.created() + } else { + events.view.updated() + } + calculationEvents(existingView, newView) + filterEvents(existingView, newView) +} + exports.destroy = async ctx => { const db = getAppDB() const viewName = decodeURI(ctx.params.viewName) @@ -49,6 +110,7 @@ exports.destroy = async ctx => { const table = await db.get(view.meta.tableId) delete table.views[viewName] await db.put(table) + events.view.deleted() ctx.body = view } diff --git a/packages/server/src/api/routes/tests/view.spec.js b/packages/server/src/api/routes/tests/view.spec.js index cf5c81b0f1..2ea90ce32d 100644 --- a/packages/server/src/api/routes/tests/view.spec.js +++ b/packages/server/src/api/routes/tests/view.spec.js @@ -30,27 +30,70 @@ describe("/views", () => { beforeEach(async () => { await config.init() + table = await config.createTable(priceTable()) }) + const saveView = async (view) => { + const viewToSave = { + name: "TestView", + field: "Price", + calculation: "stats", + tableId: table._id, + ...view + } + return request + .post(`/api/views`) + .send(viewToSave) + .set(config.defaultHeaders()) + .expect("Content-Type", /json/) + .expect(200) + } + describe("create", () => { - beforeEach(async () => { - table = await config.createTable(priceTable()) - }) it("returns a success message when the view is successfully created", async () => { - const res = await request - .post(`/api/views`) - .send({ - name: "TestView", - field: "Price", - calculation: "stats", - tableId: table._id, - }) - .set(config.defaultHeaders()) - .expect("Content-Type", /json/) - .expect(200) + const res = await saveView() + expect(res.body.tableId).toBe(table._id) + expect(events.view.created).toBeCalledTimes(1) + }) + + it("creates a view with a calculation", async () => { + jest.clearAllMocks() + + const res = await saveView({ calculation: "count" }) expect(res.body.tableId).toBe(table._id) + expect(events.view.created).toBeCalledTimes(1) + expect(events.view.updated).not.toBeCalled() + expect(events.view.calculationCreated).toBeCalledTimes(1) + expect(events.view.calculationUpdated).not.toBeCalled() + expect(events.view.calculationDeleted).not.toBeCalled() + expect(events.view.filterCreated).not.toBeCalled() + expect(events.view.filterUpdated).not.toBeCalled() + expect(events.view.filterDeleted).not.toBeCalled() + }) + + it("creates a view with a filter", async () => { + jest.clearAllMocks() + + const res = await saveView({ + calculation: null, + filters: [{ + value: "1", + condition: "EQUALS", + key: "price" + }], + }) + + expect(res.body.tableId).toBe(table._id) + expect(events.view.created).toBeCalledTimes(1) + expect(events.view.updated).not.toBeCalled() + expect(events.view.calculationCreated).not.toBeCalled() + expect(events.view.calculationUpdated).not.toBeCalled() + expect(events.view.calculationDeleted).not.toBeCalled() + expect(events.view.filterCreated).toBeCalledTimes(1) + expect(events.view.filterUpdated).not.toBeCalled() + expect(events.view.filterDeleted).not.toBeCalled() }) it("updates the table row with the new view metadata", async () => { @@ -102,6 +145,100 @@ describe("/views", () => { }) }) + describe("update", () => { + it("updates a view with no calculation or filter changed", async () => { + await saveView() + jest.clearAllMocks() + + await saveView() + + expect(events.view.created).not.toBeCalled() + expect(events.view.updated).toBeCalledTimes(1) + expect(events.view.calculationCreated).not.toBeCalled() + expect(events.view.calculationUpdated).not.toBeCalled() + expect(events.view.calculationDeleted).not.toBeCalled() + expect(events.view.filterCreated).not.toBeCalled() + expect(events.view.filterUpdated).not.toBeCalled() + expect(events.view.filterDeleted).not.toBeCalled() + }) + + it("updates a view calculation", async () => { + await saveView({ calculation: "sum" }) + jest.clearAllMocks() + + await saveView({ calculation: "count" }) + + expect(events.view.created).not.toBeCalled() + expect(events.view.updated).toBeCalledTimes(1) + expect(events.view.calculationCreated).not.toBeCalled() + expect(events.view.calculationUpdated).toBeCalledTimes(1) + expect(events.view.calculationDeleted).not.toBeCalled() + expect(events.view.filterCreated).not.toBeCalled() + expect(events.view.filterUpdated).not.toBeCalled() + expect(events.view.filterDeleted).not.toBeCalled() + }) + + it("deletes a view calculation", async () => { + await saveView({ calculation: "sum" }) + jest.clearAllMocks() + + await saveView({ calculation: null }) + + expect(events.view.created).not.toBeCalled() + expect(events.view.updated).toBeCalledTimes(1) + expect(events.view.calculationCreated).not.toBeCalled() + expect(events.view.calculationUpdated).not.toBeCalled() + expect(events.view.calculationDeleted).toBeCalledTimes(1) + expect(events.view.filterCreated).not.toBeCalled() + expect(events.view.filterUpdated).not.toBeCalled() + expect(events.view.filterDeleted).not.toBeCalled() + }) + + it("updates a view filter", async () => { + await saveView({ filters: [{ + value: "1", + condition: "EQUALS", + key: "price" + }] }) + jest.clearAllMocks() + + await saveView({ filters: [{ + value: "2", + condition: "EQUALS", + key: "price" + }] }) + + expect(events.view.created).not.toBeCalled() + expect(events.view.updated).toBeCalledTimes(1) + expect(events.view.calculationCreated).not.toBeCalled() + expect(events.view.calculationUpdated).not.toBeCalled() + expect(events.view.calculationDeleted).not.toBeCalled() + expect(events.view.filterCreated).not.toBeCalled() + expect(events.view.filterUpdated).toBeCalledTimes(1) + expect(events.view.filterDeleted).not.toBeCalled() + }) + + it("deletes a view filter", async () => { + await saveView({ filters: [{ + value: "1", + condition: "EQUALS", + key: "price" + }] }) + jest.clearAllMocks() + + await saveView({ filters: [] }) + + expect(events.view.created).not.toBeCalled() + expect(events.view.updated).toBeCalledTimes(1) + expect(events.view.calculationCreated).not.toBeCalled() + expect(events.view.calculationUpdated).not.toBeCalled() + expect(events.view.calculationDeleted).not.toBeCalled() + expect(events.view.filterCreated).not.toBeCalled() + expect(events.view.filterUpdated).not.toBeCalled() + expect(events.view.filterDeleted).toBeCalledTimes(1) + }) + }) + describe("fetch", () => { beforeEach(async () => { table = await config.createTable(priceTable()) @@ -125,10 +262,6 @@ describe("/views", () => { }) describe("query", () => { - beforeEach(async () => { - table = await config.createTable(priceTable()) - }) - it("returns data for the created view", async () => { await config.createView({ name: "TestView", @@ -202,6 +335,7 @@ describe("/views", () => { .expect(200) expect(res.body.map).toBeDefined() expect(res.body.meta.tableId).toEqual(table._id) + expect(events.view.deleted).toBeCalledTimes(1) }) })