diff --git a/.eslintrc.json b/.eslintrc.json index 79f7e56712..917443014b 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -45,6 +45,16 @@ "no-prototype-builtins": "off", "local-rules/no-budibase-imports": "error" } + }, + { + "files": [ + "packages/builder/**/*", + "packages/client/**/*", + "packages/frontend-core/**/*" + ], + "rules": { + "no-console": ["error", { "allow": ["warn", "error", "debug"] } ] + } } ], "rules": { diff --git a/lerna.json b/lerna.json index f91c51d4bb..9d0593bead 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { - "version": "2.16.0", + "version": "2.17.7", "npmClient": "yarn", "packages": [ "packages/*", diff --git a/packages/account-portal b/packages/account-portal index 485ec16a9e..cc12291732 160000 --- a/packages/account-portal +++ b/packages/account-portal @@ -1 +1 @@ -Subproject commit 485ec16a9eed48c548a5f1239772139f3319f028 +Subproject commit cc12291732ee902dc832bc7d93cf2086ffdf0cff diff --git a/packages/backend-core/package.json b/packages/backend-core/package.json index d6325e1de9..85644488f5 100644 --- a/packages/backend-core/package.json +++ b/packages/backend-core/package.json @@ -21,7 +21,7 @@ "test:watch": "jest --watchAll" }, "dependencies": { - "@budibase/nano": "10.1.4", + "@budibase/nano": "10.1.5", "@budibase/pouchdb-replication-stream": "1.2.10", "@budibase/shared-core": "0.0.0", "@budibase/types": "0.0.0", diff --git a/packages/backend-core/src/objectStore/cloudfront.ts b/packages/backend-core/src/objectStore/cloudfront.ts index 866fe9e880..3bca97d11e 100644 --- a/packages/backend-core/src/objectStore/cloudfront.ts +++ b/packages/backend-core/src/objectStore/cloudfront.ts @@ -23,7 +23,7 @@ const getCloudfrontSignParams = () => { return { keypairId: env.CLOUDFRONT_PUBLIC_KEY_ID!, privateKeyString: getPrivateKey(), - expireTime: new Date().getTime() + 1000 * 60 * 60, // 1 hour + expireTime: new Date().getTime() + 1000 * 60 * 60 * 24, // 1 day } } diff --git a/packages/backend-core/src/objectStore/objectStore.ts b/packages/backend-core/src/objectStore/objectStore.ts index 57ead0e809..3a3b9cdaab 100644 --- a/packages/backend-core/src/objectStore/objectStore.ts +++ b/packages/backend-core/src/objectStore/objectStore.ts @@ -7,7 +7,7 @@ import tar from "tar-fs" import zlib from "zlib" import { promisify } from "util" import { join } from "path" -import fs from "fs" +import fs, { ReadStream } from "fs" import env from "../environment" import { budibaseTempDir } from "./utils" import { v4 } from "uuid" @@ -184,7 +184,7 @@ export async function upload({ export async function streamUpload( bucketName: string, filename: string, - stream: any, + stream: ReadStream | ReadableStream, extra = {} ) { const objectStore = ObjectStore(bucketName) diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js new file mode 100644 index 0000000000..55208bb97e --- /dev/null +++ b/packages/builder/src/builderStore/store/frontend.js @@ -0,0 +1,1507 @@ +import { get, writable } from "svelte/store" +import { cloneDeep } from "lodash/fp" +import { + selectedScreen, + selectedComponent, + screenHistoryStore, + automationHistoryStore, +} from "builderStore" +import { + datasources, + integrations, + queries, + database, + tables, +} from "stores/backend" +import { API } from "api" +import analytics, { Events } from "analytics" +import { + findComponentParent, + findClosestMatchingComponent, + findAllMatchingComponents, + findComponent, + getComponentSettings, + makeComponentUnique, + findComponentPath, +} from "../componentUtils" +import { Helpers } from "@budibase/bbui" +import { Utils } from "@budibase/frontend-core" +import { + BUDIBASE_INTERNAL_DB_ID, + DB_TYPE_INTERNAL, + DB_TYPE_EXTERNAL, +} from "constants/backend" +import { + buildFormSchema, + getSchemaForDatasource, +} from "builderStore/dataBinding" +import { makePropSafe as safe } from "@budibase/string-templates" +import { getComponentFieldOptions } from "helpers/formFields" +import { createBuilderWebsocket } from "builderStore/websocket" +import { BuilderSocketEvent } from "@budibase/shared-core" + +const INITIAL_FRONTEND_STATE = { + initialised: false, + apps: [], + name: "", + url: "", + description: "", + layouts: [], + screens: [], + components: [], + clientFeatures: { + spectrumThemes: false, + intelligentLoading: false, + deviceAwareness: false, + state: false, + rowSelection: false, + customThemes: false, + devicePreview: false, + messagePassing: false, + continueIfAction: false, + showNotificationAction: false, + sidePanel: false, + }, + features: { + componentValidation: false, + disableUserMetadata: false, + }, + errors: [], + hasAppPackage: false, + libraries: null, + appId: "", + routes: {}, + clientLibPath: "", + theme: "", + customTheme: {}, + previewDevice: "desktop", + highlightedSettingKey: null, + propertyFocus: null, + builderSidePanel: false, + hasLock: true, + showPreview: false, + + // URL params + selectedScreenId: null, + selectedComponentId: null, + selectedLayoutId: null, + + // Client state + selectedComponentInstance: null, + + // Onboarding + onboarding: false, + tourNodes: null, +} + +export const getFrontendStore = () => { + const store = writable({ ...INITIAL_FRONTEND_STATE }) + let websocket + + // This is a fake implementation of a "patch" API endpoint to try and prevent + // 409s. All screen doc mutations (aside from creation) use this function, + // which queues up invocations sequentially and ensures pending mutations are + // always applied to the most up-to-date doc revision. + // This is slightly better than just a traditional "patch" endpoint and this + // supports deeply mutating the current doc rather than just appending data. + const sequentialScreenPatch = Utils.sequential(async (patchFn, screenId) => { + const state = get(store) + const screen = state.screens.find(screen => screen._id === screenId) + if (!screen) { + return + } + let clone = cloneDeep(screen) + const result = patchFn(clone) + // An explicit false result means skip this change + if (result === false) { + return + } + return await store.actions.screens.save(clone) + }) + + store.actions = { + reset: () => { + store.set({ ...INITIAL_FRONTEND_STATE }) + websocket?.disconnect() + websocket = null + }, + initialise: async pkg => { + const { layouts, screens, application, clientLibPath, hasLock } = pkg + if (!websocket) { + websocket = createBuilderWebsocket(application.appId) + } + await store.actions.components.refreshDefinitions(application.appId) + + // Reset store state + store.update(state => ({ + ...state, + libraries: application.componentLibraries, + name: application.name, + description: application.description, + appId: application.appId, + url: application.url, + layouts: layouts || [], + screens: screens || [], + theme: application.theme || "spectrum--light", + customTheme: application.customTheme, + hasAppPackage: true, + appInstance: application.instance, + clientLibPath, + previousTopNavPath: {}, + version: application.version, + revertableVersion: application.revertableVersion, + upgradableVersion: application.upgradableVersion, + navigation: application.navigation || {}, + usedPlugins: application.usedPlugins || [], + hasLock, + features: { + ...INITIAL_FRONTEND_STATE.features, + ...application.features, + }, + automations: application.automations || {}, + icon: application.icon || {}, + initialised: true, + })) + screenHistoryStore.reset() + automationHistoryStore.reset() + + // Initialise backend stores + database.set(application.instance) + await datasources.init() + await integrations.init() + await queries.init() + await tables.init() + }, + theme: { + save: async theme => { + const appId = get(store).appId + const app = await API.saveAppMetadata({ + appId, + metadata: { theme }, + }) + store.update(state => { + state.theme = app.theme + return state + }) + }, + }, + customTheme: { + save: async customTheme => { + const appId = get(store).appId + const app = await API.saveAppMetadata({ + appId, + metadata: { customTheme }, + }) + store.update(state => { + state.customTheme = app.customTheme + return state + }) + }, + }, + navigation: { + save: async navigation => { + const appId = get(store).appId + const app = await API.saveAppMetadata({ + appId, + metadata: { navigation }, + }) + store.update(state => { + state.navigation = app.navigation + return state + }) + }, + }, + screens: { + select: screenId => { + // Check this screen exists + const state = get(store) + const screen = state.screens.find(screen => screen._id === screenId) + if (!screen) { + return + } + + // Check screen isn't already selected + if (state.selectedScreenId === screen._id) { + return + } + + // Select new screen + store.update(state => { + state.selectedScreenId = screen._id + return state + }) + }, + validate: screen => { + // Recursive function to find any illegal children in component trees + const findIllegalChild = ( + component, + illegalChildren = [], + legalDirectChildren = [] + ) => { + const type = component._component + + if (illegalChildren.includes(type)) { + return type + } + if ( + legalDirectChildren.length && + !legalDirectChildren.includes(type) + ) { + return type + } + if (!component?._children?.length) { + return + } + + if (type === "@budibase/standard-components/sidepanel") { + illegalChildren = [] + } + + const definition = store.actions.components.getDefinition( + component._component + ) + // Reset whitelist for direct children + legalDirectChildren = [] + if (definition?.legalDirectChildren?.length) { + legalDirectChildren = definition.legalDirectChildren.map(x => { + return `@budibase/standard-components/${x}` + }) + } + + // Append blacklisted components and remove duplicates + if (definition?.illegalChildren?.length) { + const blacklist = definition.illegalChildren.map(x => { + return `@budibase/standard-components/${x}` + }) + illegalChildren = [...new Set([...illegalChildren, ...blacklist])] + } + + // Recurse on all children + for (let child of component._children) { + const illegalChild = findIllegalChild( + child, + illegalChildren, + legalDirectChildren + ) + if (illegalChild) { + return illegalChild + } + } + } + + // Validate the entire tree and throw an error if an illegal child is + // found anywhere + const illegalChild = findIllegalChild(screen.props) + if (illegalChild) { + const def = store.actions.components.getDefinition(illegalChild) + throw `You can't place a ${def.name} here` + } + }, + save: async screen => { + const state = get(store) + + // Validate screen structure if the app supports it + if (state.features?.componentValidation) { + store.actions.screens.validate(screen) + } + + // Check screen definition for any component settings which need updated + store.actions.screens.enrichEmptySettings(screen) + + // Save screen + const creatingNewScreen = screen._id === undefined + const savedScreen = await API.saveScreen(screen) + const routesResponse = await API.fetchAppRoutes() + + // If plugins changed we need to fetch the latest app metadata + let usedPlugins = state.usedPlugins + if (savedScreen.pluginAdded) { + const { application } = await API.fetchAppPackage(state.appId) + usedPlugins = application.usedPlugins || [] + } + + // Update state + store.update(state => { + // Update screen object + const idx = state.screens.findIndex(x => x._id === savedScreen._id) + if (idx !== -1) { + state.screens.splice(idx, 1, savedScreen) + } else { + state.screens.push(savedScreen) + } + + // Select the new screen if creating a new one + if (creatingNewScreen) { + state.selectedScreenId = savedScreen._id + state.selectedComponentId = savedScreen.props._id + } + + // Update routes + state.routes = routesResponse.routes + + // Update used plugins + state.usedPlugins = usedPlugins + return state + }) + return savedScreen + }, + patch: async (patchFn, screenId) => { + // Default to the currently selected screen + if (!screenId) { + const state = get(store) + screenId = state.selectedScreenId + } + if (!screenId || !patchFn) { + return + } + return await sequentialScreenPatch(patchFn, screenId) + }, + replace: async (screenId, screen) => { + if (!screenId) { + return + } + if (!screen) { + // Screen deletion + store.update(state => ({ + ...state, + screens: state.screens.filter(x => x._id !== screenId), + })) + } else { + const index = get(store).screens.findIndex(x => x._id === screen._id) + if (index === -1) { + // Screen addition + store.update(state => ({ + ...state, + screens: [...state.screens, screen], + })) + } else { + // Screen update + store.update(state => { + state.screens[index] = screen + return state + }) + } + } + }, + delete: async screens => { + const screensToDelete = Array.isArray(screens) ? screens : [screens] + + // Build array of promises to speed up bulk deletions + let promises = [] + let deleteUrls = [] + screensToDelete.forEach(screen => { + // Delete the screen + promises.push( + API.deleteScreen({ + screenId: screen._id, + screenRev: screen._rev, + }) + ) + // Remove links to this screen + deleteUrls.push(screen.routing.route) + }) + + await Promise.all(promises) + await store.actions.links.delete(deleteUrls) + const deletedIds = screensToDelete.map(screen => screen._id) + const routesResponse = await API.fetchAppRoutes() + store.update(state => { + // Remove deleted screens from state + state.screens = state.screens.filter(screen => { + return !deletedIds.includes(screen._id) + }) + + // Deselect the current screen if it was deleted + if (deletedIds.includes(state.selectedScreenId)) { + state.selectedScreenId = null + state.selectedComponentId = null + } + + // Update routing + state.routes = routesResponse.routes + + return state + }) + return null + }, + updateSetting: async (screen, name, value) => { + if (!screen || !name) { + return + } + + // Apply setting update + const patch = screen => { + if (!screen) { + return false + } + // Skip update if the value is the same + if (Helpers.deepGet(screen, name) === value) { + return false + } + Helpers.deepSet(screen, name, value) + } + await store.actions.screens.patch(patch, screen._id) + + // Ensure we don't have more than one home screen for this new role. + // This could happen after updating multiple different settings. + const state = get(store) + const updatedScreen = state.screens.find(s => s._id === screen._id) + if (!updatedScreen) { + return + } + const otherHomeScreens = state.screens.filter(s => { + return ( + s.routing.roleId === updatedScreen.routing.roleId && + s.routing.homeScreen && + s._id !== screen._id + ) + }) + if (otherHomeScreens.length && updatedScreen.routing.homeScreen) { + const patch = screen => { + screen.routing.homeScreen = false + } + for (let otherHomeScreen of otherHomeScreens) { + await store.actions.screens.patch(patch, otherHomeScreen._id) + } + } + }, + removeCustomLayout: async screen => { + // Pull relevant settings from old layout, if required + const layout = get(store).layouts.find(x => x._id === screen.layoutId) + const patch = screen => { + screen.layoutId = null + screen.showNavigation = layout?.props.navigation !== "None" + screen.width = layout?.props.width || "Large" + } + await store.actions.screens.patch(patch, screen._id) + }, + enrichEmptySettings: screen => { + // Flatten the recursive component tree + const components = findAllMatchingComponents(screen.props, x => x) + + // Iterate over all components and run checks + components.forEach(component => { + store.actions.components.enrichEmptySettings(component, { + screen, + }) + }) + }, + }, + preview: { + setDevice: device => { + store.update(state => { + state.previewDevice = device + return state + }) + }, + sendEvent: (name, payload) => { + const { previewEventHandler } = get(store) + previewEventHandler?.(name, payload) + }, + registerEventHandler: handler => { + store.update(state => { + state.previewEventHandler = handler + return state + }) + }, + }, + layouts: { + select: layoutId => { + // Check this layout exists + const state = get(store) + const layout = state.layouts.find(layout => layout._id === layoutId) + if (!layout) { + return + } + + // Check layout isn't already selected + if ( + state.selectedLayoutId === layout._id && + state.selectedComponentId === layout.props?._id + ) { + return + } + + // Select new layout + store.update(state => { + state.selectedLayoutId = layout._id + state.selectedComponentId = layout.props?._id + return state + }) + }, + delete: async layout => { + if (!layout?._id) { + return + } + await API.deleteLayout({ + layoutId: layout._id, + layoutRev: layout._rev, + }) + store.update(state => { + state.layouts = state.layouts.filter(x => x._id !== layout._id) + return state + }) + }, + }, + components: { + refreshDefinitions: async appId => { + if (!appId) { + appId = get(store).appId + } + + // Fetch definitions and filter out custom component definitions so we + // can flag them + const components = await API.fetchComponentLibDefinitions(appId) + const customComponents = Object.keys(components).filter(name => + name.startsWith("plugin/") + ) + + // Update store + store.update(state => ({ + ...state, + components, + customComponents, + clientFeatures: { + ...INITIAL_FRONTEND_STATE.clientFeatures, + ...components.features, + }, + })) + }, + getDefinition: componentName => { + if (!componentName) { + return null + } + return get(store).components[componentName] + }, + getDefaultDatasource: () => { + // Ignore users table + const validTables = get(tables).list.filter(x => x._id !== "ta_users") + + // Try to use their own internal table first + let table = validTables.find(table => { + return ( + table.sourceId !== BUDIBASE_INTERNAL_DB_ID && + table.sourceType === DB_TYPE_INTERNAL + ) + }) + if (table) { + return table + } + + // Then try sample data + table = validTables.find(table => { + return ( + table.sourceId === BUDIBASE_INTERNAL_DB_ID && + table.sourceType === DB_TYPE_INTERNAL + ) + }) + if (table) { + return table + } + + // Finally try an external table + return validTables.find(table => table.sourceType === DB_TYPE_EXTERNAL) + }, + migrateSettings: enrichedComponent => { + const componentPrefix = "@budibase/standard-components" + let migrated = false + + if (enrichedComponent?._component == `${componentPrefix}/formblock`) { + // Use default config if the 'buttons' prop has never been initialised + if (!("buttons" in enrichedComponent)) { + enrichedComponent["buttons"] = + Utils.buildFormBlockButtonConfig(enrichedComponent) + migrated = true + } else if (enrichedComponent["buttons"] == null) { + // Ignore legacy config if 'buttons' has been reset by 'resetOn' + const { _id, actionType, dataSource } = enrichedComponent + enrichedComponent["buttons"] = Utils.buildFormBlockButtonConfig({ + _id, + actionType, + dataSource, + }) + migrated = true + } + + // Ensure existing Formblocks position their buttons at the top. + if (!("buttonPosition" in enrichedComponent)) { + enrichedComponent["buttonPosition"] = "top" + migrated = true + } + } + + return migrated + }, + enrichEmptySettings: (component, opts) => { + if (!component?._component) { + return + } + const defaultDS = store.actions.components.getDefaultDatasource() + const settings = getComponentSettings(component._component) + const { parent, screen, useDefaultValues } = opts || {} + const treeId = parent?._id || component._id + if (!screen) { + return + } + settings.forEach(setting => { + const value = component[setting.key] + + // Fill empty settings + if (value == null || value === "") { + if (setting.type === "multifield" && setting.selectAllFields) { + // Select all schema fields where required + component[setting.key] = Object.keys(defaultDS?.schema || {}) + } else if ( + (setting.type === "dataSource" || setting.type === "table") && + defaultDS + ) { + // Select default datasource where required + component[setting.key] = { + label: defaultDS.name, + tableId: defaultDS._id, + resourceId: defaultDS._id, + type: "table", + } + } else if (setting.type === "dataProvider") { + // Pick closest data provider where required + const path = findComponentPath(screen.props, treeId) + const providers = path.filter(component => + component._component?.endsWith("/dataprovider") + ) + if (providers.length) { + const id = providers[providers.length - 1]?._id + component[setting.key] = `{{ literal ${safe(id)} }}` + } + } else if (setting.type.startsWith("field/")) { + // Autofill form field names + // Get all available field names in this form schema + let fieldOptions = getComponentFieldOptions( + screen.props, + treeId, + setting.type, + false + ) + + // Get all currently used fields + const form = findClosestMatchingComponent( + screen.props, + treeId, + x => x._component === "@budibase/standard-components/form" + ) + const usedFields = Object.keys(buildFormSchema(form) || {}) + + // Filter out already used fields + fieldOptions = fieldOptions.filter(x => !usedFields.includes(x)) + + // Set field name and also assume we have a label setting + if (fieldOptions[0]) { + component[setting.key] = fieldOptions[0] + component.label = fieldOptions[0] + } + } else if (useDefaultValues && setting.defaultValue !== undefined) { + // Use default value where required + component[setting.key] = setting.defaultValue + } + } + // Validate non-empty settings + else { + if (setting.type === "dataProvider") { + // Validate data provider exists, or else clear it + const providers = findAllMatchingComponents( + screen?.props, + component => component._component?.endsWith("/dataprovider") + ) + // Validate non-empty values + const valid = providers?.some(dp => value.includes?.(dp._id)) + if (!valid) { + if (providers.length) { + const id = providers[providers.length - 1]?._id + component[setting.key] = `{{ literal ${safe(id)} }}` + } else { + delete component[setting.key] + } + } + } + } + }) + }, + createInstance: (componentName, presetProps, parent) => { + const definition = store.actions.components.getDefinition(componentName) + if (!definition) { + return null + } + + // Find all existing components of this type so that we can give this + // component a unique name + const screen = get(selectedScreen).props + const otherComponents = findAllMatchingComponents( + screen, + x => x._component === definition.component && x._id !== screen._id + ) + let name = definition.friendlyName || definition.name + name = `${name} ${otherComponents.length + 1}` + + // Generate basic component structure + let instance = { + _id: Helpers.uuid(), + _component: definition.component, + _styles: { + normal: {}, + hover: {}, + active: {}, + }, + _instanceName: name, + ...presetProps, + } + + // Enrich empty settings + store.actions.components.enrichEmptySettings(instance, { + parent, + screen: get(selectedScreen), + useDefaultValues: true, + }) + + // Migrate nested component settings + store.actions.components.migrateSettings(instance) + + // Add any extra properties the component needs + let extras = {} + if (definition.hasChildren) { + extras._children = [] + } + if (componentName.endsWith("/formstep")) { + const parentForm = findClosestMatchingComponent( + get(selectedScreen).props, + get(selectedComponent)._id, + component => component._component.endsWith("/form") + ) + const formSteps = findAllMatchingComponents(parentForm, component => + component._component.endsWith("/formstep") + ) + extras.step = formSteps.length + 1 + extras._instanceName = `Step ${formSteps.length + 1}` + } + return { + ...cloneDeep(instance), + ...extras, + } + }, + create: async (componentName, presetProps, parent, index) => { + const state = get(store) + const componentInstance = store.actions.components.createInstance( + componentName, + presetProps, + parent + ) + if (!componentInstance) { + return + } + + // Insert in position if specified + if (parent && index != null) { + await store.actions.screens.patch(screen => { + let parentComponent = findComponent(screen.props, parent) + if (!parentComponent._children?.length) { + parentComponent._children = [componentInstance] + } else { + parentComponent._children.splice(index, 0, componentInstance) + } + }) + } + + // Otherwise we work out where this component should be inserted + else { + await store.actions.screens.patch(screen => { + // Find the selected component + let selectedComponentId = state.selectedComponentId + if (selectedComponentId.startsWith(`${screen._id}-`)) { + selectedComponentId = screen?.props._id + } + const currentComponent = findComponent( + screen.props, + selectedComponentId + ) + if (!currentComponent) { + return false + } + + // Find parent node to attach this component to + let parentComponent + if (currentComponent) { + // Use selected component as parent if one is selected + const definition = store.actions.components.getDefinition( + currentComponent._component + ) + if (definition?.hasChildren) { + // Use selected component if it allows children + parentComponent = currentComponent + } else { + // Otherwise we need to use the parent of this component + parentComponent = findComponentParent( + screen.props, + currentComponent._id + ) + } + } else { + // Use screen or layout if no component is selected + parentComponent = screen.props + } + + // Attach new component + if (!parentComponent) { + return false + } + if (!parentComponent._children) { + parentComponent._children = [] + } + parentComponent._children.push(componentInstance) + }) + } + + // Select new component + store.update(state => { + state.selectedComponentId = componentInstance._id + return state + }) + + // Log event + analytics.captureEvent(Events.COMPONENT_CREATED, { + name: componentInstance._component, + }) + + return componentInstance + }, + patch: async (patchFn, componentId, screenId) => { + // Use selected component by default + if (!componentId || !screenId) { + const state = get(store) + componentId = componentId || state.selectedComponentId + screenId = screenId || state.selectedScreenId + } + if (!componentId || !screenId || !patchFn) { + return + } + const patchScreen = screen => { + // findComponent looks in the tree not comp.settings[0] + let component = findComponent(screen.props, componentId) + if (!component) { + return false + } + + // Mutates the fetched component with updates + const patchResult = patchFn(component, screen) + + // Mutates the component with any required settings updates + const migrated = store.actions.components.migrateSettings(component) + + // Returning an explicit false signifies that we should skip this + // update. If we migrated something, ensure we never skip. + return migrated ? null : patchResult + } + await store.actions.screens.patch(patchScreen, screenId) + }, + delete: async component => { + if (!component) { + return + } + + // Determine the next component to select after deletion + const state = get(store) + let nextSelectedComponentId + if (state.selectedComponentId === component._id) { + nextSelectedComponentId = store.actions.components.getNext() + if (!nextSelectedComponentId) { + nextSelectedComponentId = store.actions.components.getPrevious() + } + } + + // Patch screen + await store.actions.screens.patch(screen => { + // Check component exists + component = findComponent(screen.props, component._id) + if (!component) { + return false + } + + // Check component has a valid parent + const parent = findComponentParent(screen.props, component._id) + if (!parent) { + return false + } + parent._children = parent._children.filter( + child => child._id !== component._id + ) + }) + + // Update selected component if required + if (nextSelectedComponentId) { + store.update(state => { + state.selectedComponentId = nextSelectedComponentId + return state + }) + } + }, + copy: (component, cut = false, selectParent = true) => { + // Update store with copied component + store.update(state => { + state.componentToPaste = cloneDeep(component) + state.componentToPaste.isCut = cut + return state + }) + + // Select the parent if cutting + if (cut && selectParent) { + const screen = get(selectedScreen) + const parent = findComponentParent(screen?.props, component._id) + if (parent) { + store.update(state => { + state.selectedComponentId = parent._id + return state + }) + } + } + }, + paste: async (targetComponent, mode, targetScreen) => { + const state = get(store) + if (!state.componentToPaste) { + return + } + let newComponentId + + // Remove copied component if cutting, regardless if pasting works + let componentToPaste = cloneDeep(state.componentToPaste) + if (componentToPaste.isCut) { + store.update(state => { + delete state.componentToPaste + return state + }) + } + + // Patch screen + const patch = screen => { + // Get up to date ref to target + targetComponent = findComponent(screen.props, targetComponent._id) + if (!targetComponent) { + return false + } + const cut = componentToPaste.isCut + const originalId = componentToPaste._id + delete componentToPaste.isCut + + // Make new component unique if copying + if (!cut) { + componentToPaste = makeComponentUnique(componentToPaste) + } + newComponentId = componentToPaste._id + + // Delete old component if cutting + if (cut) { + const parent = findComponentParent(screen.props, originalId) + if (parent?._children) { + parent._children = parent._children.filter( + component => component._id !== originalId + ) + } + } + + // Check inside is valid + if (mode === "inside") { + const definition = store.actions.components.getDefinition( + targetComponent._component + ) + if (!definition.hasChildren) { + mode = "below" + } + } + + // Paste new component + if (mode === "inside") { + // Paste inside target component if chosen + if (!targetComponent._children) { + targetComponent._children = [] + } + targetComponent._children.push(componentToPaste) + } else { + // Otherwise paste in the correct order in the parent's children + const parent = findComponentParent( + screen.props, + targetComponent._id + ) + if (!parent?._children) { + return false + } + const targetIndex = parent._children.findIndex(component => { + return component._id === targetComponent._id + }) + const index = mode === "above" ? targetIndex : targetIndex + 1 + parent._children.splice(index, 0, componentToPaste) + } + } + const targetScreenId = targetScreen?._id || state.selectedScreenId + await store.actions.screens.patch(patch, targetScreenId) + + // Select the new component + store.update(state => { + state.selectedScreenId = targetScreenId + state.selectedComponentId = newComponentId + return state + }) + }, + getPrevious: () => { + const state = get(store) + const componentId = state.selectedComponentId + const screen = get(selectedScreen) + const parent = findComponentParent(screen.props, componentId) + const index = parent?._children.findIndex(x => x._id === componentId) + + // Check for screen and navigation component edge cases + const screenComponentId = `${screen._id}-screen` + const navComponentId = `${screen._id}-navigation` + if (componentId === screenComponentId) { + return null + } + if (componentId === navComponentId) { + return screenComponentId + } + if (parent._id === screen.props._id && index === 0) { + return navComponentId + } + + // If we have siblings above us, choose the sibling or a descendant + if (index > 0) { + // If sibling before us accepts children, select a descendant + const previousSibling = parent._children[index - 1] + if (previousSibling._children?.length) { + let target = previousSibling + while (target._children?.length) { + target = target._children[target._children.length - 1] + } + return target._id + } + + // Otherwise just select sibling + return previousSibling._id + } + + // If no siblings above us, select the parent + return parent._id + }, + getNext: () => { + const state = get(store) + const component = get(selectedComponent) + const componentId = component?._id + const screen = get(selectedScreen) + const parent = findComponentParent(screen.props, componentId) + const index = parent?._children.findIndex(x => x._id === componentId) + + // Check for screen and navigation component edge cases + const screenComponentId = `${screen._id}-screen` + const navComponentId = `${screen._id}-navigation` + if (state.selectedComponentId === screenComponentId) { + return navComponentId + } + + // If we have children, select first child + if (component._children?.length) { + return component._children[0]._id + } else if (!parent) { + return null + } + + // Otherwise select the next sibling if we have one + if (index < parent._children.length - 1) { + const nextSibling = parent._children[index + 1] + return nextSibling._id + } + + // Last child, select our parents next sibling + let target = parent + let targetParent = findComponentParent(screen.props, target._id) + let targetIndex = targetParent?._children.findIndex( + child => child._id === target._id + ) + while ( + targetParent != null && + targetIndex === targetParent._children?.length - 1 + ) { + target = targetParent + targetParent = findComponentParent(screen.props, target._id) + targetIndex = targetParent?._children.findIndex( + child => child._id === target._id + ) + } + if (targetParent) { + return targetParent._children[targetIndex + 1]._id + } else { + return null + } + }, + selectPrevious: () => { + const previousId = store.actions.components.getPrevious() + if (previousId) { + store.update(state => { + state.selectedComponentId = previousId + return state + }) + } + }, + selectNext: () => { + const nextId = store.actions.components.getNext() + if (nextId) { + store.update(state => { + state.selectedComponentId = nextId + return state + }) + } + }, + moveUp: async component => { + await store.actions.screens.patch(screen => { + const componentId = component?._id + const parent = findComponentParent(screen.props, componentId) + + // Check we aren't right at the top of the tree + const index = parent?._children.findIndex(x => x._id === componentId) + if (!parent || (index === 0 && parent._id === screen.props._id)) { + return + } + + // Copy original component and remove it from the parent + const originalComponent = cloneDeep(parent._children[index]) + parent._children = parent._children.filter( + component => component._id !== componentId + ) + + // If we have siblings above us, move up + if (index > 0) { + // If sibling before us accepts children, move to last child of + // sibling + const previousSibling = parent._children[index - 1] + const definition = store.actions.components.getDefinition( + previousSibling._component + ) + if (definition.hasChildren) { + previousSibling._children.push(originalComponent) + } + + // Otherwise just move component above sibling + else { + parent._children.splice(index - 1, 0, originalComponent) + } + } + + // If no siblings above us, go above the parent as long as it isn't + // the screen + else if (parent._id !== screen.props._id) { + const grandParent = findComponentParent(screen.props, parent._id) + const parentIndex = grandParent._children.findIndex( + child => child._id === parent._id + ) + grandParent._children.splice(parentIndex, 0, originalComponent) + } + }) + }, + moveDown: async component => { + await store.actions.screens.patch(screen => { + const componentId = component?._id + const parent = findComponentParent(screen.props, componentId) + + // Sanity check parent is found + if (!parent?._children?.length) { + return false + } + + // Check we aren't right at the bottom of the tree + const index = parent._children.findIndex(x => x._id === componentId) + if ( + index === parent._children.length - 1 && + parent._id === screen.props._id + ) { + return + } + + // Copy the original component and remove from parent + const originalComponent = cloneDeep(parent._children[index]) + parent._children = parent._children.filter( + component => component._id !== componentId + ) + + // Move below the next sibling if we are not the last sibling + if (index < parent._children.length) { + // If the next sibling has children, become the first child + const nextSibling = parent._children[index] + const definition = store.actions.components.getDefinition( + nextSibling._component + ) + if (definition.hasChildren) { + nextSibling._children.splice(0, 0, originalComponent) + } + + // Otherwise move below next sibling + else { + parent._children.splice(index + 1, 0, originalComponent) + } + } + + // Last child, so move below our parent + else { + const grandParent = findComponentParent(screen.props, parent._id) + const parentIndex = grandParent._children.findIndex( + child => child._id === parent._id + ) + grandParent._children.splice(parentIndex + 1, 0, originalComponent) + } + }) + }, + updateStyle: async (name, value) => { + await store.actions.components.patch(component => { + if (value == null || value === "") { + delete component._styles.normal[name] + } else { + component._styles.normal[name] = value + } + }) + }, + updateStyles: async (styles, id) => { + const patchFn = component => { + component._styles.normal = { + ...component._styles.normal, + ...styles, + } + } + await store.actions.components.patch(patchFn, id) + }, + updateCustomStyle: async style => { + await store.actions.components.patch(component => { + component._styles.custom = style + }) + }, + updateConditions: async conditions => { + await store.actions.components.patch(component => { + component._conditions = conditions + }) + }, + updateSetting: async (name, value) => { + await store.actions.components.patch( + store.actions.components.updateComponentSetting(name, value) + ) + }, + updateComponentSetting: (name, value) => { + return component => { + if (!name || !component) { + return false + } + // Skip update if the value is the same + if (component[name] === value) { + return false + } + + const settings = getComponentSettings(component._component) + const updatedSetting = settings.find(setting => setting.key === name) + + // Reset dependent fields + settings.forEach(setting => { + const needsReset = + name === setting.resetOn || + (Array.isArray(setting.resetOn) && setting.resetOn.includes(name)) + if (needsReset) { + component[setting.key] = setting.defaultValue || null + } + }) + + if ( + updatedSetting?.type === "dataSource" || + updatedSetting?.type === "table" + ) { + const { schema } = getSchemaForDatasource(null, value) + const columnNames = Object.keys(schema || {}) + const multifieldKeysToSelectAll = settings + .filter(setting => { + return setting.type === "multifield" && setting.selectAllFields + }) + .map(setting => setting.key) + + multifieldKeysToSelectAll.forEach(key => { + component[key] = columnNames + }) + } + component[name] = value + return true + } + }, + requestEjectBlock: componentId => { + store.actions.preview.sendEvent("eject-block", componentId) + }, + handleEjectBlock: async (componentId, ejectedDefinition) => { + let nextSelectedComponentId + await store.actions.screens.patch(screen => { + const block = findComponent(screen.props, componentId) + const parent = findComponentParent(screen.props, componentId) + + // Sanity check + if (!block || !parent?._children?.length) { + return false + } + + // Log event + analytics.captureEvent(Events.BLOCK_EJECTED, { + block: block._component, + }) + + // Attach block children back into ejected definition, using the + // _containsSlot flag to know where to insert them + const slotContainer = findAllMatchingComponents( + ejectedDefinition, + x => x._containsSlot + )[0] + if (slotContainer) { + delete slotContainer._containsSlot + slotContainer._children = [ + ...(slotContainer._children || []), + ...(block._children || []), + ] + } + + // Replace block with ejected definition + ejectedDefinition = makeComponentUnique(ejectedDefinition) + const index = parent._children.findIndex(x => x._id === componentId) + parent._children[index] = ejectedDefinition + nextSelectedComponentId = ejectedDefinition._id + }) + + // Select new root component + if (nextSelectedComponentId) { + store.update(state => { + state.selectedComponentId = nextSelectedComponentId + return state + }) + } + }, + addParent: async (componentId, parentType) => { + if (!componentId || !parentType) { + return + } + + // Create new parent instance + const newParentDefinition = store.actions.components.createInstance( + parentType, + null, + parent + ) + if (!newParentDefinition) { + return + } + + // Replace component with a version wrapped in a new parent + await store.actions.screens.patch(screen => { + // Get this component definition and parent definition + let definition = findComponent(screen.props, componentId) + let oldParentDefinition = findComponentParent( + screen.props, + componentId + ) + if (!definition || !oldParentDefinition) { + return false + } + + // Replace component with parent + const index = oldParentDefinition._children.findIndex( + component => component._id === componentId + ) + if (index === -1) { + return false + } + oldParentDefinition._children[index] = { + ...newParentDefinition, + _children: [definition], + } + }) + + // Select the new parent + store.update(state => { + state.selectedComponentId = newParentDefinition._id + return state + }) + }, + }, + links: { + save: async (url, title) => { + const navigation = get(store).navigation + let links = [...(navigation?.links ?? [])] + + // Skip if we have an identical link + if (links.find(link => link.url === url && link.text === title)) { + return + } + + links.push({ + text: title, + url, + }) + await store.actions.navigation.save({ + ...navigation, + links: [...links], + }) + }, + delete: async urls => { + const navigation = get(store).navigation + let links = navigation?.links + if (!links?.length) { + return + } + + // Filter out the URLs to delete + urls = Array.isArray(urls) ? urls : [urls] + links = links.filter(link => !urls.includes(link.url)) + + await store.actions.navigation.save({ + ...navigation, + links, + }) + }, + }, + settings: { + highlight: key => { + store.update(state => ({ + ...state, + highlightedSettingKey: key, + })) + }, + propertyFocus: key => { + store.update(state => ({ + ...state, + propertyFocus: key, + })) + }, + }, + dnd: { + start: component => { + store.actions.preview.sendEvent("dragging-new-component", { + dragging: true, + component, + }) + }, + stop: () => { + store.actions.preview.sendEvent("dragging-new-component", { + dragging: false, + }) + }, + }, + websocket: { + selectResource: id => { + websocket.emit(BuilderSocketEvent.SelectResource, { + resourceId: id, + }) + }, + }, + metadata: { + replace: metadata => { + store.update(state => ({ + ...state, + ...metadata, + })) + }, + }, + } + + return store +} diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index 2ba5b0025c..7ba1c8a4b1 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -15,7 +15,6 @@ Icon, Checkbox, DatePicker, - Detail, } from "@budibase/bbui" import CreateWebhookModal from "components/automation/Shared/CreateWebhookModal.svelte" import { automationStore, selectedAutomation, tables } from "stores/builder" @@ -32,6 +31,8 @@ import Editor from "components/integration/QueryEditor.svelte" import ModalBindableInput from "components/common/bindings/ModalBindableInput.svelte" import CodeEditor from "components/common/CodeEditor/CodeEditor.svelte" + import BindingPicker from "components/common/bindings/BindingPicker.svelte" + import { BindingHelpers } from "components/common/bindings/utils" import { bindingsToCompletions, hbAutocomplete, @@ -55,7 +56,7 @@ let drawer let fillWidth = true let inputData - let codeBindingOpen = false + let insertAtPos, getCaretPosition $: filters = lookForFilters(schemaProperties) || [] $: tempFilters = filters $: stepId = block.stepId @@ -74,6 +75,10 @@ $: isUpdateRow = stepId === ActionStepID.UPDATE_ROW $: codeMode = stepId === "EXECUTE_BASH" ? EditorModes.Handlebars : EditorModes.JS + $: bindingsHelpers = new BindingHelpers(getCaretPosition, insertAtPos, { + disableWrapping: true, + }) + $: editingJs = codeMode === EditorModes.JS $: stepCompletions = codeMode === EditorModes.Handlebars @@ -156,6 +161,7 @@ let bindings = [] let loopBlockCount = 0 const addBinding = (name, value, icon, idx, isLoopBlock, bindingName) => { + if (!name) return const runtimeBinding = determineRuntimeBinding(name, idx, isLoopBlock) const categoryName = determineCategoryName(idx, isLoopBlock, bindingName) @@ -290,7 +296,6 @@ loopBlockCount++ continue } - Object.entries(schema).forEach(([name, value]) => addBinding(name, value, icon, idx, isLoopBlock, bindingName) ) @@ -538,39 +543,51 @@ /> {:else if value.customType === "code"} - {#if codeMode == EditorModes.JS} - (codeBindingOpen = !codeBindingOpen)} - quiet - icon={codeBindingOpen ? "ChevronDown" : "ChevronRight"} - > - Bindings - - {#if codeBindingOpen} -
{JSON.stringify(bindings, null, 2)}
- {/if} - {/if} - { - // need to pass without the value inside - onChange({ detail: e.detail }, key) - inputData[key] = e.detail - }} - completions={stepCompletions} - mode={codeMode} - autocompleteEnabled={codeMode != EditorModes.JS} - height={500} - /> -
- {#if codeMode == EditorModes.Handlebars} - -
-
- Add available bindings by typing - }} - -
+
+
+ { + // need to pass without the value inside + onChange({ detail: e.detail }, key) + inputData[key] = e.detail + }} + completions={stepCompletions} + mode={codeMode} + autocompleteEnabled={codeMode !== EditorModes.JS} + bind:getCaretPosition + bind:insertAtPos + height={500} + /> +
+ {#if codeMode === EditorModes.Handlebars} + +
+
+ Add available bindings by typing + }} + +
+
+ {/if} +
+
+ {#if editingJs} +
+ + bindingsHelpers.onSelectBinding( + inputData[key], + binding, + { + js: true, + dontDecode: true, + } + )} + mode="javascript" + />
{/if}
@@ -657,4 +674,20 @@ .test :global(.drawer) { width: 10000px !important; } + + .js-editor { + display: flex; + flex-direction: row; + flex-grow: 1; + width: 100%; + } + + .js-code { + flex: 7; + } + + .js-binding-picker { + flex: 3; + margin-top: calc((var(--spacing-xl) * -1) + 1px); + } diff --git a/packages/builder/src/components/backend/Datasources/CreateEditRelationship.svelte b/packages/builder/src/components/backend/Datasources/CreateEditRelationship.svelte index 6978516012..6b9524776c 100644 --- a/packages/builder/src/components/backend/Datasources/CreateEditRelationship.svelte +++ b/packages/builder/src/components/backend/Datasources/CreateEditRelationship.svelte @@ -373,6 +373,7 @@ confirmText="Save" onConfirm={saveRelationship} disabled={!valid} + size="L" >
Tables diff --git a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte index f8eb6c1732..f4fa762bce 100644 --- a/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte +++ b/packages/builder/src/components/common/CodeEditor/CodeEditor.svelte @@ -54,6 +54,7 @@ export let placeholder = null export let autocompleteEnabled = true export let autofocus = false + export let jsBindingWrapping = true // Export a function to expose caret position export const getCaretPosition = () => { @@ -187,7 +188,7 @@ ) complete.push( EditorView.inputHandler.of((view, from, to, insert) => { - if (insert === "$") { + if (jsBindingWrapping && insert === "$") { let { text } = view.state.doc.lineAt(from) const left = from ? text.substring(0, from) : "" diff --git a/packages/builder/src/components/common/CodeEditor/index.js b/packages/builder/src/components/common/CodeEditor/index.js index 7987deff52..0d71a475f0 100644 --- a/packages/builder/src/components/common/CodeEditor/index.js +++ b/packages/builder/src/components/common/CodeEditor/index.js @@ -286,13 +286,20 @@ export const hbInsert = (value, from, to, text) => { return parsedInsert } -export function jsInsert(value, from, to, text, { helper } = {}) { +export function jsInsert( + value, + from, + to, + text, + { helper, disableWrapping } = {} +) { let parsedInsert = "" const left = from ? value.substring(0, from) : "" const right = to ? value.substring(to) : "" - - if (helper) { + if (disableWrapping) { + parsedInsert = text + } else if (helper) { parsedInsert = `helpers.${text}()` } else if (!left.includes('$("') || !right.includes('")')) { parsedInsert = `$("${text}")` @@ -312,7 +319,7 @@ export const insertBinding = (view, from, to, text, mode) => { } else if (mode.name == "handlebars") { parsedInsert = hbInsert(view.state.doc?.toString(), from, to, text) } else { - console.log("Unsupported") + console.warn("Unsupported") return } diff --git a/packages/builder/src/components/common/RelationshipSelector.svelte b/packages/builder/src/components/common/RelationshipSelector.svelte index 0636deaf08..63f0357a8f 100644 --- a/packages/builder/src/components/common/RelationshipSelector.svelte +++ b/packages/builder/src/components/common/RelationshipSelector.svelte @@ -17,7 +17,7 @@
-
+
diff --git a/packages/builder/src/components/common/bindings/BindingPanel.svelte b/packages/builder/src/components/common/bindings/BindingPanel.svelte index 798803b735..7b567e052b 100644 --- a/packages/builder/src/components/common/bindings/BindingPanel.svelte +++ b/packages/builder/src/components/common/bindings/BindingPanel.svelte @@ -29,10 +29,9 @@ hbAutocomplete, EditorModes, bindingsToCompletions, - hbInsert, - jsInsert, } from "../CodeEditor" import BindingPicker from "./BindingPicker.svelte" + import { BindingHelpers } from "./utils" const dispatch = createEventDispatcher() @@ -60,8 +59,10 @@ let targetMode = null $: usingJS = mode === "JavaScript" - $: editorMode = mode == "JavaScript" ? EditorModes.JS : EditorModes.Handlebars + $: editorMode = + mode === "JavaScript" ? EditorModes.JS : EditorModes.Handlebars $: bindingCompletions = bindingsToCompletions(bindings, editorMode) + $: bindingHelpers = new BindingHelpers(getCaretPosition, insertAtPos) const updateValue = val => { valid = isValid(readableToRuntimeBinding(bindings, val)) @@ -70,31 +71,13 @@ } } - // Adds a JS/HBS helper to the expression const onSelectHelper = (helper, js) => { - const pos = getCaretPosition() - const { start, end } = pos - if (js) { - let js = decodeJSBinding(jsValue) - const insertVal = jsInsert(js, start, end, helper.text, { helper: true }) - insertAtPos({ start, end, value: insertVal }) - } else { - const insertVal = hbInsert(hbsValue, start, end, helper.text) - insertAtPos({ start, end, value: insertVal }) - } + bindingHelpers.onSelectHelper(js ? jsValue : hbsValue, helper, { js }) } - // Adds a data binding to the expression const onSelectBinding = (binding, { forceJS } = {}) => { - const { start, end } = getCaretPosition() - if (usingJS || forceJS) { - let js = decodeJSBinding(jsValue) - const insertVal = jsInsert(js, start, end, binding.readableBinding) - insertAtPos({ start, end, value: insertVal }) - } else { - const insertVal = hbInsert(hbsValue, start, end, binding.readableBinding) - insertAtPos({ start, end, value: insertVal }) - } + const js = usingJS || forceJS + bindingHelpers.onSelectBinding(js ? jsValue : hbsValue, binding, { js }) } const onChangeMode = e => { diff --git a/packages/builder/src/components/common/bindings/BindingPicker.svelte b/packages/builder/src/components/common/bindings/BindingPicker.svelte index 93d9d62021..9daed00324 100644 --- a/packages/builder/src/components/common/bindings/BindingPicker.svelte +++ b/packages/builder/src/components/common/bindings/BindingPicker.svelte @@ -9,6 +9,7 @@ export let bindings export let mode export let allowHelpers + export let noPaddingTop = false let search = "" let popover @@ -47,9 +48,10 @@ }) $: filteredHelpers = helpers?.filter(helper => { return ( - !search || - helper.label.match(searchRgx) || - helper.description.match(searchRgx) + (!search || + helper.label.match(searchRgx) || + helper.description.match(searchRgx)) && + (mode.name !== "javascript" || helper.allowsJs) ) }) diff --git a/packages/builder/src/components/common/bindings/utils.js b/packages/builder/src/components/common/bindings/utils.js index 8d414ffed3..a086cd0394 100644 --- a/packages/builder/src/components/common/bindings/utils.js +++ b/packages/builder/src/components/common/bindings/utils.js @@ -1,38 +1,41 @@ -export function addHBSBinding(value, caretPos, binding) { - binding = typeof binding === "string" ? binding : binding.path - value = value == null ? "" : value +import { decodeJSBinding } from "@budibase/string-templates" +import { hbInsert, jsInsert } from "components/common/CodeEditor" - const left = caretPos?.start ? value.substring(0, caretPos.start) : "" - const right = caretPos?.end ? value.substring(caretPos.end) : "" - if (!left.includes("{{") || !right.includes("}}")) { - binding = `{{ ${binding} }}` +export class BindingHelpers { + constructor(getCaretPosition, insertAtPos, { disableWrapping } = {}) { + this.getCaretPosition = getCaretPosition + this.insertAtPos = insertAtPos + this.disableWrapping = disableWrapping } - if (caretPos.start) { - value = - value.substring(0, caretPos.start) + - binding + - value.substring(caretPos.end, value.length) - } else { - value += binding - } - return value -} -export function addJSBinding(value, caretPos, binding, { helper } = {}) { - binding = typeof binding === "string" ? binding : binding.path - value = value == null ? "" : value - if (!helper) { - binding = `$("${binding}")` - } else { - binding = `helpers.${binding}()` + // Adds a JS/HBS helper to the expression + onSelectHelper(value, helper, { js, dontDecode }) { + const pos = this.getCaretPosition() + const { start, end } = pos + if (js) { + const jsVal = dontDecode ? value : decodeJSBinding(value) + const insertVal = jsInsert(jsVal, start, end, helper.text, { + helper: true, + }) + this.insertAtPos({ start, end, value: insertVal }) + } else { + const insertVal = hbInsert(value, start, end, helper.text) + this.insertAtPos({ start, end, value: insertVal }) + } } - if (caretPos.start) { - value = - value.substring(0, caretPos.start) + - binding + - value.substring(caretPos.end, value.length) - } else { - value += binding + + // Adds a data binding to the expression + onSelectBinding(value, binding, { js, dontDecode }) { + const { start, end } = this.getCaretPosition() + if (js) { + const jsVal = dontDecode ? value : decodeJSBinding(value) + const insertVal = jsInsert(jsVal, start, end, binding.readableBinding, { + disableWrapping: this.disableWrapping, + }) + this.insertAtPos({ start, end, value: insertVal }) + } else { + const insertVal = hbInsert(value, start, end, binding.readableBinding) + this.insertAtPos({ start, end, value: insertVal }) + } } - return value } diff --git a/packages/builder/src/components/integration/QueryViewer.svelte b/packages/builder/src/components/integration/QueryViewer.svelte index e0d7013459..5025e1eec8 100644 --- a/packages/builder/src/components/integration/QueryViewer.svelte +++ b/packages/builder/src/components/integration/QueryViewer.svelte @@ -93,7 +93,13 @@ notifications.success("Query executed successfully") } catch (error) { - notifications.error(`Query Error: ${error.message}`) + if (typeof error.message === "string") { + notifications.error(`Query Error: ${error.message}`) + } else if (typeof error.message?.code === "string") { + notifications.error(`Query Error: ${error.message.code}`) + } else { + notifications.error(`Query Error: ${JSON.stringify(error.message)}`) + } if (!suppressErrors) { throw error diff --git a/packages/builder/src/components/portal/onboarding/TourPopover.svelte b/packages/builder/src/components/portal/onboarding/TourPopover.svelte index 34beccda6f..23821d73a3 100644 --- a/packages/builder/src/components/portal/onboarding/TourPopover.svelte +++ b/packages/builder/src/components/portal/onboarding/TourPopover.svelte @@ -66,7 +66,7 @@ })) navigateStep(target) } else { - console.log("Could not retrieve step") + console.warn("Could not retrieve step") } } else { if (typeof tourStep.onComplete === "function") { diff --git a/packages/builder/src/components/portal/onboarding/tourHandler.js b/packages/builder/src/components/portal/onboarding/tourHandler.js index acac21478e..42d35e6d45 100644 --- a/packages/builder/src/components/portal/onboarding/tourHandler.js +++ b/packages/builder/src/components/portal/onboarding/tourHandler.js @@ -3,7 +3,7 @@ import { get } from "svelte/store" const registerNode = async (node, tourStepKey) => { if (!node) { - console.log("Tour Handler - an anchor node is required") + console.warn("Tour Handler - an anchor node is required") } if (!get(builderStore).tourKey) { diff --git a/packages/builder/src/components/portal/onboarding/tours.js b/packages/builder/src/components/portal/onboarding/tours.js index d589a68a83..623a00fcc2 100644 --- a/packages/builder/src/components/portal/onboarding/tours.js +++ b/packages/builder/src/components/portal/onboarding/tours.js @@ -45,7 +45,7 @@ const endUserOnboarding = async ({ skipped = false } = {}) => { onboarding: false, })) } catch (e) { - console.log("Onboarding failed", e) + console.error("Onboarding failed", e) return false } return true diff --git a/packages/builder/src/constants/completions.js b/packages/builder/src/constants/completions.js index 32de934324..e539a8084a 100644 --- a/packages/builder/src/constants/completions.js +++ b/packages/builder/src/constants/completions.js @@ -1,4 +1,4 @@ -import { getManifest } from "@budibase/string-templates" +import { getManifest, helpersToRemoveForJs } from "@budibase/string-templates" export function handlebarsCompletions() { const manifest = getManifest() @@ -11,6 +11,9 @@ export function handlebarsCompletions() { label: helperName, displayText: helperName, description: helperConfig.description, + allowsJs: + !helperConfig.requiresBlock && + !helpersToRemoveForJs.includes(helperName), })) ) } diff --git a/packages/builder/src/dataBinding.js b/packages/builder/src/dataBinding.js index 589d3b3170..6ec6774b34 100644 --- a/packages/builder/src/dataBinding.js +++ b/packages/builder/src/dataBinding.js @@ -1,6 +1,7 @@ import { cloneDeep } from "lodash/fp" import { get } from "svelte/store" import { + buildContextTreeLookupMap, findAllComponents, findAllMatchingComponents, findComponent, @@ -22,12 +23,14 @@ import { decodeJSBinding, encodeJSBinding, } from "@budibase/string-templates" -import { TableNames } from "constants" -import { JSONUtils } from "@budibase/frontend-core" +import { TableNames } from "../constants" +import { JSONUtils, Constants } from "@budibase/frontend-core" import ActionDefinitions from "components/design/settings/controls/ButtonActionEditor/manifest.json" import { environment, licensing } from "stores/portal" import { convertOldFieldFormat } from "components/design/settings/controls/FieldConfiguration/utils" +const { ContextScopes } = Constants + // Regex to match all instances of template strings const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g const CAPTURE_VAR_INSIDE_JS = /\$\("([^")]+)"\)/g @@ -217,6 +220,9 @@ export const getComponentContexts = ( return [] } let map = {} + const componentPath = findComponentPath(asset.props, componentId) + const componentPathIds = componentPath.map(component => component._id) + const contextTreeLookupMap = buildContextTreeLookupMap(asset.props) // Processes all contexts exposed by a component const processContexts = scope => component => { @@ -224,13 +230,16 @@ export const getComponentContexts = ( if (!def?.context) { return } - if (!map[component._id]) { - map[component._id] = { - component, - definition: def, - contexts: [], - } + + // Filter out global contexts not in the same branch. + // Global contexts are only valid if their branch root is an ancestor of + // this component. + const branch = contextTreeLookupMap[component._id] + if (branch !== "root" && !componentPathIds.includes(branch)) { + return } + + // Process all contexts provided by this component const contexts = Array.isArray(def.context) ? def.context : [def.context] contexts.forEach(context => { // Ensure type matches @@ -238,7 +247,7 @@ export const getComponentContexts = ( return } // Ensure scope matches - let contextScope = context.scope || "global" + let contextScope = context.scope || ContextScopes.Global if (contextScope !== scope) { return } @@ -246,17 +255,23 @@ export const getComponentContexts = ( if (!isContextCompatibleWithComponent(context, component)) { return } + if (!map[component._id]) { + map[component._id] = { + component, + definition: def, + contexts: [], + } + } map[component._id].contexts.push(context) }) } // Process all global contexts const allComponents = findAllComponents(asset.props) - allComponents.forEach(processContexts("global")) + allComponents.forEach(processContexts(ContextScopes.Global)) - // Process all local contexts - const localComponents = findComponentPath(asset.props, componentId) - localComponents.forEach(processContexts("local")) + // Process all local contexts in the immediate tree + componentPath.forEach(processContexts(ContextScopes.Local)) // Exclude self if required if (!options?.includeSelf) { diff --git a/packages/builder/src/helpers/components.js b/packages/builder/src/helpers/components.js index a5ca1c1694..65e4f921e1 100644 --- a/packages/builder/src/helpers/components.js +++ b/packages/builder/src/helpers/components.js @@ -6,7 +6,10 @@ import { encodeJSBinding, findHBSBlocks, } from "@budibase/string-templates" -import { capitalise } from "./index" +import { capitalise } from "helpers" +import { Constants } from "@budibase/frontend-core" + +const { ContextScopes } = Constants /** * Recursively searches for a specific component ID @@ -222,8 +225,57 @@ export const getComponentName = component => { const components = get(componentStore)?.components || {} const componentDefinition = components[component._component] || {} - const name = - componentDefinition.friendlyName || componentDefinition.name || "" - - return name + return componentDefinition.friendlyName || componentDefinition.name || "" +} + +/** + * Recurses through the component tree and builds a tree of contexts provided + * by components. + */ +export const buildContextTree = ( + rootComponent, + tree = { root: [] }, + currentBranch = "root" +) => { + // Sanity check + if (!rootComponent) { + return tree + } + + // Process this component's contexts + const def = store.actions.components.getDefinition(rootComponent._component) + if (def?.context) { + tree[currentBranch].push(rootComponent._id) + const contexts = Array.isArray(def.context) ? def.context : [def.context] + + // If we provide local context, start a new branch for our children + if (contexts.some(context => context.scope === ContextScopes.Local)) { + currentBranch = rootComponent._id + tree[rootComponent._id] = [] + } + } + + // Process children + if (rootComponent._children) { + rootComponent._children.forEach(child => { + buildContextTree(child, tree, currentBranch) + }) + } + + return tree +} + +/** + * Generates a lookup map of which context branch all components in a component + * tree are inside. + */ +export const buildContextTreeLookupMap = rootComponent => { + const tree = buildContextTree(rootComponent) + let map = {} + Object.entries(tree).forEach(([branch, ids]) => { + ids.forEach(id => { + map[id] = branch + }) + }) + return map } diff --git a/packages/builder/src/helpers/urlStateSync.js b/packages/builder/src/helpers/urlStateSync.js index 2408dde2f1..9337393f06 100644 --- a/packages/builder/src/helpers/urlStateSync.js +++ b/packages/builder/src/helpers/urlStateSync.js @@ -52,7 +52,7 @@ export const syncURLToState = options => { let cachedPage = get(routify.page) let previousParamsHash = null let debug = false - const log = (...params) => debug && console.log(`[${urlParam}]`, ...params) + const log = (...params) => debug && console.debug(`[${urlParam}]`, ...params) // Navigate to a certain URL const gotoUrl = (url, params) => { diff --git a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte index 3b07bde394..77f1ed55b5 100644 --- a/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/_components/BuilderSidePanel.svelte @@ -106,7 +106,7 @@ return } if (!prodAppId) { - console.log("Application id required") + console.error("Application id required") return } await usersFetch.update({ diff --git a/packages/builder/src/pages/builder/app/[application]/data/datasource/[datasourceId]/index.svelte b/packages/builder/src/pages/builder/app/[application]/data/datasource/[datasourceId]/index.svelte index 338d45d3de..b723788695 100644 --- a/packages/builder/src/pages/builder/app/[application]/data/datasource/[datasourceId]/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/data/datasource/[datasourceId]/index.svelte @@ -12,12 +12,16 @@ import PromptQueryModal from "./_components/PromptQueryModal.svelte" import SettingsPanel from "./_components/panels/Settings.svelte" import { helpers } from "@budibase/shared-core" + import { admin } from "stores/portal" + import { IntegrationTypes } from "constants/backend" let selectedPanel = null let panelOptions = [] $: datasource = $datasources.selected + $: isCloud = $admin.cloud + $: isPostgres = datasource?.source === IntegrationTypes.POSTGRES $: getOptions(datasource) const getOptions = datasource => { @@ -41,7 +45,13 @@ } // always the last option for SQL if (helpers.isSQL(datasource)) { - panelOptions.push("Settings") + if (isCloud && isPostgres) { + // We don't show the settings panel for Postgres on Budicloud because + // it requires pg_dump to work and we don't want to enable shell injection + // attacks. + } else { + panelOptions.push("Settings") + } } } diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Navigation/index.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Navigation/index.svelte index 7f118fb034..0aebfc97bd 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Navigation/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Navigation/index.svelte @@ -15,6 +15,7 @@ Checkbox, notifications, Select, + Combobox, } from "@budibase/bbui" import { themeStore, @@ -24,6 +25,10 @@ } from "stores/builder" import { DefaultAppTheme } from "constants" + $: screenRouteOptions = $store.screens + .map(screen => screen.routing?.route) + .filter(x => x != null) + const updateShowNavigation = async e => { await screenStore.updateSetting( get(selectedScreen), @@ -112,23 +117,6 @@ on:change={e => update("navWidth", e.detail)} /> {/if} -
- -
- update("hideLogo", !e.detail)} - /> - {#if !$navigationStore.hideLogo} -
- -
- update("logoUrl", e.detail)} - updateOnChange={false} - /> - {/if}
@@ -165,6 +153,47 @@ />
+ +
+
+
+ Logo +
+
+
+ +
+ update("hideLogo", !e.detail)} + /> + {#if !$store.navigation.hideLogo} +
+ +
+ update("logoUrl", e.detail)} + updateOnChange={false} + /> +
+ +
+ update("logoLinkUrl", e.detail)} + options={screenRouteOptions} + /> +
+ +
+ update("openLogoLinkInNewTab", !!e.detail)} + /> + {/if} +
+
{/if} diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Screen/GeneralPanel.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Screen/GeneralPanel.svelte index 332289f42e..cecfdba858 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Screen/GeneralPanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/[componentId]/_components/Screen/GeneralPanel.svelte @@ -66,7 +66,7 @@ try { await screenStore.updateSetting(get(selectedScreen), key, value) } catch (error) { - console.log(error) + console.error(error) notifications.error("Error saving screen settings") } } diff --git a/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/CreateScreenModal.svelte b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/CreateScreenModal.svelte index ca0c247b3b..eadd9d4257 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/CreateScreenModal.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/_components/NewScreen/CreateScreenModal.svelte @@ -70,7 +70,7 @@ $goto(`./${screenId}`) screenStore.select(screenId) } catch (error) { - console.log(error) + console.error(error) notifications.error("Error creating screens") } } diff --git a/packages/builder/src/pages/builder/app/[application]/settings/automations/index.svelte b/packages/builder/src/pages/builder/app/[application]/settings/automations/index.svelte index 715c154155..d421645ede 100644 --- a/packages/builder/src/pages/builder/app/[application]/settings/automations/index.svelte +++ b/packages/builder/src/pages/builder/app/[application]/settings/automations/index.svelte @@ -36,15 +36,12 @@ let status = null let timeRange = null let loaded = false - - $: app = $apps.find(app => app.devId === $appStore.appId?.includes(app.appId)) + $: app = $apps.find(app => $appStore.appId?.includes(app.appId)) $: licensePlan = $auth.user?.license?.plan $: page = $pageInfo.page $: fetchLogs(automationId, status, page, timeRange) $: isCloud = $admin.cloud - $: chainAutomations = app?.automations?.chainAutomations ?? !isCloud - const timeOptions = [ { value: "90-d", label: "Past 90 days" }, { value: "30-d", label: "Past 30 days" }, diff --git a/packages/builder/src/pages/builder/app/[application]/settings/backups/_components/ActionsRenderer.svelte b/packages/builder/src/pages/builder/app/[application]/settings/backups/_components/ActionsRenderer.svelte index b23cda548f..ffdf14fdce 100644 --- a/packages/builder/src/pages/builder/app/[application]/settings/backups/_components/ActionsRenderer.svelte +++ b/packages/builder/src/pages/builder/app/[application]/settings/backups/_components/ActionsRenderer.svelte @@ -12,7 +12,8 @@ import ConfirmDialog from "components/common/ConfirmDialog.svelte" import CreateRestoreModal from "./CreateRestoreModal.svelte" import { createEventDispatcher } from "svelte" - import { isOnlyUser } from "stores/builder" + import { isOnlyUser } from "builderStore" + import { BackupType } from "constants/backend/backups" export let row @@ -42,12 +43,11 @@
- -
- -
- - {#if row.type !== "restore"} + {#if row.type !== BackupType.RESTORE} + +
+ +
Delete Download - {/if} -
+
+ {/if}
diff --git a/packages/builder/src/pages/builder/auth/login.svelte b/packages/builder/src/pages/builder/auth/login.svelte index 0ba7e6448b..7bb2a3ad49 100644 --- a/packages/builder/src/pages/builder/auth/login.svelte +++ b/packages/builder/src/pages/builder/auth/login.svelte @@ -31,7 +31,7 @@ async function login() { form.validate() if (Object.keys(errors).length > 0) { - console.log("errors", errors) + console.error("errors", errors) return } try { diff --git a/packages/builder/src/stores/builder/websocket.js b/packages/builder/src/stores/builder/websocket.js index 4b6874a602..bbdf7b6d63 100644 --- a/packages/builder/src/stores/builder/websocket.js +++ b/packages/builder/src/stores/builder/websocket.js @@ -25,7 +25,7 @@ export const createBuilderWebsocket = appId => { }) }) socket.on("connect_error", err => { - console.log("Failed to connect to builder websocket:", err.message) + console.error("Failed to connect to builder websocket:", err.message) }) socket.on("disconnect", () => { userStore.actions.reset() diff --git a/packages/client/manifest.json b/packages/client/manifest.json index 5bbf465766..64d9366423 100644 --- a/packages/client/manifest.json +++ b/packages/client/manifest.json @@ -4720,7 +4720,8 @@ } ], "context": { - "type": "schema" + "type": "schema", + "scope": "local" } }, "daterangepicker": { @@ -6742,6 +6743,17 @@ "key": "disabled", "defaultValue": false }, + { + "type": "boolean", + "label": "Read only", + "key": "readonly", + "defaultValue": false, + "dependsOn": { + "setting": "disabled", + "value": true, + "invert": true + } + }, { "type": "select", "label": "Layout", diff --git a/packages/client/src/components/app/Layout.svelte b/packages/client/src/components/app/Layout.svelte index 68767c9bfd..903eb23ae6 100644 --- a/packages/client/src/components/app/Layout.svelte +++ b/packages/client/src/components/app/Layout.svelte @@ -34,6 +34,8 @@ export let navTextColor export let navWidth export let pageWidth + export let logoLinkUrl + export let openLogoLinkInNewTab export let embedded = false @@ -151,6 +153,16 @@ } return style } + + const getSanitizedUrl = (url, openInNewTab) => { + if (!isInternal(url)) { + return ensureExternal(url) + } + if (openInNewTab) { + return `#${url}` + } + return url + }