Add core API implementation and update most of client library to use it

This commit is contained in:
Andrew Kingston 2022-01-20 09:40:53 +00:00
parent 9d4f18f1ce
commit 6694bdc651
35 changed files with 755 additions and 653 deletions

120
packages/client/src/api.js Normal file
View File

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

View File

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

View File

@ -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,
})
}
</script>

View File

@ -1,7 +1,3 @@
export const TableNames = {
USERS: "ta_users",
}
export const FieldTypes = {
STRING: "string",
LONGFORM: "longform",

View File

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

View File

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

View File

@ -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 () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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] || []
},
})

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,8 @@
export const ApiVersion = "1"
export const TableNames = {
USERS: "ta_users",
}
export const ApiVersion = "1"
/**
* API Version Changelog
* v1:

View File

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

View File

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

View File

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

View File

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

View File

@ -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: [] }
}
}
}

View File

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

View File

@ -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: [] }
}
}
}

View File

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