Add core API implementation and update most of client library to use it
This commit is contained in:
parent
597a1e5e68
commit
fd9c5d6c0b
|
@ -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
|
||||||
|
}
|
|
@ -2,6 +2,7 @@
|
||||||
import { writable, get } from "svelte/store"
|
import { writable, get } from "svelte/store"
|
||||||
import { setContext, onMount } from "svelte"
|
import { setContext, onMount } from "svelte"
|
||||||
import { Layout, Heading, Body } from "@budibase/bbui"
|
import { Layout, Heading, Body } from "@budibase/bbui"
|
||||||
|
import ErrorSVG from "@budibase/frontend-core/assets/error.svg"
|
||||||
import Component from "./Component.svelte"
|
import Component from "./Component.svelte"
|
||||||
import SDK from "sdk"
|
import SDK from "sdk"
|
||||||
import {
|
import {
|
||||||
|
@ -24,7 +25,6 @@
|
||||||
import HoverIndicator from "components/preview/HoverIndicator.svelte"
|
import HoverIndicator from "components/preview/HoverIndicator.svelte"
|
||||||
import CustomThemeWrapper from "./CustomThemeWrapper.svelte"
|
import CustomThemeWrapper from "./CustomThemeWrapper.svelte"
|
||||||
import DNDHandler from "components/preview/DNDHandler.svelte"
|
import DNDHandler from "components/preview/DNDHandler.svelte"
|
||||||
import ErrorSVG from "builder/assets/error.svg"
|
|
||||||
import KeyboardManager from "components/preview/KeyboardManager.svelte"
|
import KeyboardManager from "components/preview/KeyboardManager.svelte"
|
||||||
|
|
||||||
// Provide contexts
|
// Provide contexts
|
||||||
|
|
|
@ -29,7 +29,10 @@
|
||||||
for (let i = 0; i < fileList.length; i++) {
|
for (let i = 0; i < fileList.length; i++) {
|
||||||
data.append("file", fileList[i])
|
data.append("file", fileList[i])
|
||||||
}
|
}
|
||||||
return await API.uploadAttachment(data, formContext?.dataSource?.tableId)
|
return await API.uploadAttachment({
|
||||||
|
data,
|
||||||
|
tableId: formContext?.dataSource?.tableId,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,3 @@
|
||||||
export const TableNames = {
|
|
||||||
USERS: "ta_users",
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FieldTypes = {
|
export const FieldTypes = {
|
||||||
STRING: "string",
|
STRING: "string",
|
||||||
LONGFORM: "longform",
|
LONGFORM: "longform",
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import * as API from "./api"
|
import { SchemaUtils } from "@budibase/frontend-core"
|
||||||
|
import { API } from "./api.js"
|
||||||
import {
|
import {
|
||||||
authStore,
|
authStore,
|
||||||
notificationStore,
|
notificationStore,
|
||||||
|
@ -9,7 +10,6 @@ import {
|
||||||
import { styleable } from "utils/styleable"
|
import { styleable } from "utils/styleable"
|
||||||
import { linkable } from "utils/linkable"
|
import { linkable } from "utils/linkable"
|
||||||
import { getAction } from "utils/getAction"
|
import { getAction } from "utils/getAction"
|
||||||
import { fetchDatasourceSchema } from "utils/schema.js"
|
|
||||||
import Provider from "components/context/Provider.svelte"
|
import Provider from "components/context/Provider.svelte"
|
||||||
import { ActionTypes } from "constants"
|
import { ActionTypes } from "constants"
|
||||||
|
|
||||||
|
@ -23,7 +23,7 @@ export default {
|
||||||
styleable,
|
styleable,
|
||||||
linkable,
|
linkable,
|
||||||
getAction,
|
getAction,
|
||||||
fetchDatasourceSchema,
|
fetchDatasourceSchema: SchemaUtils.fetchDatasourceSchema,
|
||||||
Provider,
|
Provider,
|
||||||
ActionTypes,
|
ActionTypes,
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import * as API from "../api"
|
import { API } from "../api"
|
||||||
import { get, writable } from "svelte/store"
|
import { get, writable } from "svelte/store"
|
||||||
|
|
||||||
const createAppStore = () => {
|
const createAppStore = () => {
|
||||||
const store = writable({})
|
const store = writable(null)
|
||||||
|
|
||||||
// Fetches the app definition including screens, layouts and theme
|
// Fetches the app definition including screens, layouts and theme
|
||||||
const fetchAppDefinition = async () => {
|
const fetchAppDefinition = async () => {
|
||||||
|
@ -10,17 +10,25 @@ const createAppStore = () => {
|
||||||
if (!appId) {
|
if (!appId) {
|
||||||
throw "Cannot fetch app definition without app ID set"
|
throw "Cannot fetch app definition without app ID set"
|
||||||
}
|
}
|
||||||
const appDefinition = await API.fetchAppPackage(appId)
|
try {
|
||||||
store.set({
|
const appDefinition = await API.fetchAppPackage(appId)
|
||||||
...appDefinition,
|
store.set({
|
||||||
appId: appDefinition?.application?.appId,
|
...appDefinition,
|
||||||
})
|
appId: appDefinition?.application?.appId,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
store.set(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sets the initial app ID
|
// Sets the initial app ID
|
||||||
const setAppID = id => {
|
const setAppID = id => {
|
||||||
store.update(state => {
|
store.update(state => {
|
||||||
state.appId = id
|
if (state) {
|
||||||
|
state.appId = id
|
||||||
|
} else {
|
||||||
|
state = { appId: id }
|
||||||
|
}
|
||||||
return state
|
return state
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import * as API from "../api"
|
import { API } from "../api"
|
||||||
import { writable } from "svelte/store"
|
import { writable } from "svelte/store"
|
||||||
|
|
||||||
const createAuthStore = () => {
|
const createAuthStore = () => {
|
||||||
|
@ -6,8 +6,12 @@ const createAuthStore = () => {
|
||||||
|
|
||||||
// Fetches the user object if someone is logged in and has reloaded the page
|
// Fetches the user object if someone is logged in and has reloaded the page
|
||||||
const fetchUser = async () => {
|
const fetchUser = async () => {
|
||||||
const user = await API.fetchSelf()
|
try {
|
||||||
store.set(user)
|
const user = await API.fetchSelf()
|
||||||
|
store.set(user)
|
||||||
|
} catch (error) {
|
||||||
|
store.set(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const logOut = async () => {
|
const logOut = async () => {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { writable, derived, get } from "svelte/store"
|
import { writable, derived, get } from "svelte/store"
|
||||||
import Manifest from "manifest.json"
|
import Manifest from "manifest.json"
|
||||||
import { findComponentById, findComponentPathById } from "../utils/components"
|
import { findComponentById, findComponentPathById } from "../utils/components"
|
||||||
import { pingEndUser } from "../api"
|
import { API } from "../api"
|
||||||
|
|
||||||
const dispatchEvent = (type, data = {}) => {
|
const dispatchEvent = (type, data = {}) => {
|
||||||
window.parent.postMessage({ type, data })
|
window.parent.postMessage({ type, data })
|
||||||
|
@ -65,8 +65,12 @@ const createBuilderStore = () => {
|
||||||
notifyLoaded: () => {
|
notifyLoaded: () => {
|
||||||
dispatchEvent("preview-loaded")
|
dispatchEvent("preview-loaded")
|
||||||
},
|
},
|
||||||
pingEndUser: () => {
|
pingEndUser: async () => {
|
||||||
pingEndUser()
|
try {
|
||||||
|
await API.pingEndUser()
|
||||||
|
} catch (error) {
|
||||||
|
// Do nothing
|
||||||
|
}
|
||||||
},
|
},
|
||||||
setSelectedPath: path => {
|
setSelectedPath: path => {
|
||||||
writableStore.update(state => ({ ...state, selectedPath: path }))
|
writableStore.update(state => ({ ...state, selectedPath: path }))
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { writable, derived } from "svelte/store"
|
import { writable, derived } from "svelte/store"
|
||||||
import { hashString } from "../utils/helpers"
|
import { Helpers } from "@budibase/frontend-core"
|
||||||
|
|
||||||
export const createContextStore = oldContext => {
|
export const createContextStore = oldContext => {
|
||||||
const newContext = writable({})
|
const newContext = writable({})
|
||||||
|
@ -10,7 +10,9 @@ export const createContextStore = oldContext => {
|
||||||
for (let i = 0; i < $contexts.length - 1; i++) {
|
for (let i = 0; i < $contexts.length - 1; i++) {
|
||||||
key += $contexts[i].key
|
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
|
// Reduce global state
|
||||||
const reducer = (total, context) => ({ ...total, ...context })
|
const reducer = (total, context) => ({ ...total, ...context })
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { writable, get } from "svelte/store"
|
import { writable, get } from "svelte/store"
|
||||||
import { fetchTableDefinition } from "../api"
|
import { API } from "../api"
|
||||||
import { FieldTypes } from "../constants"
|
import { FieldTypes } from "../constants"
|
||||||
import { routeStore } from "./routes"
|
import { routeStore } from "./routes"
|
||||||
|
|
||||||
|
@ -72,8 +72,14 @@ export const createDataSourceStore = () => {
|
||||||
let invalidations = [dataSourceId]
|
let invalidations = [dataSourceId]
|
||||||
|
|
||||||
// Fetch related table IDs from table schema
|
// Fetch related table IDs from table schema
|
||||||
const definition = await fetchTableDefinition(dataSourceId)
|
let schema
|
||||||
const schema = definition?.schema
|
try {
|
||||||
|
const definition = await API.fetchTableDefinition(dataSourceId)
|
||||||
|
schema = definition?.schema
|
||||||
|
} catch (error) {
|
||||||
|
schema = null
|
||||||
|
}
|
||||||
|
|
||||||
if (schema) {
|
if (schema) {
|
||||||
Object.values(schema).forEach(fieldSchema => {
|
Object.values(schema).forEach(fieldSchema => {
|
||||||
if (
|
if (
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { get, writable } from "svelte/store"
|
import { get, writable } from "svelte/store"
|
||||||
import { push } from "svelte-spa-router"
|
import { push } from "svelte-spa-router"
|
||||||
import * as API from "../api"
|
import { API } from "../api"
|
||||||
import { peekStore } from "./peek"
|
import { peekStore } from "./peek"
|
||||||
import { builderStore } from "./builder"
|
import { builderStore } from "./builder"
|
||||||
|
|
||||||
|
@ -16,10 +16,15 @@ const createRouteStore = () => {
|
||||||
const store = writable(initialState)
|
const store = writable(initialState)
|
||||||
|
|
||||||
const fetchRoutes = async () => {
|
const fetchRoutes = async () => {
|
||||||
const routeConfig = await API.fetchRoutes()
|
let routeConfig
|
||||||
|
try {
|
||||||
|
routeConfig = await API.fetchRoutes()
|
||||||
|
} catch (error) {
|
||||||
|
routeConfig = null
|
||||||
|
}
|
||||||
let routes = []
|
let routes = []
|
||||||
Object.values(routeConfig.routes).forEach(route => {
|
Object.values(routeConfig?.routes || {}).forEach(route => {
|
||||||
Object.entries(route.subpaths).forEach(([path, config]) => {
|
Object.entries(route.subpaths || {}).forEach(([path, config]) => {
|
||||||
routes.push({
|
routes.push({
|
||||||
path,
|
path,
|
||||||
screenId: config.screenId,
|
screenId: config.screenId,
|
||||||
|
|
|
@ -5,8 +5,10 @@ import {
|
||||||
confirmationStore,
|
confirmationStore,
|
||||||
authStore,
|
authStore,
|
||||||
stateStore,
|
stateStore,
|
||||||
|
notificationStore,
|
||||||
|
dataSourceStore,
|
||||||
} from "stores"
|
} from "stores"
|
||||||
import { saveRow, deleteRow, executeQuery, triggerAutomation } from "api"
|
import { API } from "api"
|
||||||
import { ActionTypes } from "constants"
|
import { ActionTypes } from "constants"
|
||||||
import { enrichDataBindings } from "./enrichDataBinding"
|
import { enrichDataBindings } from "./enrichDataBinding"
|
||||||
import { deepSet } from "@budibase/bbui"
|
import { deepSet } from "@budibase/bbui"
|
||||||
|
@ -27,9 +29,17 @@ const saveRowHandler = async (action, context) => {
|
||||||
if (tableId) {
|
if (tableId) {
|
||||||
payload.tableId = tableId
|
payload.tableId = tableId
|
||||||
}
|
}
|
||||||
const row = await saveRow(payload)
|
try {
|
||||||
return {
|
const row = await API.saveRow(payload)
|
||||||
row,
|
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._id
|
||||||
delete payload._rev
|
delete payload._rev
|
||||||
const row = await saveRow(payload)
|
try {
|
||||||
return {
|
const row = await API.saveRow(payload)
|
||||||
row,
|
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 deleteRowHandler = async action => {
|
||||||
const { tableId, revId, rowId } = action.parameters
|
const { tableId, revId, rowId } = action.parameters
|
||||||
if (tableId && revId && rowId) {
|
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 triggerAutomationHandler = async action => {
|
||||||
const { fields } = action.parameters
|
const { fields } = action.parameters
|
||||||
if (fields) {
|
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 queryExecutionHandler = async action => {
|
||||||
const { datasourceId, queryId, queryParams } = action.parameters
|
const { datasourceId, queryId, queryParams } = action.parameters
|
||||||
const result = await executeQuery({
|
try {
|
||||||
datasourceId,
|
const query = await API.fetchQueryDefinition(queryId)
|
||||||
queryId,
|
if (query?.datasourceId == null) {
|
||||||
parameters: queryParams,
|
notificationStore.actions.error("That query couldn't be found")
|
||||||
})
|
return false
|
||||||
return { result }
|
}
|
||||||
|
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 (
|
const executeActionHandler = async (
|
||||||
|
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
@ -1,10 +1,10 @@
|
||||||
import API from "./api"
|
export const buildAnalyticsEndpoints = API => ({
|
||||||
|
/**
|
||||||
/**
|
* Notifies that an end user client app has been loaded.
|
||||||
* Notifies that an end user client app has been loaded.
|
*/
|
||||||
*/
|
pingEndUser: async () => {
|
||||||
export const pingEndUser = async () => {
|
return await API.post({
|
||||||
return await API.post({
|
url: `/api/analytics/ping`,
|
||||||
url: `/api/analytics/ping`,
|
})
|
||||||
})
|
},
|
||||||
}
|
})
|
||||||
|
|
|
@ -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),
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,10 +1,10 @@
|
||||||
import API from "./api"
|
export const buildAppEndpoints = API => ({
|
||||||
|
/**
|
||||||
/**
|
* Fetches screen definition for an app.
|
||||||
* Fetches screen definition for an app.
|
*/
|
||||||
*/
|
fetchAppPackage: async appId => {
|
||||||
export const fetchAppPackage = async appId => {
|
return await API.get({
|
||||||
return await API.get({
|
url: `/api/applications/${appId}/appPackage`,
|
||||||
url: `/api/applications/${appId}/appPackage`,
|
})
|
||||||
})
|
},
|
||||||
}
|
})
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import API from "./api"
|
export const buildAttachmentEndpoints = API => ({
|
||||||
|
/**
|
||||||
/**
|
* Uploads an attachment to the server.
|
||||||
* Uploads an attachment to the server.
|
*/
|
||||||
*/
|
uploadAttachment: async ({ data, tableId }) => {
|
||||||
export const uploadAttachment = async (data, tableId = "") => {
|
return await API.post({
|
||||||
return await API.post({
|
url: `/api/attachments/${tableId}/upload`,
|
||||||
url: `/api/attachments/${tableId}/upload`,
|
body: data,
|
||||||
body: data,
|
json: false,
|
||||||
json: false,
|
})
|
||||||
})
|
},
|
||||||
}
|
})
|
||||||
|
|
|
@ -1,36 +1,27 @@
|
||||||
import API from "./api"
|
export const buildAuthEndpoints = API => ({
|
||||||
import { enrichRows } from "./rows"
|
/**
|
||||||
import { TableNames } from "../constants"
|
* Performs a log in request.
|
||||||
|
*/
|
||||||
/**
|
logIn: async ({ email, password }) => {
|
||||||
* Performs a log in request.
|
if (!email) {
|
||||||
*/
|
return API.error("Please enter your email")
|
||||||
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]
|
|
||||||
}
|
}
|
||||||
} else {
|
if (!password) {
|
||||||
return null
|
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" })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
@ -1,16 +1,11 @@
|
||||||
import { notificationStore } from "stores/notification"
|
export const buildAutomationEndpoints = API => ({
|
||||||
import API from "./api"
|
/**
|
||||||
|
* Executes an automation. Must have "App Action" trigger.
|
||||||
/**
|
*/
|
||||||
* Executes an automation. Must have "App Action" trigger.
|
triggerAutomation: async ({ automationId, fields }) => {
|
||||||
*/
|
return await API.post({
|
||||||
export const triggerAutomation = async (automationId, fields) => {
|
url: `/api/automations/${automationId}/trigger`,
|
||||||
const res = await API.post({
|
body: { fields },
|
||||||
url: `/api/automations/${automationId}/trigger`,
|
})
|
||||||
body: { fields },
|
},
|
||||||
})
|
})
|
||||||
res.error
|
|
||||||
? notificationStore.actions.error("An error has occurred")
|
|
||||||
: notificationStore.actions.success("Automation triggered")
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,11 +1,188 @@
|
||||||
export * from "./rows"
|
import { ApiVersion } from "../constants"
|
||||||
export * from "./auth"
|
import { buildAnalyticsEndpoints } from "./analytics"
|
||||||
export * from "./tables"
|
import { buildAppEndpoints } from "./app"
|
||||||
export * from "./attachments"
|
import { buildAttachmentEndpoints } from "./attachments"
|
||||||
export * from "./views"
|
import { buildAuthEndpoints } from "./auth"
|
||||||
export * from "./relationships"
|
import { buildAutomationEndpoints } from "./automations"
|
||||||
export * from "./routes"
|
import { buildQueryEndpoints } from "./queries"
|
||||||
export * from "./queries"
|
import { buildRelationshipEndpoints } from "./relationships"
|
||||||
export * from "./app"
|
import { buildRouteEndpoints } from "./routes"
|
||||||
export * from "./automations"
|
import { buildRowEndpoints } from "./rows"
|
||||||
export * from "./analytics"
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -1,34 +1,24 @@
|
||||||
import { notificationStore, dataSourceStore } from "stores"
|
export const buildQueryEndpoints = API => ({
|
||||||
import API from "./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.
|
* Fetches the definition of an external query.
|
||||||
*/
|
*/
|
||||||
export const executeQuery = async ({ queryId, pagination, parameters }) => {
|
fetchQueryDefinition: async queryId => {
|
||||||
const query = await fetchQueryDefinition(queryId)
|
return await API.get({
|
||||||
if (query?.datasourceId == null) {
|
url: `/api/queries/${queryId}`,
|
||||||
notificationStore.actions.error("That query couldn't be found")
|
cache: true,
|
||||||
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 })
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,14 +1,12 @@
|
||||||
import API from "./api"
|
export const buildRelationshipEndpoints = API => ({
|
||||||
import { enrichRows } from "./rows"
|
/**
|
||||||
|
* Fetches related rows for a certain field of a certain row.
|
||||||
/**
|
*/
|
||||||
* Fetches related rows for a certain field of a certain row.
|
fetchRelationshipData: async ({ tableId, rowId, fieldName }) => {
|
||||||
*/
|
if (!tableId || !rowId || !fieldName) {
|
||||||
export const fetchRelationshipData = async ({ tableId, rowId, fieldName }) => {
|
return []
|
||||||
if (!tableId || !rowId || !fieldName) {
|
}
|
||||||
return []
|
const response = await API.get({ url: `/api/${tableId}/${rowId}/enrich` })
|
||||||
}
|
return response[fieldName] || []
|
||||||
const response = await API.get({ url: `/api/${tableId}/${rowId}/enrich` })
|
},
|
||||||
const rows = response[fieldName] || []
|
})
|
||||||
return await enrichRows(rows, tableId)
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import API from "./api"
|
export const buildRouteEndpoints = API => ({
|
||||||
|
/**
|
||||||
/**
|
* Fetches available routes for the client app.
|
||||||
* Fetches available routes for the client app.
|
*/
|
||||||
*/
|
fetchClientAppRoutes: async () => {
|
||||||
export const fetchRoutes = async () => {
|
return await API.get({
|
||||||
return await API.get({
|
url: `/api/routing/client`,
|
||||||
url: `/api/routing/client`,
|
})
|
||||||
})
|
},
|
||||||
}
|
})
|
||||||
|
|
|
@ -1,155 +1,43 @@
|
||||||
import { notificationStore, dataSourceStore } from "stores"
|
export const buildRowEndpoints = API => ({
|
||||||
import API from "./api"
|
/**
|
||||||
import { fetchTableDefinition } from "./tables"
|
* Fetches data about a certain row in a table.
|
||||||
import { FieldTypes } from "../constants"
|
*/
|
||||||
|
fetchRow: async ({ tableId, rowId }) => {
|
||||||
/**
|
if (!tableId || !rowId) {
|
||||||
* Fetches data about a certain row in a table.
|
return null
|
||||||
*/
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
const row = await API.get({
|
||||||
return rows
|
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,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
|
@ -1,63 +1,51 @@
|
||||||
import API from "./api"
|
export const buildTableEndpoints = API => ({
|
||||||
import { enrichRows } from "./rows"
|
/**
|
||||||
|
* 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.
|
* Fetches all rows from a table.
|
||||||
* Since definitions cannot change at runtime, the result is cached.
|
*/
|
||||||
*/
|
fetchTableData: async tableId => {
|
||||||
export const fetchTableDefinition = async tableId => {
|
return await API.get({ url: `/api/${tableId}/rows` })
|
||||||
const res = await API.get({ url: `/api/tables/${tableId}`, cache: true })
|
},
|
||||||
|
|
||||||
// Wipe any HBS formulae, as these interfere with handlebars enrichment
|
/**
|
||||||
Object.keys(res?.schema || {}).forEach(field => {
|
* Searches a table using Lucene.
|
||||||
if (res.schema[field]?.type === "formula") {
|
*/
|
||||||
delete res.schema[field].formula
|
searchTable: async ({
|
||||||
|
tableId,
|
||||||
|
query,
|
||||||
|
bookmark,
|
||||||
|
limit,
|
||||||
|
sort,
|
||||||
|
sortOrder,
|
||||||
|
sortType,
|
||||||
|
paginate,
|
||||||
|
}) => {
|
||||||
|
if (!tableId || !query) {
|
||||||
|
return {
|
||||||
|
rows: [],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
return await API.post({
|
||||||
|
url: `/api/${tableId}/search`,
|
||||||
return res
|
body: {
|
||||||
}
|
query,
|
||||||
|
bookmark,
|
||||||
/**
|
limit,
|
||||||
* Fetches all rows from a table.
|
sort,
|
||||||
*/
|
sortOrder,
|
||||||
export const fetchTableData = async tableId => {
|
sortType,
|
||||||
const rows = await API.get({ url: `/api/${tableId}/rows` })
|
paginate,
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,30 +1,19 @@
|
||||||
import API from "./api"
|
export const buildViewEndpoints = API => ({
|
||||||
import { enrichRows } from "./rows"
|
/**
|
||||||
|
* Fetches all rows in a view.
|
||||||
/**
|
*/
|
||||||
* Fetches all rows in a view.
|
fetchViewData: async ({ name, field, groupBy, calculation }) => {
|
||||||
*/
|
const params = new URLSearchParams()
|
||||||
export const fetchViewData = async ({
|
if (calculation) {
|
||||||
name,
|
params.set("field", field)
|
||||||
field,
|
params.set("calculation", calculation)
|
||||||
groupBy,
|
}
|
||||||
calculation,
|
if (groupBy) {
|
||||||
tableId,
|
params.set("group", groupBy ? "true" : "false")
|
||||||
}) => {
|
}
|
||||||
const params = new URLSearchParams()
|
const QUERY_VIEW_URL = field
|
||||||
|
? `/api/views/${name}?${params}`
|
||||||
if (calculation) {
|
: `/api/views/${name}`
|
||||||
params.set("field", field)
|
return await API.get({ url: QUERY_VIEW_URL })
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
export const ApiVersion = "1"
|
export const TableNames = {
|
||||||
|
USERS: "ta_users",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ApiVersion = "1"
|
||||||
/**
|
/**
|
||||||
* API Version Changelog
|
* API Version Changelog
|
||||||
* v1:
|
* v1:
|
||||||
|
|
|
@ -2,10 +2,9 @@ import { writable, derived, get } from "svelte/store"
|
||||||
import {
|
import {
|
||||||
buildLuceneQuery,
|
buildLuceneQuery,
|
||||||
luceneLimit,
|
luceneLimit,
|
||||||
luceneQuery,
|
runLuceneQuery,
|
||||||
luceneSort,
|
luceneSort,
|
||||||
} from "../utils/lucene"
|
} from "../utils/lucene"
|
||||||
import { fetchTableDefinition } from "../api"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parent class which handles the implementation of fetching data from an
|
* 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.
|
* For other types of datasource, this class is overridden and extended.
|
||||||
*/
|
*/
|
||||||
export default class DataFetch {
|
export default class DataFetch {
|
||||||
|
// API client
|
||||||
|
API = null
|
||||||
|
|
||||||
// Feature flags
|
// Feature flags
|
||||||
featureStore = writable({
|
featureStore = writable({
|
||||||
supportsSearch: false,
|
supportsSearch: false,
|
||||||
|
@ -57,10 +59,14 @@ export default class DataFetch {
|
||||||
*/
|
*/
|
||||||
constructor(opts) {
|
constructor(opts) {
|
||||||
// Merge options with their default values
|
// Merge options with their default values
|
||||||
|
this.API = opts?.API
|
||||||
this.options = {
|
this.options = {
|
||||||
...this.options,
|
...this.options,
|
||||||
...opts,
|
...opts,
|
||||||
}
|
}
|
||||||
|
if (!this.API) {
|
||||||
|
throw "An API client is required for fetching data"
|
||||||
|
}
|
||||||
|
|
||||||
// Bind all functions to properly scope "this"
|
// Bind all functions to properly scope "this"
|
||||||
this.getData = this.getData.bind(this)
|
this.getData = this.getData.bind(this)
|
||||||
|
@ -110,12 +116,6 @@ export default class DataFetch {
|
||||||
*/
|
*/
|
||||||
async getInitialData() {
|
async getInitialData() {
|
||||||
const { datasource, filter, sortColumn, paginate } = this.options
|
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
|
// Fetch datasource definition and determine feature flags
|
||||||
const definition = await this.constructor.getDefinition(datasource)
|
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 we don't support searching, do a client search
|
||||||
if (!features.supportsSearch) {
|
if (!features.supportsSearch) {
|
||||||
rows = luceneQuery(rows, query)
|
rows = runLuceneQuery(rows, query)
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we don't support sorting, do a client-side sort
|
// If we don't support sorting, do a client-side sort
|
||||||
|
@ -228,7 +228,11 @@ export default class DataFetch {
|
||||||
if (!datasource?.tableId) {
|
if (!datasource?.tableId) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return await fetchTableDefinition(datasource.tableId)
|
try {
|
||||||
|
return await this.API.fetchTableDefinition(datasource.tableId)
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -28,7 +28,7 @@ export default class FieldFetch extends DataFetch {
|
||||||
|
|
||||||
// These sources will be available directly from context
|
// These sources will be available directly from context
|
||||||
const data = datasource?.value || []
|
const data = datasource?.value || []
|
||||||
let rows = []
|
let rows
|
||||||
if (Array.isArray(data) && data[0] && typeof data[0] !== "object") {
|
if (Array.isArray(data) && data[0] && typeof data[0] !== "object") {
|
||||||
rows = data.map(value => ({ value }))
|
rows = data.map(value => ({ value }))
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
import FieldFetch from "./FieldFetch.js"
|
import FieldFetch from "./FieldFetch.js"
|
||||||
import { fetchTableDefinition } from "../api"
|
|
||||||
import { getJSONArrayDatasourceSchema } from "../utils/json"
|
import { getJSONArrayDatasourceSchema } from "../utils/json"
|
||||||
|
|
||||||
export default class JSONArrayFetch extends FieldFetch {
|
export default class JSONArrayFetch extends FieldFetch {
|
||||||
static async getDefinition(datasource) {
|
static async getDefinition(datasource) {
|
||||||
// JSON arrays need their table definitions fetched.
|
// JSON arrays need their table definitions fetched.
|
||||||
// We can then extract their schema as a subset of the table schema.
|
// We can then extract their schema as a subset of the table schema.
|
||||||
const table = await fetchTableDefinition(datasource.tableId)
|
try {
|
||||||
const schema = getJSONArrayDatasourceSchema(table?.schema, datasource)
|
const table = await this.API.fetchTableDefinition(datasource.tableId)
|
||||||
return { schema }
|
const schema = getJSONArrayDatasourceSchema(table?.schema, datasource)
|
||||||
|
return { schema }
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import DataFetch from "./DataFetch.js"
|
import DataFetch from "./DataFetch.js"
|
||||||
import { executeQuery, fetchQueryDefinition } from "../api"
|
|
||||||
import { cloneDeep } from "lodash/fp"
|
import { cloneDeep } from "lodash/fp"
|
||||||
import { get } from "svelte/store"
|
import { get } from "svelte/store"
|
||||||
|
|
||||||
|
@ -16,7 +15,11 @@ export default class QueryFetch extends DataFetch {
|
||||||
if (!datasource?._id) {
|
if (!datasource?._id) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return await fetchQueryDefinition(datasource._id)
|
try {
|
||||||
|
return await this.API.fetchQueryDefinition(datasource._id)
|
||||||
|
} catch (error) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getData() {
|
async getData() {
|
||||||
|
@ -41,28 +44,36 @@ export default class QueryFetch extends DataFetch {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute query
|
// 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
|
// Derive pagination info from response
|
||||||
let nextCursor = null
|
let nextCursor = null
|
||||||
let hasNextPage = false
|
let hasNextPage = false
|
||||||
if (paginate && supportsPagination) {
|
if (paginate && supportsPagination) {
|
||||||
if (type === "page") {
|
if (type === "page") {
|
||||||
// For "page number" pagination, increment the existing page number
|
// For "page number" pagination, increment the existing page number
|
||||||
nextCursor = queryPayload.pagination.page + 1
|
nextCursor = queryPayload.pagination.page + 1
|
||||||
hasNextPage = data?.length === limit && limit > 0
|
hasNextPage = data?.length === limit && limit > 0
|
||||||
} else {
|
} else {
|
||||||
// For "cursor" pagination, the cursor should be in the response
|
// For "cursor" pagination, the cursor should be in the response
|
||||||
nextCursor = pagination?.cursor
|
nextCursor = pagination?.cursor
|
||||||
hasNextPage = nextCursor != null
|
hasNextPage = nextCursor != null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: data || [],
|
rows: data || [],
|
||||||
info: rest,
|
info: rest,
|
||||||
cursor: nextCursor,
|
cursor: nextCursor,
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
rows: [],
|
||||||
|
hasNextPage: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,16 +1,17 @@
|
||||||
import DataFetch from "./DataFetch.js"
|
import DataFetch from "./DataFetch.js"
|
||||||
import { fetchRelationshipData } from "../api"
|
|
||||||
|
|
||||||
export default class RelationshipFetch extends DataFetch {
|
export default class RelationshipFetch extends DataFetch {
|
||||||
async getData() {
|
async getData() {
|
||||||
const { datasource } = this.options
|
const { datasource } = this.options
|
||||||
const res = await fetchRelationshipData({
|
try {
|
||||||
rowId: datasource?.rowId,
|
const res = await this.API.fetchRelationshipData({
|
||||||
tableId: datasource?.rowTableId,
|
rowId: datasource?.rowId,
|
||||||
fieldName: datasource?.fieldName,
|
tableId: datasource?.rowTableId,
|
||||||
})
|
fieldName: datasource?.fieldName,
|
||||||
return {
|
})
|
||||||
rows: res || [],
|
return { rows: res || [] }
|
||||||
|
} catch (error) {
|
||||||
|
return { rows: [] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { get } from "svelte/store"
|
import { get } from "svelte/store"
|
||||||
import DataFetch from "./DataFetch.js"
|
import DataFetch from "./DataFetch.js"
|
||||||
import { searchTable } from "../api"
|
|
||||||
|
|
||||||
export default class TableFetch extends DataFetch {
|
export default class TableFetch extends DataFetch {
|
||||||
determineFeatureFlags() {
|
determineFeatureFlags() {
|
||||||
|
@ -18,20 +17,27 @@ export default class TableFetch extends DataFetch {
|
||||||
const { cursor, query } = get(this.store)
|
const { cursor, query } = get(this.store)
|
||||||
|
|
||||||
// Search table
|
// Search table
|
||||||
const res = await searchTable({
|
try {
|
||||||
tableId,
|
const res = await this.API.searchTable({
|
||||||
query,
|
tableId,
|
||||||
limit,
|
query,
|
||||||
sort: sortColumn,
|
limit,
|
||||||
sortOrder: sortOrder?.toLowerCase() ?? "ascending",
|
sort: sortColumn,
|
||||||
sortType,
|
sortOrder: sortOrder?.toLowerCase() ?? "ascending",
|
||||||
paginate,
|
sortType,
|
||||||
bookmark: cursor,
|
paginate,
|
||||||
})
|
bookmark: cursor,
|
||||||
return {
|
})
|
||||||
rows: res?.rows || [],
|
return {
|
||||||
hasNextPage: res?.hasNextPage || false,
|
rows: res?.rows || [],
|
||||||
cursor: res?.bookmark || null,
|
hasNextPage: res?.hasNextPage || false,
|
||||||
|
cursor: res?.bookmark || null,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
rows: [],
|
||||||
|
hasNextPage: false,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import DataFetch from "./DataFetch.js"
|
import DataFetch from "./DataFetch.js"
|
||||||
import { fetchViewData } from "../api"
|
|
||||||
|
|
||||||
export default class ViewFetch extends DataFetch {
|
export default class ViewFetch extends DataFetch {
|
||||||
static getSchema(datasource, definition) {
|
static getSchema(datasource, definition) {
|
||||||
|
@ -8,9 +7,11 @@ export default class ViewFetch extends DataFetch {
|
||||||
|
|
||||||
async getData() {
|
async getData() {
|
||||||
const { datasource } = this.options
|
const { datasource } = this.options
|
||||||
const res = await fetchViewData(datasource)
|
try {
|
||||||
return {
|
const res = await this.API.fetchViewData(datasource)
|
||||||
rows: res || [],
|
return { rows: res || [] }
|
||||||
|
} catch (error) {
|
||||||
|
return { rows: [] }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,7 +18,7 @@ const DataFetchMap = {
|
||||||
jsonarray: JSONArrayFetch,
|
jsonarray: JSONArrayFetch,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const fetchData = (datasource, options) => {
|
export const fetchData = ({ API, datasource, options }) => {
|
||||||
const Fetch = DataFetchMap[datasource?.type] || TableFetch
|
const Fetch = DataFetchMap[datasource?.type] || TableFetch
|
||||||
return new Fetch({ datasource, ...options })
|
return new Fetch({ API, datasource, ...options })
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue