diff --git a/packages/builder/cypress/support/commands.js b/packages/builder/cypress/support/commands.js index 8352c5bd6a..1a5270e33b 100644 --- a/packages/builder/cypress/support/commands.js +++ b/packages/builder/cypress/support/commands.js @@ -1,115 +1,115 @@ - / *********************************************** - // This example commands.js shows you how to - // create various custom commands and overwrite - // existing commands. - // - // For more comprehensive examples of custom - // commands please read more here: - // https://on.cypress.io/custom-commands - // *********************************************** - // - // - // -- This is a parent command -- - // Cypress.Commands.add("login", (email, password) => { ... }) - // - // - // -- This is a child command -- - // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) - // - // - // -- This is a dual command -- - // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) - // - // - // -- This will overwrite an existing command -- - // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add("login", (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) - Cypress.Commands.add("createApp", name => { - cy.contains("Create New Web App").click() +Cypress.Commands.add("createApp", name => { + cy.contains("Create New Web App").click() - cy.get("body") - .then($body => { - if ($body.find("input[name=apiKey]").length) { - // input was found, do something else here - cy.get("input[name=apiKey]") - .type(name) - .should("have.value", name) - cy.contains("Next").click() - } - }) - .then(() => { - cy.get("input[name=applicationName]") - .type(name) - .should("have.value", name) + cy.get("body") + .then($body => { + if ($body.find("input[name=apiKey]").length) { + // input was found, do something else here + cy.get("input[name=apiKey]") + .type(name) + .should("have.value", name) + cy.contains("Next").click() + } + }) + .then(() => { + cy.get("input[name=applicationName]") + .type(name) + .should("have.value", name) - cy.contains("Next").click() + cy.contains("Next").click() - cy.get("input[name=email]") - .click() - .type("test@test.com") - cy.get("input[name=password]") - .click() - .type("test") - cy.contains("Submit").click() - cy.get("[data-cy=new-table]", { - timeout: 20000, - }).should("be.visible") - }) - }) + cy.get("input[name=email]") + .click() + .type("test@test.com") + cy.get("input[name=password]") + .click() + .type("test") + cy.contains("Submit").click() + cy.get("[data-cy=new-table]", { + timeout: 20000, + }).should("be.visible") + }) +}) - Cypress.Commands.add("createTestTableWithData", () => { - cy.createTable("dog") - cy.addColumn("dog", "name", "Text") - cy.addColumn("dog", "age", "Number") - }) +Cypress.Commands.add("createTestTableWithData", () => { + cy.createTable("dog") + cy.addColumn("dog", "name", "Text") + cy.addColumn("dog", "age", "Number") +}) - Cypress.Commands.add("createTable", tableName => { - // Enter table name - cy.get("[data-cy=new-table]").click() - cy.get(".modal").within(() => { - cy.get("input") - .first() - .type(tableName) - cy.get(".buttons") - .contains("Create") - .click() - }) - cy.contains(tableName).should("be.visible") - }) +Cypress.Commands.add("createTable", tableName => { + // Enter table name + cy.get("[data-cy=new-table]").click() + cy.get(".modal").within(() => { + cy.get("input") + .first() + .type(tableName) + cy.get(".buttons") + .contains("Create") + .click() + }) + cy.contains(tableName).should("be.visible") +}) - Cypress.Commands.add("addColumn", (tableName, columnName, type) => { - // Select Table - cy.contains(".nav-item", tableName).click() - cy.contains("Create New Column").click() +Cypress.Commands.add("addColumn", (tableName, columnName, type) => { + // Select Table + cy.contains(".nav-item", tableName).click() + cy.contains("Create New Column").click() - // Configure column - cy.get(".actions").within(() => { - cy.get("input") - .first() - .type(columnName) - // Unset table display column - cy.contains("display column").click() - cy.get("select").select(type) - cy.contains("Save").click() - }) - }) + // Configure column + cy.get(".actions").within(() => { + cy.get("input") + .first() + .type(columnName) + // Unset table display column + cy.contains("display column").click() + cy.get("select").select(type) + cy.contains("Save").click() + }) +}) - Cypress.Commands.add("addRow", values => { - cy.contains("Create New Row").click() +Cypress.Commands.add("addRow", values => { + cy.contains("Create New Row").click() - cy.get(".modal").within(() => { - for (let i = 0; i < values.length; i++) { - cy.get("input") - .eq(i) - .type(values[i]) - } + cy.get(".modal").within(() => { + for (let i = 0; i < values.length; i++) { + cy.get("input") + .eq(i) + .type(values[i]) + } - // Save - cy.get(".buttons") - .contains("Create") - .click() - }) - }) + // Save + cy.get(".buttons") + .contains("Create") + .click() + }) +}) Cypress.Commands.add("createUser", (email, password, role) => { // Create User @@ -120,17 +120,17 @@ Cypress.Commands.add("createUser", (email, password, role) => { cy.get(".modal").within(() => { cy.get("input") .first() - .type(password) + .type(email) cy.get("input") .eq(1) - .type(email) + .type(password) cy.get("select") .first() .select(role) // Save cy.get(".buttons") - .contains("Create Row") + .contains("Create User") .click() }) }) diff --git a/packages/builder/package.json b/packages/builder/package.json index a4908de3da..542c35205e 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -63,7 +63,7 @@ } }, "dependencies": { - "@budibase/bbui": "^1.51.0", + "@budibase/bbui": "^1.52.2", "@budibase/client": "^0.3.8", "@budibase/colorpicker": "^1.0.1", "@budibase/svelte-ag-grid": "^0.0.16", @@ -107,6 +107,7 @@ "rollup-plugin-alias": "^1.5.2", "rollup-plugin-copy": "^3.0.0", "rollup-plugin-css-only": "^2.1.0", + "rollup-plugin-html": "^0.2.1", "rollup-plugin-livereload": "^1.0.0", "rollup-plugin-node-builtins": "^2.1.2", "rollup-plugin-node-globals": "^1.4.0", diff --git a/packages/builder/rollup.config.js b/packages/builder/rollup.config.js index 4afb8084bd..2d5ec52f52 100644 --- a/packages/builder/rollup.config.js +++ b/packages/builder/rollup.config.js @@ -11,6 +11,7 @@ import copy from "rollup-plugin-copy" import css from "rollup-plugin-css-only" import replace from "rollup-plugin-replace" import json from "@rollup/plugin-json" +import html from "rollup-plugin-html" import path from "path" @@ -75,10 +76,6 @@ export default { { src: "src/index.html", dest: outputpath }, { src: "src/favicon.png", dest: outputpath }, { src: "assets", dest: outputpath }, - { - src: "node_modules/@budibase/client/dist/budibase-client.esm.mjs", - dest: outputpath, - }, { src: "node_modules/@budibase/bbui/dist/bbui.css", dest: outputpath, @@ -147,5 +144,6 @@ export default { // instead of npm run dev), minify production && terser(), json(), + html(), ], } diff --git a/packages/builder/src/builderStore/getNewComponentName.js b/packages/builder/src/builderStore/getNewComponentName.js index a69bec21ad..98ca05b827 100644 --- a/packages/builder/src/builderStore/getNewComponentName.js +++ b/packages/builder/src/builderStore/getNewComponentName.js @@ -2,6 +2,8 @@ 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( @@ -19,14 +21,16 @@ export default function(component, state) { }) } - // check page first - findMatches(state.pages[state.currentPageName].props) + // check layouts first + for (let layout of state.layouts) { + findMatches(layout.props) + } // if viewing screen, check current screen for duplicate - if (state.currentFrontEndType === "screen") { - findMatches(state.currentPreviewItem.props) + if (state.currentFrontEndType === FrontendTypes.SCREEN) { + findMatches(get(currentAsset).props) } else { - // viewing master page - need to find against all screens + // viewing a layout - need to find against all screens for (let screen of get(allScreens)) { findMatches(screen.props) } diff --git a/packages/builder/src/builderStore/index.js b/packages/builder/src/builderStore/index.js index ae77889404..503d9b08a7 100644 --- a/packages/builder/src/builderStore/index.js +++ b/packages/builder/src/builderStore/index.js @@ -4,37 +4,74 @@ import { getAutomationStore } from "./store/automation/" import { getThemeStore } from "./store/theme" import { derived } from "svelte/store" import analytics from "analytics" +import { LAYOUT_NAMES } from "../constants" +import { makePropsSafe } from "components/userInterface/assetParsing/createProps" export const store = getFrontendStore() export const backendUiStore = getBackendUiStore() export const automationStore = getAutomationStore() export const themeStore = getThemeStore() +export const currentAsset = derived(store, $store => { + const layout = $store.layouts + ? $store.layouts.find(layout => layout._id === $store.currentAssetId) + : null + + if (layout) return layout + + const screen = $store.screens + ? $store.screens.find(screen => screen._id === $store.currentAssetId) + : null + + if (screen) return screen + + return null +}) + +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) + } + } + + 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 + } +) + +export const currentAssetName = derived(store, () => { + return currentAsset.name +}) + +// leave this as before for consistency export const allScreens = derived(store, $store => { - let screens = [] - if ($store.pages == null) { - return screens - } - for (let page of Object.values($store.pages)) { - screens = screens.concat(page._screens) - } - return screens + return $store.screens }) -export const currentScreens = derived(store, $store => { - const currentScreens = $store.pages[$store.currentPageName]?._screens - if (currentScreens == null) { - return [] - } - return Array.isArray(currentScreens) - ? currentScreens - : Object.values(currentScreens) -}) - -export const selectedPage = derived(store, $store => { - if (!$store.pages) return null - - return $store.pages[$store.currentPageName || "main"] +export const mainLayout = derived(store, $store => { + return $store.layouts?.find( + layout => layout.props?._id === LAYOUT_NAMES.MASTER.PRIVATE + ) }) export const initialise = async () => { diff --git a/packages/builder/src/builderStore/store/frontend.js b/packages/builder/src/builderStore/store/frontend.js index 50c4347583..7be4f68f25 100644 --- a/packages/builder/src/builderStore/store/frontend.js +++ b/packages/builder/src/builderStore/store/frontend.js @@ -4,34 +4,36 @@ import { createProps, getBuiltin, makePropsSafe, -} from "components/userInterface/pagesParsing/createProps" -import { allScreens, backendUiStore, selectedPage } from "builderStore" -import { generate_screen_css } from "../generate_css" +} from "components/userInterface/assetParsing/createProps" +import { + allScreens, + backendUiStore, + currentAsset, + mainLayout, + selectedComponent, +} from "builderStore" import { fetchComponentLibDefinitions } from "../loadComponentLibraries" import api from "../api" -import { DEFAULT_PAGES_OBJECT } from "../../constants" +import { FrontendTypes } from "../../constants" import getNewComponentName from "../getNewComponentName" import analytics from "analytics" import { findChildComponentType, generateNewIdsForComponent, getComponentDefinition, - getParent, + findParent, } from "../storeUtils" const INITIAL_FRONTEND_STATE = { apps: [], name: "", description: "", - pages: DEFAULT_PAGES_OBJECT, - mainUi: {}, - unauthenticatedUi: {}, + layouts: [], + screens: [], components: [], - currentPreviewItem: null, - currentComponentInfo: null, currentFrontEndType: "none", - currentPageName: "", - currentComponentProps: null, + currentAssetId: "", + selectedComponentId: "", errors: [], hasAppPackage: false, libraries: null, @@ -43,52 +45,13 @@ export const getFrontendStore = () => { const store = writable({ ...INITIAL_FRONTEND_STATE }) store.actions = { - // TODO: REFACTOR initialise: async pkg => { + const { layouts, screens, application } = pkg + store.update(state => { - state.appId = pkg.application._id + state.appId = application._id return state }) - const screens = await api.get("/api/screens").then(r => r.json()) - - const mainScreens = screens.filter(screen => - screen._id.includes(pkg.pages.main._id) - ), - unauthScreens = screens.filter(screen => - screen._id.includes(pkg.pages.unauthenticated._id) - ) - pkg.pages = { - main: { - ...pkg.pages.main, - _screens: mainScreens, - }, - unauthenticated: { - ...pkg.pages.unauthenticated, - _screens: unauthScreens, - }, - } - - // if the app has just been created - // we need to build the CSS and save - if (pkg.justCreated) { - for (let pageName of ["main", "unauthenticated"]) { - const page = pkg.pages[pageName] - store.actions.screens.regenerateCss(page) - for (let screen of page._screens) { - store.actions.screens.regenerateCss(screen) - } - - await api.post(`/api/pages/${page._id}`, { - page: { - componentLibraries: pkg.application.componentLibraries, - ...page, - }, - screens: page._screens, - }) - } - } - - pkg.justCreated = false const components = await fetchComponentLibDefinitions(pkg.application._id) @@ -99,7 +62,8 @@ export const getFrontendStore = () => { name: pkg.application.name, description: pkg.application.description, appId: pkg.application._id, - pages: pkg.pages, + layouts, + screens, hasAppPackage: true, builtins: [getBuiltin("##builtin/screenslot")], appInstance: pkg.application.instance, @@ -107,20 +71,6 @@ export const getFrontendStore = () => { await backendUiStore.actions.database.select(pkg.application.instance) }, - selectPageOrScreen: type => { - store.update(state => { - state.currentFrontEndType = type - - const page = get(selectedPage) - - const pageOrScreen = type === "page" ? page : page._screens[0] - - state.currentComponentInfo = pageOrScreen ? pageOrScreen.props : null - state.currentPreviewItem = pageOrScreen - state.currentView = "detail" - return state - }) - }, routing: { fetch: async () => { const response = await api.get("/api/routing") @@ -133,167 +83,166 @@ export const getFrontendStore = () => { }, }, screens: { - select: screenId => { + select: async screenId => { + let promise store.update(state => { const screen = get(allScreens).find(screen => screen._id === screenId) - state.currentPreviewItem = screen - state.currentFrontEndType = "screen" + state.currentFrontEndType = FrontendTypes.SCREEN + state.currentAssetId = screenId state.currentView = "detail" - store.actions.screens.regenerateCssForCurrentScreen() - const safeProps = makePropsSafe( - state.components[screen.props._component], - screen.props - ) - screen.props = safeProps - state.currentComponentInfo = safeProps + promise = store.actions.screens.regenerateCss(screen) + state.selectedComponentId = screen.props._id return state }) + await promise }, create: async screen => { - let savePromise + screen = await store.actions.screens.save(screen) store.update(state => { - state.currentPreviewItem = screen - state.currentComponentInfo = screen.props - state.currentFrontEndType = "screen" - - if (state.currentPreviewItem) { - store.actions.screens.regenerateCss(state.currentPreviewItem) - } - - savePromise = store.actions.screens.save(screen) + state.currentAssetId = screen._id + state.selectedComponentId = screen.props._id + state.currentFrontEndType = FrontendTypes.SCREEN return state }) - - await savePromise + return screen }, save: async screen => { - const page = get(selectedPage) - const currentPageScreens = page._screens - const creatingNewScreen = screen._id === undefined + const response = await api.post(`/api/screens`, screen) + screen = await response.json() - let savePromise - const response = await api.post(`/api/screens/${page._id}`, screen) - const json = await response.json() - screen._rev = json.rev - screen._id = json.id - const foundScreen = page._screens.findIndex(el => el._id === screen._id) - if (foundScreen !== -1) { - page._screens.splice(foundScreen, 1) - } - page._screens.push(screen) - - // TODO: should carry out all server updates to screen in a single call store.update(state => { - page._screens = currentPageScreens + const foundScreen = state.screens.findIndex( + el => el._id === screen._id + ) + if (foundScreen !== -1) { + state.screens.splice(foundScreen, 1) + } + state.screens.push(screen) if (creatingNewScreen) { - state.currentPreviewItem = screen const safeProps = makePropsSafe( state.components[screen.props._component], screen.props ) - state.currentComponentInfo = safeProps + state.selectedComponentId = safeProps._id screen.props = safeProps } - savePromise = store.actions.pages.save() - return state }) - if (savePromise) await savePromise + return screen }, - regenerateCss: screen => { - screen._css = generate_screen_css([screen.props]) + regenerateCss: async asset => { + const response = await api.post("/api/css/generate", asset) + asset._css = (await response.json())?.css }, - regenerateCssForCurrentScreen: () => { - const { currentPreviewItem } = get(store) - if (currentPreviewItem) { - store.actions.screens.regenerateCss(currentPreviewItem) + regenerateCssForCurrentScreen: async () => { + const asset = get(currentAsset) + if (asset) { + await store.actions.screens.regenerateCss(asset) } }, delete: async screens => { - let deletePromise - const screensToDelete = Array.isArray(screens) ? screens : [screens] + const screenDeletePromises = [] store.update(state => { - const currentPage = get(selectedPage) - for (let screenToDelete of screensToDelete) { - // Remove screen from current page as well - // TODO: Should be done server side - currentPage._screens = currentPage._screens.filter( - scr => scr._id !== screenToDelete._id + state.screens = state.screens.filter( + screen => screen._id !== screenToDelete._id ) - - deletePromise = api.delete( - `/api/screens/${screenToDelete._id}/${screenToDelete._rev}` + screenDeletePromises.push( + api.delete( + `/api/screens/${screenToDelete._id}/${screenToDelete._rev}` + ) ) } return state }) - await deletePromise + await Promise.all(screenDeletePromises) }, }, preview: { saveSelected: async () => { const state = get(store) - if (state.currentFrontEndType !== "page") { - await store.actions.screens.save(state.currentPreviewItem) + const selectedAsset = get(currentAsset) + + if (state.currentFrontEndType !== FrontendTypes.LAYOUT) { + await store.actions.screens.save(selectedAsset) + } else { + await store.actions.layouts.save(selectedAsset) } - await store.actions.pages.save() }, }, - pages: { - select: pageName => { + layouts: { + select: async layoutId => { store.update(state => { - const currentPage = state.pages[pageName] + const layout = store.actions.layouts.find(layoutId) - state.currentFrontEndType = "page" + state.currentFrontEndType = FrontendTypes.LAYOUT state.currentView = "detail" - state.currentPageName = pageName - // This is the root of many problems. - // Uncaught (in promise) TypeError: Cannot read property '_component' of undefined - // it appears that the currentPage sometimes has _props instead of props - // why - const safeProps = makePropsSafe( - state.components[currentPage.props._component], - currentPage.props - ) - state.currentComponentInfo = safeProps - currentPage.props = safeProps - state.currentPreviewItem = state.pages[pageName] - store.actions.screens.regenerateCssForCurrentScreen() - - for (let screen of get(allScreens)) { - screen._css = generate_screen_css([screen.props]) - } + state.currentAssetId = layout._id + state.selectedComponentId = layout.props._id return state }) - }, - save: async page => { - const storeContents = get(store) - const pageName = storeContents.currentPageName || "main" - const pageToSave = page || storeContents.pages[pageName] + let cssPromises = [] + cssPromises.push(store.actions.screens.regenerateCssForCurrentScreen()) - // TODO: revisit. This sends down a very weird payload - const response = await api.post(`/api/pages/${pageToSave._id}`, { - page: { - componentLibraries: storeContents.pages.componentLibraries, - ...pageToSave, - }, - screens: pageToSave._screens, - }) + for (let screen of get(allScreens)) { + cssPromises.push(store.actions.screens.regenerateCss(screen)) + } + await Promise.all(cssPromises) + }, + save: async layout => { + const layoutToSave = cloneDeep(layout) + delete layoutToSave._css + + const response = await api.post(`/api/layouts`, layoutToSave) const json = await response.json() - if (!json.ok) throw new Error("Error updating page") + store.update(state => { + const layoutIdx = state.layouts.findIndex( + stateLayout => stateLayout._id === json._id + ) + + if (layoutIdx >= 0) { + // update existing layout + state.layouts.splice(layoutIdx, 1, json) + } else { + // save new layout + state.layouts.push(json) + } + + state.currentAssetId = json._id + state.selectedComponentId = json.props._id + return state + }) + }, + find: layoutId => { + if (!layoutId) { + return get(mainLayout) + } + const storeContents = get(store) + return storeContents.layouts.find(layout => layout._id === layoutId) + }, + delete: async layoutToDelete => { + const response = await api.delete( + `/api/layouts/${layoutToDelete._id}/${layoutToDelete._rev}` + ) + + if (response.status !== 200) { + const json = await response.json() + throw new Error(json.message) + } store.update(state => { - state.pages[pageName]._rev = json.rev + state.layouts = state.layouts.filter( + layout => layout._id !== layoutToDelete._id + ) return state }) }, @@ -301,17 +250,19 @@ export const getFrontendStore = () => { components: { select: component => { store.update(state => { - const componentDef = component._component.startsWith("##") - ? component - : state.components[component._component] - state.currentComponentInfo = makePropsSafe(componentDef, component) + state.selectedComponentId = component._id state.currentView = "component" return state }) }, create: (componentToAdd, presetProps) => { + const selectedAsset = get(currentAsset) + store.update(state => { function findSlot(component_array) { + if (!component_array) { + return false + } for (let component of component_array) { if (component._component === "##builtin/screenslot") { return true @@ -324,7 +275,7 @@ export const getFrontendStore = () => { if ( componentToAdd.startsWith("##") && - findSlot(state.pages[state.currentPageName].props._children) + findSlot(selectedAsset?.props._children) ) { return state } @@ -340,29 +291,34 @@ export const getFrontendStore = () => { _instanceName: instanceName, }) - const currentComponent = - state.components[state.currentComponentInfo._component] + const selected = get(selectedComponent) - const targetParent = currentComponent.children - ? state.currentComponentInfo - : getParent( - state.currentPreviewItem.props, - state.currentComponentInfo - ) + const currentComponentDefinition = + state.components[selected._component] - // Don't continue if there's no parent - if (!targetParent) { - return state + const allowsChildren = currentComponentDefinition.children + + // Determine where to put the new component. + let targetParent + if (allowsChildren) { + // Child of the selected component + targetParent = selected + } else { + // Sibling of selected component + targetParent = findParent(selectedAsset.props, selected) } - targetParent._children = targetParent._children.concat( - newComponent.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() state.currentView = "component" - state.currentComponentInfo = newComponent.props + state.selectedComponentId = newComponent.props._id + analytics.captureEvent("Added Component", { name: newComponent.props._component, }) @@ -370,14 +326,12 @@ export const getFrontendStore = () => { }) }, copy: (component, cut = false) => { + const selectedAsset = get(currentAsset) store.update(state => { state.componentToPaste = cloneDeep(component) state.componentToPaste.isCut = cut if (cut) { - const parent = getParent( - state.currentPreviewItem.props, - component._id - ) + const parent = findParent(selectedAsset.props, component._id) parent._children = parent._children.filter( child => child._id !== component._id ) @@ -387,7 +341,9 @@ export const getFrontendStore = () => { return state }) }, - paste: (targetComponent, mode) => { + paste: async (targetComponent, mode) => { + const selectedAsset = get(currentAsset) + let promises = [] store.update(state => { if (!state.componentToPaste) return state @@ -406,54 +362,56 @@ export const getFrontendStore = () => { return state } - const parent = getParent( - state.currentPreviewItem.props, - targetComponent - ) + const parent = findParent(selectedAsset.props, targetComponent) const targetIndex = parent._children.indexOf(targetComponent) const index = mode === "above" ? targetIndex : targetIndex + 1 parent._children.splice(index, 0, cloneDeep(componentToPaste)) - store.actions.screens.regenerateCssForCurrentScreen() - store.actions.preview.saveSelected() + promises.push(store.actions.screens.regenerateCssForCurrentScreen()) + promises.push(store.actions.preview.saveSelected()) store.actions.components.select(componentToPaste) return state }) + await Promise.all(promises) }, - updateStyle: (type, name, value) => { - store.update(state => { - if (!state.currentComponentInfo._styles) { - state.currentComponentInfo._styles = {} - } - state.currentComponentInfo._styles[type][name] = value + updateStyle: async (type, name, value) => { + let promises = [] + const selected = get(selectedComponent) - store.actions.screens.regenerateCssForCurrentScreen() + store.update(state => { + if (!selected._styles) { + selected._styles = {} + } + selected._styles[type][name] = value + + promises.push(store.actions.screens.regenerateCssForCurrentScreen()) // save without messing with the store - store.actions.preview.saveSelected() + promises.push(store.actions.preview.saveSelected()) return state }) + await Promise.all(promises) }, updateProp: (name, value) => { store.update(state => { - let current_component = state.currentComponentInfo + let current_component = get(selectedComponent) current_component[name] = value - state.currentComponentInfo = current_component + state.selectedComponentId = current_component._id store.actions.preview.saveSelected() return state }) }, findRoute: component => { // Gets all the components to needed to construct a path. - const tempStore = get(store) + const selectedAsset = get(currentAsset) let pathComponents = [] let parent = component let root = false while (!root) { - parent = getParent(tempStore.currentPreviewItem.props, parent) + parent = findParent(selectedAsset.props, parent) if (!parent) { root = true } else { @@ -461,7 +419,7 @@ export const getFrontendStore = () => { } } - // Remove root entry since it's the screen or page layout. + // 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) @@ -476,11 +434,12 @@ export const getFrontendStore = () => { }, links: { save: async (url, title) => { - let savePromise + let promises = [] + const layout = get(mainLayout) store.update(state => { - // Try to extract a nav component from the master screen + // Try to extract a nav component from the master layout const nav = findChildComponentType( - state.pages.main, + layout, "@budibase/standard-components/navigation" ) if (nav) { @@ -513,18 +472,18 @@ export const getFrontendStore = () => { }).props } - // Save page and regenerate all CSS because otherwise weird things happen + // Save layout and regenerate all CSS because otherwise weird things happen nav._children = [...nav._children, newLink] - state.currentPageName = "main" - store.actions.screens.regenerateCss(state.pages.main) - for (let screen of state.pages.main._screens) { - store.actions.screens.regenerateCss(screen) + state.currentAssetId = layout._id + promises.push(store.actions.screens.regenerateCss(layout)) + for (let screen of get(allScreens)) { + promises.push(store.actions.screens.regenerateCss(screen)) } - savePromise = store.actions.pages.save() + promises.push(store.actions.layouts.save(layout)) } return state }) - await savePromise + await Promise.all(promises) }, }, }, diff --git a/packages/builder/src/builderStore/store/screenTemplates/utils/Component.js b/packages/builder/src/builderStore/store/screenTemplates/utils/Component.js index 84de7e15ea..bd03fc7cdc 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/utils/Component.js +++ b/packages/builder/src/builderStore/store/screenTemplates/utils/Component.js @@ -14,7 +14,6 @@ export class Component extends BaseStructure { active: {}, selected: {}, }, - _code: "", type: "", _instanceName: "", _children: [], diff --git a/packages/builder/src/builderStore/store/screenTemplates/utils/Screen.js b/packages/builder/src/builderStore/store/screenTemplates/utils/Screen.js index 76df96ae0c..00bd43ec2c 100644 --- a/packages/builder/src/builderStore/store/screenTemplates/utils/Screen.js +++ b/packages/builder/src/builderStore/store/screenTemplates/utils/Screen.js @@ -4,6 +4,7 @@ export class Screen extends BaseStructure { constructor() { super(true) this._json = { + layoutId: "layout_private_master", props: { _id: "", _component: "", @@ -18,7 +19,7 @@ export class Screen extends BaseStructure { }, routing: { route: "", - roleId: "", + roleId: "BASIC", }, name: "screen-id", } diff --git a/packages/builder/src/builderStore/storeUtils.js b/packages/builder/src/builderStore/storeUtils.js index 9c9d1ef940..4ee2dd7ccc 100644 --- a/packages/builder/src/builderStore/storeUtils.js +++ b/packages/builder/src/builderStore/storeUtils.js @@ -1,15 +1,21 @@ -import { getBuiltin } from "components/userInterface/pagesParsing/createProps" +import { getBuiltin } from "components/userInterface/assetParsing/createProps" import { uuid } from "./uuid" import getNewComponentName from "./getNewComponentName" -export const getParent = (rootProps, child) => { +/** + * 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 + */ +export const findParent = (rootProps, child) => { let parent - walkProps(rootProps, (p, breakWalk) => { + walkProps(rootProps, (props, breakWalk) => { if ( - p._children && - (p._children.includes(child) || p._children.some(c => c._id === child)) + props._children && + (props._children.includes(child) || + props._children.some(c => c._id === child)) ) { - parent = p + parent = props breakWalk() } }) diff --git a/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte b/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte index f064ff923c..7c26e236a6 100644 --- a/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte +++ b/packages/builder/src/components/backend/TableNavigator/modals/CreateTableModal.svelte @@ -51,14 +51,13 @@ const screens = screenTemplates($store, [table]) .filter(template => defaultScreens.includes(template.id)) .map(template => template.create()) - store.actions.pages.select("main") for (let screen of screens) { // Record the table that created this screen so we can link it later screen.autoTableId = table._id await store.actions.screens.create(screen) } - // Create autolink to newly created list page + // Create autolink to newly created list screen const listScreen = screens.find(screen => screen.props._instanceName.endsWith("List") ) diff --git a/packages/builder/src/components/start/CreateAppModal.svelte b/packages/builder/src/components/start/CreateAppModal.svelte index 15d4eb4dc6..b88397b6c4 100644 --- a/packages/builder/src/components/start/CreateAppModal.svelte +++ b/packages/builder/src/components/start/CreateAppModal.svelte @@ -153,9 +153,8 @@ const pkg = await applicationPkg.json() if (applicationPkg.ok) { backendUiStore.actions.reset() - pkg.justCreated = true await store.actions.initialise(pkg) - automationStore.actions.fetch() + await automationStore.actions.fetch() } else { throw new Error(pkg) } diff --git a/packages/builder/src/components/userInterface/AppPreview/CurrentItemPreview.svelte b/packages/builder/src/components/userInterface/AppPreview/CurrentItemPreview.svelte index 535b947f5f..ccb0153e45 100644 --- a/packages/builder/src/components/userInterface/AppPreview/CurrentItemPreview.svelte +++ b/packages/builder/src/components/userInterface/AppPreview/CurrentItemPreview.svelte @@ -1,10 +1,13 @@
- {#if $store.currentPreviewItem} - ', - _appId: "inst_app_2cc_ca3383f896034e9295345c05f7dfca0c", _instanceName: "Rick Astley Video", _children: [], }, @@ -99,5 +94,62 @@ exports.HOME_SCREEN = { route: "/", roleId: BUILTIN_ROLE_IDS.BASIC, }, - name: "d834fea2-1b3e-4320-ab34-f9009f5ecc59", -} + name: "home-screen", +}) + +exports.createLoginScreen = app => ({ + description: "", + url: "", + layoutId: BASE_LAYOUT_PROP_IDS.PUBLIC, + props: { + _instanceName: "LoginScreenContainer", + _id: "5beb4c7b-3c8b-49b2-b8b3-d447dc76dda7", + _component: "@budibase/standard-components/container", + _styles: { + normal: { + flex: "1 1 auto", + display: "flex", + "flex-direction": "column", + "justify-content": "center", + "align-items": "center", + }, + hover: {}, + active: {}, + selected: {}, + }, + type: "div", + _children: [ + { + _id: "781e497e-2e7c-11eb-adc1-0242ac120002", + _component: "@budibase/standard-components/login", + _styles: { + normal: { + padding: "64px", + background: "rgba(255, 255, 255, 0.4)", + "border-radius": "0.5rem", + "margin-top": "0px", + "box-shadow": + "0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)", + "font-size": "16px", + "font-family": "Inter", + flex: "0 1 auto", + }, + hover: {}, + active: {}, + selected: {}, + }, + logo: + "https://d33wubrfki0l68.cloudfront.net/aac32159d7207b5085e74a7ef67afbb7027786c5/2b1fd/img/logo/bb-emblem.svg", + title: `Log in to ${app.name}`, + buttonText: "Log In", + _children: [], + _instanceName: "Login", + }, + ], + }, + routing: { + route: "/", + roleId: BUILTIN_ROLE_IDS.PUBLIC, + }, + name: "login-screen", +}) diff --git a/packages/server/src/db/utils.js b/packages/server/src/db/utils.js index 6d7ea8bb93..737febbdfb 100644 --- a/packages/server/src/db/utils.js +++ b/packages/server/src/db/utils.js @@ -13,7 +13,7 @@ const DocumentTypes = { ROLE: "role", WEBHOOK: "wh", INSTANCE: "inst", - PAGE: "page", + LAYOUT: "layout", SCREEN: "screen", } @@ -179,6 +179,36 @@ exports.getRoleParams = (roleId = null, otherProps = {}) => { return getDocParams(DocumentTypes.ROLE, roleId, otherProps) } +/** + * Generates a new layout ID. + * @returns {string} The new layout ID which the layout doc can be stored under. + */ +exports.generateLayoutID = id => { + return `${DocumentTypes.LAYOUT}${SEPARATOR}${id || newid()}` +} + +/** + * Gets parameters for retrieving layout, this is a utility function for the getDocParams function. + */ +exports.getLayoutParams = (layoutId = null, otherProps = {}) => { + return getDocParams(DocumentTypes.LAYOUT, layoutId, otherProps) +} + +/** + * Generates a new screen ID. + * @returns {string} The new screen ID which the screen doc can be stored under. + */ +exports.generateScreenID = () => { + return `${DocumentTypes.SCREEN}${SEPARATOR}${newid()}` +} + +/** + * Gets parameters for retrieving screens, this is a utility function for the getDocParams function. + */ +exports.getScreenParams = (screenId = null, otherProps = {}) => { + return getDocParams(DocumentTypes.SCREEN, screenId, otherProps) +} + /** * Generates a new webhook ID. * @returns {string} The new webhook ID which the webhook doc can be stored under. @@ -187,36 +217,6 @@ exports.generateWebhookID = () => { return `${DocumentTypes.WEBHOOK}${SEPARATOR}${newid()}` } -/** - * Generates a new page ID. - * @returns {string} The new page ID which the page doc can be stored under. - */ -exports.generatePageID = () => { - return `${DocumentTypes.PAGE}${SEPARATOR}${newid()}` -} - -/** - * Gets parameters for retrieving pages, this is a utility function for the getDocParams function. - */ -exports.getPageParams = (pageId = null, otherProps = {}) => { - return getDocParams(DocumentTypes.PAGE, pageId, otherProps) -} - -/** - * Generates a new screen ID. - * @returns {string} The new screen ID which the screen doc can be stored under. - */ -exports.generateScreenID = pageId => { - return `${DocumentTypes.SCREEN}${SEPARATOR}${pageId}${SEPARATOR}${newid()}` -} - -/** - * Gets parameters for retrieving screens for a particular page, this is a utility function for the getDocParams function. - */ -exports.getScreenParams = (pageId = null, otherProps = {}) => { - return getDocParams(DocumentTypes.SCREEN, pageId, otherProps) -} - /** * Gets parameters for retrieving a webhook, this is a utility function for the getDocParams function. */ diff --git a/packages/server/src/utilities/builder/compileStaticAssets.js b/packages/server/src/utilities/builder/compileStaticAssets.js new file mode 100644 index 0000000000..763fccd35a --- /dev/null +++ b/packages/server/src/utilities/builder/compileStaticAssets.js @@ -0,0 +1,83 @@ +const { + ensureDir, + constants, + copyFile, + writeFile, + readdir, + readFile, + existsSync, +} = require("fs-extra") +const { join } = require("../centralPath") +const { budibaseAppsDir } = require("../budibaseDir") + +const CSS_DIRECTORY = "css" + +/** + * Compile all the non-db static web assets that are required for the running of + * a budibase application. This includes CSS, the JSON structure of the DOM and + * the client library, a script responsible for reading the JSON structure + * and rendering the application. + * @param {string} appId id of the application we want to compile static assets for + * @param {array|object} assets a list of screens or screen layouts for which the CSS should be extracted and stored. + */ +module.exports = async (appId, assets) => { + const publicPath = join(budibaseAppsDir(), appId, "public") + await ensureDir(publicPath) + for (let asset of Array.isArray(assets) ? assets : [assets]) { + await buildCssBundle(publicPath, asset) + await copyClientLib(publicPath) + // remove props that shouldn't be present when written to DB + if (asset._css) { + delete asset._css + } + } + return assets +} + +/** + * Reads the _css property of all screens and the screen layouts, and creates a singular CSS + * bundle for the app at /public/bundle.css + * @param {String} publicPath - path to the public assets directory of the budibase application + * @param {Object} asset a single screen or screen layout which is being updated + */ +const buildCssBundle = async (publicPath, asset) => { + const cssPath = join(publicPath, CSS_DIRECTORY) + let cssString = "" + + // create a singular CSS file for this asset + const assetCss = asset._css ? asset._css.trim() : "" + if (assetCss.length !== 0) { + await ensureDir(cssPath) + await writeFile(join(cssPath, asset._id), assetCss) + } + + // bundle up all the CSS in the directory into one top level CSS file + if (existsSync(cssPath)) { + const cssFiles = await readdir(cssPath) + for (let filename of cssFiles) { + const css = await readFile(join(cssPath, filename)) + cssString += css + } + } + + await writeFile(join(publicPath, "bundle.css"), cssString) +} + +/** + * Copy the budibase client library and sourcemap from NPM to /public/. + * The client library is then served as a static asset when the budibase application + * is running in preview or prod + * @param {String} publicPath - path to write the client library to + */ +const copyClientLib = async publicPath => { + const sourcepath = require.resolve("@budibase/client") + const destPath = join(publicPath, "budibase-client.js") + + await copyFile(sourcepath, destPath, constants.COPYFILE_FICLONE) + + await copyFile( + sourcepath + ".map", + destPath + ".map", + constants.COPYFILE_FICLONE + ) +} diff --git a/packages/server/src/utilities/builder/compileStaticAssetsForPage.js b/packages/server/src/utilities/builder/compileStaticAssetsForPage.js deleted file mode 100644 index c91ba24bb3..0000000000 --- a/packages/server/src/utilities/builder/compileStaticAssetsForPage.js +++ /dev/null @@ -1,102 +0,0 @@ -const { ensureDir, constants, copyFile, writeFile } = require("fs-extra") -const { join } = require("../centralPath") -const { budibaseAppsDir } = require("../budibaseDir") - -/** - * Compile all the non-db static web assets that are required for the running of - * a budibase application. This includes CSS, the JSON structure of the DOM and - * the client library, a script responsible for reading the JSON structure - * and rendering the application. - * @param {} appId - id of the application we want to compile static assets for - * @param {*} pageName - name of the page that the assets will be served for - * @param {*} pkg - app package information/metadata - */ -module.exports = async (appId, pageName, pkg) => { - const pagePath = join(budibaseAppsDir(), appId, "public", pageName) - - pkg.screens = pkg.screens || [] - - await ensureDir(pagePath) - - await buildPageCssBundle(pagePath, pkg) - - await buildFrontendAppDefinition(pagePath, pkg) - - await copyClientLib(pagePath) -} - -/** - * Reads the _css property of a page and its screens, and creates a singular CSS - * bundle for the page at /public//bundle.css - * @param {String} publicPagePath - path to the public assets directory of the budibase application - * @param {Object} pkg - app package information - * @param {"main" | "unauthenticated"} pageName - the pagename of the page we are compiling CSS for. - */ -const buildPageCssBundle = async (publicPagePath, pkg) => { - let cssString = "" - - for (let screen of pkg.screens || []) { - if (!screen._css) continue - if (screen._css.trim().length === 0) { - delete screen._css - continue - } - cssString += screen._css - } - - if (pkg.page._css) cssString += pkg.page._css - - writeFile(join(publicPagePath, "bundle.css"), cssString) -} - -/** - * Copy the budibase client library and sourcemap from NPM to /public/. - * The client library is then served as a static asset when the budibase application - * is running in preview or prod - * @param {String} pagePath - path to write the client library to - */ -const copyClientLib = async pagePath => { - const sourcepath = require.resolve("@budibase/client") - const destPath = join(pagePath, "budibase-client.js") - - await copyFile(sourcepath, destPath, constants.COPYFILE_FICLONE) - - await copyFile( - sourcepath + ".map", - destPath + ".map", - constants.COPYFILE_FICLONE - ) -} - -/** - * Build the frontend definition for a budibase application. This includes all page and screen information, - * and is injected into the budibase client library to tell it how to start constructing - * the DOM from components defined in the frontendDefinition. - * @param {String} pagePath - path to the public folder of the page where the definition will be written - * @param {Object} pkg - app package information from which the frontendDefinition will be built. - */ -const buildFrontendAppDefinition = async (pagePath, pkg) => { - const filename = join(pagePath, "clientFrontendDefinition.js") - - // Delete CSS code from the page and screens so it's not injected - delete pkg.page._css - - for (let screen of pkg.screens) { - if (screen._css) { - delete pkg.page._css - } - } - - const clientUiDefinition = JSON.stringify({ - page: pkg.page, - screens: pkg.screens, - libraries: ["@budibase/standard-components"], - }) - - await writeFile( - filename, - ` - window['##BUDIBASE_FRONTEND_DEFINITION##'] = ${clientUiDefinition}; - ` - ) -} diff --git a/packages/builder/src/builderStore/generate_css.js b/packages/server/src/utilities/builder/generateCss.js similarity index 72% rename from packages/builder/src/builderStore/generate_css.js rename to packages/server/src/utilities/builder/generateCss.js index 2bb5a3bd2e..c3d72c741f 100644 --- a/packages/builder/src/builderStore/generate_css.js +++ b/packages/server/src/utilities/builder/generateCss.js @@ -1,21 +1,21 @@ -export const generate_screen_css = component_arr => { +exports.generateAssetCss = component_arr => { let styles = "" for (const { _styles, _id, _children, _component } of component_arr) { let [componentName] = _component.match(/[a-z]*$/) Object.keys(_styles).forEach(selector => { - const cssString = generate_css(_styles[selector]) + const cssString = exports.generateCss(_styles[selector]) if (cssString) { - styles += apply_class(_id, componentName, cssString, selector) + styles += exports.applyClass(_id, componentName, cssString, selector) } }) if (_children && _children.length) { - styles += generate_screen_css(_children) + "\n" + styles += exports.generateAssetCss(_children) + "\n" } } return styles.trim() } -export const generate_css = style => { +exports.generateCss = style => { let cssString = Object.entries(style).reduce((str, [key, value]) => { if (typeof value === "string") { if (value) { @@ -33,7 +33,7 @@ export const generate_css = style => { return (cssString || "").trim() } -export const apply_class = (id, name = "element", styles, selector) => { +exports.applyClass = (id, name = "element", styles, selector) => { if (selector === "normal") { return `.${name}-${id} {\n${styles}\n}` } else { diff --git a/packages/server/src/utilities/mustache.js b/packages/server/src/utilities/mustache.js new file mode 100644 index 0000000000..0428bdc03d --- /dev/null +++ b/packages/server/src/utilities/mustache.js @@ -0,0 +1,73 @@ +const handlebars = require("handlebars") + +handlebars.registerHelper("object", value => { + return new handlebars.SafeString(JSON.stringify(value)) +}) + +/** + * When running mustache statements to execute on the context of the automation it possible user's may input mustache + * in a few different forms, some of which are invalid but are logically valid. An example of this would be the mustache + * statement "{{steps[0].revision}}" here it is obvious the user is attempting to access an array or object using array + * like operators. These are not supported by Mustache and therefore the statement will fail. This function will clean up + * the mustache statement so it instead reads as "{{steps.0.revision}}" which is valid and will work. It may also be expanded + * to include any other mustache statement cleanup that has been deemed necessary for the system. + * + * @param {string} string The string which *may* contain mustache statements, it is OK if it does not contain any. + * @returns {string} The string that was input with cleaned up mustache statements as required. + */ +function cleanMustache(string) { + let charToReplace = { + "[": ".", + "]": "", + } + let regex = new RegExp(/{{[^}}]*}}/g) + let matches = string.match(regex) + if (matches == null) { + return string + } + for (let match of matches) { + let baseIdx = string.indexOf(match) + for (let key of Object.keys(charToReplace)) { + let idxChar = match.indexOf(key) + if (idxChar !== -1) { + string = + string.slice(baseIdx, baseIdx + idxChar) + + charToReplace[key] + + string.slice(baseIdx + idxChar + 1) + } + } + } + return string +} + +/** + * Given an input object this will recurse through all props to try and update + * any handlebars/mustache statements within. + * @param {object|array} inputs The input structure which is to be recursed, it is important to note that + * if the structure contains any cycles then this will fail. + * @param {object} context The context that handlebars should fill data from. + * @returns {object|array} The structure input, as fully updated as possible. + */ +function recurseMustache(inputs, context) { + // JSON stringify will fail if there are any cycles, stops infinite recursion + try { + JSON.stringify(inputs) + } catch (err) { + throw "Unable to process inputs to JSON, cannot recurse" + } + for (let key of Object.keys(inputs)) { + let val = inputs[key] + if (typeof val === "string") { + val = cleanMustache(inputs[key]) + const template = handlebars.compile(val) + inputs[key] = template(context) + } + // this covers objects and arrays + else if (typeof val === "object") { + inputs[key] = recurseMustache(inputs[key], context) + } + } + return inputs +} + +exports.recurseMustache = recurseMustache