diff --git a/packages/builder/src/stores/builder/automations.ts b/packages/builder/src/stores/builder/automations.ts index 9b20b4cd031..5e17b461554 100644 --- a/packages/builder/src/stores/builder/automations.ts +++ b/packages/builder/src/stores/builder/automations.ts @@ -2,7 +2,7 @@ import { derived, get } from "svelte/store" import { API } from "@/api" import { cloneDeep } from "lodash/fp" import { generate } from "shortid" -import { createHistoryStore } from "@/stores/builder/history" +import { createHistoryStore, HistoryStore } from "@/stores/builder/history" import { licensing } from "@/stores/portal" import { tables, appStore } from "@/stores/builder" import { notifications } from "@budibase/bbui" @@ -1428,7 +1428,7 @@ const automationActions = (store: AutomationStore) => ({ }) class AutomationStore extends BudiStore { - history: any + history: HistoryStore actions: ReturnType constructor() { diff --git a/packages/builder/src/stores/builder/componentTreeNodes.ts b/packages/builder/src/stores/builder/componentTreeNodes.ts index 420c540e377..775985abdca 100644 --- a/packages/builder/src/stores/builder/componentTreeNodes.ts +++ b/packages/builder/src/stores/builder/componentTreeNodes.ts @@ -1,7 +1,7 @@ import { get } from "svelte/store" import { selectedScreen as selectedScreenStore } from "./screens" import { findComponentPath } from "@/helpers/components" -import { Screen, Component } from "@budibase/types" +import { Component } from "@budibase/types" import { BudiStore, PersistenceType } from "@/stores/BudiStore" interface OpenNodesState { @@ -49,9 +49,9 @@ export class ComponentTreeNodesStore extends BudiStore { // Will ensure all parents of a node are expanded so that it is visible in the tree makeNodeVisible(componentId: string) { - const selectedScreen: Screen = get(selectedScreenStore) + const selectedScreen = get(selectedScreenStore) - const path = findComponentPath(selectedScreen.props, componentId) + const path = findComponentPath(selectedScreen?.props, componentId) const componentIds = path.map((component: Component) => component._id) diff --git a/packages/builder/src/stores/builder/components.ts b/packages/builder/src/stores/builder/components.ts index 9ad9a75f84b..d831d35ab65 100644 --- a/packages/builder/src/stores/builder/components.ts +++ b/packages/builder/src/stores/builder/components.ts @@ -41,6 +41,7 @@ interface ComponentDefinition { settings?: ComponentSetting[] features?: Record typeSupportPresets?: Record + illegalChildren?: string[] } interface ComponentSetting { @@ -57,7 +58,7 @@ interface ComponentSetting { interface ComponentState { components: Record customComponents: string[] - selectedComponentId: string | null + selectedComponentId: string | null | undefined componentToPaste?: Component | null settingsCache: Record selectedScreenId?: string | null @@ -478,10 +479,11 @@ export class ComponentStore extends BudiStore { extras._children = [] } + const $selectedScreen = get(selectedScreen) // Add step name to form steps - if (componentName.endsWith("/formstep")) { + if (componentName.endsWith("/formstep") && $selectedScreen) { const parentForm = findClosestMatchingComponent( - get(selectedScreen).props, + $selectedScreen.props, get(selectedComponent)._id, (component: Component) => component._component.endsWith("/form") ) @@ -615,7 +617,7 @@ export class ComponentStore extends BudiStore { const state = get(this.store) componentId = componentId ?? state.selectedComponentId ?? undefined const screenState = get(screenStore) - screenId = screenId || screenState.selectedScreenId + screenId = (screenId || screenState.selectedScreenId) ?? undefined } if (!componentId || !screenId || !patchFn) { return @@ -840,7 +842,7 @@ export class ComponentStore extends BudiStore { getPrevious() { const state = get(this.store) const componentId = state.selectedComponentId - const screen = get(selectedScreen) + const screen = get(selectedScreen)! const parent = findComponentParent(screen.props, componentId) const index = parent?._children.findIndex( (x: Component) => x._id === componentId @@ -889,7 +891,7 @@ export class ComponentStore extends BudiStore { const state = get(this.store) const component = get(selectedComponent) const componentId = component?._id - const screen = get(selectedScreen) + const screen = get(selectedScreen)! const parent = findComponentParent(screen.props, componentId) const index = parent?._children.findIndex( (x: Component) => x._id === componentId diff --git a/packages/builder/src/stores/builder/history.js b/packages/builder/src/stores/builder/history.ts similarity index 79% rename from packages/builder/src/stores/builder/history.js rename to packages/builder/src/stores/builder/history.ts index 62a8ed2f977..6568c5abca4 100644 --- a/packages/builder/src/stores/builder/history.js +++ b/packages/builder/src/stores/builder/history.ts @@ -1,10 +1,25 @@ -import * as jsonpatch from "fast-json-patch/index.mjs" -import { writable, derived, get } from "svelte/store" +import { Document } from "@budibase/types" +import * as jsonpatch from "fast-json-patch" +import { writable, derived, get, Readable } from "svelte/store" -export const Operations = { - Add: "Add", - Delete: "Delete", - Change: "Change", +export const enum Operations { + Add = "Add", + Delete = "Delete", + Change = "Change", +} + +interface Operator { + id?: number + type: Operations + doc: T + forwardPatch?: jsonpatch.Operation[] + backwardsPatch?: jsonpatch.Operation[] +} + +interface HistoryState { + history: Operator[] + position: number + loading?: boolean } export const initialState = { @@ -13,14 +28,38 @@ export const initialState = { loading: false, } -export const createHistoryStore = ({ +export interface HistoryStore + extends Readable< + HistoryState & { + canUndo: boolean + canRedo: boolean + } + > { + wrapSaveDoc: ( + fn: (doc: T) => Promise + ) => (doc: T, operationId?: number) => Promise + wrapDeleteDoc: ( + fn: (doc: T) => Promise + ) => (doc: T, operationId?: number) => Promise + + reset: () => void + undo: () => Promise + redo: () => Promise +} + +export const createHistoryStore = ({ getDoc, selectDoc, beforeAction, afterAction, -}) => { +}: { + getDoc: (id: string) => T | undefined + selectDoc: (id: string) => void + beforeAction?: (operation?: Operator) => void + afterAction?: (operation?: Operator) => void +}): HistoryStore => { // Use a derived store to check if we are able to undo or redo any operations - const store = writable(initialState) + const store = writable>(initialState) const derivedStore = derived(store, $store => { return { ...$store, @@ -31,8 +70,8 @@ export const createHistoryStore = ({ // Wrapped versions of essential functions which we call ourselves when using // undo and redo - let saveFn - let deleteFn + let saveFn: (doc: T, operationId?: number) => Promise + let deleteFn: (doc: T, operationId?: number) => Promise /** * Internal util to set the loading flag @@ -66,7 +105,7 @@ export const createHistoryStore = ({ * For internal use only. * @param operation the operation to save */ - const saveOperation = operation => { + const saveOperation = (operation: Operator) => { store.update(state => { // Update history let history = state.history @@ -93,15 +132,15 @@ export const createHistoryStore = ({ * @param fn the save function * @returns {function} a wrapped version of the save function */ - const wrapSaveDoc = fn => { - saveFn = async (doc, operationId) => { + const wrapSaveDoc = (fn: (doc: T) => Promise) => { + saveFn = async (doc: T, operationId?: number) => { // Only works on a single doc at a time if (!doc || Array.isArray(doc)) { return } startLoading() try { - const oldDoc = getDoc(doc._id) + const oldDoc = getDoc(doc._id!) const newDoc = jsonpatch.deepClone(await fn(doc)) // Store the change @@ -141,8 +180,8 @@ export const createHistoryStore = ({ * @param fn the delete function * @returns {function} a wrapped version of the delete function */ - const wrapDeleteDoc = fn => { - deleteFn = async (doc, operationId) => { + const wrapDeleteDoc = (fn: (doc: T) => Promise) => { + deleteFn = async (doc: T, operationId?: number) => { // Only works on a single doc at a time if (!doc || Array.isArray(doc)) { return @@ -201,7 +240,7 @@ export const createHistoryStore = ({ // Undo ADD if (operation.type === Operations.Add) { // Try to get the latest doc version to delete - const latestDoc = getDoc(operation.doc._id) + const latestDoc = getDoc(operation.doc._id!) const doc = latestDoc || operation.doc await deleteFn(doc, operation.id) } @@ -219,7 +258,7 @@ export const createHistoryStore = ({ // Undo CHANGE else { // Get the current doc and apply the backwards patch on top of it - let doc = jsonpatch.deepClone(getDoc(operation.doc._id)) + let doc = jsonpatch.deepClone(getDoc(operation.doc._id!)) if (doc) { jsonpatch.applyPatch( doc, @@ -283,7 +322,7 @@ export const createHistoryStore = ({ // Redo DELETE else if (operation.type === Operations.Delete) { // Try to get the latest doc version to delete - const latestDoc = getDoc(operation.doc._id) + const latestDoc = getDoc(operation.doc._id!) const doc = latestDoc || operation.doc await deleteFn(doc, operation.id) } @@ -291,7 +330,7 @@ export const createHistoryStore = ({ // Redo CHANGE else { // Get the current doc and apply the forwards patch on top of it - let doc = jsonpatch.deepClone(getDoc(operation.doc._id)) + let doc = jsonpatch.deepClone(getDoc(operation.doc._id!)) if (doc) { jsonpatch.applyPatch(doc, jsonpatch.deepClone(operation.forwardPatch)) await saveFn(doc, operation.id) diff --git a/packages/builder/src/stores/builder/screens.js b/packages/builder/src/stores/builder/screens.ts similarity index 85% rename from packages/builder/src/stores/builder/screens.js rename to packages/builder/src/stores/builder/screens.ts index 8298a1469da..a31c8fb728f 100644 --- a/packages/builder/src/stores/builder/screens.js +++ b/packages/builder/src/stores/builder/screens.ts @@ -10,16 +10,27 @@ import { navigationStore, selectedComponent, } from "@/stores/builder" -import { createHistoryStore } from "@/stores/builder/history" +import { createHistoryStore, HistoryStore } from "@/stores/builder/history" import { API } from "@/api" import { BudiStore } from "../BudiStore" +import { Component, Screen } from "@budibase/types" -export const INITIAL_SCREENS_STATE = { +interface ScreenState { + screens: Screen[] + selectedScreenId: string | null | undefined + selected?: Screen +} + +export const INITIAL_SCREENS_STATE: ScreenState = { screens: [], selectedScreenId: null, } -export class ScreenStore extends BudiStore { +export class ScreenStore extends BudiStore { + history: HistoryStore + save: (doc: Screen) => Promise + delete: (doc: Screen) => Promise + constructor() { super(INITIAL_SCREENS_STATE) @@ -66,7 +77,7 @@ export class ScreenStore extends BudiStore { * Replace ALL store screens with application package screens * @param {object} pkg */ - syncAppScreens(pkg) { + syncAppScreens(pkg: { screens: Screen[] }) { this.update(state => ({ ...state, screens: [...pkg.screens], @@ -79,7 +90,7 @@ export class ScreenStore extends BudiStore { * @param {string} screenId * @returns */ - select(screenId) { + select(screenId: string) { // Check this screen exists const state = get(this.store) const screen = state.screens.find(screen => screen._id === screenId) @@ -107,13 +118,13 @@ export class ScreenStore extends BudiStore { * @throws Will throw an error containing the name of the component causing * the invalid screen state */ - validate(screen) { + validate(screen: Screen) { // Recursive function to find any illegal children in component trees const findIllegalChild = ( - component, - illegalChildren = [], - legalDirectChildren = [] - ) => { + component: Component, + illegalChildren: string[] = [], + legalDirectChildren: string[] = [] + ): string | undefined => { const type = component._component if (illegalChildren.includes(type)) { @@ -138,13 +149,6 @@ export class ScreenStore extends BudiStore { } const definition = componentStore.getDefinition(component._component) - // Reset whitelist for direct children - legalDirectChildren = [] - if (definition?.legalDirectChildren?.length) { - legalDirectChildren = definition.legalDirectChildren.map(x => { - return `@budibase/standard-components/${x}` - }) - } // Append blacklisted components and remove duplicates if (definition?.illegalChildren?.length) { @@ -172,7 +176,7 @@ export class ScreenStore extends BudiStore { const illegalChild = findIllegalChild(screen.props) if (illegalChild) { const def = componentStore.getDefinition(illegalChild) - throw `You can't place a ${def.name} here` + throw `You can't place a ${def?.name} here` } } @@ -183,7 +187,7 @@ export class ScreenStore extends BudiStore { * @param {object} screen * @returns {object} */ - async saveScreen(screen) { + async saveScreen(screen: Screen): Promise { const appState = get(appStore) // Validate screen structure if the app supports it @@ -230,7 +234,7 @@ export class ScreenStore extends BudiStore { * After saving a screen, sync plugins and routes to the appStore * @param {object} savedScreen */ - async syncScreenData(savedScreen) { + async syncScreenData(savedScreen: Screen) { const appState = get(appStore) // If plugins changed we need to fetch the latest app metadata let usedPlugins = appState.usedPlugins @@ -256,28 +260,36 @@ export class ScreenStore extends BudiStore { * This is slightly better than just a traditional "patch" endpoint and this * supports deeply mutating the current doc rather than just appending data. */ - sequentialScreenPatch = Utils.sequential(async (patchFn, screenId) => { - const state = get(this.store) - const screen = state.screens.find(screen => screen._id === screenId) - if (!screen) { - return - } - let clone = cloneDeep(screen) - const result = patchFn(clone) + sequentialScreenPatch = Utils.sequential( + async ( + patchFn: (screen: Screen) => any, + screenId: string + ): Promise => { + const state = get(this.store) + const screen = state.screens.find(screen => screen._id === screenId) + if (!screen) { + return + } + let clone = cloneDeep(screen) + const result = patchFn(clone) - // An explicit false result means skip this change - if (result === false) { - return + // An explicit false result means skip this change + if (result === false) { + return + } + return this.save(clone) } - return this.save(clone) - }) + ) /** * @param {function} patchFn * @param {string | null} screenId * @returns */ - async patch(patchFn, screenId) { + async patch( + patchFn: (screen: Screen) => void, + screenId: string | undefined | null + ) { // Default to the currently selected screen if (!screenId) { const state = get(this.store) @@ -298,7 +310,7 @@ export class ScreenStore extends BudiStore { * @param {object} screen * @returns */ - async replace(screenId, screen) { + async replace(screenId: string, screen: Screen) { if (!screenId) { return } @@ -337,14 +349,14 @@ export class ScreenStore extends BudiStore { * @param {object | array} screens * @returns */ - async deleteScreen(screens) { - const screensToDelete = Array.isArray(screens) ? screens : [screens] + async deleteScreen(screen: Screen) { + const screensToDelete = [screen] // Build array of promises to speed up bulk deletions - let promises = [] - let deleteUrls = [] + let promises: Promise[] = [] + let deleteUrls: string[] = [] screensToDelete.forEach(screen => { // Delete the screen - promises.push(API.deleteScreen(screen._id, screen._rev)) + promises.push(API.deleteScreen(screen._id!, screen._rev!)) // Remove links to this screen deleteUrls.push(screen.routing.route) }) @@ -359,7 +371,10 @@ export class ScreenStore extends BudiStore { }) // Deselect the current screen if it was deleted - if (deletedIds.includes(state.selectedScreenId)) { + if ( + state.selectedScreenId && + deletedIds.includes(state.selectedScreenId) + ) { state.selectedScreenId = null componentStore.update(state => ({ ...state, @@ -375,7 +390,7 @@ export class ScreenStore extends BudiStore { return state }) - return null + return } /** @@ -389,13 +404,13 @@ export class ScreenStore extends BudiStore { * @param {any} value * @returns */ - async updateSetting(screen, name, value) { + async updateSetting(screen: Screen, name: string, value: string) { if (!screen || !name) { return } // Apply setting update - const patchFn = screen => { + const patchFn = (screen: Screen) => { if (!screen) { return false } @@ -422,7 +437,7 @@ export class ScreenStore extends BudiStore { ) }) if (otherHomeScreens.length && updatedScreen.routing.homeScreen) { - const patchFn = screen => { + const patchFn = (screen: Screen) => { screen.routing.homeScreen = false } for (let otherHomeScreen of otherHomeScreens) { @@ -432,11 +447,11 @@ export class ScreenStore extends BudiStore { } // Move to layouts store - async removeCustomLayout(screen) { + async removeCustomLayout(screen: Screen) { // Pull relevant settings from old layout, if required const layout = get(layoutStore).layouts.find(x => x._id === screen.layoutId) - const patchFn = screen => { - screen.layoutId = null + const patchFn = (screen: Screen) => { + delete screen.layoutId screen.showNavigation = layout?.props.navigation !== "None" screen.width = layout?.props.width || "Large" } @@ -448,9 +463,9 @@ export class ScreenStore extends BudiStore { * and up-to-date. Ensures stability after a product update. * @param {object} screen */ - async enrichEmptySettings(screen) { + async enrichEmptySettings(screen: Screen) { // Flatten the recursive component tree - const components = findAllMatchingComponents(screen.props, x => x) + const components = findAllMatchingComponents(screen.props, (x: string) => x) // Iterate over all components and run checks components.forEach(component => { diff --git a/packages/builder/src/stores/builder/websocket.ts b/packages/builder/src/stores/builder/websocket.ts index bd9e2c8d4da..b9b6c0eb638 100644 --- a/packages/builder/src/stores/builder/websocket.ts +++ b/packages/builder/src/stores/builder/websocket.ts @@ -16,7 +16,14 @@ import { auth, appsStore } from "@/stores/portal" import { screenStore } from "./screens" import { SocketEvent, BuilderSocketEvent, helpers } from "@budibase/shared-core" import { notifications } from "@budibase/bbui" -import { Automation, Datasource, Role, Table, UIUser } from "@budibase/types" +import { + Automation, + Datasource, + Role, + Screen, + Table, + UIUser, +} from "@budibase/types" export const createBuilderWebsocket = (appId: string) => { const socket = createWebsocket("/socket/builder") diff --git a/packages/frontend-core/src/utils/utils.js b/packages/frontend-core/src/utils/utils.ts similarity index 82% rename from packages/frontend-core/src/utils/utils.js rename to packages/frontend-core/src/utils/utils.ts index f0635fbeac5..124f0f03b97 100644 --- a/packages/frontend-core/src/utils/utils.js +++ b/packages/frontend-core/src/utils/utils.ts @@ -1,8 +1,10 @@ import { makePropSafe as safe } from "@budibase/string-templates" import { Helpers } from "@budibase/bbui" import { cloneDeep } from "lodash" +import { SearchFilterGroup, UISearchFilter } from "@budibase/types" -export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) +export const sleep = (ms: number) => + new Promise(resolve => setTimeout(resolve, ms)) /** * Utility to wrap an async function and ensure all invocations happen @@ -10,10 +12,15 @@ export const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) * @param fn the async function to run * @return {Promise} a sequential version of the function */ -export const sequential = fn => { - let queue = [] - return (...params) => { - return new Promise((resolve, reject) => { +export const sequential = < + TReturn, + TFunction extends (...args: any[]) => Promise +>( + fn: TFunction +): ((...args: Parameters) => Promise) => { + let queue: any[] = [] + return (...params: Parameters) => { + return new Promise((resolve, reject) => { queue.push(async () => { let data, error try { @@ -45,9 +52,9 @@ export const sequential = fn => { * @param minDelay the minimum delay between invocations * @returns a debounced version of the callback */ -export const debounce = (callback, minDelay = 1000) => { - let timeout - return async (...params) => { +export const debounce = (callback: Function, minDelay = 1000) => { + let timeout: NodeJS.Timeout + return async (...params: any[]) => { return new Promise(resolve => { if (timeout) { clearTimeout(timeout) @@ -70,11 +77,11 @@ export const debounce = (callback, minDelay = 1000) => { * @param minDelay * @returns {Function} a throttled version function */ -export const throttle = (callback, minDelay = 1000) => { - let lastParams +export const throttle = (callback: Function, minDelay = 1000) => { + let lastParams: any[] let stalled = false let pending = false - const invoke = (...params) => { + const invoke = (...params: any[]) => { lastParams = params if (stalled) { pending = true @@ -98,10 +105,10 @@ export const throttle = (callback, minDelay = 1000) => { * @param callback the function to run * @returns {Function} */ -export const domDebounce = callback => { +export const domDebounce = (callback: Function) => { let active = false - let lastParams - return (...params) => { + let lastParams: any[] + return (...params: any[]) => { lastParams = params if (!active) { active = true @@ -119,7 +126,17 @@ export const domDebounce = callback => { * * @param {any} props * */ -export const buildFormBlockButtonConfig = props => { +export const buildFormBlockButtonConfig = (props?: { + _id?: string + actionType?: string + dataSource?: { resourceId: string } + notificationOverride?: boolean + actionUrl?: string + showDeleteButton?: boolean + deleteButtonLabel?: string + showSaveButton?: boolean + saveButtonLabel?: string +}) => { const { _id, actionType, @@ -227,7 +244,11 @@ export const buildFormBlockButtonConfig = props => { const defaultButtons = [] - if (["Update", "Create"].includes(actionType) && showSaveButton !== false) { + if ( + actionType && + ["Update", "Create"].includes(actionType) && + showSaveButton !== false + ) { defaultButtons.push({ text: saveText || "Save", _id: Helpers.uuid(), @@ -251,7 +272,13 @@ export const buildFormBlockButtonConfig = props => { return defaultButtons } -export const buildMultiStepFormBlockDefaultProps = props => { +export const buildMultiStepFormBlockDefaultProps = (props?: { + _id: string + stepCount: number + currentStep: number + actionType: string + dataSource: { resourceId: string } +}) => { const { _id, stepCount, currentStep, actionType, dataSource } = props || {} // Sanity check @@ -361,7 +388,7 @@ export const buildMultiStepFormBlockDefaultProps = props => { * @param {Object} filter UI filter * @returns {Object} parsed filter */ -export function parseFilter(filter) { +export function parseFilter(filter: UISearchFilter) { if (!filter?.groups) { return filter } @@ -369,13 +396,13 @@ export function parseFilter(filter) { const update = cloneDeep(filter) update.groups = update.groups - .map(group => { - group.filters = group.filters.filter(filter => { + ?.map(group => { + group.filters = group.filters?.filter((filter: any) => { return filter.field && filter.operator }) - return group.filters.length ? group : null + return group.filters?.length ? group : null }) - .filter(group => group) + .filter((group): group is SearchFilterGroup => !!group) return update }