From 723d140905aaa4150a83c1b7c7ad3067cf6b4425 Mon Sep 17 00:00:00 2001 From: Dean Date: Fri, 26 Jul 2024 15:04:14 +0100 Subject: [PATCH] Added context menu option to create a new screen from the selected component --- .../ComponentList/ComponentKeyHandler.svelte | 9 ++ .../getComponentContextMenuItems.js | 7 ++ .../NewScreen/CreateScreenModal.svelte | 37 +----- .../builder/src/stores/builder/screens.js | 113 +++++++++++++++++- .../client/src/components/ClientApp.svelte | 15 ++- packages/client/src/stores/context.js | 33 +++++ 6 files changed, 181 insertions(+), 33 deletions(-) diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentKeyHandler.svelte b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentKeyHandler.svelte index 6b27d79c155..c8c3126fde3 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentKeyHandler.svelte +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/ComponentKeyHandler.svelte @@ -5,6 +5,7 @@ componentStore, selectedComponent, componentTreeNodesStore, + screenStore, } from "stores/builder" import { findComponent, getChildIdsForComponent } from "helpers/components" import { goto, isActive } from "@roxi/routify" @@ -48,6 +49,14 @@ ["Ctrl+Enter"]: () => { $goto(`./:componentId/new`) }, + ["CloneNodeToScreen"]: async component => { + const newScreen = await screenStore.createScreenFromComponent(component) + if (newScreen) { + notifications.success("Created screen successfully") + } else { + notifications.error("Error creating screen from selection.") + } + }, ["Delete"]: component => { // Don't show confirmation for the screen itself if (component?._id === $selectedScreen.props._id) { diff --git a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/getComponentContextMenuItems.js b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/getComponentContextMenuItems.js index f2dfb73a68f..189f689ce88 100644 --- a/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/getComponentContextMenuItems.js +++ b/packages/builder/src/pages/builder/app/[application]/design/[screenId]/_components/ComponentList/getComponentContextMenuItems.js @@ -77,6 +77,13 @@ const getContextMenuItems = (component, componentCollapsed) => { disabled: noPaste, callback: () => keyboardEvent("v", true), }, + { + icon: "WebPage", + name: "Create screen", + visible: true, + disabled: false, + callback: () => keyboardEvent("CloneNodeToScreen", false), + }, { icon: "Export", name: "Eject block", 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 68d74218c8d..c5678493574 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 @@ -12,7 +12,6 @@ builderStore, } from "stores/builder" import { auth } from "stores/portal" - import { get } from "svelte/store" import getTemplates from "templates" import { Roles } from "constants/backend" import { capitalise } from "helpers" @@ -53,11 +52,13 @@ for (let screen of screens) { // Check we aren't clashing with an existing URL - if (hasExistingUrl(screen.routing.route)) { + if ( + screenStore.hasExistingUrl(screen.routing.route, screenAccessRole) + ) { let suffix = 2 - let candidateUrl = makeCandidateUrl(screen, suffix) - while (hasExistingUrl(candidateUrl)) { - candidateUrl = makeCandidateUrl(screen, ++suffix) + let candidateUrl = screenStore.makeCandidateUrl(screen, suffix) + while (screenStore.hasExistingUrl(candidateUrl, screenAccessRole)) { + candidateUrl = screenStore.makeCandidateUrl(screen, ++suffix) } screen.routing.route = candidateUrl } @@ -91,32 +92,6 @@ } } - // Checks if any screens exist in the store with the given route and - // currently selected role - const hasExistingUrl = url => { - const roleId = screenAccessRole - const screens = get(screenStore).screens.filter( - s => s.routing.roleId === roleId - ) - return !!screens.find(s => s.routing?.route === url) - } - - // Constructs a candidate URL for a new screen, suffixing the base of the - // screen's URL with a given suffix. - // e.g. "/sales/:id" => "/sales-1/:id" - const makeCandidateUrl = (screen, suffix) => { - let url = screen.routing?.route || "" - if (url.startsWith("/")) { - url = url.slice(1) - } - if (!url.includes("/")) { - return `/${url}-${suffix}` - } else { - const split = url.split("/") - return `/${split[0]}-${suffix}/${split.slice(1).join("/")}` - } - } - // Handler for NewScreenModal export const show = newMode => { mode = newMode diff --git a/packages/builder/src/stores/builder/screens.js b/packages/builder/src/stores/builder/screens.js index 10c4265e73f..77fcb773ae1 100644 --- a/packages/builder/src/stores/builder/screens.js +++ b/packages/builder/src/stores/builder/screens.js @@ -2,17 +2,25 @@ import { derived, get } from "svelte/store" import { cloneDeep } from "lodash/fp" import { Helpers } from "@budibase/bbui" import { RoleUtils, Utils } from "@budibase/frontend-core" -import { findAllMatchingComponents } from "helpers/components" +import { + findAllMatchingComponents, + makeComponentUnique, +} from "helpers/components" import { layoutStore, appStore, componentStore, navigationStore, selectedComponent, + componentTreeNodesStore, } from "stores/builder" import { createHistoryStore } from "stores/builder/history" import { API } from "api" import BudiStore from "../BudiStore" +import { Roles } from "constants/backend" +import { Screen } from "templates/Screen" +import sanitizeUrl from "helpers/sanitizeUrl" +import { capitalise } from "helpers" export const INITIAL_SCREENS_STATE = { screens: [], @@ -36,6 +44,9 @@ export class ScreenStore extends BudiStore { this.updateSetting = this.updateSetting.bind(this) this.sequentialScreenPatch = this.sequentialScreenPatch.bind(this) this.removeCustomLayout = this.removeCustomLayout.bind(this) + this.hasExistingUrl = this.hasExistingUrl.bind(this) + this.makeCandidateUrl = this.makeCandidateUrl.bind(this) + this.createScreenFromComponent = this.createScreenFromComponent.bind(this) this.history = createHistoryStore({ getDoc: id => get(this.store).screens?.find(screen => screen._id === id), @@ -176,6 +187,106 @@ export class ScreenStore extends BudiStore { } } + /** + * Checks if any screens exist in the store with the given route and + * currently selected role + * + * @param {string} screen + * @param {string} screenAccessRole + * @returns {boolean} + */ + hasExistingUrl(url, screenAccessRole) { + const state = get(this.store) + const roleId = screenAccessRole + const screens = state.screens.filter(s => s.routing.roleId === roleId) + return !!screens.find(s => s.routing?.route === url) + } + + /** + * Constructs a candidate URL for a new screen, appending a given suffix + * to the base of the screen's URL + * + * @param {string} screen + * @param {number} suffix + * @example "/sales/:id" => "/sales-1/:id" + * @returns {string} The candidate url. + */ + makeCandidateUrl(screen, suffix) { + let url = screen.routing?.route || "" + if (url.startsWith("/")) { + url = url.slice(1) + } + if (!url.includes("/")) { + return `/${url}-${suffix}` + } else { + const split = url.split("/") + return `/${split[0]}-${suffix}/${split.slice(1).join("/")}` + } + } + + /** + * Takes a component object and creates a brand new screen. + * The default screen path is '/new-screen' with a corresponding nav link. + * If a screen url collision is found, a numeric suffix will be added. + * @example "/new-screen-2" + * + * @param {object} component - The component to be copied + * @param {string} [accessRole='BASIC'] - Access role for the new screen. (default=BASIC) + * + * @returns {object|null} The new screen or null if it failed + */ + async createScreenFromComponent(component, accessRole = Roles.BASIC) { + const defaultNewScreenURL = sanitizeUrl(`/new-screen`) + let componentCopy = Helpers.cloneDeep(component) + + const screen = new Screen() + .route(defaultNewScreenURL) + .instanceName(`New Screen`) + + const screenJSON = screen.json() + + if (this.hasExistingUrl(defaultNewScreenURL, accessRole)) { + let suffix = 2 + let candidateUrl = this.makeCandidateUrl(screenJSON, suffix) + while (this.hasExistingUrl(candidateUrl, accessRole)) { + candidateUrl = this.makeCandidateUrl(screenJSON, ++suffix) + } + screenJSON.routing.route = candidateUrl + } + + // Ensure the component _id values are made unique. + const uniqueComponentCopy = makeComponentUnique(componentCopy) + + screenJSON.props._children.push(uniqueComponentCopy) + screenJSON.routing.roleId = accessRole + + try { + // Create the screen + const newScreen = await this.save(screenJSON) + + // Add a new nav link + await navigationStore.saveLink( + screenJSON.routing.route, + capitalise(screenJSON.routing.route.split("/")[1]), + accessRole + ) + + // Select the new node. + componentStore.update(state => { + state.selectedComponentId = uniqueComponentCopy._id + return state + }) + + // Ensure the node is expanded + componentTreeNodesStore.makeNodeVisible(uniqueComponentCopy._id) + + return newScreen + } catch (e) { + console.error(e) + return null + } + } + /** * Core save method. If creating a new screen, the store will sync the target * screen id to ensure that it is selected in the builder diff --git a/packages/client/src/components/ClientApp.svelte b/packages/client/src/components/ClientApp.svelte index 763c8ef7717..569abca17d7 100644 --- a/packages/client/src/components/ClientApp.svelte +++ b/packages/client/src/components/ClientApp.svelte @@ -46,11 +46,24 @@ // Provide contexts setContext("sdk", SDK) setContext("component", writable({ id: null, ancestors: [] })) - setContext("context", createContextStore()) + + const globalContext = createContextStore() + setContext("context", globalContext) let dataLoaded = false let permissionError = false let embedNoScreens = false + let cachedActiveScreenId + + // Clear component context entries from the global context + // when the active page changes. + const purgeGlobalComponentContext = screenId => { + if (!screenId || cachedActiveScreenId == screenId) return + cachedActiveScreenId = screenId + globalContext.actions.clearComponentContext() + } + + $: purgeGlobalComponentContext($screenStore?.activeScreen?._id) // Determine if we should show devtools or not $: showDevTools = $devToolsEnabled && !$routeStore.queryParams?.peek diff --git a/packages/client/src/stores/context.js b/packages/client/src/stores/context.js index c1ec18ef138..09e6501c3a0 100644 --- a/packages/client/src/stores/context.js +++ b/packages/client/src/stores/context.js @@ -60,12 +60,45 @@ export const createContextStore = parentContext => { observers.forEach(cb => cb(key)) } + /** + * Purge the global context of any component entries + * + * rowSelection (Legacy) - used by the old Table component. + */ + const clearComponentContext = () => { + context.update(ctx => { + const { + device, + snippets, + user, + user_RefreshDatasource, + state, + query, + url, + rowSelection, + } = { + ...ctx, + } + return { + device, + snippets, + user, + user_RefreshDatasource, + state, + query, + url, + rowSelection, + } + }) + } + return { subscribe: totalContext.subscribe, actions: { provideData, provideAction, observeChanges, + clearComponentContext, }, } }