From 6a7aed1c0d51e4d88de5744141a1eb7d3dc5d695 Mon Sep 17 00:00:00 2001 From: Andrew Kingston Date: Thu, 20 Jan 2022 18:42:30 +0000 Subject: [PATCH] Update parts of builder with core API refactor --- packages/builder/src/analytics/index.js | 19 +- packages/builder/src/builderStore/api.js | 66 ++-- .../builderStore/loadComponentLibraries.js | 16 - .../src/builderStore/store/frontend.js | 311 +++++++++--------- .../src/builderStore/store/notifications.js | 23 -- .../src/components/deploy/DeployModal.svelte | 22 +- .../builder/src/helpers/fetchTableData.js | 7 +- .../src/stores/backend/integrations.js | 19 +- packages/builder/vite.config.js | 4 + packages/frontend-core/src/api/analytics.js | 9 + packages/frontend-core/src/api/app.js | 23 ++ packages/frontend-core/src/api/builder.js | 17 + packages/frontend-core/src/api/index.js | 2 + packages/frontend-core/src/api/layouts.js | 23 ++ packages/frontend-core/src/api/routes.js | 6 + packages/frontend-core/src/api/screens.js | 23 ++ 16 files changed, 345 insertions(+), 245 deletions(-) delete mode 100644 packages/builder/src/builderStore/loadComponentLibraries.js delete mode 100644 packages/builder/src/builderStore/store/notifications.js create mode 100644 packages/frontend-core/src/api/builder.js create mode 100644 packages/frontend-core/src/api/layouts.js create mode 100644 packages/frontend-core/src/api/screens.js diff --git a/packages/builder/src/analytics/index.js b/packages/builder/src/analytics/index.js index e9edf38d74..26947a2320 100644 --- a/packages/builder/src/analytics/index.js +++ b/packages/builder/src/analytics/index.js @@ -1,8 +1,9 @@ -import api from "builderStore/api" +import { API } from "api" import PosthogClient from "./PosthogClient" import IntercomClient from "./IntercomClient" import SentryClient from "./SentryClient" import { Events } from "./constants" +import { notifications } from "@budibase/bbui" const posthog = new PosthogClient( process.env.POSTHOG_TOKEN, @@ -17,13 +18,15 @@ class AnalyticsHub { } async activate() { - const analyticsStatus = await api.get("/api/analytics") - const json = await analyticsStatus.json() - - // Analytics disabled - if (!json.enabled) return - - this.clients.forEach(client => client.init()) + try { + // Check analytics are enabled + const analyticsStatus = await API.getAnalyticsStatus() + if (analyticsStatus.enabled) { + this.clients.forEach(client => client.init()) + } + } catch (error) { + notifications.error("Error checking analytics status") + } } identify(id, metadata) { diff --git a/packages/builder/src/builderStore/api.js b/packages/builder/src/builderStore/api.js index 9dfd8af854..085c176f88 100644 --- a/packages/builder/src/builderStore/api.js +++ b/packages/builder/src/builderStore/api.js @@ -1,40 +1,44 @@ +import { + createAPIClient, + CookieUtils, + Constants, +} from "@budibase/frontend-core" import { store } from "./index" -import { get as svelteGet } from "svelte/store" -import { CookieUtils, Constants } from "@budibase/frontend-core" +import { get } from "svelte/store" +import { notifications } from "@budibase/bbui" -const apiCall = - method => - async (url, body, headers = { "Content-Type": "application/json" }) => { - headers["x-budibase-app-id"] = svelteGet(store).appId - const json = headers["Content-Type"] === "application/json" - const resp = await fetch(url, { - method: method, - body: json ? JSON.stringify(body) : body, - headers, - }) - if (resp.status === 403) { +export const API = createAPIClient({ + attachHeaders: headers => { + // Attach app ID header from store + headers["x-budibase-app-id"] = get(store).appId + }, + + onError: error => { + const { url, message, status } = error + + // Log all API errors to Sentry + // analytics.captureException(error) + + // Show a notification for any errors + if (message) { + notifications.error(`Error fetching ${url}: ${message}`) + } + + // Logout on 403's + if (status === 403) { + // Don't do anything if fetching templates. + // TODO: clarify why this is here if (url.includes("/api/templates")) { - return { json: () => [] } + return } + + // Remove the auth cookie CookieUtils.removeCookie(Constants.Cookies.Auth) - // reload after removing cookie, go to login + + // Reload after removing cookie, go to login if (!url.includes("self") && !url.includes("login")) { location.reload() } } - return resp - } - -export const post = apiCall("POST") -export const get = apiCall("GET") -export const patch = apiCall("PATCH") -export const del = apiCall("DELETE") -export const put = apiCall("PUT") - -export default { - post: apiCall("POST"), - get: apiCall("GET"), - patch: apiCall("PATCH"), - delete: apiCall("DELETE"), - put: apiCall("PUT"), -} + }, +}) diff --git a/packages/builder/src/builderStore/loadComponentLibraries.js b/packages/builder/src/builderStore/loadComponentLibraries.js deleted file mode 100644 index 8bdfcf7538..0000000000 --- a/packages/builder/src/builderStore/loadComponentLibraries.js +++ /dev/null @@ -1,16 +0,0 @@ -import { get } from "builderStore/api" - -/** - * Fetches the definitions for component library components. This includes - * their props and other metadata from components.json. - * @param {string} appId - ID of the currently running app - */ -export const fetchComponentLibDefinitions = async appId => { - const LIB_DEFINITION_URL = `/api/${appId}/components/definitions` - try { - const libDefinitionResponse = await get(LIB_DEFINITION_URL) - return await libDefinitionResponse.json() - } catch (err) { - console.error(`Error fetching component definitions for ${appId}`, err) - } -} diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index fdd4599e18..9b3a95d7f7 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -15,8 +15,7 @@ import { database, tables, } from "stores/backend" -import { fetchComponentLibDefinitions } from "../loadComponentLibraries" -import api from "../api" +import { API } from "api" import { FrontendTypes } from "constants" import analytics, { Events } from "analytics" import { @@ -29,6 +28,7 @@ import { } from "../componentUtils" import { Helpers } from "@budibase/bbui" import { removeBindings } from "../dataBinding" +import { notifications } from "@budibase/bbui" const INITIAL_FRONTEND_STATE = { apps: [], @@ -68,15 +68,12 @@ export const getFrontendStore = () => { store.actions = { initialise: async pkg => { const { layouts, screens, application, clientLibPath } = pkg - const components = await fetchComponentLibDefinitions(application.appId) - // make sure app isn't locked - if ( - components && - components.status === 400 && - components.message?.includes("lock") - ) { - throw { ok: false, reason: "locked" } - } + + // Fetch component definitions. + // Allow errors to propagate. + let components = await API.fetchComponentLibDefinitions(application.appId) + + // Reset store state store.update(state => ({ ...state, libraries: application.componentLibraries, @@ -100,56 +97,57 @@ export const getFrontendStore = () => { version: application.version, revertableVersion: application.revertableVersion, })) - await hostingStore.actions.fetch() // Initialise backend stores - const [_integrations] = await Promise.all([ - api.get("/api/integrations").then(r => r.json()), - ]) - datasources.init() - integrations.set(_integrations) - queries.init() database.set(application.instance) - tables.init() + await hostingStore.actions.fetch() + await datasources.init() + await integrations.init() + await queries.init() + await tables.init() }, theme: { save: async theme => { const appId = get(store).appId - const response = await api.put(`/api/applications/${appId}`, { theme }) - if (response.status === 200) { + const metadata = { appId, theme } + try { + await API.saveAppMetadata(metadata) store.update(state => { state.theme = theme return state }) - } else { - throw new Error("Error updating theme") + } catch (error) { + notifications.error("Error updating theme") } }, }, customTheme: { save: async customTheme => { const appId = get(store).appId - const response = await api.put(`/api/applications/${appId}`, { - customTheme, - }) - if (response.status === 200) { + const metadata = { appId, customTheme } + try { + await API.saveAppMetadata(metadata) store.update(state => { state.customTheme = customTheme return state }) - } else { - throw new Error("Error updating theme") + } catch (error) { + notifications.error("Error updating custom theme") } }, }, routing: { fetch: async () => { - const response = await api.get("/api/routing") - const json = await response.json() - store.update(state => { - state.routes = json.routes - return state - }) + try { + const routes = await API.getAppRoutes() + console.log(routes) + store.update(state => { + state.routes = routes.routes + return state + }) + } catch (error) { + notifications.error("Error fetching app routes") + } }, }, screens: { @@ -172,81 +170,100 @@ export const getFrontendStore = () => { }) }, create: async screen => { - screen = await store.actions.screens.save(screen) - store.update(state => { - state.selectedScreenId = screen._id - state.selectedComponentId = screen.props._id - state.currentFrontEndType = FrontendTypes.SCREEN - selectedAccessRole.set(screen.routing.roleId) - return state - }) - return screen + try { + const savedScreen = await API.saveScreen(screen) + store.update(state => { + state.selectedScreenId = savedScreen._id + state.selectedComponentId = savedScreen.props._id + state.currentFrontEndType = FrontendTypes.SCREEN + selectedAccessRole.set(savedScreen.routing.roleId) + return savedScreen + }) + + // Refresh routes + await store.actions.routing.fetch() + return savedScreen + } catch (error) { + notifications.error("Error creating screen") + return null + } }, save: async screen => { - const creatingNewScreen = screen._id === undefined - const response = await api.post(`/api/screens`, screen) - if (response.status !== 200) { - return - } - screen = await response.json() - await store.actions.routing.fetch() + try { + const creatingNewScreen = screen._id === undefined + const savedScreen = await API.saveScreen(screen) + store.update(state => { + const idx = state.screens.findIndex(x => x._id === savedScreen._id) + if (idx !== -1) { + state.screens.splice(idx, 1, savedScreen) + } else { + state.screens.push(savedScreen) + } + return state + }) - store.update(state => { - const foundScreen = state.screens.findIndex( - el => el._id === screen._id - ) - if (foundScreen !== -1) { - state.screens.splice(foundScreen, 1) + // Refresh routes + await store.actions.routing.fetch() + + // Select the new screen if creating a new one + if (creatingNewScreen) { + store.actions.screens.select(savedScreen._id) } - state.screens.push(screen) - return state - }) - - if (creatingNewScreen) { - store.actions.screens.select(screen._id) + return savedScreen + } catch (error) { + notifications.error("Error saving screen") + return null } - - return screen }, delete: async screens => { const screensToDelete = Array.isArray(screens) ? screens : [screens] - const screenDeletePromises = [] - store.update(state => { - for (let screenToDelete of screensToDelete) { - state.screens = state.screens.filter( - screen => screen._id !== screenToDelete._id + // Build array of promises to speed up bulk deletions + const promises = [] + screensToDelete.forEach(screen => { + // Delete the screen + promises.push( + API.deleteScreen({ + screenId: screen._id, + screenRev: screen._rev, + }) + ) + // Remove links to this screen + promises.push( + store.actions.components.links.delete( + screen.routing.route, + screen.props._instanceName ) - screenDeletePromises.push( - api.delete( - `/api/screens/${screenToDelete._id}/${screenToDelete._rev}` - ) - ) - if (screenToDelete._id === state.selectedScreenId) { + ) + }) + + try { + await Promise.all(promises) + const deletedIds = screensToDelete.map(screen => screen._id) + store.update(state => { + // Remove deleted screens from state + state.screens = state.screens.filter(screen => { + return !deletedIds.includes(screen._id) + }) + // Deselect the current screen if it was deleted + if (deletedIds.includes(state.selectedScreenId)) { state.selectedScreenId = null } - //remove the link for this screen - screenDeletePromises.push( - store.actions.components.links.delete( - screenToDelete.routing.route, - screenToDelete.props._instanceName - ) - ) - } - return state - }) - await Promise.all(screenDeletePromises) + return state + }) + } catch (error) { + notifications.error("Error deleting screens") + } }, }, preview: { saveSelected: async () => { const state = get(store) const selectedAsset = get(currentAsset) - if (state.currentFrontEndType !== FrontendTypes.LAYOUT) { - await store.actions.screens.save(selectedAsset) + return await store.actions.screens.save(selectedAsset) } else { - await store.actions.layouts.save(selectedAsset) + return await store.actions.layouts.save(selectedAsset) } }, setDevice: device => { @@ -270,36 +287,28 @@ export const getFrontendStore = () => { }) }, save: async layout => { - const layoutToSave = cloneDeep(layout) - const creatingNewLayout = layoutToSave._id === undefined - const response = await api.post(`/api/layouts`, layoutToSave) - const savedLayout = await response.json() + try { + const creatingNewLayout = layout._id === undefined + const savedLayout = await API.saveLayout(layout) + store.update(state => { + const idx = state.layouts.findIndex(x => x._id === savedLayout._id) + if (idx !== -1) { + state.layouts.splice(idx, 1, savedLayout) + } else { + state.layouts.push(savedLayout) + } + return state + }) - // Abort if saving failed - if (response.status !== 200) { - return - } - - store.update(state => { - const layoutIdx = state.layouts.findIndex( - stateLayout => stateLayout._id === savedLayout._id - ) - if (layoutIdx >= 0) { - // update existing layout - state.layouts.splice(layoutIdx, 1, savedLayout) - } else { - // save new layout - state.layouts.push(savedLayout) + // Select layout if creating a new one + if (creatingNewLayout) { + store.actions.layouts.select(savedLayout._id) } - return state - }) - - // Select layout if creating a new one - if (creatingNewLayout) { - store.actions.layouts.select(savedLayout._id) + return savedLayout + } catch (error) { + notifications.error("Error saving layout") + return null } - - return savedLayout }, find: layoutId => { if (!layoutId) { @@ -308,23 +317,26 @@ export const getFrontendStore = () => { const storeContents = get(store) return storeContents.layouts.find(layout => layout._id === layoutId) }, - delete: async layoutToDelete => { - const response = await api.delete( - `/api/layouts/${layoutToDelete._id}/${layoutToDelete._rev}` - ) - if (response.status !== 200) { - const json = await response.json() - throw new Error(json.message) + delete: async layout => { + if (!layout?._id) { + return + } + try { + await API.deleteLayout({ + layoutId: layout._id, + layoutRev: layout._rev, + }) + store.update(state => { + // Select main layout if we deleted the selected layout + if (layout._id === state.selectedLayoutId) { + state.selectedLayoutId = get(mainLayout)._id + } + state.layouts = state.layouts.filter(x => x._id !== layout._id) + return state + }) + } catch (error) { + notifications.error("Failed to delete layout") } - store.update(state => { - state.layouts = state.layouts.filter( - layout => layout._id !== layoutToDelete._id - ) - if (layoutToDelete._id === state.selectedLayoutId) { - state.selectedLayoutId = get(mainLayout)._id - } - return state - }) }, }, components: { @@ -414,16 +426,12 @@ export const getFrontendStore = () => { componentName, presetProps ) - if (!componentInstance) { + if (!componentInstance || !asset) { return } // Find parent node to attach this component to let parentComponent - - if (!asset) { - return - } if (selected) { // Use current screen or layout as parent if no component is selected const definition = store.actions.components.getDefinition( @@ -451,19 +459,24 @@ export const getFrontendStore = () => { parentComponent._children.push(componentInstance) // Save components and update UI - await store.actions.preview.saveSelected() - store.update(state => { - state.currentView = "component" - state.selectedComponentId = componentInstance._id - return state - }) + const savedAsset = await store.actions.preview.saveSelected() + if (savedAsset) { + store.update(state => { + state.currentView = "component" + state.selectedComponentId = componentInstance._id + return state + }) - // Log event - analytics.captureEvent(Events.COMPONENT.CREATED, { - name: componentInstance._component, - }) + // Log event + analytics.captureEvent(Events.COMPONENT.CREATED, { + name: componentInstance._component, + }) - return componentInstance + return componentInstance + } else { + notifications.error("Failed to create component") + return null + } }, delete: async component => { if (!component) { diff --git a/packages/builder/src/builderStore/store/notifications.js b/packages/builder/src/builderStore/store/notifications.js deleted file mode 100644 index 85e708e92a..0000000000 --- a/packages/builder/src/builderStore/store/notifications.js +++ /dev/null @@ -1,23 +0,0 @@ -import { writable } from "svelte/store" -import { generate } from "shortid" - -export const notificationStore = writable({ - notifications: [], -}) - -export function send(message, type = "default") { - notificationStore.update(state => { - state.notifications = [ - ...state.notifications, - { id: generate(), type, message }, - ] - return state - }) -} - -export const notifier = { - danger: msg => send(msg, "danger"), - warning: msg => send(msg, "warning"), - info: msg => send(msg, "info"), - success: msg => send(msg, "success"), -} diff --git a/packages/builder/src/components/deploy/DeployModal.svelte b/packages/builder/src/components/deploy/DeployModal.svelte index 33fe8d5321..4a86394c72 100644 --- a/packages/builder/src/components/deploy/DeployModal.svelte +++ b/packages/builder/src/components/deploy/DeployModal.svelte @@ -1,6 +1,6 @@ diff --git a/packages/builder/src/helpers/fetchTableData.js b/packages/builder/src/helpers/fetchTableData.js index c854b066d8..375b018039 100644 --- a/packages/builder/src/helpers/fetchTableData.js +++ b/packages/builder/src/helpers/fetchTableData.js @@ -3,7 +3,7 @@ // This will eventually be replaced by the new client implementation when we // add a core package. import { writable, derived, get } from "svelte/store" -import * as API from "../builderStore/api" +import { API } from "api" import { LuceneUtils } from "@budibase/frontend-core" const defaultOptions = { @@ -50,7 +50,7 @@ export const fetchTableData = opts => { const fetchPage = async bookmark => { lastBookmark = bookmark const { tableId, limit, sortColumn, sortOrder, paginate } = options - const res = await API.post(`/api/${options.tableId}/search`, { + return await API.searchTable({ tableId, query, limit, @@ -60,7 +60,6 @@ export const fetchTableData = opts => { paginate, bookmark, }) - return await res.json() } // Fetches a fresh set of results from the server @@ -77,7 +76,7 @@ export const fetchTableData = opts => { // are objects let enrichedSchema = schema if (!enrichedSchema) { - const definition = await API.get(`/api/tables/${tableId}`) + const definition = await API.fetchTableDefinition(tableId) enrichedSchema = definition?.schema ?? null } if (enrichedSchema) { diff --git a/packages/builder/src/stores/backend/integrations.js b/packages/builder/src/stores/backend/integrations.js index d1df818248..c19b09f1fb 100644 --- a/packages/builder/src/stores/backend/integrations.js +++ b/packages/builder/src/stores/backend/integrations.js @@ -1,3 +1,20 @@ import { writable } from "svelte/store" +import { API } from "api" -export const integrations = writable({}) +const createIntegrationsStore = () => { + const store = writable(null) + + return { + ...store, + init: async () => { + try { + const integrations = await API.getIntegrations() + store.set(integrations) + } catch (error) { + store.set(null) + } + }, + } +} + +export const integrations = createIntegrationsStore() diff --git a/packages/builder/vite.config.js b/packages/builder/vite.config.js index d66d677555..dbaf1ffb2b 100644 --- a/packages/builder/vite.config.js +++ b/packages/builder/vite.config.js @@ -56,6 +56,10 @@ export default ({ mode }) => { find: "stores", replacement: path.resolve("./src/stores"), }, + { + find: "api", + replacement: path.resolve("./src/builderStore/api.js"), + }, { find: "constants", replacement: path.resolve("./src/constants"), diff --git a/packages/frontend-core/src/api/analytics.js b/packages/frontend-core/src/api/analytics.js index 5acaf0ac76..0402365e73 100644 --- a/packages/frontend-core/src/api/analytics.js +++ b/packages/frontend-core/src/api/analytics.js @@ -7,4 +7,13 @@ export const buildAnalyticsEndpoints = API => ({ url: `/api/analytics/ping`, }) }, + + /** + * Gets the current status of analytics for this environment + */ + getAnalyticsStatus: async () => { + return await API.get({ + url: "/api/analytics", + }) + }, }) diff --git a/packages/frontend-core/src/api/app.js b/packages/frontend-core/src/api/app.js index c1dc5deea6..13fd831196 100644 --- a/packages/frontend-core/src/api/app.js +++ b/packages/frontend-core/src/api/app.js @@ -7,4 +7,27 @@ export const buildAppEndpoints = API => ({ url: `/api/applications/${appId}/appPackage`, }) }, + + /** + * Saves and patches metadata about an app + * @param metadata the app metadata to save + */ + saveAppMetadata: async metadata => { + if (!metadata?.appId) { + throw API.error("App metadata must have an appId set") + } + return await API.put({ + url: `/api/applications/${metadata.appId}`, + body: metadata, + }) + }, + + /** + * Deploys the current app. + */ + deployApp: async () => { + return await API.post({ + url: "/api/deploy", + }) + }, }) diff --git a/packages/frontend-core/src/api/builder.js b/packages/frontend-core/src/api/builder.js new file mode 100644 index 0000000000..dbe64e611e --- /dev/null +++ b/packages/frontend-core/src/api/builder.js @@ -0,0 +1,17 @@ +export const buildBuilderEndpoints = API => ({ + /** + * Fetches the definitions for component library components. This includes + * their props and other metadata from components.json. + * @param {string} appId - ID of the currently running app + */ + fetchComponentLibDefinitions: async appId => { + return await API.get({ url: `/api/${appId}/components/definitions` }) + }, + + /** + * Gets the list of available integrations. + */ + getIntegrations: async () => { + return await API.get({ url: "/api/integrations" }) + }, +}) diff --git a/packages/frontend-core/src/api/index.js b/packages/frontend-core/src/api/index.js index 8fb61c9edb..f1c12dc941 100644 --- a/packages/frontend-core/src/api/index.js +++ b/packages/frontend-core/src/api/index.js @@ -8,6 +8,7 @@ import { buildQueryEndpoints } from "./queries" import { buildRelationshipEndpoints } from "./relationships" import { buildRouteEndpoints } from "./routes" import { buildRowEndpoints } from "./rows" +import { buildScreenEndpoints } from "./screens" import { buildTableEndpoints } from "./tables" import { buildViewEndpoints } from "./views" @@ -167,6 +168,7 @@ export const createAPIClient = config => { ...buildRelationshipEndpoints(API), ...buildRouteEndpoints(API), ...buildRowEndpoints(API), + ...buildScreenEndpoints(API), ...buildTableEndpoints(API), ...buildViewEndpoints(API), } diff --git a/packages/frontend-core/src/api/layouts.js b/packages/frontend-core/src/api/layouts.js new file mode 100644 index 0000000000..51bce1f533 --- /dev/null +++ b/packages/frontend-core/src/api/layouts.js @@ -0,0 +1,23 @@ +export const buildLayoutEndpoints = API => ({ + /** + * Saves a layout. + * @param layout the layout to save + */ + saveLayout: async layout => { + return await API.post({ + url: "/api/layouts", + body: layout, + }) + }, + + /** + * Deletes a layout. + * @param layoutId the ID of the layout to delete + * @param layoutRev the rev of the layout to delete + */ + deleteLayout: async ({ layoutId, layoutRev }) => { + return await API.delete({ + url: `/api/layouts/${layoutId}/${layoutRev}`, + }) + }, +}) diff --git a/packages/frontend-core/src/api/routes.js b/packages/frontend-core/src/api/routes.js index 171f4f45d8..d87988103f 100644 --- a/packages/frontend-core/src/api/routes.js +++ b/packages/frontend-core/src/api/routes.js @@ -7,4 +7,10 @@ export const buildRouteEndpoints = API => ({ url: `/api/routing/client`, }) }, + + fetchAppRoutes: async () => { + return await API.get({ + url: "/api/routing", + }) + }, }) diff --git a/packages/frontend-core/src/api/screens.js b/packages/frontend-core/src/api/screens.js new file mode 100644 index 0000000000..1daa79153b --- /dev/null +++ b/packages/frontend-core/src/api/screens.js @@ -0,0 +1,23 @@ +export const buildScreenEndpoints = API => ({ + /** + * Saves a screen definition + * @param screen the screen to save + */ + saveScreen: async screen => { + return await API.post({ + url: "/api/screens", + body: screen, + }) + }, + + /** + * Deletes a screen. + * @param screenId the ID of the screen to delete + * @param screenRev the rev of the screen to delete + */ + deleteScreen: async ({ screenId, screenRev }) => { + return await API.delete({ + url: `/api/screens/${screenId}/${screenRev}`, + }) + }, +})