diff --git a/packages/client/src/api.js b/packages/client/src/api.js new file mode 100644 index 0000000000..71eb8fb2a4 --- /dev/null +++ b/packages/client/src/api.js @@ -0,0 +1,120 @@ +import { createAPIClient } from "@budibase/frontend-core" +import { notificationStore } from "./stores" +import { FieldTypes } from "./constants" +import { TableNames } from "@budibase/frontend-core/src/constants.js" + +export const API = createAPIClient({ + // Attach client specific headers + attachHeaders: headers => { + // Attach app ID header + headers["x-budibase-app-id"] = window["##BUDIBASE_APP_ID##"] + + // Attach client header if not inside the builder preview + if (!window["##BUDIBASE_IN_BUILDER##"]) { + headers["x-budibase-type"] = "client" + } + }, + + // Show an error notification for all API failures. + // We could also log these to sentry. + // Or we could check error.status and redirect to login on a 403 etc. + onError: error => { + notificationStore.actions.error(error.message) + }, + + // Patch certain endpoints with functionality specific to client apps + patches: { + // Enrich rows so they properly handle client bindings + fetchSelf: async ({ output }) => { + const user = output + if (user && user._id) { + if (user.roleId === "PUBLIC") { + // Don't try to enrich a public user as it will 403 + return user + } else { + return (await enrichRows([user], TableNames.USERS))[0] + } + } else { + return null + } + }, + fetchRelationshipData: async ({ params, output }) => { + const tableId = params[0]?.tableId + return await enrichRows(output, tableId) + }, + fetchTableData: async ({ params, output }) => { + const tableId = params[0] + return await enrichRows(output, tableId) + }, + searchTable: async ({ params, output }) => { + const tableId = params[0]?.tableId + return { + ...output, + rows: await enrichRows(output?.rows, tableId), + } + }, + fetchViewData: async ({ params, output }) => { + const tableId = params[0]?.tableId + return await enrichRows(output, tableId) + }, + + // Wipe any HBS formulae from table definitions, as these interfere with + // handlebars enrichment + fetchTableDefinition: async ({ output }) => { + Object.keys(output?.schema || {}).forEach(field => { + if (output.schema[field]?.type === "formula") { + delete output.schema[field].formula + } + }) + return output + }, + }, +}) + +/** + * Enriches rows which contain certain field types so that they can + * be properly displayed. + * The ability to create these bindings has been removed, but they will still + * exist in client apps to support backwards compatibility. + */ +const enrichRows = async (rows, tableId) => { + if (!Array.isArray(rows)) { + return [] + } + if (rows.length) { + const tables = {} + for (let row of rows) { + // Fall back to passed in tableId if row doesn't have it specified + let rowTableId = row.tableId || tableId + let table = tables[rowTableId] + if (!table) { + // Fetch table schema so we can check column types + table = await API.fetchTableDefinition(rowTableId) + tables[rowTableId] = table + } + const schema = table?.schema + if (schema) { + const keys = Object.keys(schema) + for (let key of keys) { + const type = schema[key].type + if (type === FieldTypes.LINK && Array.isArray(row[key])) { + // Enrich row a string join of relationship fields + row[`${key}_text`] = + row[key] + ?.map(option => option?.primaryDisplay) + .filter(option => !!option) + .join(", ") || "" + } else if (type === "attachment") { + // Enrich row with the first image URL for any attachment fields + let url = null + if (Array.isArray(row[key]) && row[key][0] != null) { + url = row[key][0].url + } + row[`${key}_first`] = url + } + } + } + } + } + return rows +} diff --git a/packages/client/src/components/ClientApp.svelte b/packages/client/src/components/ClientApp.svelte index 7f5bed210e..65d3f67179 100644 --- a/packages/client/src/components/ClientApp.svelte +++ b/packages/client/src/components/ClientApp.svelte @@ -2,6 +2,7 @@ import { writable, get } from "svelte/store" import { setContext, onMount } from "svelte" import { Layout, Heading, Body } from "@budibase/bbui" + import ErrorSVG from "@budibase/frontend-core/assets/error.svg" import Component from "./Component.svelte" import SDK from "sdk" import { @@ -24,7 +25,6 @@ import HoverIndicator from "components/preview/HoverIndicator.svelte" import CustomThemeWrapper from "./CustomThemeWrapper.svelte" import DNDHandler from "components/preview/DNDHandler.svelte" - import ErrorSVG from "builder/assets/error.svg" import KeyboardManager from "components/preview/KeyboardManager.svelte" // Provide contexts diff --git a/packages/client/src/components/app/forms/AttachmentField.svelte b/packages/client/src/components/app/forms/AttachmentField.svelte index 4d8a0b482d..a5eec03ac4 100644 --- a/packages/client/src/components/app/forms/AttachmentField.svelte +++ b/packages/client/src/components/app/forms/AttachmentField.svelte @@ -29,7 +29,10 @@ for (let i = 0; i < fileList.length; i++) { data.append("file", fileList[i]) } - return await API.uploadAttachment(data, formContext?.dataSource?.tableId) + return await API.uploadAttachment({ + data, + tableId: formContext?.dataSource?.tableId, + }) } diff --git a/packages/client/src/constants.js b/packages/client/src/constants.js index e81bab115c..965ca788e1 100644 --- a/packages/client/src/constants.js +++ b/packages/client/src/constants.js @@ -1,7 +1,3 @@ -export const TableNames = { - USERS: "ta_users", -} - export const FieldTypes = { STRING: "string", LONGFORM: "longform", diff --git a/packages/client/src/sdk.js b/packages/client/src/sdk.js index 1c73361dc8..aa87330edb 100644 --- a/packages/client/src/sdk.js +++ b/packages/client/src/sdk.js @@ -1,4 +1,5 @@ -import * as API from "./api" +import { SchemaUtils } from "@budibase/frontend-core" +import { API } from "./api.js" import { authStore, notificationStore, @@ -9,7 +10,6 @@ import { import { styleable } from "utils/styleable" import { linkable } from "utils/linkable" import { getAction } from "utils/getAction" -import { fetchDatasourceSchema } from "utils/schema.js" import Provider from "components/context/Provider.svelte" import { ActionTypes } from "constants" @@ -23,7 +23,7 @@ export default { styleable, linkable, getAction, - fetchDatasourceSchema, + fetchDatasourceSchema: SchemaUtils.fetchDatasourceSchema, Provider, ActionTypes, } diff --git a/packages/client/src/stores/app.js b/packages/client/src/stores/app.js index 0cabaec4ab..31aa5d0b00 100644 --- a/packages/client/src/stores/app.js +++ b/packages/client/src/stores/app.js @@ -1,8 +1,8 @@ -import * as API from "../api" +import { API } from "../api" import { get, writable } from "svelte/store" const createAppStore = () => { - const store = writable({}) + const store = writable(null) // Fetches the app definition including screens, layouts and theme const fetchAppDefinition = async () => { @@ -10,17 +10,25 @@ const createAppStore = () => { if (!appId) { throw "Cannot fetch app definition without app ID set" } - const appDefinition = await API.fetchAppPackage(appId) - store.set({ - ...appDefinition, - appId: appDefinition?.application?.appId, - }) + try { + const appDefinition = await API.fetchAppPackage(appId) + store.set({ + ...appDefinition, + appId: appDefinition?.application?.appId, + }) + } catch (error) { + store.set(null) + } } // Sets the initial app ID const setAppID = id => { store.update(state => { - state.appId = id + if (state) { + state.appId = id + } else { + state = { appId: id } + } return state }) } diff --git a/packages/client/src/stores/auth.js b/packages/client/src/stores/auth.js index 1fa4ae17b0..72a651e0d2 100644 --- a/packages/client/src/stores/auth.js +++ b/packages/client/src/stores/auth.js @@ -1,4 +1,4 @@ -import * as API from "../api" +import { API } from "../api" import { writable } from "svelte/store" const createAuthStore = () => { @@ -6,8 +6,12 @@ const createAuthStore = () => { // Fetches the user object if someone is logged in and has reloaded the page const fetchUser = async () => { - const user = await API.fetchSelf() - store.set(user) + try { + const user = await API.fetchSelf() + store.set(user) + } catch (error) { + store.set(null) + } } const logOut = async () => { diff --git a/packages/client/src/stores/builder.js b/packages/client/src/stores/builder.js index 35fb3edae2..e6a0cf3e41 100644 --- a/packages/client/src/stores/builder.js +++ b/packages/client/src/stores/builder.js @@ -1,7 +1,7 @@ import { writable, derived, get } from "svelte/store" import Manifest from "manifest.json" import { findComponentById, findComponentPathById } from "../utils/components" -import { pingEndUser } from "../api" +import { API } from "../api" const dispatchEvent = (type, data = {}) => { window.parent.postMessage({ type, data }) @@ -65,8 +65,12 @@ const createBuilderStore = () => { notifyLoaded: () => { dispatchEvent("preview-loaded") }, - pingEndUser: () => { - pingEndUser() + pingEndUser: async () => { + try { + await API.pingEndUser() + } catch (error) { + // Do nothing + } }, setSelectedPath: path => { writableStore.update(state => ({ ...state, selectedPath: path })) diff --git a/packages/client/src/stores/context.js b/packages/client/src/stores/context.js index fcbcf0f592..f8f8a49fe7 100644 --- a/packages/client/src/stores/context.js +++ b/packages/client/src/stores/context.js @@ -1,5 +1,5 @@ import { writable, derived } from "svelte/store" -import { hashString } from "../utils/helpers" +import { Helpers } from "@budibase/frontend-core" export const createContextStore = oldContext => { const newContext = writable({}) @@ -10,7 +10,9 @@ export const createContextStore = oldContext => { for (let i = 0; i < $contexts.length - 1; i++) { key += $contexts[i].key } - key = hashString(key + JSON.stringify($contexts[$contexts.length - 1])) + key = Helpers.hashString( + key + JSON.stringify($contexts[$contexts.length - 1]) + ) // Reduce global state const reducer = (total, context) => ({ ...total, ...context }) diff --git a/packages/client/src/stores/dataSource.js b/packages/client/src/stores/dataSource.js index 46ac0b6c86..5b7e38054d 100644 --- a/packages/client/src/stores/dataSource.js +++ b/packages/client/src/stores/dataSource.js @@ -1,5 +1,5 @@ import { writable, get } from "svelte/store" -import { fetchTableDefinition } from "../api" +import { API } from "../api" import { FieldTypes } from "../constants" import { routeStore } from "./routes" @@ -72,8 +72,14 @@ export const createDataSourceStore = () => { let invalidations = [dataSourceId] // Fetch related table IDs from table schema - const definition = await fetchTableDefinition(dataSourceId) - const schema = definition?.schema + let schema + try { + const definition = await API.fetchTableDefinition(dataSourceId) + schema = definition?.schema + } catch (error) { + schema = null + } + if (schema) { Object.values(schema).forEach(fieldSchema => { if ( diff --git a/packages/client/src/stores/routes.js b/packages/client/src/stores/routes.js index 1d5dca1645..bc0b8fdc7b 100644 --- a/packages/client/src/stores/routes.js +++ b/packages/client/src/stores/routes.js @@ -1,6 +1,6 @@ import { get, writable } from "svelte/store" import { push } from "svelte-spa-router" -import * as API from "../api" +import { API } from "../api" import { peekStore } from "./peek" import { builderStore } from "./builder" @@ -16,10 +16,15 @@ const createRouteStore = () => { const store = writable(initialState) const fetchRoutes = async () => { - const routeConfig = await API.fetchRoutes() + let routeConfig + try { + routeConfig = await API.fetchRoutes() + } catch (error) { + routeConfig = null + } let routes = [] - Object.values(routeConfig.routes).forEach(route => { - Object.entries(route.subpaths).forEach(([path, config]) => { + Object.values(routeConfig?.routes || {}).forEach(route => { + Object.entries(route.subpaths || {}).forEach(([path, config]) => { routes.push({ path, screenId: config.screenId, diff --git a/packages/client/src/utils/buttonActions.js b/packages/client/src/utils/buttonActions.js index 6b4dd4235a..edf455d82b 100644 --- a/packages/client/src/utils/buttonActions.js +++ b/packages/client/src/utils/buttonActions.js @@ -5,8 +5,10 @@ import { confirmationStore, authStore, stateStore, + notificationStore, + dataSourceStore, } from "stores" -import { saveRow, deleteRow, executeQuery, triggerAutomation } from "api" +import { API } from "api" import { ActionTypes } from "constants" import { enrichDataBindings } from "./enrichDataBinding" import { deepSet } from "@budibase/bbui" @@ -27,9 +29,17 @@ const saveRowHandler = async (action, context) => { if (tableId) { payload.tableId = tableId } - const row = await saveRow(payload) - return { - row, + try { + const row = await API.saveRow(payload) + notificationStore.actions.success("Row saved") + + // Refresh related datasources + await dataSourceStore.actions.invalidateDataSource(row.tableId) + + return { row } + } catch (error) { + // Abort next actions + return false } } @@ -47,9 +57,17 @@ const duplicateRowHandler = async (action, context) => { } delete payload._id delete payload._rev - const row = await saveRow(payload) - return { - row, + try { + const row = await API.saveRow(payload) + notificationStore.actions.success("Row saved") + + // Refresh related datasources + await dataSourceStore.actions.invalidateDataSource(row.tableId) + + return { row } + } catch (error) { + // Abort next actions + return false } } } @@ -57,14 +75,32 @@ const duplicateRowHandler = async (action, context) => { const deleteRowHandler = async action => { const { tableId, revId, rowId } = action.parameters if (tableId && revId && rowId) { - await deleteRow({ tableId, rowId, revId }) + try { + await API.deleteRow({ tableId, rowId, revId }) + notificationStore.actions.success("Row deleted") + + // Refresh related datasources + await dataSourceStore.actions.invalidateDataSource(tableId) + } catch (error) { + // Abort next actions + return false + } } } const triggerAutomationHandler = async action => { const { fields } = action.parameters if (fields) { - await triggerAutomation(action.parameters.automationId, fields) + try { + await API.triggerAutomation({ + automationId: action.parameters.automationId, + fields, + }) + notificationStore.actions.success("Automation triggered") + } catch (error) { + // Abort next actions + return false + } } } @@ -75,12 +111,30 @@ const navigationHandler = action => { const queryExecutionHandler = async action => { const { datasourceId, queryId, queryParams } = action.parameters - const result = await executeQuery({ - datasourceId, - queryId, - parameters: queryParams, - }) - return { result } + try { + const query = await API.fetchQueryDefinition(queryId) + if (query?.datasourceId == null) { + notificationStore.actions.error("That query couldn't be found") + return false + } + const result = await API.executeQuery({ + datasourceId, + queryId, + parameters: queryParams, + }) + + // Trigger a notification and invalidate the datasource as long as this + // was not a readable query + if (!query.readable) { + API.notifications.error.success("Query executed successfully") + await dataSourceStore.actions.invalidateDataSource(query.datasourceId) + } + + return { result } + } catch (error) { + // Abort next actions + return false + } } const executeActionHandler = async ( diff --git a/packages/builder/assets/error.svg b/packages/frontend-core/assets/error.svg similarity index 100% rename from packages/builder/assets/error.svg rename to packages/frontend-core/assets/error.svg diff --git a/packages/frontend-core/src/api/analytics.js b/packages/frontend-core/src/api/analytics.js index 5a089eaa21..5acaf0ac76 100644 --- a/packages/frontend-core/src/api/analytics.js +++ b/packages/frontend-core/src/api/analytics.js @@ -1,10 +1,10 @@ -import API from "./api" - -/** - * Notifies that an end user client app has been loaded. - */ -export const pingEndUser = async () => { - return await API.post({ - url: `/api/analytics/ping`, - }) -} +export const buildAnalyticsEndpoints = API => ({ + /** + * Notifies that an end user client app has been loaded. + */ + pingEndUser: async () => { + return await API.post({ + url: `/api/analytics/ping`, + }) + }, +}) diff --git a/packages/frontend-core/src/api/api.js b/packages/frontend-core/src/api/api.js deleted file mode 100644 index fac3da36c0..0000000000 --- a/packages/frontend-core/src/api/api.js +++ /dev/null @@ -1,145 +0,0 @@ -import { ApiVersion } from "../constants" - -const defaultAPIClientConfig = { - attachHeaders: null, - onError: null, -} - -export const createAPIClient = config => { - config = { - ...defaultAPIClientConfig, - ...config, - } - - /** - * API cache for cached request responses. - */ - let cache = {} - - /** - * Handler for API errors. - */ - const makeErrorFromResponse = async response => { - // Try to read a message from the error - let message - try { - const json = await response.json() - if (json?.error) { - message = json.error - } - } catch (error) { - // Do nothing - } - console.log("building error from", response) - return { - message, - status: response.status, - } - } - - const makeError = message => { - return { - message, - status: 400, - } - } - - /** - * Performs an API call to the server. - * App ID header is always correctly set. - */ - const makeApiCall = async ({ - method, - url, - body, - json = true, - external = false, - }) => { - // Build headers - let headers = { Accept: "application/json" } - if (!external) { - headers["x-budibase-api-version"] = ApiVersion - } - if (json) { - headers["Content-Type"] = "application/json" - } - if (config?.attachHeaders) { - config.attachHeaders(headers) - } - - // Build request body - let requestBody = body - if (json) { - try { - requestBody = JSON.stringify(body) - } catch (error) { - throw makeError("Invalid JSON body") - } - } - - // Make request - let response - try { - response = await fetch(url, { - method, - headers, - body: requestBody, - credentials: "same-origin", - }) - } catch (error) { - throw makeError("Failed to send request") - } - - // Handle response - if (response.status >= 200 && response.status < 400) { - try { - return await response.json() - } catch (error) { - return null - } - } else { - const error = await makeErrorFromResponse(response) - if (config?.onError) { - config.onError(error) - } - throw error - } - } - - /** - * Performs an API call to the server and caches the response. - * Future invocation for this URL will return the cached result instead of - * hitting the server again. - */ - const makeCachedApiCall = async params => { - const identifier = params.url - if (!identifier) { - return null - } - if (!cache[identifier]) { - cache[identifier] = makeApiCall(params) - cache[identifier] = await cache[identifier] - } - return await cache[identifier] - } - - /** - * Constructs an API call function for a particular HTTP method. - */ - const requestApiCall = method => async params => { - let { url, cache = false, external = false } = params - if (!external) { - url = `/${url}`.replace("//", "/") - } - const enrichedParams = { ...params, method, url } - return await (cache ? makeCachedApiCall : makeApiCall)(enrichedParams) - } - - return { - post: requestApiCall("POST"), - get: requestApiCall("GET"), - patch: requestApiCall("PATCH"), - delete: requestApiCall("DELETE"), - error: message => throw makeError(message), - } -} diff --git a/packages/frontend-core/src/api/app.js b/packages/frontend-core/src/api/app.js index c5ee305cda..c1dc5deea6 100644 --- a/packages/frontend-core/src/api/app.js +++ b/packages/frontend-core/src/api/app.js @@ -1,10 +1,10 @@ -import API from "./api" - -/** - * Fetches screen definition for an app. - */ -export const fetchAppPackage = async appId => { - return await API.get({ - url: `/api/applications/${appId}/appPackage`, - }) -} +export const buildAppEndpoints = API => ({ + /** + * Fetches screen definition for an app. + */ + fetchAppPackage: async appId => { + return await API.get({ + url: `/api/applications/${appId}/appPackage`, + }) + }, +}) diff --git a/packages/frontend-core/src/api/attachments.js b/packages/frontend-core/src/api/attachments.js index 2693034d2e..5dd2582ea5 100644 --- a/packages/frontend-core/src/api/attachments.js +++ b/packages/frontend-core/src/api/attachments.js @@ -1,12 +1,12 @@ -import API from "./api" - -/** - * Uploads an attachment to the server. - */ -export const uploadAttachment = async (data, tableId = "") => { - return await API.post({ - url: `/api/attachments/${tableId}/upload`, - body: data, - json: false, - }) -} +export const buildAttachmentEndpoints = API => ({ + /** + * Uploads an attachment to the server. + */ + uploadAttachment: async ({ data, tableId }) => { + return await API.post({ + url: `/api/attachments/${tableId}/upload`, + body: data, + json: false, + }) + }, +}) diff --git a/packages/frontend-core/src/api/auth.js b/packages/frontend-core/src/api/auth.js index 68ca5dbc80..3f35c53ca3 100644 --- a/packages/frontend-core/src/api/auth.js +++ b/packages/frontend-core/src/api/auth.js @@ -1,36 +1,27 @@ -import API from "./api" -import { enrichRows } from "./rows" -import { TableNames } from "../constants" - -/** - * Performs a log in request. - */ -export const logIn = async ({ email, password }) => { - if (!email) { - return API.error("Please enter your email") - } - if (!password) { - return API.error("Please enter your password") - } - return await API.post({ - url: "/api/global/auth", - body: { username: email, password }, - }) -} - -/** - * Fetches the currently logged in user object - */ -export const fetchSelf = async () => { - const user = await API.get({ url: "/api/self" }) - if (user && user._id) { - if (user.roleId === "PUBLIC") { - // Don't try to enrich a public user as it will 403 - return user - } else { - return (await enrichRows([user], TableNames.USERS))[0] +export const buildAuthEndpoints = API => ({ + /** + * Performs a log in request. + */ + logIn: async ({ email, password }) => { + if (!email) { + return API.error("Please enter your email") } - } else { - return null - } -} + if (!password) { + return API.error("Please enter your password") + } + return await API.post({ + url: "/api/global/auth", + body: { + username: email, + password, + }, + }) + }, + + /** + * Fetches the currently logged in user object + */ + fetchSelf: async () => { + return await API.get({ url: "/api/self" }) + }, +}) diff --git a/packages/frontend-core/src/api/automations.js b/packages/frontend-core/src/api/automations.js index cb3e4623ad..95605a91a5 100644 --- a/packages/frontend-core/src/api/automations.js +++ b/packages/frontend-core/src/api/automations.js @@ -1,16 +1,11 @@ -import { notificationStore } from "stores/notification" -import API from "./api" - -/** - * Executes an automation. Must have "App Action" trigger. - */ -export const triggerAutomation = async (automationId, fields) => { - const res = await API.post({ - url: `/api/automations/${automationId}/trigger`, - body: { fields }, - }) - res.error - ? notificationStore.actions.error("An error has occurred") - : notificationStore.actions.success("Automation triggered") - return res -} +export const buildAutomationEndpoints = API => ({ + /** + * Executes an automation. Must have "App Action" trigger. + */ + triggerAutomation: async ({ automationId, fields }) => { + return await API.post({ + url: `/api/automations/${automationId}/trigger`, + body: { fields }, + }) + }, +}) diff --git a/packages/frontend-core/src/api/index.js b/packages/frontend-core/src/api/index.js index d429eb437c..3814f77ae6 100644 --- a/packages/frontend-core/src/api/index.js +++ b/packages/frontend-core/src/api/index.js @@ -1,11 +1,188 @@ -export * from "./rows" -export * from "./auth" -export * from "./tables" -export * from "./attachments" -export * from "./views" -export * from "./relationships" -export * from "./routes" -export * from "./queries" -export * from "./app" -export * from "./automations" -export * from "./analytics" +import { ApiVersion } from "../constants" +import { buildAnalyticsEndpoints } from "./analytics" +import { buildAppEndpoints } from "./app" +import { buildAttachmentEndpoints } from "./attachments" +import { buildAuthEndpoints } from "./auth" +import { buildAutomationEndpoints } from "./automations" +import { buildQueryEndpoints } from "./queries" +import { buildRelationshipEndpoints } from "./relationships" +import { buildRouteEndpoints } from "./routes" +import { buildRowEndpoints } from "./rows" +import { buildTableEndpoints } from "./tables" +import { buildViewEndpoints } from "./views" + +const defaultAPIClientConfig = { + attachHeaders: null, + onError: null, + patches: null, +} + +/** + * Constructs an API client with the provided configuration. + * @param config the API client configuration + * @return {object} the API client + */ +export const createAPIClient = config => { + config = { + ...defaultAPIClientConfig, + ...config, + } + + /** + * Handler for API errors. + */ + const makeErrorFromResponse = async response => { + // Try to read a message from the error + let message + try { + const json = await response.json() + if (json?.error) { + message = json.error + } + } catch (error) { + // Do nothing + } + console.log("building error from", response) + return { + message, + status: response.status, + } + } + + const makeError = message => { + return { + message, + status: 400, + } + } + + /** + * Performs an API call to the server. + * App ID header is always correctly set. + */ + const makeApiCall = async ({ + method, + url, + body, + json = true, + external = false, + }) => { + // Build headers + let headers = { Accept: "application/json" } + if (!external) { + headers["x-budibase-api-version"] = ApiVersion + } + if (json) { + headers["Content-Type"] = "application/json" + } + if (config?.attachHeaders) { + config.attachHeaders(headers) + } + + // Build request body + let requestBody = body + if (json) { + try { + requestBody = JSON.stringify(body) + } catch (error) { + throw makeError("Invalid JSON body") + } + } + + // Make request + let response + try { + response = await fetch(url, { + method, + headers, + body: requestBody, + credentials: "same-origin", + }) + } catch (error) { + throw makeError("Failed to send request") + } + + // Handle response + if (response.status >= 200 && response.status < 400) { + try { + return await response.json() + } catch (error) { + return null + } + } else { + const error = await makeErrorFromResponse(response) + if (config?.onError) { + config.onError(error) + } + throw error + } + } + + /** + * Performs an API call to the server and caches the response. + * Future invocation for this URL will return the cached result instead of + * hitting the server again. + */ + let cache = {} + const makeCachedApiCall = async params => { + const identifier = params.url + if (!identifier) { + return null + } + if (!cache[identifier]) { + cache[identifier] = makeApiCall(params) + cache[identifier] = await cache[identifier] + } + return await cache[identifier] + } + + /** + * Constructs an API call function for a particular HTTP method. + */ + const requestApiCall = method => async params => { + let { url, cache = false, external = false } = params + if (!external) { + url = `/${url}`.replace("//", "/") + } + const enrichedParams = { ...params, method, url } + return await (cache ? makeCachedApiCall : makeApiCall)(enrichedParams) + } + + // Build the underlying core API methods + let API = { + post: requestApiCall("POST"), + get: requestApiCall("GET"), + patch: requestApiCall("PATCH"), + delete: requestApiCall("DELETE"), + error: message => throw makeError(message), + } + + // Attach all other endpoints + API = { + ...API, + ...buildAnalyticsEndpoints(API), + ...buildAppEndpoints(API), + ...buildAttachmentEndpoints(API), + ...buildAuthEndpoints(API), + ...buildAutomationEndpoints(API), + ...buildQueryEndpoints(API), + ...buildRelationshipEndpoints(API), + ...buildRouteEndpoints(API), + ...buildRowEndpoints(API), + ...buildTableEndpoints(API), + ...buildViewEndpoints(API), + } + + // Assign any patches + const patches = Object.entries(config.patches || {}) + if (patches.length) { + patches.forEach(([method, fn]) => { + API[method] = async (...params) => { + const output = await API[method](...params) + return await fn({ params, output }) + } + }) + } + + return API +} diff --git a/packages/frontend-core/src/api/queries.js b/packages/frontend-core/src/api/queries.js index e8972f657e..6c3ce900f9 100644 --- a/packages/frontend-core/src/api/queries.js +++ b/packages/frontend-core/src/api/queries.js @@ -1,34 +1,24 @@ -import { notificationStore, dataSourceStore } from "stores" -import API from "./api" +export const buildQueryEndpoints = API => ({ + /** + * Executes a query against an external data connector. + */ + executeQuery: async ({ queryId, pagination, parameters }) => { + return await API.post({ + url: `/api/v2/queries/${queryId}`, + body: { + parameters, + pagination, + }, + }) + }, -/** - * Executes a query against an external data connector. - */ -export const executeQuery = async ({ queryId, pagination, parameters }) => { - const query = await fetchQueryDefinition(queryId) - if (query?.datasourceId == null) { - notificationStore.actions.error("That query couldn't be found") - return - } - const res = await API.post({ - url: `/api/v2/queries/${queryId}`, - body: { - parameters, - pagination, - }, - }) - if (res.error) { - notificationStore.actions.error("An error has occurred") - } else if (!query.readable) { - notificationStore.actions.success("Query executed successfully") - await dataSourceStore.actions.invalidateDataSource(query.datasourceId) - } - return res -} - -/** - * Fetches the definition of an external query. - */ -export const fetchQueryDefinition = async queryId => { - return await API.get({ url: `/api/queries/${queryId}`, cache: true }) -} + /** + * Fetches the definition of an external query. + */ + fetchQueryDefinition: async queryId => { + return await API.get({ + url: `/api/queries/${queryId}`, + cache: true, + }) + }, +}) diff --git a/packages/frontend-core/src/api/relationships.js b/packages/frontend-core/src/api/relationships.js index fe92bfd038..33ed334c5e 100644 --- a/packages/frontend-core/src/api/relationships.js +++ b/packages/frontend-core/src/api/relationships.js @@ -1,14 +1,12 @@ -import API from "./api" -import { enrichRows } from "./rows" - -/** - * Fetches related rows for a certain field of a certain row. - */ -export const fetchRelationshipData = async ({ tableId, rowId, fieldName }) => { - if (!tableId || !rowId || !fieldName) { - return [] - } - const response = await API.get({ url: `/api/${tableId}/${rowId}/enrich` }) - const rows = response[fieldName] || [] - return await enrichRows(rows, tableId) -} +export const buildRelationshipEndpoints = API => ({ + /** + * Fetches related rows for a certain field of a certain row. + */ + fetchRelationshipData: async ({ tableId, rowId, fieldName }) => { + if (!tableId || !rowId || !fieldName) { + return [] + } + const response = await API.get({ url: `/api/${tableId}/${rowId}/enrich` }) + return response[fieldName] || [] + }, +}) diff --git a/packages/frontend-core/src/api/routes.js b/packages/frontend-core/src/api/routes.js index d762461075..171f4f45d8 100644 --- a/packages/frontend-core/src/api/routes.js +++ b/packages/frontend-core/src/api/routes.js @@ -1,10 +1,10 @@ -import API from "./api" - -/** - * Fetches available routes for the client app. - */ -export const fetchRoutes = async () => { - return await API.get({ - url: `/api/routing/client`, - }) -} +export const buildRouteEndpoints = API => ({ + /** + * Fetches available routes for the client app. + */ + fetchClientAppRoutes: async () => { + return await API.get({ + url: `/api/routing/client`, + }) + }, +}) diff --git a/packages/frontend-core/src/api/rows.js b/packages/frontend-core/src/api/rows.js index 2d6df90e83..9cf28b3bbf 100644 --- a/packages/frontend-core/src/api/rows.js +++ b/packages/frontend-core/src/api/rows.js @@ -1,155 +1,43 @@ -import { notificationStore, dataSourceStore } from "stores" -import API from "./api" -import { fetchTableDefinition } from "./tables" -import { FieldTypes } from "../constants" - -/** - * Fetches data about a certain row in a table. - */ -export const fetchRow = async ({ tableId, rowId }) => { - if (!tableId || !rowId) { - return - } - const row = await API.get({ - url: `/api/${tableId}/rows/${rowId}`, - }) - return (await enrichRows([row], tableId))[0] -} - -/** - * Creates a row in a table. - */ -export const saveRow = async row => { - if (!row?.tableId) { - return - } - const res = await API.post({ - url: `/api/${row.tableId}/rows`, - body: row, - }) - res.error - ? notificationStore.actions.error("An error has occurred") - : notificationStore.actions.success("Row saved") - - // Refresh related datasources - await dataSourceStore.actions.invalidateDataSource(row.tableId) - - return res -} - -/** - * Updates a row in a table. - */ -export const updateRow = async row => { - if (!row?.tableId || !row?._id) { - return - } - const res = await API.patch({ - url: `/api/${row.tableId}/rows`, - body: row, - }) - res.error - ? notificationStore.actions.error("An error has occurred") - : notificationStore.actions.success("Row updated") - - // Refresh related datasources - await dataSourceStore.actions.invalidateDataSource(row.tableId) - - return res -} - -/** - * Deletes a row from a table. - */ -export const deleteRow = async ({ tableId, rowId, revId }) => { - if (!tableId || !rowId || !revId) { - return - } - const res = await API.del({ - url: `/api/${tableId}/rows`, - body: { - _id: rowId, - _rev: revId, - }, - }) - res.error - ? notificationStore.actions.error("An error has occurred") - : notificationStore.actions.success("Row deleted") - - // Refresh related datasources - await dataSourceStore.actions.invalidateDataSource(tableId) - - return res -} - -/** - * Deletes many rows from a table. - */ -export const deleteRows = async ({ tableId, rows }) => { - if (!tableId || !rows) { - return - } - const res = await API.del({ - url: `/api/${tableId}/rows`, - body: { - rows, - }, - }) - res.error - ? notificationStore.actions.error("An error has occurred") - : notificationStore.actions.success(`${rows.length} row(s) deleted`) - - // Refresh related datasources - await dataSourceStore.actions.invalidateDataSource(tableId) - - return res -} - -/** - * Enriches rows which contain certain field types so that they can - * be properly displayed. - * The ability to create these bindings has been removed, but they will still - * exist in client apps to support backwards compatibility. - */ -export const enrichRows = async (rows, tableId) => { - if (!Array.isArray(rows)) { - return [] - } - if (rows.length) { - // map of tables, incase a row being loaded is not from the same table - const tables = {} - for (let row of rows) { - // fallback to passed in tableId if row doesn't have it specified - let rowTableId = row.tableId || tableId - let table = tables[rowTableId] - if (!table) { - // Fetch table schema so we can check column types - table = await fetchTableDefinition(rowTableId) - tables[rowTableId] = table - } - const schema = table?.schema - if (schema) { - const keys = Object.keys(schema) - for (let key of keys) { - const type = schema[key].type - if (type === FieldTypes.LINK && Array.isArray(row[key])) { - // Enrich row a string join of relationship fields - row[`${key}_text`] = - row[key] - ?.map(option => option?.primaryDisplay) - .filter(option => !!option) - .join(", ") || "" - } else if (type === "attachment") { - // Enrich row with the first image URL for any attachment fields - let url = null - if (Array.isArray(row[key]) && row[key][0] != null) { - url = row[key][0].url - } - row[`${key}_first`] = url - } - } - } +export const buildRowEndpoints = API => ({ + /** + * Fetches data about a certain row in a table. + */ + fetchRow: async ({ tableId, rowId }) => { + if (!tableId || !rowId) { + return null } - } - return rows -} + const row = await API.get({ + url: `/api/${tableId}/rows/${rowId}`, + }) + return (await API.enrichRows([row], tableId))[0] + }, + + /** + * Creates a row in a table. + */ + saveRow: async row => { + if (!row?.tableId) { + return + } + return await API.post({ + url: `/api/${row.tableId}/rows`, + body: row, + }) + }, + + /** + * Deletes a row from a table. + */ + deleteRow: async ({ tableId, rowId, revId }) => { + if (!tableId || !rowId || !revId) { + return + } + return await API.delete({ + url: `/api/${tableId}/rows`, + body: { + _id: rowId, + _rev: revId, + }, + }) + }, +}) diff --git a/packages/frontend-core/src/api/tables.js b/packages/frontend-core/src/api/tables.js index 09f77de6ee..64dca98511 100644 --- a/packages/frontend-core/src/api/tables.js +++ b/packages/frontend-core/src/api/tables.js @@ -1,63 +1,51 @@ -import API from "./api" -import { enrichRows } from "./rows" +export const buildTableEndpoints = API => ({ + /** + * Fetches a table definition. + * Since definitions cannot change at runtime, the result is cached. + */ + fetchTableDefinition: async tableId => { + return await API.get({ + url: `/api/tables/${tableId}`, + cache: true, + }) + }, -/** - * Fetches a table definition. - * Since definitions cannot change at runtime, the result is cached. - */ -export const fetchTableDefinition = async tableId => { - const res = await API.get({ url: `/api/tables/${tableId}`, cache: true }) + /** + * Fetches all rows from a table. + */ + fetchTableData: async tableId => { + return await API.get({ url: `/api/${tableId}/rows` }) + }, - // Wipe any HBS formulae, as these interfere with handlebars enrichment - Object.keys(res?.schema || {}).forEach(field => { - if (res.schema[field]?.type === "formula") { - delete res.schema[field].formula + /** + * Searches a table using Lucene. + */ + searchTable: async ({ + tableId, + query, + bookmark, + limit, + sort, + sortOrder, + sortType, + paginate, + }) => { + if (!tableId || !query) { + return { + rows: [], + } } - }) - - return res -} - -/** - * Fetches all rows from a table. - */ -export const fetchTableData = async tableId => { - const rows = await API.get({ url: `/api/${tableId}/rows` }) - return await enrichRows(rows, tableId) -} - -/** - * Searches a table using Lucene. - */ -export const searchTable = async ({ - tableId, - query, - bookmark, - limit, - sort, - sortOrder, - sortType, - paginate, -}) => { - if (!tableId || !query) { - return { - rows: [], - } - } - const res = await API.post({ - url: `/api/${tableId}/search`, - body: { - query, - bookmark, - limit, - sort, - sortOrder, - sortType, - paginate, - }, - }) - return { - ...res, - rows: await enrichRows(res?.rows, tableId), - } -} + return await API.post({ + url: `/api/${tableId}/search`, + body: { + query, + bookmark, + limit, + sort, + sortOrder, + sortType, + paginate, + }, + }) + }, +}) diff --git a/packages/frontend-core/src/api/views.js b/packages/frontend-core/src/api/views.js index d173e53d53..53bb462d13 100644 --- a/packages/frontend-core/src/api/views.js +++ b/packages/frontend-core/src/api/views.js @@ -1,30 +1,19 @@ -import API from "./api" -import { enrichRows } from "./rows" - -/** - * Fetches all rows in a view. - */ -export const fetchViewData = async ({ - name, - field, - groupBy, - calculation, - tableId, -}) => { - const params = new URLSearchParams() - - if (calculation) { - params.set("field", field) - params.set("calculation", calculation) - } - if (groupBy) { - params.set("group", groupBy ? "true" : "false") - } - - const QUERY_VIEW_URL = field - ? `/api/views/${name}?${params}` - : `/api/views/${name}` - - const rows = await API.get({ url: QUERY_VIEW_URL }) - return await enrichRows(rows, tableId) -} +export const buildViewEndpoints = API => ({ + /** + * Fetches all rows in a view. + */ + fetchViewData: async ({ name, field, groupBy, calculation }) => { + const params = new URLSearchParams() + if (calculation) { + params.set("field", field) + params.set("calculation", calculation) + } + if (groupBy) { + params.set("group", groupBy ? "true" : "false") + } + const QUERY_VIEW_URL = field + ? `/api/views/${name}?${params}` + : `/api/views/${name}` + return await API.get({ url: QUERY_VIEW_URL }) + }, +}) diff --git a/packages/frontend-core/src/constants.js b/packages/frontend-core/src/constants.js index 688f28c4de..01c3b742b2 100644 --- a/packages/frontend-core/src/constants.js +++ b/packages/frontend-core/src/constants.js @@ -1,5 +1,8 @@ -export const ApiVersion = "1" +export const TableNames = { + USERS: "ta_users", +} +export const ApiVersion = "1" /** * API Version Changelog * v1: diff --git a/packages/frontend-core/src/fetch/DataFetch.js b/packages/frontend-core/src/fetch/DataFetch.js index a75b54ca46..cf47f984cf 100644 --- a/packages/frontend-core/src/fetch/DataFetch.js +++ b/packages/frontend-core/src/fetch/DataFetch.js @@ -2,10 +2,9 @@ import { writable, derived, get } from "svelte/store" import { buildLuceneQuery, luceneLimit, - luceneQuery, + runLuceneQuery, luceneSort, } from "../utils/lucene" -import { fetchTableDefinition } from "../api" /** * Parent class which handles the implementation of fetching data from an @@ -13,6 +12,9 @@ import { fetchTableDefinition } from "../api" * For other types of datasource, this class is overridden and extended. */ export default class DataFetch { + // API client + API = null + // Feature flags featureStore = writable({ supportsSearch: false, @@ -57,10 +59,14 @@ export default class DataFetch { */ constructor(opts) { // Merge options with their default values + this.API = opts?.API this.options = { ...this.options, ...opts, } + if (!this.API) { + throw "An API client is required for fetching data" + } // Bind all functions to properly scope "this" this.getData = this.getData.bind(this) @@ -110,12 +116,6 @@ export default class DataFetch { */ async getInitialData() { const { datasource, filter, sortColumn, paginate } = this.options - const tableId = datasource?.tableId - - // Ensure table ID exists - if (!tableId) { - return - } // Fetch datasource definition and determine feature flags const definition = await this.constructor.getDefinition(datasource) @@ -184,7 +184,7 @@ export default class DataFetch { // If we don't support searching, do a client search if (!features.supportsSearch) { - rows = luceneQuery(rows, query) + rows = runLuceneQuery(rows, query) } // If we don't support sorting, do a client-side sort @@ -228,7 +228,11 @@ export default class DataFetch { if (!datasource?.tableId) { return null } - return await fetchTableDefinition(datasource.tableId) + try { + return await this.API.fetchTableDefinition(datasource.tableId) + } catch (error) { + return null + } } /** diff --git a/packages/frontend-core/src/fetch/FieldFetch.js b/packages/frontend-core/src/fetch/FieldFetch.js index ef9902ed27..755e367ac4 100644 --- a/packages/frontend-core/src/fetch/FieldFetch.js +++ b/packages/frontend-core/src/fetch/FieldFetch.js @@ -28,7 +28,7 @@ export default class FieldFetch extends DataFetch { // These sources will be available directly from context const data = datasource?.value || [] - let rows = [] + let rows if (Array.isArray(data) && data[0] && typeof data[0] !== "object") { rows = data.map(value => ({ value })) } else { diff --git a/packages/frontend-core/src/fetch/JSONArrayFetch.js b/packages/frontend-core/src/fetch/JSONArrayFetch.js index 953b3f1bbd..783ad9973c 100644 --- a/packages/frontend-core/src/fetch/JSONArrayFetch.js +++ b/packages/frontend-core/src/fetch/JSONArrayFetch.js @@ -1,13 +1,16 @@ import FieldFetch from "./FieldFetch.js" -import { fetchTableDefinition } from "../api" import { getJSONArrayDatasourceSchema } from "../utils/json" export default class JSONArrayFetch extends FieldFetch { static async getDefinition(datasource) { // JSON arrays need their table definitions fetched. // We can then extract their schema as a subset of the table schema. - const table = await fetchTableDefinition(datasource.tableId) - const schema = getJSONArrayDatasourceSchema(table?.schema, datasource) - return { schema } + try { + const table = await this.API.fetchTableDefinition(datasource.tableId) + const schema = getJSONArrayDatasourceSchema(table?.schema, datasource) + return { schema } + } catch (error) { + return null + } } } diff --git a/packages/frontend-core/src/fetch/QueryFetch.js b/packages/frontend-core/src/fetch/QueryFetch.js index 921866e3d4..a95ede95de 100644 --- a/packages/frontend-core/src/fetch/QueryFetch.js +++ b/packages/frontend-core/src/fetch/QueryFetch.js @@ -1,5 +1,4 @@ import DataFetch from "./DataFetch.js" -import { executeQuery, fetchQueryDefinition } from "../api" import { cloneDeep } from "lodash/fp" import { get } from "svelte/store" @@ -16,7 +15,11 @@ export default class QueryFetch extends DataFetch { if (!datasource?._id) { return null } - return await fetchQueryDefinition(datasource._id) + try { + return await this.API.fetchQueryDefinition(datasource._id) + } catch (error) { + return null + } } async getData() { @@ -41,28 +44,36 @@ export default class QueryFetch extends DataFetch { } // Execute query - const { data, pagination, ...rest } = await executeQuery(queryPayload) + try { + const res = await this.API.executeQuery(queryPayload) + const { data, pagination, ...rest } = res - // Derive pagination info from response - let nextCursor = null - let hasNextPage = false - if (paginate && supportsPagination) { - if (type === "page") { - // For "page number" pagination, increment the existing page number - nextCursor = queryPayload.pagination.page + 1 - hasNextPage = data?.length === limit && limit > 0 - } else { - // For "cursor" pagination, the cursor should be in the response - nextCursor = pagination?.cursor - hasNextPage = nextCursor != null + // Derive pagination info from response + let nextCursor = null + let hasNextPage = false + if (paginate && supportsPagination) { + if (type === "page") { + // For "page number" pagination, increment the existing page number + nextCursor = queryPayload.pagination.page + 1 + hasNextPage = data?.length === limit && limit > 0 + } else { + // For "cursor" pagination, the cursor should be in the response + nextCursor = pagination?.cursor + hasNextPage = nextCursor != null + } } - } - return { - rows: data || [], - info: rest, - cursor: nextCursor, - hasNextPage, + return { + rows: data || [], + info: rest, + cursor: nextCursor, + hasNextPage, + } + } catch (error) { + return { + rows: [], + hasNextPage: false, + } } } } diff --git a/packages/frontend-core/src/fetch/RelationshipFetch.js b/packages/frontend-core/src/fetch/RelationshipFetch.js index 943999598f..04797fcdf1 100644 --- a/packages/frontend-core/src/fetch/RelationshipFetch.js +++ b/packages/frontend-core/src/fetch/RelationshipFetch.js @@ -1,16 +1,17 @@ import DataFetch from "./DataFetch.js" -import { fetchRelationshipData } from "../api" export default class RelationshipFetch extends DataFetch { async getData() { const { datasource } = this.options - const res = await fetchRelationshipData({ - rowId: datasource?.rowId, - tableId: datasource?.rowTableId, - fieldName: datasource?.fieldName, - }) - return { - rows: res || [], + try { + const res = await this.API.fetchRelationshipData({ + rowId: datasource?.rowId, + tableId: datasource?.rowTableId, + fieldName: datasource?.fieldName, + }) + return { rows: res || [] } + } catch (error) { + return { rows: [] } } } } diff --git a/packages/frontend-core/src/fetch/TableFetch.js b/packages/frontend-core/src/fetch/TableFetch.js index cfdb9684b8..cf0e124020 100644 --- a/packages/frontend-core/src/fetch/TableFetch.js +++ b/packages/frontend-core/src/fetch/TableFetch.js @@ -1,6 +1,5 @@ import { get } from "svelte/store" import DataFetch from "./DataFetch.js" -import { searchTable } from "../api" export default class TableFetch extends DataFetch { determineFeatureFlags() { @@ -18,20 +17,27 @@ export default class TableFetch extends DataFetch { const { cursor, query } = get(this.store) // Search table - const res = await searchTable({ - tableId, - query, - limit, - sort: sortColumn, - sortOrder: sortOrder?.toLowerCase() ?? "ascending", - sortType, - paginate, - bookmark: cursor, - }) - return { - rows: res?.rows || [], - hasNextPage: res?.hasNextPage || false, - cursor: res?.bookmark || null, + try { + const res = await this.API.searchTable({ + tableId, + query, + limit, + sort: sortColumn, + sortOrder: sortOrder?.toLowerCase() ?? "ascending", + sortType, + paginate, + bookmark: cursor, + }) + return { + rows: res?.rows || [], + hasNextPage: res?.hasNextPage || false, + cursor: res?.bookmark || null, + } + } catch (error) { + return { + rows: [], + hasNextPage: false, + } } } } diff --git a/packages/frontend-core/src/fetch/ViewFetch.js b/packages/frontend-core/src/fetch/ViewFetch.js index aab6958309..431b2046df 100644 --- a/packages/frontend-core/src/fetch/ViewFetch.js +++ b/packages/frontend-core/src/fetch/ViewFetch.js @@ -1,5 +1,4 @@ import DataFetch from "./DataFetch.js" -import { fetchViewData } from "../api" export default class ViewFetch extends DataFetch { static getSchema(datasource, definition) { @@ -8,9 +7,11 @@ export default class ViewFetch extends DataFetch { async getData() { const { datasource } = this.options - const res = await fetchViewData(datasource) - return { - rows: res || [], + try { + const res = await this.API.fetchViewData(datasource) + return { rows: res || [] } + } catch (error) { + return { rows: [] } } } } diff --git a/packages/frontend-core/src/fetch/fetchData.js b/packages/frontend-core/src/fetch/fetchData.js index 9e200465e9..e914ff863f 100644 --- a/packages/frontend-core/src/fetch/fetchData.js +++ b/packages/frontend-core/src/fetch/fetchData.js @@ -18,7 +18,7 @@ const DataFetchMap = { jsonarray: JSONArrayFetch, } -export const fetchData = (datasource, options) => { +export const fetchData = ({ API, datasource, options }) => { const Fetch = DataFetchMap[datasource?.type] || TableFetch - return new Fetch({ datasource, ...options }) + return new Fetch({ API, datasource, ...options }) }