diff --git a/.prettierignore b/.prettierignore index 3f27c3f9da..13f012fd89 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,4 @@ -packages/builder/src/userInterface/CurrentItemPreview.svelte +packages/builder/src/components/design/AppPreview/CurrentItemPreview.svelte public dist packages/server/builder diff --git a/packages/builder/src/builderStore/dataBinding.js b/packages/builder/src/builderStore/dataBinding.js new file mode 100644 index 0000000000..004c7170d3 --- /dev/null +++ b/packages/builder/src/builderStore/dataBinding.js @@ -0,0 +1,209 @@ +import { cloneDeep } from "lodash/fp" +import { get } from "svelte/store" +import { backendUiStore, store } from "builderStore" +import { findAllMatchingComponents, findComponentPath } from "./storeUtils" + +// Regex to match all instances of template strings +const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g + +/** + * Gets all bindable data context fields and instance fields. + */ +export const getBindableProperties = (rootComponent, componentId) => { + const contextBindings = getContextBindings(rootComponent, componentId) + const componentBindings = getComponentBindings(rootComponent) + return [...contextBindings, ...componentBindings] +} + +/** + * Gets all data provider components above a component. + */ +export const getDataProviderComponents = (rootComponent, componentId) => { + if (!rootComponent || !componentId) { + return [] + } + + // Get the component tree leading up to this component, ignoring the component + // itself + const path = findComponentPath(rootComponent, componentId) + path.pop() + + // Filter by only data provider components + return path.filter(component => { + const def = store.actions.components.getDefinition(component._component) + return def?.dataProvider + }) +} + +/** + * Gets a datasource object for a certain data provider component + */ +export const getDatasourceForProvider = component => { + const def = store.actions.components.getDefinition(component?._component) + if (!def) { + return null + } + + // Extract datasource from component instance + const datasourceSetting = def.settings.find(setting => { + return setting.key === def.datasourceSetting + }) + if (!datasourceSetting) { + return null + } + + // There are different types of setting which can be a datasource, for + // example an actual datasource object, or a table ID string. + // Convert the datasource setting into a proper datasource object so that + // we can use it properly + if (datasourceSetting.type === "datasource") { + return component[datasourceSetting?.key] + } else if (datasourceSetting.type === "table") { + return { + tableId: component[datasourceSetting?.key], + type: "table", + } + } + return null +} + +/** + * Gets all bindable data contexts. These are fields of schemas of data contexts + * provided by data provider components, such as lists or row detail components. + */ +export const getContextBindings = (rootComponent, componentId) => { + // Extract any components which provide data contexts + const dataProviders = getDataProviderComponents(rootComponent, componentId) + let contextBindings = [] + dataProviders.forEach(component => { + const datasource = getDatasourceForProvider(component) + if (!datasource) { + return + } + + // Get schema and add _id and _rev fields for certain types + let { schema, table } = getSchemaForDatasource(datasource) + if (!schema || !table) { + return + } + if (datasource.type === "table" || datasource.type === "link") { + schema["_id"] = { type: "string" } + schema["_rev"] = { type: "string " } + } + const keys = Object.keys(schema).sort() + + // Create bindable properties for each schema field + keys.forEach(key => { + const fieldSchema = schema[key] + // Replace certain bindings with a new property to help display components + let runtimeBoundKey = key + if (fieldSchema.type === "link") { + runtimeBoundKey = `${key}_count` + } else if (fieldSchema.type === "attachment") { + runtimeBoundKey = `${key}_first` + } + + contextBindings.push({ + type: "context", + runtimeBinding: `${component._id}.${runtimeBoundKey}`, + readableBinding: `${component._instanceName}.${table.name}.${key}`, + fieldSchema, + providerId: component._id, + tableId: datasource.tableId, + field: key, + }) + }) + }) + return contextBindings +} + +/** + * Gets all bindable components. These are form components which allow their + * values to be bound to. + */ +export const getComponentBindings = rootComponent => { + if (!rootComponent) { + return [] + } + const componentSelector = component => { + const type = component._component + const definition = store.actions.components.getDefinition(type) + return definition?.bindable + } + const components = findAllMatchingComponents(rootComponent, componentSelector) + return components.map(component => { + return { + type: "instance", + providerId: component._id, + runtimeBinding: `${component._id}`, + readableBinding: `${component._instanceName}`, + } + }) +} + +/** + * Gets a schema for a datasource object. + */ +export const getSchemaForDatasource = datasource => { + let schema, table + if (datasource) { + const { type } = datasource + if (type === "query") { + const queries = get(backendUiStore).queries + table = queries.find(query => query._id === datasource._id) + } else { + const tables = get(backendUiStore).tables + table = tables.find(table => table._id === datasource.tableId) + } + if (table) { + if (type === "view") { + schema = cloneDeep(table.views?.[datasource.name]?.schema) + } else { + schema = cloneDeep(table.schema) + } + } + } + return { schema, table } +} + +/** + * Converts a readable data binding into a runtime data binding + */ +export function readableToRuntimeBinding(bindableProperties, textWithBindings) { + if (typeof textWithBindings !== "string") { + return textWithBindings + } + const boundValues = textWithBindings.match(CAPTURE_VAR_INSIDE_TEMPLATE) || [] + let result = textWithBindings + boundValues.forEach(boundValue => { + const binding = bindableProperties.find(({ readableBinding }) => { + return boundValue === `{{ ${readableBinding} }}` + }) + if (binding) { + result = result.replace(boundValue, `{{ ${binding.runtimeBinding} }}`) + } + }) + return result +} + +/** + * Converts a runtime data binding into a readable data binding + */ +export function runtimeToReadableBinding(bindableProperties, textWithBindings) { + if (typeof textWithBindings !== "string") { + return textWithBindings + } + const boundValues = textWithBindings.match(CAPTURE_VAR_INSIDE_TEMPLATE) || [] + let result = textWithBindings + boundValues.forEach(boundValue => { + const binding = bindableProperties.find(({ runtimeBinding }) => { + return boundValue === `{{ ${runtimeBinding} }}` + }) + // Show invalid bindings as invalid rather than a long ID + result = result.replace( + boundValue, + `{{ ${binding?.readableBinding ?? "Invalid binding"} }}` + ) + }) + return result +} diff --git a/packages/builder/src/builderStore/fetchBindableProperties.js b/packages/builder/src/builderStore/fetchBindableProperties.js deleted file mode 100644 index 9302f3c0f0..0000000000 --- a/packages/builder/src/builderStore/fetchBindableProperties.js +++ /dev/null @@ -1,205 +0,0 @@ -import { cloneDeep, difference } from "lodash/fp" - -/** - * parameter for fetchBindableProperties function - * @typedef {Object} fetchBindablePropertiesParameter - * @property {string} componentInstanceId - an _id of a component that has been added to a screen, which you want to fetch bindable props for - * @propperty {Object} screen - current screen - where componentInstanceId lives - * @property {Object} components - dictionary of component definitions - * @property {Array} tables - array of all tables - */ - -/** - * - * @typedef {Object} BindableProperty - * @property {string} type - either "instance" (binding to a component instance) or "context" (binding to data in context e.g. List Item) - * @property {Object} instance - relevant component instance. If "context" type, this instance is the component that provides the context... e.g. the List - * @property {string} runtimeBinding - a binding string that is a) saved against the string, and b) used at runtime to read/write the value - * @property {string} readableBinding - a binding string that is displayed to the user, in the builder - */ - -/** - * Generates all allowed bindings from within any particular component instance - * @param {fetchBindablePropertiesParameter} param - * @returns {Array.} - */ -export default function({ - componentInstanceId, - screen, - components, - tables, - queries, -}) { - const result = walk({ - // cloning so we are free to mutate props (e.g. by adding _contexts) - instance: cloneDeep(screen.props), - targetId: componentInstanceId, - components, - tables, - queries, - }) - - return [ - ...result.bindableInstances - .filter(isInstanceInSharedContext(result)) - .map(componentInstanceToBindable), - ...(result.target?._contexts.map(contextToBindables(tables)).flat() ?? []), - ...(result.target?._contexts - .map(context => queriesToBindables(queries, context)) - .flat() ?? []), - ] -} - -const isInstanceInSharedContext = walkResult => i => - // should cover - // - neither are in any context - // - both in same context - // - instance is in ancestor context of target - i.instance._contexts.length <= walkResult.target._contexts.length && - difference(i.instance._contexts, walkResult.target._contexts).length === 0 - -// turns a component instance prop into binding expressions -// used by the UI -const componentInstanceToBindable = i => { - return { - type: "instance", - instance: i.instance, - // how the binding expression persists, and is used in the app at runtime - runtimeBinding: `${i.instance._id}`, - // how the binding exressions looks to the user of the builder - readableBinding: `${i.instance._instanceName}`, - } -} - -const queriesToBindables = (queries, context) => { - let queryId = context.table._id - - const query = queries.find(query => query._id === queryId) - let schema = query?.schema - - // Avoid crashing whenever no data source has been selected - if (!schema) { - return [] - } - - const queryBindings = Object.entries(schema).map(([key, value]) => ({ - type: "context", - fieldSchema: value, - instance: context.instance, - // how the binding expression persists, and is used in the app at runtime - runtimeBinding: `${context.instance._id}.${key}`, - // how the binding expressions looks to the user of the builder - readableBinding: `${context.instance._instanceName}.${query.name}.${key}`, - // table / view info - table: context.table, - })) - - return queryBindings -} - -const contextToBindables = tables => context => { - let tableId = context.table?.tableId ?? context.table - - const table = tables.find(table => table._id === tableId || context.table._id) - let schema = - context.table?.type === "view" - ? table?.views?.[context.table.name]?.schema - : table?.schema - - // Avoid crashing whenever no data source has been selected - if (!schema) { - return [] - } - - const newBindable = ([key, fieldSchema]) => { - // Replace certain bindings with a new property to help display components - let runtimeBoundKey = key - if (fieldSchema.type === "link") { - runtimeBoundKey = `${key}_count` - } else if (fieldSchema.type === "attachment") { - runtimeBoundKey = `${key}_first` - } - return { - type: "context", - fieldSchema, - instance: context.instance, - // how the binding expression persists, and is used in the app at runtime - runtimeBinding: `${context.instance._id}.${runtimeBoundKey}`, - // how the binding expressions looks to the user of the builder - readableBinding: `${context.instance._instanceName}.${table.name}.${key}`, - // table / view info - table: context.table, - } - } - - const stringType = { type: "string" } - return ( - Object.entries(schema) - .map(newBindable) - // add _id and _rev fields - not part of schema, but always valid - .concat([ - newBindable(["_id", stringType]), - newBindable(["_rev", stringType]), - ]) - ) -} - -const walk = ({ instance, targetId, components, tables, result }) => { - if (!result) { - result = { - target: null, - bindableInstances: [], - allContexts: [], - currentContexts: [], - } - } - - if (!instance._contexts) instance._contexts = [] - - // "component" is the component definition (object in component.json) - const component = components[instance._component] - - if (instance._id === targetId) { - // found it - result.target = instance - } else { - if (component && component.bindable) { - // pushing all components in here initially - // but this will not be correct, as some of - // these components will be in another context - // but we dont know this until the end of the walk - // so we will filter in another method - result.bindableInstances.push({ - instance, - prop: component.bindable, - }) - } - } - - // a component that provides context to it's children - const contextualInstance = - component && component.context && instance[component.context] - - if (contextualInstance) { - // add to currentContexts (ancestory of context) - // before walking children - const table = instance[component.context] - result.currentContexts.push({ instance, table }) - } - - const currentContexts = [...result.currentContexts] - for (let child of instance._children || []) { - // attaching _contexts of components, for easy comparison later - // these have been deep cloned above, so shouldn't modify the - // original component instances - child._contexts = currentContexts - walk({ instance: child, targetId, components, tables, result }) - } - - if (contextualInstance) { - // child walk done, remove from currentContexts - result.currentContexts.pop() - } - - return result -} diff --git a/packages/builder/src/builderStore/getNewComponentName.js b/packages/builder/src/builderStore/getNewComponentName.js deleted file mode 100644 index 98ca05b827..0000000000 --- a/packages/builder/src/builderStore/getNewComponentName.js +++ /dev/null @@ -1,48 +0,0 @@ -import { walkProps } from "./storeUtils" -import { get_capitalised_name } from "../helpers" -import { get } from "svelte/store" -import { allScreens } from "builderStore" -import { FrontendTypes } from "../constants" -import { currentAsset } from "." - -export default function(component, state) { - const capitalised = get_capitalised_name( - component.name || component._component - ) - - const matchingComponents = [] - - const findMatches = props => { - walkProps(props, c => { - const thisInstanceName = get_capitalised_name(c._instanceName) - if ((thisInstanceName || "").startsWith(capitalised)) { - matchingComponents.push(thisInstanceName) - } - }) - } - - // check layouts first - for (let layout of state.layouts) { - findMatches(layout.props) - } - - // if viewing screen, check current screen for duplicate - if (state.currentFrontEndType === FrontendTypes.SCREEN) { - findMatches(get(currentAsset).props) - } else { - // viewing a layout - need to find against all screens - for (let screen of get(allScreens)) { - findMatches(screen.props) - } - } - - let index = 1 - let name - while (!name) { - const tryName = `${capitalised || "Copy"} ${index}` - if (!matchingComponents.includes(tryName)) name = tryName - index++ - } - - return name -} diff --git a/packages/builder/src/builderStore/index.js b/packages/builder/src/builderStore/index.js index ca2864aeaf..2e9ec1166c 100644 --- a/packages/builder/src/builderStore/index.js +++ b/packages/builder/src/builderStore/index.js @@ -7,7 +7,7 @@ import { getThemeStore } from "./store/theme" import { derived, writable } from "svelte/store" import analytics from "analytics" import { FrontendTypes, LAYOUT_NAMES } from "../constants" -import { makePropsSafe } from "components/userInterface/assetParsing/createProps" +import { findComponent } from "./storeUtils" export const store = getFrontendStore() export const backendUiStore = getBackendUiStore() @@ -28,31 +28,10 @@ export const currentAsset = derived(store, $store => { export const selectedComponent = derived( [store, currentAsset], ([$store, $currentAsset]) => { - if (!$currentAsset || !$store.selectedComponentId) return null - - function traverse(node, callback) { - if (node._id === $store.selectedComponentId) return callback(node) - - if (node._children) { - node._children.forEach(child => traverse(child, callback)) - } - - if (node.props) { - traverse(node.props, callback) - } + if (!$currentAsset || !$store.selectedComponentId) { + return null } - - let component - traverse($currentAsset, found => { - const componentIdentifier = found._component ?? found.props._component - const componentDef = componentIdentifier.startsWith("##") - ? found - : $store.components[componentIdentifier] - - component = makePropsSafe(componentDef, found) - }) - - return component + return findComponent($currentAsset.props, $store.selectedComponentId) } ) diff --git a/packages/builder/src/builderStore/loadComponentLibraries.js b/packages/builder/src/builderStore/loadComponentLibraries.js index 9d534f86fe..e6516f7d79 100644 --- a/packages/builder/src/builderStore/loadComponentLibraries.js +++ b/packages/builder/src/builderStore/loadComponentLibraries.js @@ -21,12 +21,9 @@ export const fetchComponentLibDefinitions = async appId => { */ export const fetchComponentLibModules = async application => { const allLibraries = {} - for (let libraryName of application.componentLibraries) { const LIBRARY_URL = `/${application._id}/componentlibrary?library=${libraryName}` - const libraryModule = await import(LIBRARY_URL) - allLibraries[libraryName] = libraryModule + allLibraries[libraryName] = await import(LIBRARY_URL) } - return allLibraries } diff --git a/packages/builder/src/builderStore/replaceBindings.js b/packages/builder/src/builderStore/replaceBindings.js deleted file mode 100644 index 3afda0c658..0000000000 --- a/packages/builder/src/builderStore/replaceBindings.js +++ /dev/null @@ -1,39 +0,0 @@ -export const CAPTURE_VAR_INSIDE_TEMPLATE = /{{([^}]+)}}/g - -export function readableToRuntimeBinding(bindableProperties, textWithBindings) { - // Find all instances of template strings - const boundValues = textWithBindings.match(CAPTURE_VAR_INSIDE_TEMPLATE) - - let result = textWithBindings - // Replace readableBindings with runtimeBindings - boundValues && - boundValues.forEach(boundValue => { - const binding = bindableProperties.find(({ readableBinding }) => { - return boundValue === `{{ ${readableBinding} }}` - }) - if (binding) { - result = result.replace(boundValue, `{{ ${binding.runtimeBinding} }}`) - } - }) - return result -} - -export function runtimeToReadableBinding(bindableProperties, textWithBindings) { - let temp = textWithBindings - const boundValues = - (typeof textWithBindings === "string" && - textWithBindings.match(CAPTURE_VAR_INSIDE_TEMPLATE)) || - [] - - // Replace runtimeBindings with readableBindings: - boundValues.forEach(v => { - const binding = bindableProperties.find(({ runtimeBinding }) => { - return v === `{{ ${runtimeBinding} }}` - }) - if (binding) { - temp = temp.replace(v, `{{ ${binding.readableBinding} }}`) - } - }) - - return temp -} diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index 2082a3e49b..340e2829aa 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -1,9 +1,5 @@ import { get, writable } from "svelte/store" import { cloneDeep } from "lodash/fp" -import { - createProps, - getBuiltin, -} from "components/userInterface/assetParsing/createProps" import { allScreens, backendUiStore, @@ -15,15 +11,10 @@ import { } from "builderStore" import { fetchComponentLibDefinitions } from "../loadComponentLibraries" import api from "../api" -import { FrontendTypes } from "../../constants" -import getNewComponentName from "../getNewComponentName" +import { FrontendTypes } from "constants" import analytics from "analytics" -import { - findChildComponentType, - generateNewIdsForComponent, - getComponentDefinition, - findParent, -} from "../storeUtils" +import { findComponentType, findComponentParent } from "../storeUtils" +import { uuid } from "../uuid" const INITIAL_FRONTEND_STATE = { apps: [], @@ -50,37 +41,27 @@ export const getFrontendStore = () => { store.actions = { initialise: async pkg => { const { layouts, screens, application } = pkg - - store.update(state => { - state.appId = application._id - return state - }) - - const components = await fetchComponentLibDefinitions(pkg.application._id) - + const components = await fetchComponentLibDefinitions(application._id) store.update(state => ({ ...state, - libraries: pkg.application.componentLibraries, + libraries: application.componentLibraries, components, - name: pkg.application.name, - url: pkg.application.url, - description: pkg.application.description, - appId: pkg.application._id, + name: application.name, + description: application.description, + appId: application._id, + url: application.url, layouts, screens, hasAppPackage: true, - builtins: [getBuiltin("##builtin/screenslot")], - appInstance: pkg.application.instance, + appInstance: application.instance, })) - await hostingStore.actions.fetch() - await backendUiStore.actions.database.select(pkg.application.instance) + await backendUiStore.actions.database.select(application.instance) }, routing: { fetch: async () => { const response = await api.get("/api/routing") const json = await response.json() - store.update(state => { state.routes = json.routes return state @@ -243,128 +224,231 @@ export const getFrontendStore = () => { }, components: { select: component => { + if (!component) { + return + } + + // If this is the root component, select the asset instead + const asset = get(currentAsset) + const parent = findComponentParent(asset.props, component._id) + if (parent == null) { + const state = get(store) + const isLayout = state.currentFrontEndType === FrontendTypes.LAYOUT + if (isLayout) { + store.actions.layouts.select(asset._id) + } else { + store.actions.screens.select(asset._id) + } + return + } + + // Otherwise select the component store.update(state => { state.selectedComponentId = component._id state.currentView = "component" return state }) }, - create: (componentToAdd, presetProps) => { - const selectedAsset = get(currentAsset) + getDefinition: componentName => { + if (!componentName) { + return null + } + if (!componentName.startsWith("@budibase")) { + componentName = `@budibase/standard-components/${componentName}` + } + return get(store).components[componentName] + }, + createInstance: (componentName, presetProps) => { + const definition = store.actions.components.getDefinition(componentName) + if (!definition) { + return null + } - store.update(state => { - function findSlot(component_array) { - if (!component_array) { - return false + // Generate default props + let props = { ...presetProps } + if (definition.settings) { + definition.settings.forEach(setting => { + if (setting.defaultValue !== undefined) { + props[setting.key] = setting.defaultValue } - for (let component of component_array) { - if (component._component === "##builtin/screenslot") { - return true - } - - if (component._children) findSlot(component) - } - return false - } - - if ( - componentToAdd.startsWith("##") && - findSlot(selectedAsset?.props._children) - ) { - return state - } - - const component = getComponentDefinition(state, componentToAdd) - - const instanceId = get(backendUiStore).selectedDatabase._id - const instanceName = getNewComponentName(component, state) - - const newComponent = createProps(component, { - ...presetProps, - _instanceId: instanceId, - _instanceName: instanceName, }) + } - const selected = get(selectedComponent) + // Add any extra properties the component needs + let extras = {} + if (definition.hasChildren) { + extras._children = [] + } - const currentComponentDefinition = - state.components[selected._component] + return { + _id: uuid(), + _component: definition.component, + _styles: { normal: {}, hover: {}, active: {} }, + _instanceName: `New ${definition.name}`, + ...cloneDeep(props), + ...extras, + } + }, + create: (componentName, presetProps) => { + const selected = get(selectedComponent) + const asset = get(currentAsset) + const state = get(store) - const allowsChildren = currentComponentDefinition.children + // Only allow one screen slot, and in the layout + if (componentName.endsWith("screenslot")) { + const isLayout = state.currentFrontEndType === FrontendTypes.LAYOUT + const slot = findComponentType(asset.props, componentName) + if (!isLayout || slot != null) { + return + } + } - // Determine where to put the new component. - let targetParent - if (allowsChildren) { - // Child of the selected component - targetParent = selected + // Create new component + const componentInstance = store.actions.components.createInstance( + componentName, + presetProps + ) + if (!componentInstance) { + return + } + + // Find parent node to attach this component to + let parentComponent + + if (!asset) { + return + } + if (selected) { + // Use current screen or layout as parent if no component is selected + const definition = store.actions.components.getDefinition( + selected._component + ) + if (definition?.hasChildren) { + // Use selected component if it allows children + parentComponent = selected } else { - // Sibling of selected component - targetParent = findParent(selectedAsset.props, selected) + // Otherwise we need to use the parent of this component + parentComponent = findComponentParent(asset.props, selected._id) } + } else { + // Use screen or layout if no component is selected + parentComponent = asset.props + } - // Don't continue if there's no parent - if (!targetParent) return state - - // Push the new component - targetParent._children.push(newComponent.props) - - store.actions.preview.saveSelected() + // Attach component + if (!parentComponent) { + return + } + if (!parentComponent._children) { + parentComponent._children = [] + } + parentComponent._children.push(componentInstance) + // Save components and update UI + store.actions.preview.saveSelected() + store.update(state => { state.currentView = "component" - state.selectedComponentId = newComponent.props._id - - analytics.captureEvent("Added Component", { - name: newComponent.props._component, - }) + state.selectedComponentId = componentInstance._id return state }) + + // Log event + analytics.captureEvent("Added Component", { + name: componentInstance._component, + }) + + return componentInstance + }, + delete: component => { + if (!component) { + return + } + const asset = get(currentAsset) + if (!asset) { + return + } + const parent = findComponentParent(asset.props, component._id) + if (parent) { + parent._children = parent._children.filter( + child => child._id !== component._id + ) + store.actions.components.select(parent) + } + store.actions.preview.saveSelected() }, copy: (component, cut = false) => { const selectedAsset = get(currentAsset) + if (!selectedAsset) { + return null + } + + // Update store with copied component store.update(state => { state.componentToPaste = cloneDeep(component) state.componentToPaste.isCut = cut - if (cut) { - const parent = findParent(selectedAsset.props, component._id) + return state + }) + + // Remove the component from its parent if we're cutting + if (cut) { + const parent = findComponentParent(selectedAsset.props, component._id) + if (parent) { parent._children = parent._children.filter( child => child._id !== component._id ) store.actions.components.select(parent) } - - return state - }) + } }, paste: async (targetComponent, mode) => { - const selectedAsset = get(currentAsset) let promises = [] store.update(state => { - if (!state.componentToPaste) return state - - const componentToPaste = cloneDeep(state.componentToPaste) - // retain the same ids as things may be referencing this component - if (componentToPaste.isCut) { - // in case we paste a second time - state.componentToPaste.isCut = false - } else { - generateNewIdsForComponent(componentToPaste, state) - } - delete componentToPaste.isCut - - if (mode === "inside") { - targetComponent._children.push(componentToPaste) + // Stop if we have nothing to paste + if (!state.componentToPaste) { return state } - const parent = findParent(selectedAsset.props, targetComponent) + // Clone the component to paste + // Retain the same ID if cutting as things may be referencing this component + const cut = state.componentToPaste.isCut + delete state.componentToPaste.isCut + let componentToPaste = cloneDeep(state.componentToPaste) + if (cut) { + state.componentToPaste = null + } else { + componentToPaste._id = uuid() + } - const targetIndex = parent._children.indexOf(targetComponent) - const index = mode === "above" ? targetIndex : targetIndex + 1 - parent._children.splice(index, 0, cloneDeep(componentToPaste)) + if (mode === "inside") { + // Paste inside target component if chosen + if (!targetComponent._children) { + targetComponent._children = [] + } + targetComponent._children.push(componentToPaste) + } else { + // Otherwise find the parent so we can paste in the correct order + // in the parents child components + const selectedAsset = get(currentAsset) + if (!selectedAsset) { + return state + } + const parent = findComponentParent( + selectedAsset.props, + targetComponent._id + ) + if (!parent) { + return state + } + // Insert the component in the correct position + const targetIndex = parent._children.indexOf(targetComponent) + const index = mode === "above" ? targetIndex : targetIndex + 1 + parent._children.splice(index, 0, cloneDeep(componentToPaste)) + } + + // Save and select the new component promises.push(store.actions.preview.saveSelected()) store.actions.components.select(componentToPaste) - return state }) await Promise.all(promises) @@ -389,90 +473,56 @@ export const getFrontendStore = () => { await store.actions.preview.saveSelected() }, updateProp: (name, value) => { + let component = get(selectedComponent) + if (!name || !component) { + return + } + component[name] = value store.update(state => { - let current_component = get(selectedComponent) - current_component[name] = value - - state.selectedComponentId = current_component._id - store.actions.preview.saveSelected() + state.selectedComponentId = component._id return state }) - }, - findRoute: component => { - // Gets all the components to needed to construct a path. - const selectedAsset = get(currentAsset) - let pathComponents = [] - let parent = component - let root = false - while (!root) { - parent = findParent(selectedAsset.props, parent) - if (!parent) { - root = true - } else { - pathComponents.push(parent) - } - } - - // Remove root entry since it's the screen or layout. - // Reverse array since we need the correct order of the IDs - const reversedComponents = pathComponents.reverse().slice(1) - - // Add component - const allComponents = [...reversedComponents, component] - - // Map IDs - const IdList = allComponents.map(c => c._id) - - // Construct ID Path: - return IdList.join("/") + store.actions.preview.saveSelected() }, links: { save: async (url, title) => { - let promises = [] const layout = get(mainLayout) - store.update(state => { - // Try to extract a nav component from the master layout - const nav = findChildComponentType( - layout, - "@budibase/standard-components/navigation" - ) - if (nav) { - let newLink + if (!layout) { + return + } - // Clone an existing link if one exists - if (nav._children && nav._children.length) { - // Clone existing link style - newLink = cloneDeep(nav._children[0]) + // Find a nav bar in the main layout + const nav = findComponentType( + layout.props, + "@budibase/standard-components/navigation" + ) + if (!nav) { + return + } - // Manipulate IDs to ensure uniqueness - generateNewIdsForComponent(newLink, state, false) + let newLink + if (nav._children && nav._children.length) { + // Clone an existing link if one exists + newLink = cloneDeep(nav._children[0]) - // Set our new props - newLink._instanceName = `${title} Link` - newLink.url = url - newLink.text = title - } else { - // Otherwise create vanilla new link - const component = getComponentDefinition( - state, - "@budibase/standard-components/link" - ) - const instanceId = get(backendUiStore).selectedDatabase._id - newLink = createProps(component, { - url, - text: title, - _instanceName: `${title} Link`, - _instanceId: instanceId, - }).props - } - - // Save layout - nav._children = [...nav._children, newLink] - promises.push(store.actions.layouts.save(layout)) + // Set our new props + newLink._id = uuid() + newLink._instanceName = `${title} Link` + newLink.url = url + newLink.text = title + } else { + // Otherwise create vanilla new link + newLink = { + ...store.actions.components.createInstance("link"), + url, + text: title, + _instanceName: `${title} Link`, } - return state - }) - await Promise.all(promises) + } + + // Save layout + nav._children = [...nav._children, newLink] + await store.actions.layouts.save(layout) }, }, }, diff --git a/packages/builder/src/builderStore/store/screenTemplates/index.js b/packages/builder/src/builderStore/store/screenTemplates/index.js index ddf48cbe44..7272f3514c 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/index.js +++ b/packages/builder/src/builderStore/store/screenTemplates/index.js @@ -4,8 +4,6 @@ import rowListScreen from "./rowListScreen" import emptyNewRowScreen from "./emptyNewRowScreen" import createFromScratchScreen from "./createFromScratchScreen" import emptyRowDetailScreen from "./emptyRowDetailScreen" -import { generateNewIdsForComponent } from "../../storeUtils" -import { uuid } from "builderStore/uuid" const allTemplates = tables => [ createFromScratchScreen, @@ -16,13 +14,9 @@ const allTemplates = tables => [ emptyRowDetailScreen, ] -// allows us to apply common behaviour to all create() functions +// Allows us to apply common behaviour to all create() functions const createTemplateOverride = (frontendState, create) => () => { const screen = create() - for (let component of screen.props._children) { - generateNewIdsForComponent(component, frontendState, false) - } - screen.props._id = uuid() screen.name = screen.props._id screen.routing.route = screen.routing.route.toLowerCase() return screen diff --git a/packages/builder/src/builderStore/store/screenTemplates/newRowScreen.js b/packages/builder/src/builderStore/store/screenTemplates/newRowScreen.js index 74f9b55fc8..2790a68677 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/newRowScreen.js +++ b/packages/builder/src/builderStore/store/screenTemplates/newRowScreen.js @@ -21,26 +21,29 @@ export default function(tables) { export const newRowUrl = table => sanitizeUrl(`/${table.name}/new/row`) export const NEW_ROW_TEMPLATE = "NEW_ROW_TEMPLATE" -function generateTitleContainer(table) { - return makeTitleContainer("New Row").addChild(makeSaveButton(table)) +function generateTitleContainer(table, providerId) { + return makeTitleContainer("New Row").addChild( + makeSaveButton(table, providerId) + ) } const createScreen = table => { - const dataform = new Component( - "@budibase/standard-components/dataformwide" - ).instanceName("Form") - - const container = makeMainContainer() - .addChild(makeBreadcrumbContainer(table.name, "New")) - .addChild(generateTitleContainer(table)) - .addChild(dataform) - - return new Screen() + const screen = new Screen() .component("@budibase/standard-components/newrow") .table(table._id) .route(newRowUrl(table)) .instanceName(`${table.name} - New`) .name("") - .addChild(container) - .json() + + const dataform = new Component( + "@budibase/standard-components/dataformwide" + ).instanceName("Form") + + const providerId = screen._json.props._id + const container = makeMainContainer() + .addChild(makeBreadcrumbContainer(table.name, "New")) + .addChild(generateTitleContainer(table, providerId)) + .addChild(dataform) + + return screen.addChild(container).json() } diff --git a/packages/builder/src/builderStore/store/screenTemplates/rowDetailScreen.js b/packages/builder/src/builderStore/store/screenTemplates/rowDetailScreen.js index 526f457f3e..0a9148eaf8 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/rowDetailScreen.js +++ b/packages/builder/src/builderStore/store/screenTemplates/rowDetailScreen.js @@ -25,9 +25,9 @@ export default function(tables) { export const ROW_DETAIL_TEMPLATE = "ROW_DETAIL_TEMPLATE" export const rowDetailUrl = table => sanitizeUrl(`/${table.name}/:id`) -function generateTitleContainer(table, title) { +function generateTitleContainer(table, title, providerId) { // have to override style for this, its missing margin - const saveButton = makeSaveButton(table).normalStyle({ + const saveButton = makeSaveButton(table, providerId).normalStyle({ background: "#000000", "border-width": "0", "border-style": "None", @@ -60,8 +60,8 @@ function generateTitleContainer(table, title) { onClick: [ { parameters: { - rowId: "{{ data._id }}", - revId: "{{ data._rev }}", + rowId: `{{ ${providerId}._id }}`, + revId: `{{ ${providerId}._rev }}`, tableId: table._id, }, "##eventHandlerType": "Delete Row", @@ -82,21 +82,22 @@ function generateTitleContainer(table, title) { } const createScreen = (table, heading) => { - const dataform = new Component( - "@budibase/standard-components/dataformwide" - ).instanceName("Form") - - const container = makeMainContainer() - .addChild(makeBreadcrumbContainer(table.name, heading || "Edit")) - .addChild(generateTitleContainer(table, heading || "Edit Row")) - .addChild(dataform) - - return new Screen() + const screen = new Screen() .component("@budibase/standard-components/rowdetail") .table(table._id) .instanceName(`${table.name} - Detail`) .route(rowDetailUrl(table)) .name("") - .addChild(container) - .json() + + const dataform = new Component( + "@budibase/standard-components/dataformwide" + ).instanceName("Form") + + const providerId = screen._json.props._id + const container = makeMainContainer() + .addChild(makeBreadcrumbContainer(table.name, heading || "Edit")) + .addChild(generateTitleContainer(table, heading || "Edit Row", providerId)) + .addChild(dataform) + + return screen.addChild(container).json() } diff --git a/packages/builder/src/builderStore/store/screenTemplates/utils/Component.js b/packages/builder/src/builderStore/store/screenTemplates/utils/Component.js index bd03fc7cdc..a74ea526f7 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/utils/Component.js +++ b/packages/builder/src/builderStore/store/screenTemplates/utils/Component.js @@ -1,4 +1,4 @@ -import { v4 } from "uuid" +import { uuid } from "builderStore/uuid" import { BaseStructure } from "./BaseStructure" export class Component extends BaseStructure { @@ -6,7 +6,7 @@ export class Component extends BaseStructure { super(false) this._children = [] this._json = { - _id: v4(), + _id: uuid(), _component: name, _styles: { normal: {}, diff --git a/packages/builder/src/builderStore/store/screenTemplates/utils/Screen.js b/packages/builder/src/builderStore/store/screenTemplates/utils/Screen.js index 00bd43ec2c..79e1632ad2 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/utils/Screen.js +++ b/packages/builder/src/builderStore/store/screenTemplates/utils/Screen.js @@ -1,4 +1,5 @@ import { BaseStructure } from "./BaseStructure" +import { uuid } from "builderStore/uuid" export class Screen extends BaseStructure { constructor() { @@ -6,7 +7,7 @@ export class Screen extends BaseStructure { this._json = { layoutId: "layout_private_master", props: { - _id: "", + _id: uuid(), _component: "", _styles: { normal: {}, diff --git a/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js b/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js index 89e08cecdb..a00f66f828 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js +++ b/packages/builder/src/builderStore/store/screenTemplates/utils/commonComponents.js @@ -78,7 +78,7 @@ export function makeBreadcrumbContainer(tableName, text, capitalise = false) { .addChild(identifierText) } -export function makeSaveButton(table) { +export function makeSaveButton(table, providerId) { return new Component("@budibase/standard-components/button") .normalStyle({ background: "#000000", @@ -100,8 +100,7 @@ export function makeSaveButton(table) { onClick: [ { parameters: { - contextPath: "data", - tableId: table._id, + providerId, }, "##eventHandlerType": "Save Row", }, diff --git a/packages/builder/src/builderStore/storeUtils.js b/packages/builder/src/builderStore/storeUtils.js index 4ee2dd7ccc..00f5a209a3 100644 --- a/packages/builder/src/builderStore/storeUtils.js +++ b/packages/builder/src/builderStore/storeUtils.js @@ -1,80 +1,105 @@ -import { getBuiltin } from "components/userInterface/assetParsing/createProps" -import { uuid } from "./uuid" -import getNewComponentName from "./getNewComponentName" +/** + * Recursively searches for a specific component ID + */ +export const findComponent = (rootComponent, id) => { + return searchComponentTree(rootComponent, comp => comp._id === id) +} /** - * Find the parent component of the passed in child. - * @param {Object} rootProps - props to search for the parent in - * @param {String|Object} child - id of the child or the child itself to find the parent of + * Recursively searches for a specific component type */ -export const findParent = (rootProps, child) => { - let parent - walkProps(rootProps, (props, breakWalk) => { - if ( - props._children && - (props._children.includes(child) || - props._children.some(c => c._id === child)) - ) { - parent = props - breakWalk() - } - }) - return parent +export const findComponentType = (rootComponent, type) => { + return searchComponentTree(rootComponent, comp => comp._component === type) } -export const walkProps = (props, action, cancelToken = null) => { - cancelToken = cancelToken || { cancelled: false } - action(props, () => { - cancelToken.cancelled = true - }) - - if (props._children) { - for (let child of props._children) { - if (cancelToken.cancelled) return - walkProps(child, action, cancelToken) - } - } -} - -export const generateNewIdsForComponent = ( - component, - state, - changeName = true -) => - walkProps(component, prop => { - prop._id = uuid() - if (changeName) prop._instanceName = getNewComponentName(prop, state) - }) - -export const getComponentDefinition = (state, name) => - name.startsWith("##") ? getBuiltin(name) : state.components[name] - -export const findChildComponentType = (node, typeToFind) => { - // Stop recursion if invalid props - if (!node || !typeToFind) { +/** + * Recursively searches for the parent component of a specific component ID + */ +export const findComponentParent = (rootComponent, id, parentComponent) => { + if (!rootComponent || !id) { return null } - - // Stop recursion if this element matches - if (node._component === typeToFind) { - return node + if (rootComponent._id === id) { + return parentComponent } - - // Otherwise check if any children match - // Stop recursion if no valid children to process - const children = node._children || (node.props && node.props._children) - if (!children || !children.length) { + if (!rootComponent._children) { return null } - - // Recurse and check each child component - for (let child of children) { - const childResult = findChildComponentType(child, typeToFind) + for (const child of rootComponent._children) { + const childResult = findComponentParent(child, id, rootComponent) + if (childResult) { + return childResult + } + } + return null +} + +/** + * Recursively searches for a specific component ID and records the component + * path to this component + */ +export const findComponentPath = (rootComponent, id, path = []) => { + if (!rootComponent || !id) { + return [] + } + if (rootComponent._id === id) { + return [...path, rootComponent] + } + if (!rootComponent._children) { + return [] + } + for (const child of rootComponent._children) { + const newPath = [...path, rootComponent] + const childResult = findComponentPath(child, id, newPath) + if (childResult?.length) { + return childResult + } + } + return [] +} + +/** + * Recurses through the component tree and finds all components of a certain + * type. + */ +export const findAllMatchingComponents = (rootComponent, selector) => { + if (!rootComponent || !selector) { + return [] + } + let components = [] + if (rootComponent._children) { + rootComponent._children.forEach(child => { + components = [ + ...components, + ...findAllMatchingComponents(child, selector), + ] + }) + } + if (selector(rootComponent)) { + components.push(rootComponent) + } + return components.reverse() +} + +/** + * Recurses through a component tree evaluating a matching function against + * components until a match is found + */ +const searchComponentTree = (rootComponent, matchComponent) => { + if (!rootComponent || !matchComponent) { + return null + } + if (matchComponent(rootComponent)) { + return rootComponent + } + if (!rootComponent._children) { + return null + } + for (const child of rootComponent._children) { + const childResult = searchComponentTree(child, matchComponent) if (childResult) { return childResult } } - - // If we reach here then no children were valid return null } diff --git a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte index 5d3371ee7d..439687d0c2 100644 --- a/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte +++ b/packages/builder/src/components/automation/SetupPanel/AutomationBlockSetup.svelte @@ -5,7 +5,7 @@ import { Button, Input, Select, Label } from "@budibase/bbui" import { automationStore } from "builderStore" import WebhookDisplay from "../Shared/WebhookDisplay.svelte" - import BindableInput from "components/userInterface/BindableInput.svelte" + import BindableInput from "../../common/BindableInput.svelte" export let block export let webhookModal diff --git a/packages/builder/src/components/userInterface/GenericBindingPopover.svelte b/packages/builder/src/components/automation/SetupPanel/GenericBindingPopover.svelte similarity index 100% rename from packages/builder/src/components/userInterface/GenericBindingPopover.svelte rename to packages/builder/src/components/automation/SetupPanel/GenericBindingPopover.svelte diff --git a/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte b/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte index 117f00d1a3..43fa4b8bf1 100644 --- a/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte +++ b/packages/builder/src/components/automation/SetupPanel/RowSelector.svelte @@ -1,7 +1,7 @@ -