diff --git a/packages/builder/src/builderStore/index.js b/packages/builder/src/builderStore/index.js index 28ef1f4376..35c4587874 100644 --- a/packages/builder/src/builderStore/index.js +++ b/packages/builder/src/builderStore/index.js @@ -2,7 +2,6 @@ import { getFrontendStore } from "./store/frontend" import { getAutomationStore } from "./store/automation" import { getThemeStore } from "./store/theme" import { derived } from "svelte/store" -import { LAYOUT_NAMES } from "../constants" import { findComponent, findComponentPath } from "./componentUtils" import { RoleUtils } from "@budibase/frontend-core" @@ -28,6 +27,10 @@ export const selectedComponent = derived( } ) +// For legacy compatibility only, but with the new design UI this is just +// the selected screen +export const currentAsset = selectedScreen + export const sortedScreens = derived(store, $store => { return $store.screens.slice().sort((a, b) => { // Sort by role first @@ -66,12 +69,3 @@ export const selectedComponentPath = derived( ).map(component => component._id) } ) - -export const mainLayout = derived(store, $store => { - return $store.layouts?.find( - layout => layout._id === LAYOUT_NAMES.MASTER.PRIVATE - ) -}) - -// For compatibility -export const currentAsset = selectedScreen diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index 16ae5ce215..58d803aa03 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -1,6 +1,6 @@ import { get, writable } from "svelte/store" import { cloneDeep } from "lodash/fp" -import { currentAsset, mainLayout, selectedComponent } from "builderStore" +import { selectedScreen, selectedComponent } from "builderStore" import { datasources, integrations, @@ -11,7 +11,6 @@ import { import { API } from "api" import analytics, { Events } from "analytics" import { - findComponentType, findComponentParent, findClosestMatchingComponent, findAllMatchingComponents, @@ -21,6 +20,7 @@ import { } from "../componentUtils" import { Helpers } from "@budibase/bbui" import { DefaultAppTheme, LAYOUT_NAMES } from "../../constants" +import { Utils } from "@budibase/frontend-core" const INITIAL_FRONTEND_STATE = { apps: [], @@ -61,6 +61,26 @@ const INITIAL_FRONTEND_STATE = { export const getFrontendStore = () => { const store = writable({ ...INITIAL_FRONTEND_STATE }) + // 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) + if (result === false) { + return + } + return await store.actions.screens.save(clone) + }) + store.actions = { reset: () => { store.set({ ...INITIAL_FRONTEND_STATE }) @@ -137,12 +157,12 @@ export const getFrontendStore = () => { theme: { save: async theme => { const appId = get(store).appId - await API.saveAppMetadata({ + const app = await API.saveAppMetadata({ appId, metadata: { theme }, }) store.update(state => { - state.theme = theme + state.theme = app.theme return state }) }, @@ -150,12 +170,12 @@ export const getFrontendStore = () => { customTheme: { save: async customTheme => { const appId = get(store).appId - await API.saveAppMetadata({ + const app = await API.saveAppMetadata({ appId, metadata: { customTheme }, }) store.update(state => { - state.customTheme = customTheme + state.customTheme = app.customTheme return state }) }, @@ -163,33 +183,35 @@ export const getFrontendStore = () => { navigation: { save: async navigation => { const appId = get(store).appId - await API.saveAppMetadata({ + const app = await API.saveAppMetadata({ appId, metadata: { navigation }, }) store.update(state => { - state.navigation = navigation - return state - }) - }, - }, - routing: { - fetch: async () => { - const response = await API.fetchAppRoutes() - store.update(state => { - state.routes = response.routes + state.navigation = app.navigation return state }) }, }, screens: { select: screenId => { - store.update(state => { - let screens = state.screens - let screen = - screens.find(screen => screen._id === screenId) || screens[0] - if (!screen) return state + // 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 && + state.selectedComponentId === screen.props?._id + ) { + return + } + + // Select new screen + store.update(state => { state.selectedScreenId = screen._id state.selectedComponentId = screen.props?._id return state @@ -198,25 +220,40 @@ export const getFrontendStore = () => { save: async screen => { const creatingNewScreen = screen._id === undefined const savedScreen = await API.saveScreen(screen) + const routesResponse = await API.fetchAppRoutes() 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 + return state }) - - // Refresh routes - await store.actions.routing.fetch() - - // Select the new screen if creating a new one - if (creatingNewScreen) { - store.actions.screens.select(savedScreen._id) - } 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) + }, delete: async screens => { const screensToDelete = Array.isArray(screens) ? screens : [screens] @@ -238,60 +275,78 @@ export const getFrontendStore = () => { promises.push(store.actions.links.delete(deleteUrls)) await Promise.all(promises) 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 }) - - // Refresh routes - await store.actions.routing.fetch() }, - updateHomeScreen: async (screen, makeHomeScreen = true) => { - let promises = [] - - // Find any existing home screen for this role so we can remove it, - // if we are setting this to be the new home screen - if (makeHomeScreen) { - const roleId = screen.routing.roleId - let existingHomeScreen = get(store).screens.find(s => { - return ( - s.routing.roleId === roleId && - s.routing.homeScreen && - s._id !== screen._id - ) - }) - if (existingHomeScreen) { - existingHomeScreen.routing.homeScreen = false - promises.push(store.actions.screens.save(existingHomeScreen)) - } + updateSetting: async (screen, name, value) => { + if (!screen || !name) { + return } - // Update the passed in screen - screen.routing.homeScreen = makeHomeScreen - promises.push(store.actions.screens.save(screen)) - return await Promise.all(promises) + // 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) { + 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) - screen.layoutId = null - screen.showNavigation = layout?.props.navigation !== "None" - screen.width = layout?.props.width || "Large" - await store.actions.screens.save(screen) + 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) }, }, preview: { - saveSelected: async () => { - const selectedAsset = get(currentAsset) - return await store.actions.screens.save(selectedAsset) - }, setDevice: device => { store.update(state => { state.previewDevice = device @@ -301,41 +356,28 @@ export const getFrontendStore = () => { }, 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 => { - const layout = - store.actions.layouts.find(layoutId) || get(store).layouts[0] - if (!layout) return state.selectedLayoutId = layout._id state.selectedComponentId = layout.props?._id return state }) }, - save: async layout => { - const creatingNewLayout = layout._id === undefined - const savedLayout = await API.saveLayout(layout) - store.update(state => { - const idx = state.layouts.findIndex(x => x._id === savedLayout._id) - if (idx !== -1) { - state.layouts.splice(idx, 1, savedLayout) - } else { - state.layouts.push(savedLayout) - } - return state - }) - - // Select layout if creating a new one - if (creatingNewLayout) { - store.actions.layouts.select(savedLayout._id) - } - return savedLayout - }, - find: layoutId => { - if (!layoutId) { - return get(mainLayout) - } - const storeContents = get(store) - return storeContents.layouts.find(layout => layout._id === layoutId) - }, delete: async layout => { if (!layout?._id) { return @@ -345,10 +387,6 @@ export const getFrontendStore = () => { layoutRev: layout._rev, }) store.update(state => { - // Select main layout if we deleted the selected layout - if (layout._id === state.selectedLayoutId) { - state.selectedLayoutId = get(mainLayout)._id - } state.layouts = state.layouts.filter(x => x._id !== layout._id) return state }) @@ -386,7 +424,7 @@ export const getFrontendStore = () => { } if (componentName.endsWith("/formstep")) { const parentForm = findClosestMatchingComponent( - get(currentAsset).props, + get(selectedScreen).props, get(selectedComponent)._id, component => component._component.endsWith("/form") ) @@ -407,48 +445,59 @@ export const getFrontendStore = () => { } }, create: async (componentName, presetProps) => { - const selected = get(selectedComponent) - const asset = get(currentAsset) - - // Create new component + const state = get(store) const componentInstance = store.actions.components.createInstance( componentName, presetProps ) - if (!componentInstance || !asset) { + if (!componentInstance) { return } - // Find parent node to attach this component to - let parentComponent - if (selected) { - // Use current screen or layout as parent if no component is selected - const definition = store.actions.components.getDefinition( - selected._component + // Patch selected screen + await store.actions.screens.patch(screen => { + // Find the selected component + const currentComponent = findComponent( + screen.props, + state.selectedComponentId ) - if (definition?.hasChildren) { - // Use selected component if it allows children - parentComponent = selected - } else { - // Otherwise we need to use the parent of this component - parentComponent = findComponentParent(asset?.props, selected._id) + if (!currentComponent) { + return false } - } else { - // Use screen or layout if no component is selected - parentComponent = asset?.props - } - // Attach component - if (!parentComponent) { - return - } - if (!parentComponent._children) { - parentComponent._children = [] - } - parentComponent._children.push(componentInstance) + // 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 + } - // Save components and update UI - await store.actions.preview.saveSelected() + // 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 @@ -461,50 +510,58 @@ export const getFrontendStore = () => { return componentInstance }, + patch: async (patchFn, componentId, screenId) => { + // Use selected component by default + if (!componentId && !screenId) { + const state = get(store) + componentId = state.selectedComponentId + screenId = state.selectedScreenId + } + // Invalid if only a screen or component ID provided + if (!componentId || !screenId || !patchFn) { + return + } + const patchScreen = screen => { + let component = findComponent(screen.props, componentId) + if (!component) { + return false + } + return patchFn(component, screen) + } + await store.actions.screens.patch(patchScreen, screenId) + }, delete: async component => { if (!component) { return } - const asset = get(currentAsset) - if (!asset) { - return - } + let parentId - // Fetch full definition - component = findComponent(asset.props, component._id) + // Patch screen + await store.actions.screens.patch(screen => { + // Check component exists + component = findComponent(screen.props, component._id) + if (!component) { + return false + } - // Ensure we aren't deleting the screen slot - if (component._component?.endsWith("/screenslot")) { - throw "You can't delete the screen slot" - } - - // Ensure we aren't deleting something that contains the screen slot - const screenslot = findComponentType( - component, - "@budibase/standard-components/screenslot" - ) - if (screenslot != null) { - throw "You can't delete a component that contains the screen slot" - } - - const parent = findComponentParent(asset.props, component._id) - if (parent) { + // Check component has a valid parent + const parent = findComponentParent(screen.props, component._id) + if (!parent) { + return false + } + parentId = parent._id parent._children = parent._children.filter( child => child._id !== component._id ) - store.update(state => { - state.selectedComponentId = parent._id - return state - }) - } - await store.actions.preview.saveSelected() + }) + + // Select the deleted component's parent + store.update(state => { + state.selectedComponentId = parentId + return state + }) }, copy: (component, cut = false, selectParent = true) => { - const selectedAsset = get(currentAsset) - if (!selectedAsset) { - return null - } - // Update store with copied component store.update(state => { state.componentToPaste = cloneDeep(component) @@ -512,13 +569,11 @@ export const getFrontendStore = () => { return state }) - // Remove the component from its parent if we're cutting + // Select the parent if cutting if (cut) { - const parent = findComponentParent(selectedAsset.props, component._id) + const screen = get(selectedScreen) + const parent = findComponentParent(screen?.props, component._id) if (parent) { - parent._children = parent._children.filter( - child => child._id !== component._id - ) if (selectParent) { store.update(state => { state.selectedComponentId = parent._id @@ -528,24 +583,42 @@ export const getFrontendStore = () => { } } }, - paste: async (targetComponent, mode) => { - let promises = [] - store.update(state => { - // Stop if we have nothing to paste - if (!state.componentToPaste) { - return state + paste: async (targetComponent, mode, targetScreen) => { + const state = get(store) + if (!state.componentToPaste) { + return + } + let newComponentId + + // Patch screen + const patch = screen => { + // Get up to date ref to target + targetComponent = findComponent(screen.props, targetComponent._id) + if (!targetComponent) { + return } const cut = state.componentToPaste.isCut - - // Clone the component to paste and make unique if copying - delete state.componentToPaste.isCut + const originalId = state.componentToPaste._id let componentToPaste = cloneDeep(state.componentToPaste) - if (cut) { - state.componentToPaste = null - } else { + delete componentToPaste.isCut + + // Make new component unique if copying + if (!cut) { 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 + ) + } + } + + // Paste new component if (mode === "inside") { // Paste inside target component if chosen if (!targetComponent._children) { @@ -553,66 +626,106 @@ export const getFrontendStore = () => { } 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 - } + // Otherwise paste in the correct order in the parent's children const parent = findComponentParent( - selectedAsset.props, + screen.props, targetComponent._id ) - if (!parent) { - return state + if (!parent?._children) { + return false } - - // Insert the component in the correct position - const targetIndex = parent._children.indexOf(targetComponent) + const targetIndex = parent._children.findIndex(component => { + return component._id === targetComponent._id + }) const index = mode === "above" ? targetIndex : targetIndex + 1 - parent._children.splice(index, 0, cloneDeep(componentToPaste)) + parent._children.splice(index, 0, componentToPaste) } + } + const targetScreenId = targetScreen?._id || state.selectedScreenId + await store.actions.screens.patch(patch, targetScreenId) - // Save and select the new component - promises.push(store.actions.preview.saveSelected()) - state.selectedComponentId = componentToPaste._id + store.update(state => { + // Remove copied component if cutting + if (state.componentToPaste.isCut) { + delete state.componentToPaste + } + state.selectedScreenId = targetScreenId + state.selectedComponentId = newComponentId return state }) - await Promise.all(promises) + }, + moveUp: async component => { + await store.actions.screens.patch(screen => { + const componentId = component?._id + const parent = findComponentParent(screen.props, componentId) + if (!parent?._children?.length) { + return false + } + const currentIndex = parent._children.findIndex( + child => child._id === componentId + ) + if (currentIndex === 0) { + return false + } + const originalComponent = cloneDeep(parent._children[currentIndex]) + const newChildren = parent._children.filter( + component => component._id !== componentId + ) + newChildren.splice(currentIndex - 1, 0, originalComponent) + parent._children = newChildren + }) + }, + moveDown: async component => { + await store.actions.screens.patch(screen => { + const componentId = component?._id + const parent = findComponentParent(screen.props, componentId) + if (!parent?._children?.length) { + return false + } + const currentIndex = parent._children.findIndex( + child => child._id === componentId + ) + if (currentIndex === parent._children.length - 1) { + return false + } + const originalComponent = cloneDeep(parent._children[currentIndex]) + const newChildren = parent._children.filter( + component => component._id !== componentId + ) + newChildren.splice(currentIndex + 1, 0, originalComponent) + parent._children = newChildren + }) }, updateStyle: async (name, value) => { - const selected = get(selectedComponent) - if (value == null || value === "") { - delete selected._styles.normal[name] - } else { - selected._styles.normal[name] = value - } - await store.actions.preview.saveSelected() + await store.actions.components.patch(component => { + if (value == null || value === "") { + delete component._styles.normal[name] + } else { + component._styles.normal[name] = value + } + }) }, updateCustomStyle: async style => { - const selected = get(selectedComponent) - selected._styles.custom = style - await store.actions.preview.saveSelected() + await store.actions.components.patch(component => { + component._styles.custom = style + }) }, updateConditions: async conditions => { - const selected = get(selectedComponent) - selected._conditions = conditions - await store.actions.preview.saveSelected() - }, - updateProp: async (name, value) => { - let component = get(selectedComponent) - if (!name || !component) { - return - } - if (component[name] === value) { - return - } - component[name] = value - store.update(state => { - state.selectedComponentId = component._id - return state + await store.actions.components.patch(component => { + component._conditions = conditions + }) + }, + updateSetting: async (name, value) => { + await store.actions.components.patch(component => { + if (!name || !component) { + return false + } + // Skip update if the value is the same + if (component[name] === value) { + return false + } + component[name] = value }) - await store.actions.preview.saveSelected() }, }, links: { diff --git a/packages/builder/src/components/design/settings/controls/ResetFieldsButton.svelte b/packages/builder/src/components/design/settings/controls/ResetFieldsButton.svelte index e927526b92..16aaf91ce2 100644 --- a/packages/builder/src/components/design/settings/controls/ResetFieldsButton.svelte +++ b/packages/builder/src/components/design/settings/controls/ResetFieldsButton.svelte @@ -18,7 +18,7 @@ const dataSource = form?.dataSource const fields = makeDatasourceFormComponents(dataSource) try { - await store.actions.components.updateProp( + await store.actions.components.updateSetting( "_children", fields.map(field => field.json()) ) diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPanel.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPanel.svelte index d86e4a3c8d..76118cc9c8 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPanel.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/AppPanel.svelte @@ -17,7 +17,8 @@ getOptionValue={x => x._id} getOptionIcon={x => (x.routing.homeScreen ? "Home" : "WebPage")} getOptionColour={x => RoleUtils.getRoleColour(x.routing.roleId)} - bind:value={$store.selectedScreenId} + value={$store.selectedScreenId} + on:change={e => store.actions.screens.select(e.detail)} />