From a782ed061835c3c91003075d95037e7caa7db41b Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Tue, 3 Dec 2024 13:30:35 +0100 Subject: [PATCH 01/52] fix(plugins): improve types of plugins returned from the container --- packages/plugins/src/PluginsContainer.ts | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/plugins/src/PluginsContainer.ts b/packages/plugins/src/PluginsContainer.ts index 48ecdd719ae..7407178beb8 100644 --- a/packages/plugins/src/PluginsContainer.ts +++ b/packages/plugins/src/PluginsContainer.ts @@ -1,6 +1,8 @@ import { Plugin, PluginCollection } from "./types"; import uniqid from "uniqid"; +export type WithName<T extends Plugin> = T & { name: string }; + const isOptionsObject = (item?: any) => item && !Array.isArray(item) && !item.type && !item.name; const normalizeArgs = (args: any[]): [Plugin[], any] => { let options = {}; @@ -53,32 +55,32 @@ const assign = ( export class PluginsContainer { private plugins: Record<string, Plugin> = {}; - private _byTypeCache: Record<string, Plugin[]> = {}; + private _byTypeCache: Record<string, WithName<Plugin>[]> = {}; constructor(...args: PluginCollection) { this.register(...args); } - public byName<T extends Plugin>(name: T["name"]): T | null { + public byName<T extends Plugin>(name: T["name"]) { if (!name) { return null; } /** * We can safely cast name as string, we know it is so. */ - return this.plugins[name as string] as T; + return this.plugins[name as string] as WithName<T>; } - public byType<T extends Plugin>(type: T["type"]): T[] { + public byType<T extends Plugin>(type: T["type"]) { if (this._byTypeCache[type]) { - return Array.from(this._byTypeCache[type]) as T[]; + return Array.from(this._byTypeCache[type]) as WithName<T>[]; } const plugins = this.findByType<T>(type); this._byTypeCache[type] = plugins; return Array.from(plugins); } - public atLeastOneByType<T extends Plugin>(type: T["type"]): T[] { + public atLeastOneByType<T extends Plugin>(type: T["type"]) { const list = this.byType<T>(type); if (list.length === 0) { throw new Error(`There are no plugins by type "${type}".`); @@ -86,7 +88,7 @@ export class PluginsContainer { return list; } - public oneByType<T extends Plugin>(type: T["type"]): T { + public oneByType<T extends Plugin>(type: T["type"]) { const list = this.atLeastOneByType<T>(type); if (list.length > 1) { throw new Error( @@ -125,7 +127,9 @@ export class PluginsContainer { delete this.plugins[name]; } - private findByType<T extends Plugin>(type: T["type"]): T[] { - return Object.values(this.plugins).filter((pl): pl is T => pl.type === type) as T[]; + private findByType<T extends Plugin>(type: T["type"]) { + return Object.values(this.plugins).filter( + (pl): pl is T => pl.type === type + ) as WithName<T>[]; } } From 10cd1e567e00b843beb9580df91fed22bfd9f446 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Tue, 3 Dec 2024 13:31:31 +0100 Subject: [PATCH 02/52] fix(app-page-builder): create plugin loader components --- .../admin/components/EditorPluginsLoader.tsx | 138 ------------------ .../PluginLoaders/EditorPluginsLoader.tsx | 8 + .../PluginLoaders/RenderPluginsLoader.tsx | 8 + .../PluginLoaders/createPluginsLoader.tsx | 65 +++++++++ .../src/admin/plugins/routes.tsx | 128 +++++++--------- packages/app-page-builder/src/types.ts | 4 +- 6 files changed, 139 insertions(+), 212 deletions(-) delete mode 100644 packages/app-page-builder/src/admin/components/EditorPluginsLoader.tsx create mode 100644 packages/app-page-builder/src/admin/components/PluginLoaders/EditorPluginsLoader.tsx create mode 100644 packages/app-page-builder/src/admin/components/PluginLoaders/RenderPluginsLoader.tsx create mode 100644 packages/app-page-builder/src/admin/components/PluginLoaders/createPluginsLoader.tsx diff --git a/packages/app-page-builder/src/admin/components/EditorPluginsLoader.tsx b/packages/app-page-builder/src/admin/components/EditorPluginsLoader.tsx deleted file mode 100644 index 0bb6d03f2b0..00000000000 --- a/packages/app-page-builder/src/admin/components/EditorPluginsLoader.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import React, { useEffect, useReducer } from "react"; -import * as History from "history"; -import { plugins } from "@webiny/plugins"; -import { CircularProgress } from "@webiny/ui/Progress"; -import { PbPluginsLoader } from "~/types"; - -const globalState: State = { - render: false, - editor: false -}; - -// Since these plugins are loaded asynchronously, and some overrides might've been registered -// already by the developer (e.g. in the main App.tsx file), we only register new plugins. -// In other words, if the plugin with a particular name already exists, we skip its registration. - -interface State { - render?: boolean; - editor?: boolean; -} - -interface EditorPluginsLoaderProps { - location: History.Location; - children: React.ReactNode; -} - -export const EditorPluginsLoader = ({ children, location }: EditorPluginsLoaderProps) => { - const [loaded, setLoaded] = useReducer( - (state: State, newState: Partial<State>) => ({ ...state, ...newState }), - globalState - ); - - const isEditorRoute = [ - "/page-builder/editor", - "/page-builder/block-editor", - "/page-builder/template-editor" - ].some(path => location.pathname.startsWith(path)); - - const loadPlugins = async () => { - const pbPlugins = plugins.byType<PbPluginsLoader>("pb-plugins-loader"); - // load all editor admin plugins - const loadEditorPlugins = async () => - await Promise.all( - pbPlugins - .map(plugin => plugin.loadEditorPlugins && plugin.loadEditorPlugins()) - .filter(Boolean) - ); - // load all editor render plugins - const loadRenderPlugins = async () => - await Promise.all( - pbPlugins - .map(plugin => plugin.loadRenderPlugins && plugin.loadRenderPlugins()) - .filter(Boolean) - ); - - // If we are on pages list route, import plugins required to render the page content. - if (location.pathname.startsWith("/page-builder/pages") && !loaded.render) { - const renderPlugins = await loadRenderPlugins(); - - // "skipExisting" will ensure existing plugins (with the same name) are not overridden. - plugins.register(renderPlugins, { skipExisting: true }); - - globalState.render = true; - setLoaded({ render: true }); - } - - // If we are on pages list route, import plugins required to render the page content. - if (location.pathname.startsWith("/page-builder/page-blocks") && !loaded.render) { - const renderPlugins = await loadRenderPlugins(); - - // "skipExisting" will ensure existing plugins (with the same name) are not overridden. - plugins.register(renderPlugins, { skipExisting: true }); - - globalState.render = true; - setLoaded({ render: true }); - } - - // If we are on page templates list route, import plugins required to render the template content. - if (location.pathname.startsWith("/page-builder/page-templates") && !loaded.render) { - const renderPlugins = await loadRenderPlugins(); - - // "skipExisting" will ensure existing plugins (with the same name) are not overridden. - plugins.register(renderPlugins, { skipExisting: true }); - - globalState.render = true; - setLoaded({ render: true }); - } - - // If we are on the Editor route, import plugins required to render both editor and preview. - if (isEditorRoute && !loaded.editor) { - const renderPlugins = !loaded.render ? await loadRenderPlugins() : []; - const editorAdminPlugins = await loadEditorPlugins(); - // merge both editor admin and render plugins - const editorRenderPlugins = [...editorAdminPlugins, ...renderPlugins].filter(Boolean); - - // "skipExisting" will ensure existing plugins (with the same name) are not overridden. - plugins.register(editorRenderPlugins, { skipExisting: true }); - - globalState.editor = true; - globalState.render = true; - - setLoaded({ editor: true, render: true }); - } - }; - - useEffect(() => { - loadPlugins(); - }, [location.pathname]); - - /** - * This condition is for the list of pages. - * Page can be selected at this point. - */ - if (location.pathname.startsWith("/page-builder/pages") && loaded.render) { - return children as unknown as React.ReactElement; - } - /** - * This condition is for the list of page blocks. - * Blocks can be selected at this point. - */ - if (location.pathname.startsWith("/page-builder/page-blocks") && loaded.render) { - return children as unknown as React.ReactElement; - } - /** - * This condition is for the list of page templates. - * Page template can be selected at this point. - */ - if (location.pathname.startsWith("/page-builder/page-templates") && loaded.render) { - return children as unknown as React.ReactElement; - } - /** - * This condition is for editing of the selected page/template. - */ - if (isEditorRoute && loaded.editor) { - return children as unknown as React.ReactElement; - } - - return <CircularProgress />; -}; diff --git a/packages/app-page-builder/src/admin/components/PluginLoaders/EditorPluginsLoader.tsx b/packages/app-page-builder/src/admin/components/PluginLoaders/EditorPluginsLoader.tsx new file mode 100644 index 00000000000..9a469d77e63 --- /dev/null +++ b/packages/app-page-builder/src/admin/components/PluginLoaders/EditorPluginsLoader.tsx @@ -0,0 +1,8 @@ +import { createPluginsLoader } from "~/admin/components/PluginLoaders/createPluginsLoader"; + +export const EditorPluginsLoader = createPluginsLoader({ + type: "pb-editor-page-element", + factory: plugin => { + return plugin.loadEditorPlugins ? plugin.loadEditorPlugins() : undefined; + } +}); diff --git a/packages/app-page-builder/src/admin/components/PluginLoaders/RenderPluginsLoader.tsx b/packages/app-page-builder/src/admin/components/PluginLoaders/RenderPluginsLoader.tsx new file mode 100644 index 00000000000..ce354e95df0 --- /dev/null +++ b/packages/app-page-builder/src/admin/components/PluginLoaders/RenderPluginsLoader.tsx @@ -0,0 +1,8 @@ +import { createPluginsLoader } from "~/admin/components/PluginLoaders/createPluginsLoader"; + +export const RenderPluginsLoader = createPluginsLoader({ + type: "pb-render-page-element", + factory: plugin => { + return plugin.loadRenderPlugins ? plugin.loadRenderPlugins() : undefined; + } +}); diff --git a/packages/app-page-builder/src/admin/components/PluginLoaders/createPluginsLoader.tsx b/packages/app-page-builder/src/admin/components/PluginLoaders/createPluginsLoader.tsx new file mode 100644 index 00000000000..52c6bdb3822 --- /dev/null +++ b/packages/app-page-builder/src/admin/components/PluginLoaders/createPluginsLoader.tsx @@ -0,0 +1,65 @@ +import React, { useEffect, useState } from "react"; +import type { Plugin } from "@webiny/plugins/types"; +import { GenericRecord } from "@webiny/app/types"; +import { plugins } from "@webiny/plugins"; +import { CircularProgress } from "@webiny/ui/Progress"; +import type { PbEditorPageElementPlugin, PbPluginsLoader, PbRenderElementPlugin } from "~/types"; + +export interface CreatePluginsLoaderParams { + // Plugin type + type: PbRenderElementPlugin["type"] | PbEditorPageElementPlugin["type"]; + // Plugin factory + factory: (plugin: PbPluginsLoader) => Promise<Plugin[]> | undefined; +} + +const globalCache: GenericRecord<string, boolean> = {}; + +export interface PluginsLoaderProps { + children: React.ReactNode; +} + +export const createPluginsLoader = ({ type, factory }: CreatePluginsLoaderParams) => { + const PluginsLoader = ({ children }: PluginsLoaderProps) => { + const [loaded, setLoaded] = useState(false); + + const loadPlugins = async () => { + const pluginsLoaders = plugins.byType<PbPluginsLoader>("pb-plugins-loader"); + + const lazyLoadedPlugins = await Promise.all( + pluginsLoaders.map(plugin => factory(plugin)).filter(Boolean) + ); + + // Here comes an awkward hack: there's a chance that a user registered some custom plugins through React, + // and they're already in the registry. But we want to make sure that user plugins are applied _after_ the lazy-loaded + // plugins, loaded via the `pb-plugins-loader`. To achieve that, we unregister existing plugins, and register them + // _after_ the lazy-loaded ones. + + const existingPlugins = plugins.byType(type); + + existingPlugins.forEach(plugin => { + plugins.unregister(plugin.name); + }); + + // Register lazy-loaded plugins first. + plugins.register(lazyLoadedPlugins); + plugins.register(existingPlugins); + }; + + useEffect(() => { + if (!globalCache[type]) { + loadPlugins().then(() => { + globalCache[type] = true; + setLoaded(true); + }); + } else { + setLoaded(true); + } + }, []); + + return loaded ? <>{children}</> : <CircularProgress />; + }; + + PluginsLoader.displayName = `PluginsLoader<${type}>`; + + return PluginsLoader; +}; diff --git a/packages/app-page-builder/src/admin/plugins/routes.tsx b/packages/app-page-builder/src/admin/plugins/routes.tsx index ab702c51081..8cd18ad6eb2 100644 --- a/packages/app-page-builder/src/admin/plugins/routes.tsx +++ b/packages/app-page-builder/src/admin/plugins/routes.tsx @@ -5,7 +5,6 @@ import { AdminLayout } from "@webiny/app-admin/components/AdminLayout"; import { SecureRoute } from "@webiny/app-security/components"; import { RoutePlugin } from "@webiny/app/types"; import { CompositionScope } from "@webiny/react-composition"; -import { EditorPluginsLoader } from "../components/EditorPluginsLoader"; import Categories from "../views/Categories/Categories"; import Menus from "../views/Menus/Menus"; @@ -17,6 +16,8 @@ import PageTemplates from "~/admin/views/PageTemplates/PageTemplates"; import { PageEditor } from "~/pageEditor/Editor"; import { BlockEditor } from "~/blockEditor/Editor"; import { TemplateEditor } from "~/templateEditor/Editor"; +import { RenderPluginsLoader } from "~/admin/components/PluginLoaders/RenderPluginsLoader"; +import { EditorPluginsLoader } from "~/admin/components/PluginLoaders/EditorPluginsLoader"; const ROLE_PB_CATEGORY = "pb.category"; const ROLE_PB_MENUS = "pb.menu"; @@ -30,16 +31,15 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( <Route - exact path="/page-builder/categories" - render={() => ( + element={ <SecureRoute permission={ROLE_PB_CATEGORY}> <AdminLayout> <Helmet title={"Page Builder - Categories"} /> <Categories /> </AdminLayout> </SecureRoute> - )} + } /> ) }, @@ -48,16 +48,15 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( <Route - exact path="/page-builder/menus" - render={() => ( + element={ <SecureRoute permission={ROLE_PB_MENUS}> <AdminLayout> <Helmet title={"Page Builder - Menus"} /> <Menus /> </AdminLayout> </SecureRoute> - )} + } /> ) }, @@ -66,20 +65,19 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( <Route - exact path="/page-builder/pages" - render={({ location }) => ( + element={ <SecureRoute permission={ROLE_PB_PAGES}> - <EditorPluginsLoader location={location}> + <RenderPluginsLoader> <AdminLayout> <Helmet title={"Page Builder - Pages"} /> <CompositionScope name={"pb.page"}> <Pages /> </CompositionScope> </AdminLayout> - </EditorPluginsLoader> + </RenderPluginsLoader> </SecureRoute> - )} + } /> ) }, @@ -88,20 +86,17 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( <Route - exact path="/page-builder/editor/:id" - render={({ location }) => { - return ( - <SecureRoute permission={ROLE_PB_PAGES}> - <EditorPluginsLoader location={location}> - <Helmet title={"Page Builder - Edit page"} /> - <CompositionScope name={"pb.pageEditor"}> - <PageEditor /> - </CompositionScope> - </EditorPluginsLoader> - </SecureRoute> - ); - }} + element={ + <SecureRoute permission={ROLE_PB_PAGES}> + <EditorPluginsLoader> + <Helmet title={"Page Builder - Edit page"} /> + <CompositionScope name={"pb.pageEditor"}> + <PageEditor /> + </CompositionScope> + </EditorPluginsLoader> + </SecureRoute> + } /> ) }, @@ -110,20 +105,17 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( <Route - exact path="/page-builder/page-templates" - render={({ location }) => { - return ( - <SecureRoute permission={ROLE_PB_TEMPLATE}> - <EditorPluginsLoader location={location}> - <AdminLayout> - <Helmet title={"Page Builder - Page Templates"} /> - <PageTemplates /> - </AdminLayout> - </EditorPluginsLoader> - </SecureRoute> - ); - }} + element={ + <SecureRoute permission={ROLE_PB_TEMPLATE}> + <RenderPluginsLoader> + <AdminLayout> + <Helmet title={"Page Builder - Page Templates"} /> + <PageTemplates /> + </AdminLayout> + </RenderPluginsLoader> + </SecureRoute> + } /> ) }, @@ -132,20 +124,17 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( <Route - exact path="/page-builder/template-editor/:id" - render={({ location }) => { - return ( - <SecureRoute permission={ROLE_PB_TEMPLATE}> - <EditorPluginsLoader location={location}> - <Helmet title={"Page Builder - Edit template"} /> - <CompositionScope name={"pb.templateEditor"}> - <TemplateEditor /> - </CompositionScope> - </EditorPluginsLoader> - </SecureRoute> - ); - }} + element={ + <SecureRoute permission={ROLE_PB_TEMPLATE}> + <EditorPluginsLoader> + <Helmet title={"Page Builder - Edit template"} /> + <CompositionScope name={"pb.templateEditor"}> + <TemplateEditor /> + </CompositionScope> + </EditorPluginsLoader> + </SecureRoute> + } /> ) }, @@ -154,16 +143,15 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( <Route - exact path="/page-builder/block-categories" - render={() => ( + element={ <SecureRoute permission={ROLE_PB_BLOCK}> <AdminLayout> <Helmet title={"Page Builder - Block Categories"} /> <BlockCategories /> </AdminLayout> </SecureRoute> - )} + } /> ) }, @@ -172,18 +160,17 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( <Route - exact path="/page-builder/page-blocks" - render={({ location }) => ( + element={ <SecureRoute permission={ROLE_PB_BLOCK}> - <EditorPluginsLoader location={location}> + <RenderPluginsLoader> <AdminLayout> <Helmet title={"Page Builder - Blocks"} /> <PageBlocks /> </AdminLayout> - </EditorPluginsLoader> + </RenderPluginsLoader> </SecureRoute> - )} + } /> ) }, @@ -192,20 +179,17 @@ const plugins: RoutePlugin[] = [ type: "route", route: ( <Route - exact path="/page-builder/block-editor/:id" - render={({ location }) => { - return ( - <SecureRoute permission={ROLE_PB_PAGES}> - <EditorPluginsLoader location={location}> - <Helmet title={"Page Builder - Edit block"} /> - <CompositionScope name={"pb.blockEditor"}> - <BlockEditor /> - </CompositionScope> - </EditorPluginsLoader> - </SecureRoute> - ); - }} + element={ + <SecureRoute permission={ROLE_PB_PAGES}> + <EditorPluginsLoader> + <Helmet title={"Page Builder - Edit block"} /> + <CompositionScope name={"pb.blockEditor"}> + <BlockEditor /> + </CompositionScope> + </EditorPluginsLoader> + </SecureRoute> + } /> ) } diff --git a/packages/app-page-builder/src/types.ts b/packages/app-page-builder/src/types.ts index 80bf940ad76..f16448da794 100644 --- a/packages/app-page-builder/src/types.ts +++ b/packages/app-page-builder/src/types.ts @@ -302,8 +302,8 @@ export interface PbTheme { } export type PbPluginsLoader = Plugin & { - loadEditorPlugins?: () => Promise<any>; - loadRenderPlugins?: () => Promise<any>; + loadEditorPlugins?: () => Promise<Plugin[]> | undefined; + loadRenderPlugins?: () => Promise<Plugin[]> | undefined; }; export type PbThemePlugin = Plugin & { From 445bb48de3b07fee5bf3c15e597d617e5841984d Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Tue, 3 Dec 2024 13:51:11 +0100 Subject: [PATCH 03/52] fix(app-page-builder): export loader components --- packages/app-page-builder/src/admin/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/app-page-builder/src/admin/index.ts b/packages/app-page-builder/src/admin/index.ts index 03cfdd14766..e5467bb3793 100644 --- a/packages/app-page-builder/src/admin/index.ts +++ b/packages/app-page-builder/src/admin/index.ts @@ -5,3 +5,6 @@ export * from "./hooks/usePageBuilderSettings"; export * from "./hooks/useConfigureWebsiteUrl"; export * from "./hooks/useSiteStatus"; export * from "./hooks/useAdminPageBuilder"; + +export * from "./components/PluginLoaders/EditorPluginsLoader"; +export * from "./components/PluginLoaders/RenderPluginsLoader"; From bd1354d8ee670ec47eebfcbcfd8cf09c24b8efc0 Mon Sep 17 00:00:00 2001 From: Adrian Smijulj <adrian1358@gmail.com> Date: Thu, 5 Dec 2024 05:44:32 +0100 Subject: [PATCH 04/52] fix: introduce `useLoader` hook (#4424) --- .github/workflows/pullRequests.yml | 36 +++++----- .github/workflows/wac/pullRequests.wac.ts | 5 +- .../extractPeLoaderDataFromHtml.test.ts | 45 ++++++++++++ .../api-prerendering-service/package.json | 2 + .../src/render/extractPeLoaderDataFromHtml.ts | 69 +++++++++++++++++++ .../src/render/renderUrl.ts | 31 +++++---- .../src/render/types.ts | 16 ++++- .../src/contexts/PageElements.tsx | 15 ++-- .../src/hooks/useLoader.ts | 61 ++++++++++++++++ .../src/hooks/useLoader/ILoaderCache.ts | 6 ++ .../src/hooks/useLoader/NullLoaderCache.ts | 19 +++++ .../src/hooks/useLoader/createObjectHash.ts | 17 +++++ .../app-page-builder-elements/src/index.ts | 1 + .../app-page-builder-elements/src/types.ts | 2 + packages/app-page-builder/src/PageBuilder.tsx | 9 ++- .../ResponsiveElementsProvider.tsx | 9 ++- .../PageBuilder/PageBuilderContext.tsx | 6 +- .../PageBuilder/PageElementsProvider.tsx | 9 ++- .../contexts/EditorPageElementsProvider.tsx | 6 ++ .../elementSettings/save/SaveDialog.tsx | 9 ++- packages/app-website/src/LinkPreload.tsx | 33 ++++++--- packages/app-website/src/Website.tsx | 9 ++- .../src/utils/WebsiteLoaderCache.ts | 37 ++++++++++ .../WebsiteLoaderCache/PeLoaderHtmlCache.ts | 26 +++++++ .../ddbPutItemConditionalCheckFailed.js | 12 ++++ .../gracefulPulumiErrorHandlers/index.js | 3 +- .../src/promptQuestions.ts | 5 +- yarn.lock | 9 +++ 28 files changed, 446 insertions(+), 61 deletions(-) create mode 100644 packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts create mode 100644 packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts create mode 100644 packages/app-page-builder-elements/src/hooks/useLoader.ts create mode 100644 packages/app-page-builder-elements/src/hooks/useLoader/ILoaderCache.ts create mode 100644 packages/app-page-builder-elements/src/hooks/useLoader/NullLoaderCache.ts create mode 100644 packages/app-page-builder-elements/src/hooks/useLoader/createObjectHash.ts create mode 100644 packages/app-website/src/utils/WebsiteLoaderCache.ts create mode 100644 packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts create mode 100644 packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/ddbPutItemConditionalCheckFailed.js diff --git a/.github/workflows/pullRequests.yml b/.github/workflows/pullRequests.yml index adb49a400f8..07a21d893db 100644 --- a/.github/workflows/pullRequests.yml +++ b/.github/workflows/pullRequests.yml @@ -3,7 +3,7 @@ # and run "github-actions-wac build" (or "ghawac build") to regenerate this file. # For more information, run "github-actions-wac --help". name: Pull Requests -'on': pull_request +"on": pull_request concurrency: group: pr-${{ github.event.pull_request.number }} cancel-in-progress: true @@ -19,7 +19,7 @@ jobs: - uses: webiny/action-conventional-commits@v1.3.0 runs-on: ubuntu-latest env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false validateCommitsDev: name: Validate commit messages (dev branch, 'feat' commits not allowed) @@ -34,7 +34,7 @@ jobs: allowed-commit-types: fix,docs,style,refactor,test,build,perf,ci,chore,revert,merge,wip runs-on: ubuntu-latest env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false constants: name: Create constants @@ -87,12 +87,14 @@ jobs: $GITHUB_OUTPUT runs-on: ubuntu-latest env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false assignMilestone: name: Assign milestone needs: constants - if: needs.constants.outputs.is-fork-pr != 'true' + if: >- + needs.constants.outputs.is-fork-pr != 'true' && + github.event.pull_request.milestone == null steps: - uses: actions/setup-node@v4 with: @@ -115,7 +117,7 @@ jobs: milestone: ${{ steps.get-milestone-to-assign.outputs.milestone }} runs-on: ubuntu-latest env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false build: name: Build @@ -147,7 +149,7 @@ jobs: path: ${{ github.base_ref }}/.webiny/cached-packages key: ${{ needs.constants.outputs.run-cache-key }} env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false staticCodeAnalysis: needs: @@ -185,7 +187,7 @@ jobs: working-directory: ${{ github.base_ref }} runs-on: ubuntu-latest env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false staticCodeAnalysisTs: name: Static code analysis (TypeScript) @@ -211,7 +213,7 @@ jobs: run: yarn cy:ts working-directory: ${{ github.base_ref }} env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false jestTestsNoStorageConstants: needs: @@ -239,7 +241,7 @@ jobs: echo '${{ steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}' env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false jestTestsNoStorageRun: needs: @@ -260,7 +262,7 @@ jobs: }} runs-on: ${{ matrix.os }} env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false AWS_REGION: eu-central-1 if: needs.jestTestsNoStorageConstants.outputs.packages-to-jest-test != '[]' @@ -357,7 +359,7 @@ jobs: echo '${{ steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}' env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false jestTestsddbRun: needs: @@ -377,7 +379,7 @@ jobs: fromJson(needs.jestTestsddbConstants.outputs.packages-to-jest-test) }} runs-on: ${{ matrix.os }} env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false AWS_REGION: eu-central-1 if: needs.jestTestsddbConstants.outputs.packages-to-jest-test != '[]' @@ -474,7 +476,7 @@ jobs: echo '${{ steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}' env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false jestTestsddb-esRun: needs: @@ -495,7 +497,7 @@ jobs: }} runs-on: ${{ matrix.os }} env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false AWS_REGION: eu-central-1 AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }} @@ -604,7 +606,7 @@ jobs: echo '${{ steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}' env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false jestTestsddb-osRun: needs: @@ -625,7 +627,7 @@ jobs: }} runs-on: ${{ matrix.os }} env: - NODE_OPTIONS: '--max_old_space_size=4096' + NODE_OPTIONS: "--max_old_space_size=4096" YARN_ENABLE_IMMUTABLE_INSTALLS: false AWS_REGION: eu-central-1 AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_OPEN_SEARCH_DOMAIN_NAME }} diff --git a/.github/workflows/wac/pullRequests.wac.ts b/.github/workflows/wac/pullRequests.wac.ts index 723dcc6e574..60b4a28f061 100644 --- a/.github/workflows/wac/pullRequests.wac.ts +++ b/.github/workflows/wac/pullRequests.wac.ts @@ -210,7 +210,10 @@ export const pullRequests = createWorkflow({ assignMilestone: createJob({ name: "Assign milestone", needs: "constants", - if: "needs.constants.outputs.is-fork-pr != 'true'", + if: [ + "needs.constants.outputs.is-fork-pr != 'true'", + "github.event.pull_request.milestone == null" + ].join(" && "), steps: [ { name: "Print latest Webiny version", diff --git a/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts b/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts new file mode 100644 index 00000000000..967a6704370 --- /dev/null +++ b/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts @@ -0,0 +1,45 @@ +import extractPeLoaderDataFromHtml from "../../src/render/extractPeLoaderDataFromHtml"; + +describe("extractPeLoaderDataFromHtml Tests", () => { + it("must detect pe-loader-data-cache tags in given HTML", async () => { + const results = extractPeLoaderDataFromHtml(TEST_STRING); + + expect(results).toEqual([ + { + key: "GfT8AoRsYT-1238102521", + value: [ + { + description: + "The Falcon 1 was an expendable launch system privately developed and manufactured by SpaceX during 2006-2009. On 28 September 2008, Falcon 1 became the first privately-developed liquid-fuel launch vehicle to go into orbit around the Earth.", + id: "5e9d0d95eda69955f709d1eb", + name: "Falcon 1", + wikipedia: "https://en.wikipedia.org/wiki/Falcon_1" + }, + { + description: + "Falcon 9 is a two-stage rocket designed and manufactured by SpaceX for the reliable and safe transport of satellites and the Dragon spacecraft into orbit.", + id: "5e9d0d95eda69973a809d1ec", + name: "Falcon 9", + wikipedia: "https://en.wikipedia.org/wiki/Falcon_9" + }, + { + description: + "With the ability to lift into orbit over 54 metric tons (119,000 lb)--a mass equivalent to a 737 jetliner loaded with passengers, crew, luggage and fuel--Falcon Heavy can lift more than twice the payload of the next closest operational vehicle, the Delta IV Heavy, at one-third the cost.", + id: "5e9d0d95eda69974db09d1ed", + name: "Falcon Heavy", + wikipedia: "https://en.wikipedia.org/wiki/Falcon_Heavy" + }, + { + description: + "Starship and Super Heavy Rocket represent a fully reusable transportation system designed to service all Earth orbit needs as well as the Moon and Mars. This two-stage vehicle — composed of the Super Heavy rocket (booster) and Starship (ship) — will eventually replace Falcon 9, Falcon Heavy and Dragon.", + id: "5e9d0d96eda699382d09d1ee", + name: "Starship", + wikipedia: "https://en.wikipedia.org/wiki/SpaceX_Starship" + } + ] + } + ]); + }); +}); + +const TEST_STRING = `...<li><h1>Starship</h1><div>Starship and Super Heavy Rocket represent a fully reusable transportation system designed to service all Earth orbit needs as well as the Moon and Mars. This two-stage vehicle — composed of the Super Heavy rocket (booster) and Starship (ship) — will eventually replace Falcon 9, Falcon Heavy and Dragon.</div><br><div>More info at <a href="https://en.wikipedia.org/wiki/SpaceX_Starship" target="_blank" rel="noreferrer">https://en.wikipedia.org/wiki/SpaceX_Starship</a></div></li></ul></pb-spacex></pb-cell></pb-grid></pb-block></pb-document></main><footer data-testid="pb-footer" class="wby-1lh86qf"><div class="wby-xv6w56"><div class="logo wby-1i3ok2b"><a href="/"></a><div class="copy">DEVR © 2024</div></div></div></footer></div></div><pe-loader-data-cache data-key="GfT8AoRsYT-1238102521" data-value="[{"id":"5e9d0d95eda69955f709d1eb","name":"Falcon 1","description":"The Falcon 1 was an expendable launch system privately developed and manufactured by SpaceX during 2006-2009. On 28 September 2008, Falcon 1 became the first privately-developed liquid-fuel launch vehicle to go into orbit around the Earth.","wikipedia":"https://en.wikipedia.org/wiki/Falcon_1"},{"id":"5e9d0d95eda69973a809d1ec","name":"Falcon 9","description":"Falcon 9 is a two-stage rocket designed and manufactured by SpaceX for the reliable and safe transport of satellites and the Dragon spacecraft into orbit.","wikipedia":"https://en.wikipedia.org/wiki/Falcon_9"},{"id":"5e9d0d95eda69974db09d1ed","name":"Falcon Heavy","description":"With the ability to lift into orbit over 54 metric tons (119,000 lb)--a mass equivalent to a 737 jetliner loaded with passengers, crew, luggage and fuel--Falcon Heavy can lift more than twice the payload of the next closest operational vehicle, the Delta IV Heavy, at one-third the cost.","wikipedia":"https://en.wikipedia.org/wiki/Falcon_Heavy"},{"id":"5e9d0d96eda699382d09d1ee","name":"Starship","description":"Starship and Super Heavy Rocket represent a fully reusable transportation system designed to service all Earth orbit needs as well as the Moon and Mars. This two-stage vehicle — composed of the Super Heavy rocket (booster) and Starship (ship) — will eventually replace Falcon 9, Falcon Heavy and Dragon.","wikipedia":"https://en.wikipedia.org/wiki/SpaceX_Starship"}]"></pe-loader-data-cache></body></html>`; diff --git a/packages/api-prerendering-service/package.json b/packages/api-prerendering-service/package.json index 4f5b541a939..6de200a1885 100644 --- a/packages/api-prerendering-service/package.json +++ b/packages/api-prerendering-service/package.json @@ -24,6 +24,7 @@ "@webiny/handler-client": "0.0.0", "@webiny/plugins": "0.0.0", "@webiny/utils": "0.0.0", + "he": "^1.2.0", "lodash": "^4.17.21", "object-hash": "^3.0.0", "pluralize": "^8.0.0", @@ -40,6 +41,7 @@ "@babel/plugin-proposal-export-default-from": "^7.23.3", "@babel/preset-env": "^7.24.0", "@babel/preset-typescript": "^7.23.3", + "@types/he": "^1.2.3", "@types/object-hash": "^2.2.1", "@types/puppeteer-core": "^5.4.0", "@webiny/cli": "0.0.0", diff --git a/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts b/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts new file mode 100644 index 00000000000..1618e550a27 --- /dev/null +++ b/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts @@ -0,0 +1,69 @@ +import { PeLoaderCacheEntry } from "./types"; +import he from "he"; + +const parsePeLoaderDataCacheTag = (content: string): PeLoaderCacheEntry | null => { + const regex = + /<pe-loader-data-cache data-key="([a-zA-Z0-9-#]+)" data-value="(.*)"><\/pe-loader-data-cache>/gm; + let m; + + while ((m = regex.exec(content)) !== null) { + // This is necessary to avoid infinite loops with zero-width matches + if (m.index === regex.lastIndex) { + regex.lastIndex++; + } + + const [, key, value] = m; + + // JSON in `data-value` is HTML Entities-encoded. So, we need to decode it here first. + const heParsedValue = he.decode(value); + const parsedValue = JSON.parse(heParsedValue); + return { key, value: parsedValue }; + } + + return null; +}; + +export default (content: string): PeLoaderCacheEntry[] => { + if (!content) { + return []; + } + + const cachedData: PeLoaderCacheEntry[] = []; + const regex = /<pe-loader-data-cache .*><\/pe-loader-data-cache>/gm; + let m; + + while ((m = regex.exec(content)) !== null) { + // This is necessary to avoid infinite loops with zero-width matches + if (m.index === regex.lastIndex) { + regex.lastIndex++; + } + + const [matchedTag] = m; + + if (!matchedTag) { + continue; + } + + const parsedTag = parsePeLoaderDataCacheTag(matchedTag); + if (!parsedTag) { + continue; + } + + cachedData.push(parsedTag); + } + + if (cachedData.length > 0) { + const uniqueMap: Record<string, PeLoaderCacheEntry> = cachedData.reduce( + (collection, peLoaderDataCache) => { + collection[`${peLoaderDataCache.key || ""}${peLoaderDataCache.value || ""}`] = + peLoaderDataCache; + + return collection; + }, + {} as Record<string, PeLoaderCacheEntry> + ); + + return Object.values(uniqueMap); + } + return cachedData; +}; diff --git a/packages/api-prerendering-service/src/render/renderUrl.ts b/packages/api-prerendering-service/src/render/renderUrl.ts index 57aaf7b1fcd..c3333b71c5f 100644 --- a/packages/api-prerendering-service/src/render/renderUrl.ts +++ b/packages/api-prerendering-service/src/render/renderUrl.ts @@ -14,8 +14,11 @@ import injectRenderTs from "./injectRenderTs"; import injectTenantLocale from "./injectTenantLocale"; import injectNotFoundPageFlag from "./injectNotFoundPageFlag"; import getPsTags from "./getPsTags"; +import extractPeLoaderDataFromHtml from "./extractPeLoaderDataFromHtml"; import shortid from "shortid"; import { + GraphQLCacheEntry, + PeLoaderCacheEntry, RenderResult, RenderUrlCallableParams, RenderUrlParams, @@ -108,8 +111,8 @@ export default async (url: string, args: RenderUrlParams): Promise<[File[], Meta } }, { - name: "graphql.json", - body: JSON.stringify(render.meta.gqlCache), + name: "cache.json", + body: JSON.stringify(render.meta.cachedData), type: "application/json", meta: {} } @@ -118,12 +121,6 @@ export default async (url: string, args: RenderUrlParams): Promise<[File[], Meta ]; }; -interface GraphQLCache { - query: any; - variables: Record<string, any>; - data: Record<string, any>; -} - export const defaultRenderUrlFunction = async ( url: string, params: RenderUrlCallableParams @@ -168,7 +165,13 @@ export const defaultRenderUrlFunction = async ( } }); - const gqlCache: GraphQLCache[] = []; + const cachedData: { + apolloGraphQl: GraphQLCacheEntry[]; + peLoaders: PeLoaderCacheEntry[]; + } = { + apolloGraphQl: [], + peLoaders: [] + }; // TODO: should be a plugin. browserPage.on("response", async response => { @@ -189,7 +192,7 @@ export const defaultRenderUrlFunction = async ( if (mustCache) { const data = Array.isArray(responses) ? responses[i].data : responses.data; - gqlCache.push({ + cachedData.apolloGraphQl.push({ query, variables, data @@ -208,11 +211,15 @@ export const defaultRenderUrlFunction = async ( return window.getApolloState(); }); + const content = await browserPage.content(); + + cachedData.peLoaders = extractPeLoaderDataFromHtml(content); + return { - content: await browserPage.content(), + content, // TODO: ideally, meta should be assigned here in a more "plugins style" way, not hardcoded. meta: { - gqlCache, + cachedData, apolloState } }; diff --git a/packages/api-prerendering-service/src/render/types.ts b/packages/api-prerendering-service/src/render/types.ts index b629aa8cee5..d92a917d6f0 100644 --- a/packages/api-prerendering-service/src/render/types.ts +++ b/packages/api-prerendering-service/src/render/types.ts @@ -23,11 +23,25 @@ export interface RenderApolloState { /** * @internal */ + +export interface GraphQLCacheEntry { + query: any; + variables: Record<string, any>; + data: Record<string, any>; +} + +export interface PeLoaderCacheEntry { + key: string; + value: string; +} + export interface RenderResult { content: string; meta: { apolloState: RenderApolloState; - gqlCache: { + cachedData: { + apolloGraphQl: GraphQLCacheEntry[]; + peLoaders: PeLoaderCacheEntry[]; [key: string]: any; }; [key: string]: any; diff --git a/packages/app-page-builder-elements/src/contexts/PageElements.tsx b/packages/app-page-builder-elements/src/contexts/PageElements.tsx index e05dd3fc659..42b994ff4db 100644 --- a/packages/app-page-builder-elements/src/contexts/PageElements.tsx +++ b/packages/app-page-builder-elements/src/contexts/PageElements.tsx @@ -31,7 +31,8 @@ export const PageElementsProvider = ({ renderers = {}, modifiers, beforeRenderer = null, - afterRenderer = null + afterRenderer = null, + loaderCache }: PageElementsProviderProps) => { // Attributes-related callbacks. const getElementAttributes = useCallback<GetElementAttributes>( @@ -42,7 +43,8 @@ export const PageElementsProvider = ({ renderers, modifiers, beforeRenderer, - afterRenderer + afterRenderer, + loaderCache }); }, [theme] @@ -79,7 +81,8 @@ export const PageElementsProvider = ({ modifiers, assignStyles: customAssignStylesCallback, beforeRenderer, - afterRenderer + afterRenderer, + loaderCache }); }, [theme, customElementStylesCallback, customAssignStylesCallback] @@ -95,7 +98,8 @@ export const PageElementsProvider = ({ modifiers, assignStyles: customAssignStylesCallback, beforeRenderer, - afterRenderer + afterRenderer, + loaderCache }); }, [theme, customStylesCallback, customAssignStylesCallback] @@ -122,7 +126,8 @@ export const PageElementsProvider = ({ setElementStylesCallback, setStylesCallback, beforeRenderer, - afterRenderer + afterRenderer, + loaderCache }; return ( diff --git a/packages/app-page-builder-elements/src/hooks/useLoader.ts b/packages/app-page-builder-elements/src/hooks/useLoader.ts new file mode 100644 index 00000000000..095b13e4bba --- /dev/null +++ b/packages/app-page-builder-elements/src/hooks/useLoader.ts @@ -0,0 +1,61 @@ +import { useEffect, useMemo, useState, type DependencyList } from "react"; +import { createObjectHash } from "./useLoader/createObjectHash"; +import { useRenderer } from ".."; + +export interface RendererLoader<TData = unknown> { + data: TData | null; + loading: boolean; + cacheHit: boolean; + cacheKey: null | string; +} + +export interface UseLoaderOptions { + cacheKey?: DependencyList; +} + +export function useLoader<TData = unknown>( + loaderFn: () => Promise<TData>, + options?: UseLoaderOptions +): RendererLoader<TData> { + const { getElement, loaderCache } = useRenderer(); + + const element = getElement(); + + const elementDataCacheKey = element.id; + const optionsCacheKey = options?.cacheKey || []; + const cacheKey = createObjectHash([elementDataCacheKey, ...optionsCacheKey]); + + const cachedData = useMemo(() => { + return loaderCache.read<TData>(cacheKey); + }, [cacheKey]); + + const [loader, setLoader] = useState<RendererLoader<TData>>( + cachedData + ? { + data: cachedData, + loading: false, + cacheHit: true, + cacheKey + } + : { data: null, loading: true, cacheHit: false, cacheKey: null } + ); + + useEffect(() => { + if (cacheKey === loader.cacheKey) { + return; + } + + if (cachedData) { + setLoader({ data: cachedData, loading: false, cacheKey, cacheHit: true }); + return; + } + + setLoader({ data: loader.data, loading: true, cacheKey, cacheHit: false }); + loaderFn().then(data => { + loaderCache.write(cacheKey, data); + setLoader({ data, loading: false, cacheKey, cacheHit: false }); + }); + }, [cacheKey]); + + return loader; +} diff --git a/packages/app-page-builder-elements/src/hooks/useLoader/ILoaderCache.ts b/packages/app-page-builder-elements/src/hooks/useLoader/ILoaderCache.ts new file mode 100644 index 00000000000..31abcbdc536 --- /dev/null +++ b/packages/app-page-builder-elements/src/hooks/useLoader/ILoaderCache.ts @@ -0,0 +1,6 @@ +export interface ILoaderCache { + read: <TData = unknown>(key: string) => TData | null; + write: <TData = unknown>(key: string, value: TData) => void; + remove: (key: string) => void; + clear: () => void; +} diff --git a/packages/app-page-builder-elements/src/hooks/useLoader/NullLoaderCache.ts b/packages/app-page-builder-elements/src/hooks/useLoader/NullLoaderCache.ts new file mode 100644 index 00000000000..5649d75f195 --- /dev/null +++ b/packages/app-page-builder-elements/src/hooks/useLoader/NullLoaderCache.ts @@ -0,0 +1,19 @@ +import { ILoaderCache } from "./ILoaderCache"; + +export class NullLoaderCache implements ILoaderCache { + read() { + return null; + } + + write() { + return; + } + + remove() { + return; + } + + clear() { + return; + } +} diff --git a/packages/app-page-builder-elements/src/hooks/useLoader/createObjectHash.ts b/packages/app-page-builder-elements/src/hooks/useLoader/createObjectHash.ts new file mode 100644 index 00000000000..f619c11e202 --- /dev/null +++ b/packages/app-page-builder-elements/src/hooks/useLoader/createObjectHash.ts @@ -0,0 +1,17 @@ +export const createObjectHash = (object: Record<string, any>) => { + const jsonString = JSON.stringify(object); + + // Create a hash string from string. + if (jsonString.length === 0) { + return ""; + } + + let hash = 0; + for (let i = 0; i < jsonString.length; i++) { + const charCode = jsonString.charCodeAt(i); + hash = (hash << 5) - hash + charCode; + hash |= 0; // Convert to 32bit integer + } + + return String(hash); +}; diff --git a/packages/app-page-builder-elements/src/index.ts b/packages/app-page-builder-elements/src/index.ts index 4865e5ee8f2..19ca8705fd2 100644 --- a/packages/app-page-builder-elements/src/index.ts +++ b/packages/app-page-builder-elements/src/index.ts @@ -6,6 +6,7 @@ export * from "./hooks/usePage"; export * from "./hooks/usePageElements"; export * from "./hooks/useRenderer"; export * from "./hooks/useFacepaint"; +export * from "./hooks/useLoader"; export * from "./contexts/PageElements"; export * from "./contexts/Page"; diff --git a/packages/app-page-builder-elements/src/types.ts b/packages/app-page-builder-elements/src/types.ts index a96ec8f6a17..19ea0dac7e3 100644 --- a/packages/app-page-builder-elements/src/types.ts +++ b/packages/app-page-builder-elements/src/types.ts @@ -6,6 +6,7 @@ import React, { HTMLAttributes } from "react"; import { type CSSObject } from "@emotion/react"; import { StylesObject, ThemeBreakpoints, Theme } from "@webiny/theme/types"; import { ElementInputs, ElementInputValues } from "~/inputs/ElementInput"; +import { ILoaderCache } from "~/hooks/useLoader/ILoaderCache"; export interface Page { id: string; @@ -34,6 +35,7 @@ export interface PageElementsProviderProps { beforeRenderer?: React.ComponentType | null; afterRenderer?: React.ComponentType | null; children?: React.ReactNode; + loaderCache: ILoaderCache; } export type AttributesObject = React.ComponentProps<any>; diff --git a/packages/app-page-builder/src/PageBuilder.tsx b/packages/app-page-builder/src/PageBuilder.tsx index 259b3561bb7..b3bae68ed7d 100644 --- a/packages/app-page-builder/src/PageBuilder.tsx +++ b/packages/app-page-builder/src/PageBuilder.tsx @@ -1,4 +1,4 @@ -import React, { Fragment } from "react"; +import React, { Fragment, useMemo } from "react"; import { HasPermission } from "@webiny/app-security"; import { Plugins, AddMenu as Menu, createProviderPlugin } from "@webiny/app-admin"; import { Global, css } from "@emotion/react"; @@ -16,6 +16,7 @@ import { AddButtonClickHandlers } from "~/elementDecorators/AddButtonClickHandle import { InjectElementVariables } from "~/render/variables/InjectElementVariables"; import { LexicalParagraphRenderer } from "~/render/plugins/elements/paragraph/LexicalParagraph"; import { LexicalHeadingRenderer } from "~/render/plugins/elements/heading/LexicalHeading"; +import { NullLoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/NullLoaderCache"; export type { EditorProps }; export { EditorRenderer }; @@ -24,8 +25,12 @@ export * from "~/admin/views/Pages/hooks"; const PageBuilderProviderPlugin = createProviderPlugin(Component => { return function PageBuilderProvider({ children }) { + const noLoaderCache = useMemo(() => { + return new NullLoaderCache(); + }, []); + return ( - <ContextProvider> + <ContextProvider loaderCache={noLoaderCache}> <AdminPageBuilderContextProvider> <Component>{children}</Component> </AdminPageBuilderContextProvider> diff --git a/packages/app-page-builder/src/admin/components/ResponsiveElementsProvider/ResponsiveElementsProvider.tsx b/packages/app-page-builder/src/admin/components/ResponsiveElementsProvider/ResponsiveElementsProvider.tsx index dc331449aec..d3481e39903 100644 --- a/packages/app-page-builder/src/admin/components/ResponsiveElementsProvider/ResponsiveElementsProvider.tsx +++ b/packages/app-page-builder/src/admin/components/ResponsiveElementsProvider/ResponsiveElementsProvider.tsx @@ -4,6 +4,7 @@ import { usePageBuilder } from "~/hooks/usePageBuilder"; import { mediaToContainer } from "./mediaToContainer"; import { PageElementsProvider } from "~/contexts/PageBuilder/PageElementsProvider"; import styled from "@emotion/styled"; +import { NullLoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/NullLoaderCache"; const ResponsiveContainer = styled.div` container-type: inline-size; @@ -39,9 +40,15 @@ export const ResponsiveElementsProvider = ({ children }: { children: React.React } as Theme; }, [pageBuilder.theme]); + const nullLoaderCache = useMemo(() => { + return new NullLoaderCache(); + }, []); + return ( <ResponsiveContainer> - <PageElementsProvider theme={containerizedTheme!}>{children}</PageElementsProvider> + <PageElementsProvider theme={containerizedTheme!} loaderCache={nullLoaderCache}> + {children} + </PageElementsProvider> </ResponsiveContainer> ); }; diff --git a/packages/app-page-builder/src/contexts/PageBuilder/PageBuilderContext.tsx b/packages/app-page-builder/src/contexts/PageBuilder/PageBuilderContext.tsx index 5caadd3d916..0eda5c49f37 100644 --- a/packages/app-page-builder/src/contexts/PageBuilder/PageBuilderContext.tsx +++ b/packages/app-page-builder/src/contexts/PageBuilder/PageBuilderContext.tsx @@ -4,6 +4,7 @@ import { DisplayMode, PbTheme } from "~/types"; import { Theme } from "@webiny/app-theme/types"; import { useTheme } from "@webiny/app-theme"; import { PageElementsProvider } from "./PageElementsProvider"; +import { ILoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/ILoaderCache"; export interface ResponsiveDisplayMode { displayMode: DisplayMode; @@ -35,12 +36,13 @@ export interface PageBuilderContext { } export interface PageBuilderProviderProps { + loaderCache: ILoaderCache; children?: React.ReactChild | React.ReactChild[]; } export const PageBuilderContext = React.createContext<PageBuilderContext | undefined>(undefined); -export const PageBuilderProvider = ({ children }: PageBuilderProviderProps) => { +export const PageBuilderProvider = ({ children, loaderCache }: PageBuilderProviderProps) => { const [displayMode, setDisplayMode] = React.useState(DisplayMode.DESKTOP); const [revisionType, setRevisionType] = React.useState<PbRevisionType>( PbRevisionType.published @@ -62,7 +64,7 @@ export const PageBuilderProvider = ({ children }: PageBuilderProviderProps) => { } }} > - <PageElementsProvider>{children}</PageElementsProvider> + <PageElementsProvider loaderCache={loaderCache}>{children}</PageElementsProvider> </PageBuilderContext.Provider> ); }; diff --git a/packages/app-page-builder/src/contexts/PageBuilder/PageElementsProvider.tsx b/packages/app-page-builder/src/contexts/PageBuilder/PageElementsProvider.tsx index 7c6e2cce40b..9c390c040ea 100644 --- a/packages/app-page-builder/src/contexts/PageBuilder/PageElementsProvider.tsx +++ b/packages/app-page-builder/src/contexts/PageBuilder/PageElementsProvider.tsx @@ -27,13 +27,19 @@ import { Theme } from "@webiny/app-theme/types"; import { plugins } from "@webiny/plugins"; import { PbRenderElementPlugin } from "~/types"; +import { ILoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/ILoaderCache"; interface PageElementsProviderProps { theme?: Theme; + loaderCache: ILoaderCache; children: React.ReactNode; } -export const PageElementsProvider = ({ theme, children }: PageElementsProviderProps) => { +export const PageElementsProvider = ({ + theme, + loaderCache, + children +}: PageElementsProviderProps) => { const pageBuilder = usePageBuilder(); const getRenderers = useCallback(() => { @@ -76,6 +82,7 @@ export const PageElementsProvider = ({ theme, children }: PageElementsProviderPr theme={theme ?? (pageBuilder.theme as Theme)} renderers={getRenderers} modifiers={modifiers} + loaderCache={loaderCache} > {children} </PbPageElementsProvider> diff --git a/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider.tsx b/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider.tsx index c1a36616691..8531ad23b56 100644 --- a/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider.tsx +++ b/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider.tsx @@ -32,6 +32,7 @@ import { plugins } from "@webiny/plugins"; import { PbEditorPageElementPlugin } from "~/types"; import { ElementControls } from "./EditorPageElementsProvider/ElementControls"; import { mediaToContainer } from "./EditorPageElementsProvider/mediaToContainer"; +import { NullLoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/NullLoaderCache"; interface EditorPageElementsProviderProps { children: React.ReactNode; @@ -96,11 +97,16 @@ export const EditorPageElementsProvider = ({ children }: EditorPageElementsProvi } as Theme; }, [pageBuilder.theme]); + const nullLoaderCache = useMemo(() => { + return new NullLoaderCache(); + }, []); + return ( <PbPageElementsProvider theme={containerizedTheme!} renderers={renderers} modifiers={modifiers} + loaderCache={nullLoaderCache} beforeRenderer={ElementControls} > {children} diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveDialog.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveDialog.tsx index 41d07a3bf1e..8a7035ce9dd 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveDialog.tsx +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/save/SaveDialog.tsx @@ -1,9 +1,10 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { css } from "emotion"; import { plugins } from "@webiny/plugins"; import ElementPreview from "./SaveDialog/ElementPreview"; import { CircularProgress } from "@webiny/ui/Progress"; import { PageElementsProvider } from "~/contexts/PageBuilder/PageElementsProvider"; +import { NullLoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/NullLoaderCache"; import { Dialog, @@ -91,6 +92,10 @@ const SaveDialog = (props: Props) => { } }; + const nullLoaderCache = useMemo(() => { + return new NullLoaderCache(); + }, []); + return ( <Dialog open={open} onClose={onClose} className={narrowDialog}> <Form onSubmit={onSubmit} data={{ type, id: element.id }}> @@ -142,7 +147,7 @@ const SaveDialog = (props: Props) => { <Grid> <Cell span={12}> <PreviewBox> - <PageElementsProvider> + <PageElementsProvider loaderCache={nullLoaderCache}> <ElementPreview element={pbElement} /> </PageElementsProvider> </PreviewBox> diff --git a/packages/app-website/src/LinkPreload.tsx b/packages/app-website/src/LinkPreload.tsx index 82a6fae34ab..594e56dc795 100644 --- a/packages/app-website/src/LinkPreload.tsx +++ b/packages/app-website/src/LinkPreload.tsx @@ -5,6 +5,7 @@ import { makeDecoratable } from "@webiny/app"; import { Link, To } from "@webiny/react-router"; import { getPrerenderId, isPrerendering } from "@webiny/app/utils"; import { GET_PUBLISHED_PAGE } from "./Page/graphql"; +import { usePageElements } from "@webiny/app-page-builder-elements"; const preloadedPaths: string[] = []; @@ -23,6 +24,8 @@ const defaultGetPreloadPagePath: GetPreloadPagePath = path => { const useLinkPreload = (path: string | To, options: LinkPreloadOptions) => { const getPreloadPagePath = options.getPreloadPagePath ?? defaultGetPreloadPagePath; + const { loaderCache } = usePageElements(); + const apolloClient = useApolloClient(); const preloadPath = async (pathname: string) => { // We only need a clean pathname, without query parameters. @@ -34,22 +37,32 @@ const useLinkPreload = (path: string | To, options: LinkPreloadOptions) => { preloadedPaths.push(pathname); - const graphqlJson = `graphql.json?k=${getPrerenderId()}`; + const graphqlJson = `cache.json?k=${getPrerenderId()}`; const fetchPath = pathname !== "/" ? `${pathname}/${graphqlJson}` : `/${graphqlJson}`; const pageState = await fetch(fetchPath.replace("//", "/")) .then(res => res.json()) .catch(() => null); if (pageState) { - for (let i = 0; i < pageState.length; i++) { - const { query, variables, data } = pageState[i]; - apolloClient.writeQuery({ - query: gql` - ${query} - `, - data, - variables - }); + const { apolloGraphQl, peLoaders } = pageState; + if (Array.isArray(apolloGraphQl)) { + for (let i = 0; i < apolloGraphQl.length; i++) { + const { query, variables, data } = apolloGraphQl[i]; + apolloClient.writeQuery({ + query: gql` + ${query} + `, + data, + variables + }); + } + } + + if (Array.isArray(peLoaders)) { + for (let i = 0; i < peLoaders.length; i++) { + const { key, value } = peLoaders[i]; + loaderCache.write(key, value); + } } } else { const finalPath = getPreloadPagePath(pathname); diff --git a/packages/app-website/src/Website.tsx b/packages/app-website/src/Website.tsx index cd6dabbad88..5b917e0845c 100644 --- a/packages/app-website/src/Website.tsx +++ b/packages/app-website/src/Website.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useMemo } from "react"; import { App, AppProps, Decorator, GenericComponent } from "@webiny/app"; import { ApolloProvider } from "@apollo/react-hooks"; import { CacheProvider } from "@emotion/react"; @@ -9,6 +9,7 @@ import { PageBuilderProvider } from "@webiny/app-page-builder/contexts/PageBuild import { PageBuilder } from "@webiny/app-page-builder/render"; import { RouteProps } from "@webiny/react-router"; import { LinkPreload } from "~/LinkPreload"; +import { WebsiteLoaderCache } from "~/utils/WebsiteLoaderCache"; export interface WebsiteProps extends AppProps { apolloClient?: ReturnType<typeof createApolloClient>; @@ -17,9 +18,13 @@ export interface WebsiteProps extends AppProps { const PageBuilderProviderHOC: Decorator< GenericComponent<{ children: React.ReactNode }> > = PreviousProvider => { + const websiteLoaderCache = useMemo(() => { + return new WebsiteLoaderCache(); + }, []); + return function PageBuilderProviderHOC({ children }) { return ( - <PageBuilderProvider> + <PageBuilderProvider loaderCache={websiteLoaderCache}> <PreviousProvider>{children}</PreviousProvider> </PageBuilderProvider> ); diff --git a/packages/app-website/src/utils/WebsiteLoaderCache.ts b/packages/app-website/src/utils/WebsiteLoaderCache.ts new file mode 100644 index 00000000000..56d31a03264 --- /dev/null +++ b/packages/app-website/src/utils/WebsiteLoaderCache.ts @@ -0,0 +1,37 @@ +import type { ILoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/ILoaderCache"; +import { getPrerenderId, isPrerendering } from "@webiny/app/utils"; +import { PeLoaderHtmlCache } from "~/utils/WebsiteLoaderCache/PeLoaderHtmlCache"; + +export class WebsiteLoaderCache implements ILoaderCache { + private loaderCache: Record<string, any> = {}; + + read<TData = unknown>(key: string) { + if (key in this.loaderCache) { + return this.loaderCache[key]; + } + + if (getPrerenderId()) { + this.loaderCache[key] = PeLoaderHtmlCache.read<TData>(key); + return this.loaderCache[key]; + } + + this.loaderCache[key] = null; + return this.loaderCache[key]; + } + + write<TData = unknown>(key: string, value: TData) { + this.loaderCache[key] = value; + + if (isPrerendering()) { + PeLoaderHtmlCache.write(key, value); + } + } + + remove(key: string) { + delete this.loaderCache[key]; + } + + clear() { + this.loaderCache = {}; + } +} diff --git a/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts b/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts new file mode 100644 index 00000000000..10b3b83a1bb --- /dev/null +++ b/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts @@ -0,0 +1,26 @@ +export class PeLoaderHtmlCache { + static read<TData = unknown>(key: string) { + const htmlElement = document.querySelector(`pe-loader-data-cache[data-key="${key}"]`); + if (!htmlElement) { + return null; + } + + const cachedResultElementValue = htmlElement.getAttribute("data-value"); + if (!cachedResultElementValue) { + return null; + } + + try { + return JSON.parse(cachedResultElementValue) as TData; + } catch { + return null; + } + } + + static write<TData = unknown>(key: string, value: TData) { + const html = `<pe-loader-data-cache data-key="${key}" data-value='${JSON.stringify( + value + )}'></pe-loader-data-cache>`; + document.body.insertAdjacentHTML("beforeend", html); + } +} diff --git a/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/ddbPutItemConditionalCheckFailed.js b/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/ddbPutItemConditionalCheckFailed.js new file mode 100644 index 00000000000..db5be4d75bc --- /dev/null +++ b/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/ddbPutItemConditionalCheckFailed.js @@ -0,0 +1,12 @@ +const MATCH_STRING = "ConditionalCheckFailedException: The conditional request failed"; + +module.exports = ({ error }) => { + const { message } = error; + + if (typeof message === "string" && message.includes(MATCH_STRING)) { + return { + message: `Looks like the deployment failed because Pulumi tried to insert a record into a DynamoDB table, but the record already exists. The easiest way to resolve this is to delete the record from the table and try again.`, + learnMore: "https://webiny.link/deployment-ddb-conditional-check-failed" + }; + } +}; diff --git a/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/index.js b/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/index.js index 9d8c0c559b6..ddf536f4487 100644 --- a/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/index.js +++ b/packages/cli-plugin-deploy-pulumi/utils/gracefulPulumiErrorHandlers/index.js @@ -1,4 +1,5 @@ +const ddbPutItemConditionalCheckFailed = require("./ddbPutItemConditionalCheckFailed"); const missingFilesInBuild = require("./missingFilesInBuild"); const pendingOperationsInfo = require("./pendingOperationsInfo"); -module.exports = [missingFilesInBuild, pendingOperationsInfo]; +module.exports = [ddbPutItemConditionalCheckFailed, missingFilesInBuild, pendingOperationsInfo]; diff --git a/packages/cli-plugin-extensions/src/promptQuestions.ts b/packages/cli-plugin-extensions/src/promptQuestions.ts index 0d526db9ccb..fc5f949fb18 100644 --- a/packages/cli-plugin-extensions/src/promptQuestions.ts +++ b/packages/cli-plugin-extensions/src/promptQuestions.ts @@ -23,10 +23,7 @@ export const promptQuestions: QuestionCollection = [ choices: [ { name: "Admin extension", value: "admin" }, { name: "API extension", value: "api" }, - - // TODO: Bring back when we design the new PB Element React Configs API. - // { name: "Page Builder element", value: "pbElement" }, - + { name: "Page Builder element", value: "pbElement" }, { name: "Website extension", value: "website" } ] }, diff --git a/yarn.lock b/yarn.lock index e052fcca3e4..101bb1ad4f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12121,6 +12121,13 @@ __metadata: languageName: node linkType: hard +"@types/he@npm:^1.2.3": + version: 1.2.3 + resolution: "@types/he@npm:1.2.3" + checksum: e77851c73dd7b9902d92fe0118a26246a7f3676a3a1c6eb1408305187ef73b57c22550b1435946b983267f961d935554d5d0e1b458416932552f31e763e1aa41 + languageName: node + linkType: hard + "@types/hoist-non-react-statics@npm:^3.3.5": version: 3.3.5 resolution: "@types/hoist-non-react-statics@npm:3.3.5" @@ -14836,6 +14843,7 @@ __metadata: "@babel/preset-typescript": ^7.23.3 "@babel/runtime": ^7.24.0 "@sparticuz/chromium": 123.0.1 + "@types/he": ^1.2.3 "@types/object-hash": ^2.2.1 "@types/puppeteer-core": ^5.4.0 "@webiny/api": 0.0.0 @@ -14848,6 +14856,7 @@ __metadata: "@webiny/plugins": 0.0.0 "@webiny/project-utils": 0.0.0 "@webiny/utils": 0.0.0 + he: ^1.2.0 lodash: ^4.17.21 object-hash: ^3.0.0 pluralize: ^8.0.0 From 76ad3ee8a38c46df38421c2bea1bace19492037c Mon Sep 17 00:00:00 2001 From: adrians5j <adrian@webiny.com> Date: Thu, 5 Dec 2024 06:32:17 +0100 Subject: [PATCH 05/52] fix: allow specifying dependency with exact version --- .../src/generateExtension.ts | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/cli-plugin-extensions/src/generateExtension.ts b/packages/cli-plugin-extensions/src/generateExtension.ts index 67af6b17418..b405ac5c265 100644 --- a/packages/cli-plugin-extensions/src/generateExtension.ts +++ b/packages/cli-plugin-extensions/src/generateExtension.ts @@ -93,9 +93,26 @@ export const generateExtension = async ({ input, ora, context }: GenerateExtensi } try { - const { stdout } = await execa("npm", ["view", packageName, "version", "json"]); - - packageJsonUpdates[packageName] = `^${stdout}`; + const parsedPackageName = (() => { + const parts = packageName.split("@"); + if (packageName.startsWith("@")) { + return { name: parts[0] + parts[1], version: parts[2] }; + } + + return { name: parts[0], version: parts[1] }; + })(); + + if (parsedPackageName.version) { + packageJsonUpdates[parsedPackageName.name] = parsedPackageName.version; + } else { + const { stdout } = await execa("npm", [ + "view", + parsedPackageName.name, + "version" + ]); + + packageJsonUpdates[packageName] = `^${stdout}`; + } } catch (e) { throw new Error( `Could not find ${log.error.hl( From 7e37ccb4eca0981976b5af9d51b26a74ef840df9 Mon Sep 17 00:00:00 2001 From: adrians5j <adrian@webiny.com> Date: Thu, 5 Dec 2024 07:01:54 +0100 Subject: [PATCH 06/52] fix: add "extension" suffix to the label --- packages/cli-plugin-extensions/src/promptQuestions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli-plugin-extensions/src/promptQuestions.ts b/packages/cli-plugin-extensions/src/promptQuestions.ts index fc5f949fb18..5c6bda69459 100644 --- a/packages/cli-plugin-extensions/src/promptQuestions.ts +++ b/packages/cli-plugin-extensions/src/promptQuestions.ts @@ -23,7 +23,7 @@ export const promptQuestions: QuestionCollection = [ choices: [ { name: "Admin extension", value: "admin" }, { name: "API extension", value: "api" }, - { name: "Page Builder element", value: "pbElement" }, + { name: "Page Builder element extension", value: "pbElement" }, { name: "Website extension", value: "website" } ] }, From 7b2e8e45b6145768a46b613887cc86108c423895 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Sun, 8 Dec 2024 17:39:52 +0100 Subject: [PATCH 07/52] fix(form): commit field value to form even if the field doesn't exist (cherry picked from commit 80b12cd2cba2c9c066bea68bfbead0de69c3d516) --- packages/form/src/FormPresenter.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/form/src/FormPresenter.ts b/packages/form/src/FormPresenter.ts index 3fd9837aa8b..3478aecefb9 100644 --- a/packages/form/src/FormPresenter.ts +++ b/packages/form/src/FormPresenter.ts @@ -104,6 +104,7 @@ export class FormPresenter<T extends GenericFormData = GenericFormData> { setFieldValue(name: string, value: unknown) { const field = this.formFields.get(name); if (!field) { + this.commitValueToData(name, value); return; } From 91cf386db2a381d54c584efc6a53d5897a41922c Mon Sep 17 00:00:00 2001 From: Adrian Smijulj <adrian1358@gmail.com> Date: Thu, 12 Dec 2024 23:55:03 +0100 Subject: [PATCH 08/52] fix: compress cached loader data (#4435) --- .../extractPeLoaderDataFromHtml.test.ts | 34 ++----------------- .../api-prerendering-service/package.json | 2 -- .../src/render/extractPeLoaderDataFromHtml.ts | 6 +--- packages/app-website/package.json | 1 + packages/app-website/src/Website.tsx | 8 ++--- .../src/utils/WebsiteLoaderCache.ts | 9 +++-- .../WebsiteLoaderCache/PeLoaderHtmlCache.ts | 22 ++++++++++-- yarn.lock | 10 +----- 8 files changed, 37 insertions(+), 55 deletions(-) diff --git a/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts b/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts index 967a6704370..ae5cdb8191d 100644 --- a/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts +++ b/packages/api-prerendering-service/__tests__/render/extractPeLoaderDataFromHtml.test.ts @@ -4,42 +4,14 @@ describe("extractPeLoaderDataFromHtml Tests", () => { it("must detect pe-loader-data-cache tags in given HTML", async () => { const results = extractPeLoaderDataFromHtml(TEST_STRING); + // The value is not decompressed, so it's still a string. expect(results).toEqual([ { key: "GfT8AoRsYT-1238102521", - value: [ - { - description: - "The Falcon 1 was an expendable launch system privately developed and manufactured by SpaceX during 2006-2009. On 28 September 2008, Falcon 1 became the first privately-developed liquid-fuel launch vehicle to go into orbit around the Earth.", - id: "5e9d0d95eda69955f709d1eb", - name: "Falcon 1", - wikipedia: "https://en.wikipedia.org/wiki/Falcon_1" - }, - { - description: - "Falcon 9 is a two-stage rocket designed and manufactured by SpaceX for the reliable and safe transport of satellites and the Dragon spacecraft into orbit.", - id: "5e9d0d95eda69973a809d1ec", - name: "Falcon 9", - wikipedia: "https://en.wikipedia.org/wiki/Falcon_9" - }, - { - description: - "With the ability to lift into orbit over 54 metric tons (119,000 lb)--a mass equivalent to a 737 jetliner loaded with passengers, crew, luggage and fuel--Falcon Heavy can lift more than twice the payload of the next closest operational vehicle, the Delta IV Heavy, at one-third the cost.", - id: "5e9d0d95eda69974db09d1ed", - name: "Falcon Heavy", - wikipedia: "https://en.wikipedia.org/wiki/Falcon_Heavy" - }, - { - description: - "Starship and Super Heavy Rocket represent a fully reusable transportation system designed to service all Earth orbit needs as well as the Moon and Mars. This two-stage vehicle — composed of the Super Heavy rocket (booster) and Starship (ship) — will eventually replace Falcon 9, Falcon Heavy and Dragon.", - id: "5e9d0d96eda699382d09d1ee", - name: "Starship", - wikipedia: "https://en.wikipedia.org/wiki/SpaceX_Starship" - } - ] + value: "pe_NobwRAJgpgzgxgJwJYAcAuSD2A7MAuMAFQAsoACAMQEMAbOHMgRjIHcqYyrsyoAPFKNghUARjXI0qAV2xxiZGAE8YaKAFsyKZADcqqmorLRtUGpgEROQsmq5SAZlThopCKJZGGAyiidQAGkauSNgA5mQATAAMUQBsALTRUQCcAHRkAPLcEQAcZF5Q6OoiUAiRMTkANJS09NzMJXBUauRopGT2SAgqmjp6porxxqbm7mQ0SACOUkgQ8fZSpuPSsvImxEhw4mRomGSheyG7ZJgIIkhonAiYMpZt5ACiVAhtqWCVYLP4YACsUMkQKIQZJ/YSxZIgn72ADsKQgjCgIneYGwzSg32odAYjGRLCQAGtUO4kFRvsQ0GgUDA8AB6GmCVJ4wkWEmpU6hGlMpA0zF1AD6OIAvpVwNB4Mh0FhcAReQxkmQkBwqDsWJh4ioqKFyNc4PioJcxUhQtgxlxLLZsA4nC43B5vL44AEOqcdu03BNRNszQoqPZWgguDAUKdLph7D79BNVErrPcyAARAMHbhBvyIX2XI57U7nNBvD5fAh/AFAkHuKjg5LQgDMVBycIRcGRqJaGNqctxBKJEBJZIpVNp9OwjK7LKobIQHK5PPb2D5yTAwtFsEQqAwOG+AHULvI46IkFHDMcJvZM9hjjmLicTGUfgAWGz65BwHY4DgACkYjGSlRiUXGIgAJTxPEyq2DAHBQNMSC6OI56vpwZA1tCZAAFb6hMJplGYVDQJYeJtJo7AwIIWrdNUiBQCw1Q0FIoShJq5DegspggbK3AABJQFQ2iGE03AnpcainK0xBcCqmyieQviKDhlhhq65AmrwlxbJgJE9KMAbrqiNBkOsmziNUcbxqYaDKgAkgAamQXE8Yo1R6CcJrxG0XR3O09AqPmnwQN8xaAsCoIVhC0J3hAIgNu4zZom2WKcdxvGdsyxKkgQ5KUtSdIMlyY4TlOXYzvFfJ2UlS6QCuEo6d8Xjmd0GwoFYlheFIAhlKVhgAEqYLq+pkG4WiwIIlzKgsNAGP1UBSDAnr+oGwYvHoUoKMoqgaIaxpjMcJEINokmcONZBPC88iXpcJruEqHAsKYensIpZAALKYAw3qPc8MDpCQioqmqGpavpUAbFs5AAMMAEUAEMAAGQwAKWQ9BqMGJHyeGcYtW1tmJYYOp6pc74iC9KilIBTX5HVMANWQ75U6gZMQzD8OsAeelQCY55SLQE0DZIjo1PFZA/gLdTY/Z5OJpqOA+YWvz/IFySxOWlbVjkESAgCCLoh8LbogQtUfQ1yXdr26X9llQ4jilPbjuynKFT4fj+HyBv1agi4ALpAA==" } ]); }); }); -const TEST_STRING = `...<li><h1>Starship</h1><div>Starship and Super Heavy Rocket represent a fully reusable transportation system designed to service all Earth orbit needs as well as the Moon and Mars. This two-stage vehicle — composed of the Super Heavy rocket (booster) and Starship (ship) — will eventually replace Falcon 9, Falcon Heavy and Dragon.</div><br><div>More info at <a href="https://en.wikipedia.org/wiki/SpaceX_Starship" target="_blank" rel="noreferrer">https://en.wikipedia.org/wiki/SpaceX_Starship</a></div></li></ul></pb-spacex></pb-cell></pb-grid></pb-block></pb-document></main><footer data-testid="pb-footer" class="wby-1lh86qf"><div class="wby-xv6w56"><div class="logo wby-1i3ok2b"><a href="/"></a><div class="copy">DEVR © 2024</div></div></div></footer></div></div><pe-loader-data-cache data-key="GfT8AoRsYT-1238102521" data-value="[{"id":"5e9d0d95eda69955f709d1eb","name":"Falcon 1","description":"The Falcon 1 was an expendable launch system privately developed and manufactured by SpaceX during 2006-2009. On 28 September 2008, Falcon 1 became the first privately-developed liquid-fuel launch vehicle to go into orbit around the Earth.","wikipedia":"https://en.wikipedia.org/wiki/Falcon_1"},{"id":"5e9d0d95eda69973a809d1ec","name":"Falcon 9","description":"Falcon 9 is a two-stage rocket designed and manufactured by SpaceX for the reliable and safe transport of satellites and the Dragon spacecraft into orbit.","wikipedia":"https://en.wikipedia.org/wiki/Falcon_9"},{"id":"5e9d0d95eda69974db09d1ed","name":"Falcon Heavy","description":"With the ability to lift into orbit over 54 metric tons (119,000 lb)--a mass equivalent to a 737 jetliner loaded with passengers, crew, luggage and fuel--Falcon Heavy can lift more than twice the payload of the next closest operational vehicle, the Delta IV Heavy, at one-third the cost.","wikipedia":"https://en.wikipedia.org/wiki/Falcon_Heavy"},{"id":"5e9d0d96eda699382d09d1ee","name":"Starship","description":"Starship and Super Heavy Rocket represent a fully reusable transportation system designed to service all Earth orbit needs as well as the Moon and Mars. This two-stage vehicle — composed of the Super Heavy rocket (booster) and Starship (ship) — will eventually replace Falcon 9, Falcon Heavy and Dragon.","wikipedia":"https://en.wikipedia.org/wiki/SpaceX_Starship"}]"></pe-loader-data-cache></body></html>`; +const TEST_STRING = `...<li><h1>Starship</h1><div>Starship and Super Heavy Rocket represent a fully reusable transportation system designed to service all Earth orbit needs as well as the Moon and Mars. This two-stage vehicle — composed of the Super Heavy rocket (booster) and Starship (ship) — will eventually replace Falcon 9, Falcon Heavy and Dragon.</div><br><div>More info at <a href="https://en.wikipedia.org/wiki/SpaceX_Starship" target="_blank" rel="noreferrer">https://en.wikipedia.org/wiki/SpaceX_Starship</a></div></li></ul></pb-spacex></pb-cell></pb-grid></pb-block></pb-document></main><footer data-testid="pb-footer" class="wby-1lh86qf"><div class="wby-xv6w56"><div class="logo wby-1i3ok2b"><a href="/"></a><div class="copy">DEVR © 2024</div></div></div></footer></div></div><pe-loader-data-cache data-key="GfT8AoRsYT-1238102521" data-value="pe_NobwRAJgpgzgxgJwJYAcAuSD2A7MAuMAFQAsoACAMQEMAbOHMgRjIHcqYyrsyoAPFKNghUARjXI0qAV2xxiZGAE8YaKAFsyKZADcqqmorLRtUGpgEROQsmq5SAZlThopCKJZGGAyiidQAGkauSNgA5mQATAAMUQBsALTRUQCcAHRkAPLcEQAcZF5Q6OoiUAiRMTkANJS09NzMJXBUauRopGT2SAgqmjp6porxxqbm7mQ0SACOUkgQ8fZSpuPSsvImxEhw4mRomGSheyG7ZJgIIkhonAiYMpZt5ACiVAhtqWCVYLP4YACsUMkQKIQZJ/YSxZIgn72ADsKQgjCgIneYGwzSg32odAYjGRLCQAGtUO4kFRvsQ0GgUDA8AB6GmCVJ4wkWEmpU6hGlMpA0zF1AD6OIAvpVwNB4Mh0FhcAReQxkmQkBwqDsWJh4ioqKFyNc4PioJcxUhQtgxlxLLZsA4nC43B5vL44AEOqcdu03BNRNszQoqPZWgguDAUKdLph7D79BNVErrPcyAARAMHbhBvyIX2XI57U7nNBvD5fAh/AFAkHuKjg5LQgDMVBycIRcGRqJaGNqctxBKJEBJZIpVNp9OwjK7LKobIQHK5PPb2D5yTAwtFsEQqAwOG+AHULvI46IkFHDMcJvZM9hjjmLicTGUfgAWGz65BwHY4DgACkYjGSlRiUXGIgAJTxPEyq2DAHBQNMSC6OI56vpwZA1tCZAAFb6hMJplGYVDQJYeJtJo7AwIIWrdNUiBQCw1Q0FIoShJq5DegspggbK3AABJQFQ2iGE03AnpcainK0xBcCqmyieQviKDhlhhq65AmrwlxbJgJE9KMAbrqiNBkOsmziNUcbxqYaDKgAkgAamQXE8Yo1R6CcJrxG0XR3O09AqPmnwQN8xaAsCoIVhC0J3hAIgNu4zZom2WKcdxvGdsyxKkgQ5KUtSdIMlyY4TlOXYzvFfJ2UlS6QCuEo6d8Xjmd0GwoFYlheFIAhlKVhgAEqYLq+pkG4WiwIIlzKgsNAGP1UBSDAnr+oGwYvHoUoKMoqgaIaxpjMcJEINokmcONZBPC88iXpcJruEqHAsKYensIpZAALKYAw3qPc8MDpCQioqmqGpavpUAbFs5AAMMAEUAEMAAGQwAKWQ9BqMGJHyeGcYtW1tmJYYOp6pc74iC9KilIBTX5HVMANWQ75U6gZMQzD8OsAeelQCY55SLQE0DZIjo1PFZA/gLdTY/Z5OJpqOA+YWvz/IFySxOWlbVjkESAgCCLoh8LbogQtUfQ1yXdr26X9llQ4jilPbjuynKFT4fj+HyBv1agi4ALpAA=="></pe-loader-data-cache></body></html>`; diff --git a/packages/api-prerendering-service/package.json b/packages/api-prerendering-service/package.json index 6de200a1885..4f5b541a939 100644 --- a/packages/api-prerendering-service/package.json +++ b/packages/api-prerendering-service/package.json @@ -24,7 +24,6 @@ "@webiny/handler-client": "0.0.0", "@webiny/plugins": "0.0.0", "@webiny/utils": "0.0.0", - "he": "^1.2.0", "lodash": "^4.17.21", "object-hash": "^3.0.0", "pluralize": "^8.0.0", @@ -41,7 +40,6 @@ "@babel/plugin-proposal-export-default-from": "^7.23.3", "@babel/preset-env": "^7.24.0", "@babel/preset-typescript": "^7.23.3", - "@types/he": "^1.2.3", "@types/object-hash": "^2.2.1", "@types/puppeteer-core": "^5.4.0", "@webiny/cli": "0.0.0", diff --git a/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts b/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts index 1618e550a27..cff831707b6 100644 --- a/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts +++ b/packages/api-prerendering-service/src/render/extractPeLoaderDataFromHtml.ts @@ -1,5 +1,4 @@ import { PeLoaderCacheEntry } from "./types"; -import he from "he"; const parsePeLoaderDataCacheTag = (content: string): PeLoaderCacheEntry | null => { const regex = @@ -14,10 +13,7 @@ const parsePeLoaderDataCacheTag = (content: string): PeLoaderCacheEntry | null = const [, key, value] = m; - // JSON in `data-value` is HTML Entities-encoded. So, we need to decode it here first. - const heParsedValue = he.decode(value); - const parsedValue = JSON.parse(heParsedValue); - return { key, value: parsedValue }; + return { key, value }; } return null; diff --git a/packages/app-website/package.json b/packages/app-website/package.json index 347e88c4c29..70ee6d324bb 100644 --- a/packages/app-website/package.json +++ b/packages/app-website/package.json @@ -26,6 +26,7 @@ "apollo-link": "^1.2.14", "apollo-link-batch-http": "^1.2.14", "graphql-tag": "^2.12.6", + "lz-string": "^1.5.0", "react": "18.2.0", "react-dom": "18.2.0", "react-helmet": "^6.1.0", diff --git a/packages/app-website/src/Website.tsx b/packages/app-website/src/Website.tsx index 5b917e0845c..229798fdfff 100644 --- a/packages/app-website/src/Website.tsx +++ b/packages/app-website/src/Website.tsx @@ -18,11 +18,11 @@ export interface WebsiteProps extends AppProps { const PageBuilderProviderHOC: Decorator< GenericComponent<{ children: React.ReactNode }> > = PreviousProvider => { - const websiteLoaderCache = useMemo(() => { - return new WebsiteLoaderCache(); - }, []); - return function PageBuilderProviderHOC({ children }) { + const websiteLoaderCache = useMemo(() => { + return new WebsiteLoaderCache(); + }, []); + return ( <PageBuilderProvider loaderCache={websiteLoaderCache}> <PreviousProvider>{children}</PreviousProvider> diff --git a/packages/app-website/src/utils/WebsiteLoaderCache.ts b/packages/app-website/src/utils/WebsiteLoaderCache.ts index 56d31a03264..bb1a7efcba3 100644 --- a/packages/app-website/src/utils/WebsiteLoaderCache.ts +++ b/packages/app-website/src/utils/WebsiteLoaderCache.ts @@ -1,6 +1,6 @@ import type { ILoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/ILoaderCache"; import { getPrerenderId, isPrerendering } from "@webiny/app/utils"; -import { PeLoaderHtmlCache } from "~/utils/WebsiteLoaderCache/PeLoaderHtmlCache"; +import { PeLoaderHtmlCache } from "./WebsiteLoaderCache/PeLoaderHtmlCache"; export class WebsiteLoaderCache implements ILoaderCache { private loaderCache: Record<string, any> = {}; @@ -19,7 +19,12 @@ export class WebsiteLoaderCache implements ILoaderCache { return this.loaderCache[key]; } - write<TData = unknown>(key: string, value: TData) { + write<TData = unknown>(key: string, rawValue: TData) { + // We assume it's compressed data if the value is a string. + const value = PeLoaderHtmlCache.isCompressedData<TData>(rawValue) + ? PeLoaderHtmlCache.decompressData(rawValue as string) + : rawValue; + this.loaderCache[key] = value; if (isPrerendering()) { diff --git a/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts b/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts index 10b3b83a1bb..87cbe6d8113 100644 --- a/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts +++ b/packages/app-website/src/utils/WebsiteLoaderCache/PeLoaderHtmlCache.ts @@ -1,3 +1,7 @@ +import lzString from "lz-string"; + +const COMPRESSED_DATA_PREFIX = "pe_"; + export class PeLoaderHtmlCache { static read<TData = unknown>(key: string) { const htmlElement = document.querySelector(`pe-loader-data-cache[data-key="${key}"]`); @@ -11,16 +15,30 @@ export class PeLoaderHtmlCache { } try { - return JSON.parse(cachedResultElementValue) as TData; + return PeLoaderHtmlCache.decompressData(cachedResultElementValue) as TData; } catch { return null; } } static write<TData = unknown>(key: string, value: TData) { - const html = `<pe-loader-data-cache data-key="${key}" data-value='${JSON.stringify( + const html = `<pe-loader-data-cache data-key="${key}" data-value='${PeLoaderHtmlCache.compressData<TData>( value )}'></pe-loader-data-cache>`; document.body.insertAdjacentHTML("beforeend", html); } + + static compressData<TData>(data: TData) { + return COMPRESSED_DATA_PREFIX + lzString.compressToBase64(JSON.stringify(data)); + } + + static decompressData(data: string) { + return JSON.parse( + lzString.decompressFromBase64(data.replace(COMPRESSED_DATA_PREFIX, "")) as string + ); + } + + static isCompressedData<TData>(data: TData) { + return typeof data === "string" && data.startsWith(COMPRESSED_DATA_PREFIX); + } } diff --git a/yarn.lock b/yarn.lock index 101bb1ad4f7..9474c8c091f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12121,13 +12121,6 @@ __metadata: languageName: node linkType: hard -"@types/he@npm:^1.2.3": - version: 1.2.3 - resolution: "@types/he@npm:1.2.3" - checksum: e77851c73dd7b9902d92fe0118a26246a7f3676a3a1c6eb1408305187ef73b57c22550b1435946b983267f961d935554d5d0e1b458416932552f31e763e1aa41 - languageName: node - linkType: hard - "@types/hoist-non-react-statics@npm:^3.3.5": version: 3.3.5 resolution: "@types/hoist-non-react-statics@npm:3.3.5" @@ -14843,7 +14836,6 @@ __metadata: "@babel/preset-typescript": ^7.23.3 "@babel/runtime": ^7.24.0 "@sparticuz/chromium": 123.0.1 - "@types/he": ^1.2.3 "@types/object-hash": ^2.2.1 "@types/puppeteer-core": ^5.4.0 "@webiny/api": 0.0.0 @@ -14856,7 +14848,6 @@ __metadata: "@webiny/plugins": 0.0.0 "@webiny/project-utils": 0.0.0 "@webiny/utils": 0.0.0 - he: ^1.2.0 lodash: ^4.17.21 object-hash: ^3.0.0 pluralize: ^8.0.0 @@ -16722,6 +16713,7 @@ __metadata: apollo-link: ^1.2.14 apollo-link-batch-http: ^1.2.14 graphql-tag: ^2.12.6 + lz-string: ^1.5.0 react: 18.2.0 react-dom: 18.2.0 react-helmet: ^6.1.0 From bb1a258ad4f91197fa55ecf963d0112cb40124e8 Mon Sep 17 00:00:00 2001 From: Adrian Smijulj <adrian1358@gmail.com> Date: Fri, 13 Dec 2024 08:11:37 +0100 Subject: [PATCH 09/52] fix:replace `render` prop with `renderer` (#4444) --- .../src/plugins/PbEditorPageElementPlugin.tsx | 27 +++++++++++++------ .../src/plugins/PbRenderElementPlugin.tsx | 16 ++++++++--- .../src/plugins/PbRenderElementPlugin.tsx | 16 ++++++++--- .../src/utils/legacyPluginToReactComponent.ts | 11 +++++--- 4 files changed, 51 insertions(+), 19 deletions(-) diff --git a/packages/app-page-builder/src/plugins/PbEditorPageElementPlugin.tsx b/packages/app-page-builder/src/plugins/PbEditorPageElementPlugin.tsx index ba8f4247c5f..7c9e065edee 100644 --- a/packages/app-page-builder/src/plugins/PbEditorPageElementPlugin.tsx +++ b/packages/app-page-builder/src/plugins/PbEditorPageElementPlugin.tsx @@ -1,8 +1,10 @@ import type { PbEditorPageElementPlugin as BasePbEditorPageElementPlugin } from "~/types"; +import type { Renderer } from "@webiny/app-page-builder-elements/types"; + import { legacyPluginToReactComponent } from "@webiny/app/utils"; -export const PbEditorPageElementPlugin = legacyPluginToReactComponent< - Pick< +export interface PbEditorPageElementPluginProps + extends Pick< BasePbEditorPageElementPlugin, | "elementType" | "toolbar" @@ -10,15 +12,24 @@ export const PbEditorPageElementPlugin = legacyPluginToReactComponent< | "target" | "settings" | "create" - | "render" | "canDelete" | "canReceiveChildren" | "onReceived" | "onChildDeleted" | "onCreate" | "renderElementPreview" - > ->({ - pluginType: "pb-editor-page-element", - componentDisplayName: "PbEditorPageElementPlugin" -}); + > { + renderer: Renderer; +} + +export const PbEditorPageElementPlugin = + legacyPluginToReactComponent<PbEditorPageElementPluginProps>({ + pluginType: "pb-editor-page-element", + componentDisplayName: "PbEditorPageElementPlugin", + mapProps: props => { + return { + ...props, + render: props.renderer + }; + } + }); diff --git a/packages/app-page-builder/src/plugins/PbRenderElementPlugin.tsx b/packages/app-page-builder/src/plugins/PbRenderElementPlugin.tsx index cedbadab567..90e79829c9a 100644 --- a/packages/app-page-builder/src/plugins/PbRenderElementPlugin.tsx +++ b/packages/app-page-builder/src/plugins/PbRenderElementPlugin.tsx @@ -1,9 +1,17 @@ import type { PbRenderElementPlugin as BasePbRenderElementPlugin } from "~/types"; import { legacyPluginToReactComponent } from "@webiny/app/utils"; -export const PbRenderElementPlugin = legacyPluginToReactComponent< - Pick<BasePbRenderElementPlugin, "elementType" | "render"> ->({ +interface PbRenderElementPluginProps extends Pick<BasePbRenderElementPlugin, "elementType"> { + renderer: BasePbRenderElementPlugin["render"]; +} + +export const PbRenderElementPlugin = legacyPluginToReactComponent<PbRenderElementPluginProps>({ pluginType: "pb-render-page-element", - componentDisplayName: "PbRenderElementPlugin" + componentDisplayName: "PbRenderElementPlugin", + mapProps: props => { + return { + ...props, + render: props.renderer + }; + } }); diff --git a/packages/app-website/src/plugins/PbRenderElementPlugin.tsx b/packages/app-website/src/plugins/PbRenderElementPlugin.tsx index ffaa7fc5125..0c07c47c2ae 100644 --- a/packages/app-website/src/plugins/PbRenderElementPlugin.tsx +++ b/packages/app-website/src/plugins/PbRenderElementPlugin.tsx @@ -1,9 +1,17 @@ import type { PbRenderElementPlugin as BasePbRenderElementPlugin } from "@webiny/app-page-builder/types"; import { legacyPluginToReactComponent } from "@webiny/app/utils"; -export const PbRenderElementPlugin = legacyPluginToReactComponent< - Pick<BasePbRenderElementPlugin, "elementType" | "render"> ->({ +interface PbRenderElementPluginProps extends Pick<BasePbRenderElementPlugin, "elementType"> { + renderer: BasePbRenderElementPlugin["render"]; +} + +export const PbRenderElementPlugin = legacyPluginToReactComponent<PbRenderElementPluginProps>({ pluginType: "pb-render-page-element", - componentDisplayName: "PbRenderElementPlugin" + componentDisplayName: "PbRenderElementPlugin", + mapProps: props => { + return { + ...props, + render: props.renderer + }; + } }); diff --git a/packages/app/src/utils/legacyPluginToReactComponent.ts b/packages/app/src/utils/legacyPluginToReactComponent.ts index 3d93cc3915a..5da2e3f999c 100644 --- a/packages/app/src/utils/legacyPluginToReactComponent.ts +++ b/packages/app/src/utils/legacyPluginToReactComponent.ts @@ -1,16 +1,21 @@ import React from "react"; import { useRegisterLegacyPlugin } from "~/hooks/useRegisterLegacyPlugin"; -export interface LegacyPluginToReactComponentParams { +export interface LegacyPluginToReactComponentParams<TProps extends Record<string, any>> { pluginType: string; componentDisplayName: string; + mapProps?: (props: TProps) => TProps; } export const legacyPluginToReactComponent = function <TProps extends Record<string, any>>( - params: LegacyPluginToReactComponentParams + params: LegacyPluginToReactComponentParams<TProps> ) { const Component: React.ComponentType<TProps> = props => { - useRegisterLegacyPlugin({ ...props, type: params.pluginType }); + const plugin = Object.assign( + { type: params.pluginType }, + params.mapProps ? params.mapProps(props) : props + ); + useRegisterLegacyPlugin(plugin); return null; }; From 2e18a18c4600c16434bd72237c2d57f1da028a79 Mon Sep 17 00:00:00 2001 From: Adrian Smijulj <adrian1358@gmail.com> Date: Fri, 13 Dec 2024 09:26:25 +0100 Subject: [PATCH 10/52] fix: improve import identifier uniqueness (#4437) --- .../cli-plugin-extensions/src/extensions/AdminExtension.ts | 7 +++---- .../cli-plugin-extensions/src/extensions/ApiExtension.ts | 7 ++++--- .../src/extensions/PbElementExtension.ts | 7 +++---- .../src/extensions/WebsiteExtension.ts | 7 +++---- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts b/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts index 75136f5e471..87575cfb6f7 100644 --- a/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts +++ b/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts @@ -5,6 +5,7 @@ import chalk from "chalk"; import { JsxFragment, Node, Project } from "ts-morph"; import { formatCode } from "@webiny/cli-plugin-scaffold/utils"; import { updateDependencies, updateWorkspaces } from "~/utils"; +import Case from "case"; export class AdminExtension extends AbstractExtension { async link() { @@ -38,12 +39,10 @@ export class AdminExtension extends AbstractExtension { } private async addPluginToAdminApp() { - const { name, packageName } = this.params; - const extensionsFilePath = path.join("apps", "admin", "src", "Extensions.tsx"); - const ucFirstName = name.charAt(0).toUpperCase() + name.slice(1); - const componentName = ucFirstName + "Extension"; + const { packageName } = this.params; + const componentName = Case.pascal(packageName) + "Extension"; const importName = "{ Extension as " + componentName + " }"; const importPath = packageName; diff --git a/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts b/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts index 776374c84a8..35e6c269235 100644 --- a/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts +++ b/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts @@ -5,6 +5,7 @@ import chalk from "chalk"; import { ArrayLiteralExpression, Node, Project } from "ts-morph"; import { formatCode } from "@webiny/cli-plugin-scaffold/utils"; import { updateDependencies, updateWorkspaces } from "~/utils"; +import Case from "case"; export class ApiExtension extends AbstractExtension { async link() { @@ -38,11 +39,11 @@ export class ApiExtension extends AbstractExtension { } private async addPluginToApiApp() { - const { name, packageName } = this.params; - const extensionsFilePath = path.join("apps", "api", "graphql", "src", "extensions.ts"); - const extensionFactory = name + "ExtensionFactory"; + const { packageName } = this.params; + const extensionFactory = Case.pascal(packageName) + "ExtensionFactory"; + const importName = "{ createExtension as " + extensionFactory + " }"; const importPath = packageName; diff --git a/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts b/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts index 9ca965948c5..0f7be7d90ce 100644 --- a/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts +++ b/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts @@ -5,6 +5,7 @@ import chalk from "chalk"; import { JsxFragment, Node, Project } from "ts-morph"; import { formatCode } from "@webiny/cli-plugin-scaffold/utils"; import { updateDependencies, updateWorkspaces } from "~/utils"; +import Case from "case"; export class PbElementExtension extends AbstractExtension { async link() { @@ -46,12 +47,10 @@ export class PbElementExtension extends AbstractExtension { } private async addPluginToApp(app: "admin" | "website") { - const { name: extensionName, packageName } = this.params; - const extensionsFilePath = path.join("apps", app, "src", "Extensions.tsx"); - const ucFirstExtName = extensionName.charAt(0).toUpperCase() + extensionName.slice(1); - const componentName = ucFirstExtName + "Extension"; + const { packageName } = this.params; + const componentName = Case.pascal(packageName) + "Extension"; const importName = "{ Extension as " + componentName + " }"; const importPath = packageName + "/src/" + app; diff --git a/packages/cli-plugin-extensions/src/extensions/WebsiteExtension.ts b/packages/cli-plugin-extensions/src/extensions/WebsiteExtension.ts index 42326e3bb91..875d29f010f 100644 --- a/packages/cli-plugin-extensions/src/extensions/WebsiteExtension.ts +++ b/packages/cli-plugin-extensions/src/extensions/WebsiteExtension.ts @@ -5,6 +5,7 @@ import chalk from "chalk"; import { JsxFragment, Node, Project } from "ts-morph"; import { formatCode } from "@webiny/cli-plugin-scaffold/utils"; import { updateDependencies, updateWorkspaces } from "~/utils"; +import Case from "case"; export class WebsiteExtension extends AbstractExtension { async link() { @@ -38,12 +39,10 @@ export class WebsiteExtension extends AbstractExtension { } private async addPluginToWebsiteApp() { - const { name, packageName } = this.params; - const extensionsFilePath = path.join("apps", "website", "src", "Extensions.tsx"); - const ucFirstName = name.charAt(0).toUpperCase() + name.slice(1); - const componentName = ucFirstName + "Extension"; + const { packageName } = this.params; + const componentName = Case.pascal(packageName) + "Extension"; const importName = "{ Extension as " + componentName + " }"; const importPath = packageName; From 34b5ab0da4aed14c27d42defaa4d2c328a2034b3 Mon Sep 17 00:00:00 2001 From: Adrian Smijulj <adrian1358@gmail.com> Date: Mon, 16 Dec 2024 10:51:16 +0100 Subject: [PATCH 11/52] fix: add error handling capabilities (#4447) --- .../src/hooks/useLoader.ts | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/app-page-builder-elements/src/hooks/useLoader.ts b/packages/app-page-builder-elements/src/hooks/useLoader.ts index 095b13e4bba..e96795b9941 100644 --- a/packages/app-page-builder-elements/src/hooks/useLoader.ts +++ b/packages/app-page-builder-elements/src/hooks/useLoader.ts @@ -2,21 +2,22 @@ import { useEffect, useMemo, useState, type DependencyList } from "react"; import { createObjectHash } from "./useLoader/createObjectHash"; import { useRenderer } from ".."; -export interface RendererLoader<TData = unknown> { +export interface RendererLoader<TData = unknown, TError = unknown> { data: TData | null; loading: boolean; cacheHit: boolean; cacheKey: null | string; + error: null | TError; } export interface UseLoaderOptions { cacheKey?: DependencyList; } -export function useLoader<TData = unknown>( +export function useLoader<TData = unknown, TError = unknown>( loaderFn: () => Promise<TData>, options?: UseLoaderOptions -): RendererLoader<TData> { +): RendererLoader<TData, TError> { const { getElement, loaderCache } = useRenderer(); const element = getElement(); @@ -29,15 +30,16 @@ export function useLoader<TData = unknown>( return loaderCache.read<TData>(cacheKey); }, [cacheKey]); - const [loader, setLoader] = useState<RendererLoader<TData>>( + const [loader, setLoader] = useState<RendererLoader<TData, TError>>( cachedData ? { data: cachedData, loading: false, cacheHit: true, - cacheKey + cacheKey, + error: null } - : { data: null, loading: true, cacheHit: false, cacheKey: null } + : { data: null, loading: true, cacheHit: false, cacheKey: null, error: null } ); useEffect(() => { @@ -46,15 +48,25 @@ export function useLoader<TData = unknown>( } if (cachedData) { - setLoader({ data: cachedData, loading: false, cacheKey, cacheHit: true }); + setLoader({ data: cachedData, loading: false, cacheKey, cacheHit: true, error: null }); return; } - setLoader({ data: loader.data, loading: true, cacheKey, cacheHit: false }); - loaderFn().then(data => { - loaderCache.write(cacheKey, data); - setLoader({ data, loading: false, cacheKey, cacheHit: false }); + setLoader({ + data: loader.data, + error: loader.error, + loading: true, + cacheKey, + cacheHit: false }); + loaderFn() + .then(data => { + loaderCache.write(cacheKey, data); + setLoader({ data, error: null, loading: false, cacheKey, cacheHit: false }); + }) + .catch(error => { + setLoader({ data: null, error, loading: false, cacheKey, cacheHit: false }); + }); }, [cacheKey]); return loader; From 9c9318648bdbf0af91cc9b4bbf4594c9eff9733b Mon Sep 17 00:00:00 2001 From: adrians5j <adrian@webiny.com> Date: Mon, 16 Dec 2024 10:57:05 +0100 Subject: [PATCH 12/52] fix: remove exports section --- .../cli-plugin-extensions/templates/pbElement/package.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/cli-plugin-extensions/templates/pbElement/package.json b/packages/cli-plugin-extensions/templates/pbElement/package.json index 016ab73ce41..5857ab2dc1f 100644 --- a/packages/cli-plugin-extensions/templates/pbElement/package.json +++ b/packages/cli-plugin-extensions/templates/pbElement/package.json @@ -1,9 +1,5 @@ { "name": "PACKAGE_NAME", - "exports": { - "./admin": "./src/admin.tsx", - "./website": "./src/website.tsx" - }, "version": "1.0.0", "keywords": [ "webiny-extension", From a9313d6fe58d640fb3b146a936a68f8d6ca8671d Mon Sep 17 00:00:00 2001 From: adrians5j <adrian@webiny.com> Date: Mon, 16 Dec 2024 11:03:30 +0100 Subject: [PATCH 13/52] fix: export utils from `@webiny/app` --- packages/app-admin/src/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/app-admin/src/index.ts b/packages/app-admin/src/index.ts index f1d1bf6f39e..56dfe4fed45 100644 --- a/packages/app-admin/src/index.ts +++ b/packages/app-admin/src/index.ts @@ -62,3 +62,11 @@ export { AaclPermission } from "@webiny/app-wcp/types"; export { useTheme, ThemeProvider } from "@webiny/app-theme"; export * from "@webiny/app/renderApp"; + +// Exporting chosen utils from `@webiny/app` package. +export * from "@webiny/app/utils/getApiUrl"; +export * from "@webiny/app/utils/getGqlApiUrl"; +export * from "@webiny/app/utils/getHeadlessCmsGqlApiUrl"; +export * from "@webiny/app/utils/getLocaleCode"; +export * from "@webiny/app/utils/getTenantId"; +export * from "@webiny/app/utils/isLocalhost"; From 017b55b5f13794fac397dbe6d6e987adcb940698 Mon Sep 17 00:00:00 2001 From: Adrian Smijulj <adrian1358@gmail.com> Date: Tue, 17 Dec 2024 08:46:07 +0100 Subject: [PATCH 14/52] fix: create settings entry when new locale is created (#4446) --- .../__tests__/graphql/i18n.ts | 15 ++ .../__tests__/settings.test.ts | 40 ++++- .../__tests__/useGqlHandler.ts | 22 +-- .../src/plugins/crud/index.ts | 167 ++++++++++-------- packages/api-i18n/src/graphql/context.ts | 22 ++- packages/api-i18n/src/types.ts | 4 + 6 files changed, 180 insertions(+), 90 deletions(-) create mode 100644 packages/api-form-builder/__tests__/graphql/i18n.ts diff --git a/packages/api-form-builder/__tests__/graphql/i18n.ts b/packages/api-form-builder/__tests__/graphql/i18n.ts new file mode 100644 index 00000000000..f33ffc38db5 --- /dev/null +++ b/packages/api-form-builder/__tests__/graphql/i18n.ts @@ -0,0 +1,15 @@ +export const CREATE_LOCALE = /* GraphQL */ ` + mutation CreateI18NLocale($data: I18NLocaleInput!) { + i18n { + createI18NLocale(data: $data) { + data { + code + } + error { + message + code + } + } + } + } +`; diff --git a/packages/api-form-builder/__tests__/settings.test.ts b/packages/api-form-builder/__tests__/settings.test.ts index 764543af83c..48a100cc22d 100644 --- a/packages/api-form-builder/__tests__/settings.test.ts +++ b/packages/api-form-builder/__tests__/settings.test.ts @@ -1,7 +1,8 @@ import useGqlHandler from "./useGqlHandler"; +import { GET_SETTINGS } from "~tests/graphql/formBuilderSettings"; describe("Settings Test", () => { - const { getSettings, updateSettings, install, isInstalled } = useGqlHandler(); + const { getSettings, updateSettings, install, createI18NLocale, isInstalled } = useGqlHandler(); test(`Should not be able to get & update settings before "install"`, async () => { // Should not have any settings without install @@ -154,4 +155,41 @@ describe("Settings Test", () => { } }); }); + + test(`Should be able to get & update settings after in a new locale`, async () => { + // Let's install the `Form builder` + await install({ domain: "http://localhost:3001" }); + + await createI18NLocale({ data: { code: "de-DE" } }); + + const { invoke } = useGqlHandler(); + + // Had to do it via `invoke` directly because this way it's possible to + // set the locale header. Wasn't easily possible via the `getSettings` helper. + const [newLocaleFbSettings] = await invoke({ + body: { query: GET_SETTINGS }, + headers: { + "x-i18n-locale": "default:de-DE;content:de-DE;" + } + }); + + // Settings should exist in the newly created locale. + expect(newLocaleFbSettings).toEqual({ + data: { + formBuilder: { + getSettings: { + data: { + domain: null, + reCaptcha: { + enabled: null, + secretKey: null, + siteKey: null + } + }, + error: null + } + } + } + }); + }); }); diff --git a/packages/api-form-builder/__tests__/useGqlHandler.ts b/packages/api-form-builder/__tests__/useGqlHandler.ts index 0d6a8108469..e5fc524dd47 100644 --- a/packages/api-form-builder/__tests__/useGqlHandler.ts +++ b/packages/api-form-builder/__tests__/useGqlHandler.ts @@ -8,8 +8,12 @@ import i18nContext from "@webiny/api-i18n/graphql/context"; import { mockLocalesPlugins } from "@webiny/api-i18n/graphql/testing"; import { SecurityIdentity, SecurityPermission } from "@webiny/api-security/types"; import { createFormBuilder } from "~/index"; +import { createI18NGraphQL } from "@webiny/api-i18n/graphql"; + // Graphql import { INSTALL as INSTALL_FILE_MANAGER } from "./graphql/fileManagerSettings"; +import { CREATE_LOCALE } from "./graphql/i18n"; + import { GET_SETTINGS, INSTALL, @@ -41,11 +45,7 @@ import { PluginCollection } from "@webiny/plugins/types"; import { getStorageOps } from "@webiny/project-utils/testing/environment"; import { FileManagerStorageOperations } from "@webiny/api-file-manager/types"; import { HeadlessCmsStorageOperations } from "@webiny/api-headless-cms/types"; -import { - CmsParametersPlugin, - createHeadlessCmsContext, - createHeadlessCmsGraphQL -} from "@webiny/api-headless-cms"; +import { createHeadlessCmsContext, createHeadlessCmsGraphQL } from "@webiny/api-headless-cms"; import { FormBuilderStorageOperations } from "~/types"; import { APIGatewayEvent, LambdaContext } from "@webiny/handler-aws/types"; import { createPageBuilderContext } from "@webiny/api-page-builder"; @@ -83,14 +83,9 @@ export default (params: UseGqlHandlerParams = {}) => { graphqlHandlerPlugins(), ...createTenancyAndSecurity({ permissions, identity }), i18nContext(), + createI18NGraphQL(), i18nStorage.storageOperations, mockLocalesPlugins(), - new CmsParametersPlugin(async () => { - return { - locale: "en-US", - type: "manage" - }; - }), createHeadlessCmsContext({ storageOperations: cmsStorage.storageOperations }), createHeadlessCmsGraphQL(), createPageBuilderContext({ @@ -228,6 +223,11 @@ export default (params: UseGqlHandlerParams = {}) => { }, async exportFormSubmissions(variables: Record<string, any>) { return invoke({ body: { query: EXPORT_FORM_SUBMISSIONS, variables } }); + }, + + // Locales. + async createI18NLocale(variables: Record<string, any>) { + return invoke({ body: { query: CREATE_LOCALE, variables } }); } }; }; diff --git a/packages/api-form-builder/src/plugins/crud/index.ts b/packages/api-form-builder/src/plugins/crud/index.ts index a4df2a2e981..5a20f2300b6 100644 --- a/packages/api-form-builder/src/plugins/crud/index.ts +++ b/packages/api-form-builder/src/plugins/crud/index.ts @@ -15,94 +15,107 @@ export interface CreateFormBuilderCrudParams { export default (params: CreateFormBuilderCrudParams) => { const { storageOperations } = params; - return new ContextPlugin<FormBuilderContext>(async context => { - const getLocale = () => { - const locale = context.i18n.getContentLocale(); - if (!locale) { - throw new WebinyError( - "Missing locale on context.i18n locale in API Form Builder.", - "LOCALE_ERROR" - ); + return [ + new ContextPlugin<FormBuilderContext>(async context => { + const getLocale = () => { + const locale = context.i18n.getContentLocale(); + if (!locale) { + throw new WebinyError( + "Missing locale on context.i18n locale in API Form Builder.", + "LOCALE_ERROR" + ); + } + return locale; + }; + + const getIdentity = () => { + return context.security.getIdentity(); + }; + + const getTenant = () => { + return context.tenancy.getCurrentTenant(); + }; + + if (storageOperations.beforeInit) { + try { + await storageOperations.beforeInit(context); + } catch (ex) { + throw new WebinyError( + ex.message || + "Could not run before init in Form Builder storage operations.", + ex.code || "STORAGE_OPERATIONS_BEFORE_INIT_ERROR", + { + ...ex + } + ); + } } - return locale; - }; - const getIdentity = () => { - return context.security.getIdentity(); - }; + const basePermissionsArgs = { + getIdentity, + fullAccessPermissionName: "fb.*" + }; + + const formsPermissions = new FormsPermissions({ + ...basePermissionsArgs, + getPermissions: () => context.security.getPermissions("fb.form") + }); - const getTenant = () => { - return context.tenancy.getCurrentTenant(); - }; + const settingsPermissions = new SettingsPermissions({ + ...basePermissionsArgs, + getPermissions: () => context.security.getPermissions("fb.settings") + }); - if (storageOperations.beforeInit) { + context.formBuilder = { + storageOperations, + ...createSystemCrud({ + getIdentity, + getTenant, + getLocale, + context + }), + ...createSettingsCrud({ + getTenant, + getLocale, + settingsPermissions, + context + }), + ...createFormsCrud({ + getTenant, + getLocale, + formsPermissions, + context + }), + ...createSubmissionsCrud({ + context, + formsPermissions + }) + }; + + if (!storageOperations.init) { + return; + } try { - await storageOperations.beforeInit(context); + await storageOperations.init(context); } catch (ex) { throw new WebinyError( - ex.message || "Could not run before init in Form Builder storage operations.", - ex.code || "STORAGE_OPERATIONS_BEFORE_INIT_ERROR", + ex.message || "Could not run init in Form Builder storage operations.", + ex.code || "STORAGE_OPERATIONS_INIT_ERROR", { ...ex } ); } - } - - const basePermissionsArgs = { - getIdentity, - fullAccessPermissionName: "fb.*" - }; - - const formsPermissions = new FormsPermissions({ - ...basePermissionsArgs, - getPermissions: () => context.security.getPermissions("fb.form") - }); + }), - const settingsPermissions = new SettingsPermissions({ - ...basePermissionsArgs, - getPermissions: () => context.security.getPermissions("fb.settings") - }); - - context.formBuilder = { - storageOperations, - ...createSystemCrud({ - getIdentity, - getTenant, - getLocale, - context - }), - ...createSettingsCrud({ - getTenant, - getLocale, - settingsPermissions, - context - }), - ...createFormsCrud({ - getTenant, - getLocale, - formsPermissions, - context - }), - ...createSubmissionsCrud({ - context, - formsPermissions - }) - }; - - if (!storageOperations.init) { - return; - } - try { - await storageOperations.init(context); - } catch (ex) { - throw new WebinyError( - ex.message || "Could not run init in Form Builder storage operations.", - ex.code || "STORAGE_OPERATIONS_INIT_ERROR", - { - ...ex - } - ); - } - }); + // Once a new locale is created, we need to create a new settings entry for it. + new ContextPlugin<FormBuilderContext>(async context => { + context.i18n.locales.onLocaleAfterCreate.subscribe(async params => { + const { locale } = params; + await context.i18n.withLocale(locale, async () => { + return context.formBuilder.createSettings({}); + }); + }); + }) + ]; }; diff --git a/packages/api-i18n/src/graphql/context.ts b/packages/api-i18n/src/graphql/context.ts index 4aaa948073a..9293ea3af4b 100644 --- a/packages/api-i18n/src/graphql/context.ts +++ b/packages/api-i18n/src/graphql/context.ts @@ -239,6 +239,25 @@ const createBaseContextPlugin = () => { return results; }; + const withLocale: I18NContextObject["withLocale"] = async (locale, cb) => { + const initialLocale = getDefaultLocale(); + if (!initialLocale) { + return; + } + + setContentLocale(locale); + setCurrentLocale("default", locale); + + try { + // We have to await the callback, because, in case it's an async function, + // the `finally` block would get executed before the callback finishes. + return await cb(); + } finally { + setContentLocale(initialLocale); + setCurrentLocale("default", initialLocale); + } + }; + context.i18n = { ...context.i18n, getDefaultLocale, @@ -252,7 +271,8 @@ const createBaseContextPlugin = () => { reloadLocales, hasI18NContentPermission: () => hasI18NContentPermission(context), checkI18NContentPermission, - withEachLocale + withEachLocale, + withLocale }; }); }; diff --git a/packages/api-i18n/src/types.ts b/packages/api-i18n/src/types.ts index f0b83d43d04..407d6aea0bb 100644 --- a/packages/api-i18n/src/types.ts +++ b/packages/api-i18n/src/types.ts @@ -44,6 +44,10 @@ export interface I18NContextObject { locales: I18NLocale[], cb: (locale: I18NLocale) => Promise<TReturn> ) => Promise<TReturn[] | undefined>; + withLocale: <TReturn>( + locale: I18NLocale, + cb: () => Promise<TReturn> + ) => Promise<TReturn | undefined>; } export interface SystemInstallParams { From f2b573cf2644fb5b8bdc3e7f315c8bcdf7e532ab Mon Sep 17 00:00:00 2001 From: Adrian Smijulj <adrian1358@gmail.com> Date: Tue, 17 Dec 2024 17:03:49 +0100 Subject: [PATCH 15/52] fix: preload fonts (#4450) --- .../handlers/render/linkPreloading.test.ts | 8 +- .../render/handlers/render/renderUrl.test.ts | 8 +- .../src/render/defaultRenderUrlFunction.ts | 157 ++++++++++++++++ .../src/render/preloadCss.ts | 7 + .../src/render/preloadFonts.ts | 37 ++++ .../src/render/preloadJs.ts | 7 + .../src/render/renderUrl.ts | 169 +----------------- .../src/render/types.ts | 5 + 8 files changed, 233 insertions(+), 165 deletions(-) create mode 100644 packages/api-prerendering-service/src/render/defaultRenderUrlFunction.ts create mode 100644 packages/api-prerendering-service/src/render/preloadCss.ts create mode 100644 packages/api-prerendering-service/src/render/preloadFonts.ts create mode 100644 packages/api-prerendering-service/src/render/preloadJs.ts diff --git a/packages/api-prerendering-service/__tests__/render/handlers/render/linkPreloading.test.ts b/packages/api-prerendering-service/__tests__/render/handlers/render/linkPreloading.test.ts index 9ce663a69fe..88c106c5071 100644 --- a/packages/api-prerendering-service/__tests__/render/handlers/render/linkPreloading.test.ts +++ b/packages/api-prerendering-service/__tests__/render/handlers/render/linkPreloading.test.ts @@ -15,7 +15,9 @@ describe(`"renderUrl" Function Test`, () => { renderUrlFunction: async () => { return { content: BASE_HTML, - meta: {} + meta: { + interceptedRequests: [] + } }; } }); @@ -58,7 +60,9 @@ describe(`"renderUrl" Function Test`, () => { renderUrlFunction: async () => { return { content: BASE_HTML, - meta: {} + meta: { + interceptedRequests: [] + } }; } }); diff --git a/packages/api-prerendering-service/__tests__/render/handlers/render/renderUrl.test.ts b/packages/api-prerendering-service/__tests__/render/handlers/render/renderUrl.test.ts index 9ce663a69fe..88c106c5071 100644 --- a/packages/api-prerendering-service/__tests__/render/handlers/render/renderUrl.test.ts +++ b/packages/api-prerendering-service/__tests__/render/handlers/render/renderUrl.test.ts @@ -15,7 +15,9 @@ describe(`"renderUrl" Function Test`, () => { renderUrlFunction: async () => { return { content: BASE_HTML, - meta: {} + meta: { + interceptedRequests: [] + } }; } }); @@ -58,7 +60,9 @@ describe(`"renderUrl" Function Test`, () => { renderUrlFunction: async () => { return { content: BASE_HTML, - meta: {} + meta: { + interceptedRequests: [] + } }; } }); diff --git a/packages/api-prerendering-service/src/render/defaultRenderUrlFunction.ts b/packages/api-prerendering-service/src/render/defaultRenderUrlFunction.ts new file mode 100644 index 00000000000..6bbddfa5b9b --- /dev/null +++ b/packages/api-prerendering-service/src/render/defaultRenderUrlFunction.ts @@ -0,0 +1,157 @@ +import chromium from "@sparticuz/chromium"; +import puppeteer, { Browser, Page } from "puppeteer-core"; +import extractPeLoaderDataFromHtml from "./extractPeLoaderDataFromHtml"; +import { RenderResult, RenderUrlCallableParams } from "./types"; +import { TagPathLink } from "~/types"; + +const windowSet = (page: Page, name: string, value: string | boolean) => { + page.evaluateOnNewDocument(` + Object.defineProperty(window, '${name}', { + get() { + return '${value}' + } + })`); +}; + +export interface File { + type: string; + body: any; + name: string; + meta: { + tags?: TagPathLink[]; + [key: string]: any; + }; +} + +export const defaultRenderUrlFunction = async ( + url: string, + params: RenderUrlCallableParams +): Promise<RenderResult> => { + let browser!: Browser; + + try { + browser = await puppeteer.launch({ + args: chromium.args, + defaultViewport: chromium.defaultViewport, + executablePath: await chromium.executablePath(), + headless: chromium.headless, + ignoreHTTPSErrors: true + }); + + const browserPage = await browser.newPage(); + + // Can be used to add additional logic - e.g. skip a GraphQL query to be made when in pre-rendering process. + windowSet(browserPage, "__PS_RENDER__", true); + + const tenant = params.args.tenant; + if (tenant) { + console.log("Setting tenant (__PS_RENDER_TENANT__) to window object...."); + windowSet(browserPage, "__PS_RENDER_TENANT__", tenant); + } + + const locale = params.args.locale; + if (locale) { + console.log("Setting locale (__PS_RENDER_LOCALE__) to window object...."); + windowSet(browserPage, "__PS_RENDER_LOCALE__", locale); + } + + const renderResult: RenderResult = { + content: "", + meta: { + interceptedRequests: [], + apolloState: {}, + cachedData: { + apolloGraphQl: [], + peLoaders: [] + } + } + }; + + // Don't load these resources during prerender. + const skipResources = ["image"]; + await browserPage.setRequestInterception(true); + + browserPage.on("request", request => { + const issuedRequest = { + type: request.resourceType(), + url: request.url(), + aborted: false + }; + + if (skipResources.includes(issuedRequest.type)) { + issuedRequest.aborted = true; + request.abort(); + } else { + request.continue(); + } + + renderResult.meta.interceptedRequests.push(issuedRequest); + }); + + // TODO: should be a plugin. + browserPage.on("response", async response => { + const request = response.request(); + const url = request.url(); + if (url.includes("/graphql") && request.method() === "POST") { + const responses = (await response.json()) as Record<string, any>; + const postData = JSON.parse(request.postData() as string); + const operations = Array.isArray(postData) ? postData : [postData]; + + for (let i = 0; i < operations.length; i++) { + const { query, variables } = operations[i]; + + // For now, we're doing a basic @ps(cache: true) match to determine if the + // cache was set true. In the future, if we start introducing additional + // parameters here, we should probably make this parsing smarter. + const mustCache = query.match(/@ps\((cache: true)\)/); + + if (mustCache) { + const data = Array.isArray(responses) ? responses[i].data : responses.data; + renderResult.meta.cachedData.apolloGraphQl.push({ + query, + variables, + data + }); + } + } + return; + } + }); + + // Load URL and wait for all network requests to settle. + await browserPage.goto(url, { waitUntil: "networkidle0" }); + + renderResult.content = await browserPage.content(); + + renderResult.meta.apolloState = await browserPage.evaluate(() => { + // @ts-expect-error + return window.getApolloState(); + }); + + renderResult.meta.cachedData.peLoaders = extractPeLoaderDataFromHtml(renderResult.content); + + return renderResult; + } finally { + if (browser) { + // We need to close all open pages first, to prevent browser from hanging when closed. + const pages = await browser.pages(); + for (const page of pages) { + await page.close(); + } + + // This is fixing an issue where the `await browser.close()` would hang indefinitely. + // The "inspiration" for this fix came from the following issue: + // https://github.com/Sparticuz/chromium/issues/85 + console.log("Killing browser process..."); + const childProcess = browser.process(); + if (childProcess) { + childProcess.kill(9); + } + + console.log("Browser process killed."); + } + } + + // There's no catch block here because errors are already being handled + // in the entrypoint function, located in `./index.ts` file. +}; diff --git a/packages/api-prerendering-service/src/render/preloadCss.ts b/packages/api-prerendering-service/src/render/preloadCss.ts new file mode 100644 index 00000000000..805967db6c7 --- /dev/null +++ b/packages/api-prerendering-service/src/render/preloadCss.ts @@ -0,0 +1,7 @@ +import { RenderResult } from "./types"; + +export const preloadCss = (render: RenderResult): void => { + const regex = /<link href="\/static\/css\//gm; + const subst = `<link data-link-preload data-link-preload-type="markup" href="/https/github.com/static/css/`; + render.content = render.content.replace(regex, subst); +}; diff --git a/packages/api-prerendering-service/src/render/preloadFonts.ts b/packages/api-prerendering-service/src/render/preloadFonts.ts new file mode 100644 index 00000000000..e177403a985 --- /dev/null +++ b/packages/api-prerendering-service/src/render/preloadFonts.ts @@ -0,0 +1,37 @@ +import { RenderResult } from "~/render/types"; + +function getFontType(url: string) { + if (url.endsWith(".woff2")) { + return "woff2"; + } + if (url.endsWith(".woff")) { + return "woff"; + } + if (url.endsWith(".ttf")) { + return "truetype"; + } + if (url.endsWith(".otf")) { + return "opentype"; + } + if (url.endsWith(".eot")) { + return "embedded-opentype"; + } + return "font"; +} + +export const preloadFonts = (render: RenderResult): void => { + const fontsRequests = render.meta.interceptedRequests.filter( + req => req.type === "font" && req.url + ); + + const preloadLinks: string = Array.from(fontsRequests) + .map(req => { + return `<link rel="preload" href="${req.url}" as="font" type="font/${getFontType( + req.url + )}" crossorigin="anonymous">`; + }) + .join("\n"); + + // Inject the preload tags into the <head> section + render.content = render.content.replace("</head>", `${preloadLinks}</head>`); +}; diff --git a/packages/api-prerendering-service/src/render/preloadJs.ts b/packages/api-prerendering-service/src/render/preloadJs.ts new file mode 100644 index 00000000000..63fae20d827 --- /dev/null +++ b/packages/api-prerendering-service/src/render/preloadJs.ts @@ -0,0 +1,7 @@ +import { RenderResult } from "~/render/types"; + +export const preloadJs = (render: RenderResult): void => { + const regex = /<script (src="\/static\/js\/)/gm; + const subst = `<script data-link-preload data-link-preload-type="markup" src="/https/github.com/static/js/`; + render.content = render.content.replace(regex, subst); +}; diff --git a/packages/api-prerendering-service/src/render/renderUrl.ts b/packages/api-prerendering-service/src/render/renderUrl.ts index c3333b71c5f..a82acdc8c58 100644 --- a/packages/api-prerendering-service/src/render/renderUrl.ts +++ b/packages/api-prerendering-service/src/render/renderUrl.ts @@ -1,5 +1,3 @@ -import chromium from "@sparticuz/chromium"; -import puppeteer, { Browser, Page } from "puppeteer-core"; import posthtml from "posthtml"; import { noopener } from "posthtml-noopener"; /** @@ -14,26 +12,13 @@ import injectRenderTs from "./injectRenderTs"; import injectTenantLocale from "./injectTenantLocale"; import injectNotFoundPageFlag from "./injectNotFoundPageFlag"; import getPsTags from "./getPsTags"; -import extractPeLoaderDataFromHtml from "./extractPeLoaderDataFromHtml"; +import { defaultRenderUrlFunction } from "./defaultRenderUrlFunction"; import shortid from "shortid"; -import { - GraphQLCacheEntry, - PeLoaderCacheEntry, - RenderResult, - RenderUrlCallableParams, - RenderUrlParams, - RenderUrlPostHtmlParams -} from "./types"; +import { RenderResult, RenderUrlParams, RenderUrlPostHtmlParams } from "./types"; import { TagPathLink } from "~/types"; - -const windowSet = (page: Page, name: string, value: string | boolean) => { - page.evaluateOnNewDocument(` - Object.defineProperty(window, '${name}', { - get() { - return '${value}' - } - })`); -}; +import { preloadJs } from "~/render/preloadJs"; +import { preloadCss } from "~/render/preloadCss"; +import { preloadFonts } from "~/render/preloadFonts"; interface Meta { path: string; @@ -66,22 +51,11 @@ export default async (url: string, args: RenderUrlParams): Promise<[File[], Meta const render = await renderUrl(url, args); // Process HTML. - // TODO: should be plugins (will also eliminate lower ts-ignore instructions). console.log("Processing HTML..."); - // TODO: regular text processing plugins... - - { - const regex = /<script (src="\/static\/js\/)/gm; - const subst = `<script data-link-preload data-link-preload-type="markup" src="/https/github.com/static/js/`; - render.content = render.content.replace(regex, subst); - } - - { - const regex = /<link href="\/static\/css\//gm; - const subst = `<link data-link-preload data-link-preload-type="markup" href="/https/github.com/static/css/`; - render.content = render.content.replace(regex, subst); - } + preloadJs(render); + preloadCss(render); + preloadFonts(render); const allArgs: RenderUrlPostHtmlParams = { render, args, path: args.args.path, id, ts }; const { html } = await posthtml([ @@ -120,130 +94,3 @@ export default async (url: string, args: RenderUrlParams): Promise<[File[], Meta allArgs ]; }; - -export const defaultRenderUrlFunction = async ( - url: string, - params: RenderUrlCallableParams -): Promise<RenderResult> => { - let browser!: Browser; - - try { - browser = await puppeteer.launch({ - args: chromium.args, - defaultViewport: chromium.defaultViewport, - executablePath: await chromium.executablePath(), - headless: chromium.headless, - ignoreHTTPSErrors: true - }); - - const browserPage = await browser.newPage(); - - // Can be used to add additional logic - e.g. skip a GraphQL query to be made when in pre-rendering process. - windowSet(browserPage, "__PS_RENDER__", true); - - const tenant = params.args.tenant; - if (tenant) { - console.log("Setting tenant (__PS_RENDER_TENANT__) to window object...."); - windowSet(browserPage, "__PS_RENDER_TENANT__", tenant); - } - - const locale = params.args.locale; - if (locale) { - console.log("Setting locale (__PS_RENDER_LOCALE__) to window object...."); - windowSet(browserPage, "__PS_RENDER_LOCALE__", locale); - } - - // Don't load these resources during prerender. - const skipResources = ["image", "stylesheet"]; - await browserPage.setRequestInterception(true); - - browserPage.on("request", request => { - if (skipResources.includes(request.resourceType())) { - request.abort(); - } else { - request.continue(); - } - }); - - const cachedData: { - apolloGraphQl: GraphQLCacheEntry[]; - peLoaders: PeLoaderCacheEntry[]; - } = { - apolloGraphQl: [], - peLoaders: [] - }; - - // TODO: should be a plugin. - browserPage.on("response", async response => { - const request = response.request(); - const url = request.url(); - if (url.includes("/graphql") && request.method() === "POST") { - const responses = (await response.json()) as Record<string, any>; - const postData = JSON.parse(request.postData() as string); - const operations = Array.isArray(postData) ? postData : [postData]; - - for (let i = 0; i < operations.length; i++) { - const { query, variables } = operations[i]; - - // For now, we're doing a basic @ps(cache: true) match to determine if the - // cache was set true. In the future, if we start introducing additional - // parameters here, we should probably make this parsing smarter. - const mustCache = query.match(/@ps\((cache: true)\)/); - - if (mustCache) { - const data = Array.isArray(responses) ? responses[i].data : responses.data; - cachedData.apolloGraphQl.push({ - query, - variables, - data - }); - } - } - return; - } - }); - - // Load URL and wait for all network requests to settle. - await browserPage.goto(url, { waitUntil: "networkidle0" }); - - const apolloState = await browserPage.evaluate(() => { - // @ts-expect-error - return window.getApolloState(); - }); - - const content = await browserPage.content(); - - cachedData.peLoaders = extractPeLoaderDataFromHtml(content); - - return { - content, - // TODO: ideally, meta should be assigned here in a more "plugins style" way, not hardcoded. - meta: { - cachedData, - apolloState - } - }; - } finally { - if (browser) { - // We need to close all open pages first, to prevent browser from hanging when closed. - const pages = await browser.pages(); - for (const page of pages) { - await page.close(); - } - - // This is fixing an issue where the `await browser.close()` would hang indefinitely. - // The "inspiration" for this fix came from the following issue: - // https://github.com/Sparticuz/chromium/issues/85 - console.log("Killing browser process..."); - const childProcess = browser.process(); - if (childProcess) { - childProcess.kill(9); - } - - console.log("Browser process killed."); - } - } - - // There's no catch block here because errors are already being handled - // in the entrypoint function, located in `./index.ts` file. -}; diff --git a/packages/api-prerendering-service/src/render/types.ts b/packages/api-prerendering-service/src/render/types.ts index d92a917d6f0..527c8d14a57 100644 --- a/packages/api-prerendering-service/src/render/types.ts +++ b/packages/api-prerendering-service/src/render/types.ts @@ -20,6 +20,7 @@ export interface RenderHookPlugin extends Plugin { export interface RenderApolloState { [key: string]: string; } + /** * @internal */ @@ -38,6 +39,7 @@ export interface PeLoaderCacheEntry { export interface RenderResult { content: string; meta: { + interceptedRequests: Array<{ type: string; url: string }>; apolloState: RenderApolloState; cachedData: { apolloGraphQl: GraphQLCacheEntry[]; @@ -47,6 +49,7 @@ export interface RenderResult { [key: string]: any; }; } + /** * @internal */ @@ -54,12 +57,14 @@ export interface RenderUrlCallableParams { context: Context; args: RenderEvent; } + /** * @internal */ export interface RenderUrlParams extends RenderUrlCallableParams { renderUrlFunction?: (url: string, params: RenderUrlCallableParams) => Promise<RenderResult>; } + /** * @internal */ From edaf911c8ba285f73efacabbc98c2f784a2a1a80 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Wed, 18 Dec 2024 10:45:13 +0100 Subject: [PATCH 16/52] fix(app-headless-cms): add delete prompt to objects and dynamic zones (#4428) --- .../dynamicZone/MultiValueDynamicZone.tsx | 15 ++++++++++++++- .../dynamicZone/SingleValueDynamicZone.tsx | 11 ++++++++++- .../fieldRenderers/object/multipleObjects.tsx | 19 ++++++++++++++++++- .../object/multipleObjectsAccordion.tsx | 19 ++++++++++++++++++- 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx index ea6efceeead..44d6abacb7d 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx @@ -22,6 +22,7 @@ import { import { makeDecoratable } from "@webiny/react-composition"; import { TemplateProvider } from "~/admin/plugins/fieldRenderers/dynamicZone/TemplateProvider"; import { ParentValueIndexProvider } from "~/admin/components/ModelFieldProvider"; +import { useConfirmationDialog } from "@webiny/app-admin"; const BottomMargin = styled.div` margin-bottom: 20px; @@ -189,6 +190,12 @@ interface MultiValueDynamicZoneProps { } export const MultiValueDynamicZone = (props: MultiValueDynamicZoneProps) => { + const { showConfirmation } = useConfirmationDialog({ + message: `Are you sure you want to delete this item? This action is not reversible.`, + acceptLabel: `Yes, I'm sure!`, + cancelLabel: `No, leave it.` + }); + const { bind, getBind, contentModel } = props; const onTemplate = (template: CmsDynamicZoneTemplateWithTypename) => { bind.appendValue({ _templateId: template.id, __typename: template.__typename }); @@ -211,6 +218,12 @@ export const MultiValueDynamicZone = (props: MultiValueDynamicZoneProps) => { {values.map((value, index) => { const Bind = getBind(index); + const onDelete = () => { + showConfirmation(() => { + bind.removeValue(index); + }); + }; + return ( <ParentValueIndexProvider key={index} index={index}> <TemplateValueForm @@ -221,7 +234,7 @@ export const MultiValueDynamicZone = (props: MultiValueDynamicZoneProps) => { isLast={index === values.length - 1} onMoveUp={() => bind.moveValueUp(index)} onMoveDown={() => bind.moveValueDown(index)} - onDelete={() => bind.removeValue(index)} + onDelete={onDelete} onClone={value => cloneValue(value, index)} /> </ParentValueIndexProvider> diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx index aa7450755f8..aabf0891b7f 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx @@ -18,6 +18,7 @@ import { ParentValueIndexProvider, ModelFieldProvider } from "~/admin/components/ModelFieldProvider"; +import { useConfirmationDialog } from "@webiny/app-admin"; type GetBind = CmsModelFieldRendererProps["getBind"]; @@ -34,6 +35,12 @@ export const SingleValueDynamicZone = ({ contentModel, getBind }: SingleValueDynamicZoneProps) => { + const { showConfirmation } = useConfirmationDialog({ + message: `Are you sure you want to remove this item? This action is not reversible.`, + acceptLabel: `Yes, I'm sure!`, + cancelLabel: `No, leave it.` + }); + const onTemplate = (template: CmsDynamicZoneTemplateWithTypename) => { bind.onChange({ _templateId: template.id, __typename: template.__typename }); }; @@ -47,7 +54,9 @@ export const SingleValueDynamicZone = ({ const Bind = getBind(); const unsetValue = () => { - bind.onChange(null); + showConfirmation(() => { + bind.onChange(null); + }); }; return ( diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjects.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjects.tsx index 8d09900a8da..4e33f8cc59e 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjects.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjects.tsx @@ -22,6 +22,7 @@ import { } from "./StyledComponents"; import { generateAlphaNumericLowerCaseId } from "@webiny/utils"; import { FieldSettings } from "~/admin/plugins/fieldRenderers/object/FieldSettings"; +import { useConfirmationDialog } from "@webiny/app-admin"; const t = i18n.ns("app-headless-cms/admin/fields/text"); @@ -35,6 +36,12 @@ interface ActionsProps { } const Actions = ({ setHighlightIndex, bind, index }: ActionsProps) => { + const { showConfirmation } = useConfirmationDialog({ + message: `Are you sure you want to delete this item? This action is not reversible.`, + acceptLabel: `Yes, I'm sure!`, + cancelLabel: `No, leave it.` + }); + const { moveValueDown, moveValueUp } = bind.field; const onDown = useCallback( @@ -61,11 +68,21 @@ const Actions = ({ setHighlightIndex, bind, index }: ActionsProps) => { [moveValueUp, index] ); + const onDelete = useCallback( + (ev: React.BaseSyntheticEvent) => { + ev.stopPropagation(); + showConfirmation(() => { + bind.field.removeValue(index); + }); + }, + [index] + ); + return ( <> <IconButton icon={<ArrowDown />} onClick={onDown} /> <IconButton icon={<ArrowUp />} onClick={onUp} /> - <IconButton icon={<DeleteIcon />} onClick={() => bind.field.removeValue(index)} /> + <IconButton icon={<DeleteIcon />} onClick={onDelete} /> </> ); }; diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx index bdcc6fe438b..24d35ac9f7f 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx @@ -24,6 +24,7 @@ import { import { generateAlphaNumericLowerCaseId } from "@webiny/utils"; import { FieldSettings } from "./FieldSettings"; import { AccordionRenderSettings, getAccordionRenderSettings } from "../AccordionRenderSettings"; +import { useConfirmationDialog } from "@webiny/app-admin"; const t = i18n.ns("app-headless-cms/admin/fields/text"); @@ -39,6 +40,12 @@ interface ActionsProps { const Actions = ({ setHighlightIndex, bind, index }: ActionsProps) => { const { moveValueDown, moveValueUp } = bind.field; + const { showConfirmation } = useConfirmationDialog({ + message: `Are you sure you want to delete this item? This action is not reversible.`, + acceptLabel: `Yes, I'm sure!`, + cancelLabel: `No, leave it.` + }); + const onDown = useCallback( (ev: React.BaseSyntheticEvent) => { ev.stopPropagation(); @@ -63,11 +70,21 @@ const Actions = ({ setHighlightIndex, bind, index }: ActionsProps) => { [moveValueUp, index] ); + const onDelete = useCallback( + (ev: React.BaseSyntheticEvent) => { + ev.stopPropagation(); + showConfirmation(() => { + bind.field.removeValue(index); + }); + }, + [index] + ); + return ( <> <IconButton icon={<ArrowDown />} onClick={onDown} /> <IconButton icon={<ArrowUp />} onClick={onUp} /> - <IconButton icon={<DeleteIcon />} onClick={() => bind.field.removeValue(index)} /> + <IconButton icon={<DeleteIcon />} onClick={onDelete} /> </> ); }; From e359463f00237a5af8167aca60a0ff2821bbb5df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= <bruno.zoric@gmail.com> Date: Wed, 18 Dec 2024 11:54:07 +0100 Subject: [PATCH 17/52] fix(db-dynamodb): tools for batch write (#4445) Co-authored-by: Pavel Denisjuk <pavel@webiny.com> --- .../src/definitions/entry.ts | 14 +- .../src/tasks/Manager.ts | 34 +- .../tasks/reindexing/ReindexingTaskRunner.ts | 20 +- packages/api-elasticsearch-tasks/src/types.ts | 27 +- .../operations/AliasesStorageOperations.ts | 52 +- .../src/definitions/elasticsearch.ts | 1 - .../src/operations/form/index.ts | 266 +++-- .../src/operations/submission/index.ts | 2 +- .../src/operations/form/index.ts | 285 +++-- .../__tests__/__api__/setupFile.js | 3 + .../converters/convertersDisabled.test.ts | 2 - .../__tests__/graphql/dummyLocales.ts | 5 +- .../src/definitions/entryElasticsearch.ts | 3 + .../dataLoader/getLatestRevisionByEntryId.ts | 2 +- .../getPublishedRevisionByEntryId.ts | 2 +- .../entry/dataLoader/getRevisionById.ts | 2 +- .../src/operations/entry/index.ts | 1034 ++++++++--------- .../dataLoader/getLatestRevisionByEntryId.ts | 2 +- .../getPublishedRevisionByEntryId.ts | 2 +- .../entry/dataLoader/getRevisionById.ts | 2 +- .../src/operations/entry/index.ts | 765 ++++++------ .../src/definitions/localeEntity.ts | 18 +- .../src/definitions/systemEntity.ts | 16 +- .../locales/LocalesStorageOperations.ts | 93 +- .../system/SystemStorageOperations.ts | 32 +- .../definitions/pageElasticsearchEntity.ts | 3 + .../operations/blockCategory/dataLoader.ts | 2 +- .../src/operations/category/dataLoader.ts | 2 +- .../src/operations/pageBlock/dataLoader.ts | 2 +- .../src/operations/pageTemplate/dataLoader.ts | 2 +- .../src/operations/pages/index.ts | 517 ++++----- packages/api-page-builder-so-ddb/package.json | 3 +- .../operations/blockCategory/dataLoader.ts | 2 +- .../src/operations/category/dataLoader.ts | 2 +- .../src/operations/pageBlock/dataLoader.ts | 2 +- .../src/operations/pageTemplate/dataLoader.ts | 2 +- .../src/operations/pages/index.ts | 307 +++-- .../src/operations/queueJob.ts | 28 +- .../src/operations/render.ts | 101 +- packages/api-security-so-ddb/src/index.ts | 75 +- packages/api-tenancy-so-ddb/src/index.ts | 109 +- packages/db-dynamodb/README.md | 82 +- packages/db-dynamodb/package.json | 1 - packages/db-dynamodb/src/index.ts | 2 +- packages/db-dynamodb/src/toolbox.ts | 2 + .../src/utils/{ => batch}/batchRead.ts | 0 .../src/utils/{ => batch}/batchWrite.ts | 21 +- packages/db-dynamodb/src/utils/batch/index.ts | 3 + packages/db-dynamodb/src/utils/batch/types.ts | 30 + packages/db-dynamodb/src/utils/delete.ts | 14 +- .../db-dynamodb/src/utils/entity/Entity.ts | 112 ++ .../src/utils/entity/EntityReadBatch.ts | 55 + .../utils/entity/EntityReadBatchBuilder.ts | 34 + .../src/utils/entity/EntityWriteBatch.ts | 77 ++ .../utils/entity/EntityWriteBatchBuilder.ts | 28 + .../db-dynamodb/src/utils/entity/getEntity.ts | 14 + .../db-dynamodb/src/utils/entity/index.ts | 7 + .../db-dynamodb/src/utils/entity/types.ts | 69 ++ packages/db-dynamodb/src/utils/get.ts | 10 +- packages/db-dynamodb/src/utils/index.ts | 6 +- packages/db-dynamodb/src/utils/put.ts | 19 +- packages/db-dynamodb/src/utils/scan.ts | 8 +- packages/db-dynamodb/src/utils/table.ts | 16 - packages/db-dynamodb/src/utils/table/Table.ts | 44 + .../src/utils/table/TableReadBatch.ts | 75 ++ .../src/utils/table/TableWriteBatch.ts | 84 ++ packages/db-dynamodb/src/utils/table/index.ts | 4 + packages/db-dynamodb/src/utils/table/types.ts | 53 + packages/db-dynamodb/src/utils/update.ts | 4 +- packages/db-dynamodb/tsconfig.build.json | 1 - packages/db-dynamodb/tsconfig.json | 3 - .../5.35.0/001/ddb-es/FileDataMigration.ts | 15 +- .../src/migrations/5.35.0/003/index.ts | 42 +- .../src/migrations/5.35.0/005/index.ts | 36 +- .../src/migrations/5.37.0/002/ddb-es/index.ts | 8 +- .../5.37.0/003/ddb-es/AcoFolderMigration.ts | 33 +- .../src/migrations/5.40.0/001/ddb/index.ts | 26 +- .../src/migrations/5.41.0/001/index.ts | 38 +- .../src/utils/forEachTenantLocale.ts | 12 +- yarn.lock | 2 - 80 files changed, 2763 insertions(+), 2170 deletions(-) rename packages/db-dynamodb/src/utils/{ => batch}/batchRead.ts (100%) rename packages/db-dynamodb/src/utils/{ => batch}/batchWrite.ts (79%) create mode 100644 packages/db-dynamodb/src/utils/batch/index.ts create mode 100644 packages/db-dynamodb/src/utils/batch/types.ts create mode 100644 packages/db-dynamodb/src/utils/entity/Entity.ts create mode 100644 packages/db-dynamodb/src/utils/entity/EntityReadBatch.ts create mode 100644 packages/db-dynamodb/src/utils/entity/EntityReadBatchBuilder.ts create mode 100644 packages/db-dynamodb/src/utils/entity/EntityWriteBatch.ts create mode 100644 packages/db-dynamodb/src/utils/entity/EntityWriteBatchBuilder.ts create mode 100644 packages/db-dynamodb/src/utils/entity/getEntity.ts create mode 100644 packages/db-dynamodb/src/utils/entity/index.ts create mode 100644 packages/db-dynamodb/src/utils/entity/types.ts delete mode 100644 packages/db-dynamodb/src/utils/table.ts create mode 100644 packages/db-dynamodb/src/utils/table/Table.ts create mode 100644 packages/db-dynamodb/src/utils/table/TableReadBatch.ts create mode 100644 packages/db-dynamodb/src/utils/table/TableWriteBatch.ts create mode 100644 packages/db-dynamodb/src/utils/table/index.ts create mode 100644 packages/db-dynamodb/src/utils/table/types.ts diff --git a/packages/api-elasticsearch-tasks/src/definitions/entry.ts b/packages/api-elasticsearch-tasks/src/definitions/entry.ts index fa3c9bb7afd..e2559d417f4 100644 --- a/packages/api-elasticsearch-tasks/src/definitions/entry.ts +++ b/packages/api-elasticsearch-tasks/src/definitions/entry.ts @@ -1,13 +1,18 @@ -import { Entity, TableDef } from "@webiny/db-dynamodb/toolbox"; +/** + * TODO If adding GSIs to the Elasticsearch table, add them here. + */ +import type { TableDef } from "@webiny/db-dynamodb/toolbox"; +import type { IEntity } from "@webiny/db-dynamodb"; +import { createEntity } from "@webiny/db-dynamodb"; interface Params { table: TableDef; entityName: string; } -export const createEntry = (params: Params): Entity<any> => { +export const createEntry = (params: Params): IEntity => { const { table, entityName } = params; - return new Entity({ + return createEntity({ name: entityName, table, attributes: { @@ -24,6 +29,9 @@ export const createEntry = (params: Params): Entity<any> => { }, data: { type: "map" + }, + TYPE: { + type: "string" } } }); diff --git a/packages/api-elasticsearch-tasks/src/tasks/Manager.ts b/packages/api-elasticsearch-tasks/src/tasks/Manager.ts index a62664742a0..2d981674e49 100644 --- a/packages/api-elasticsearch-tasks/src/tasks/Manager.ts +++ b/packages/api-elasticsearch-tasks/src/tasks/Manager.ts @@ -1,19 +1,16 @@ import { DynamoDBDocument, getDocumentClient } from "@webiny/aws-sdk/client-dynamodb"; import { Client, createElasticsearchClient } from "@webiny/api-elasticsearch"; import { createTable } from "~/definitions"; -import { Context, IManager } from "~/types"; +import type { Context, IManager } from "~/types"; import { createEntry } from "~/definitions/entry"; -import { Entity } from "@webiny/db-dynamodb/toolbox"; -import { ITaskResponse } from "@webiny/tasks/response/abstractions"; -import { IIsCloseToTimeoutCallable, ITaskManagerStore } from "@webiny/tasks/runner/abstractions"; -import { - batchReadAll, - BatchReadItem, - batchWriteAll, - BatchWriteItem, - BatchWriteResult -} from "@webiny/db-dynamodb"; -import { ITimer } from "@webiny/handler-aws/utils"; +import type { ITaskResponse } from "@webiny/tasks/response/abstractions"; +import type { + IIsCloseToTimeoutCallable, + ITaskManagerStore +} from "@webiny/tasks/runner/abstractions"; +import type { BatchReadItem, IEntity } from "@webiny/db-dynamodb"; +import { batchReadAll } from "@webiny/db-dynamodb"; +import type { ITimer } from "@webiny/handler-aws/utils"; export interface ManagerParams<T> { context: Context; @@ -37,7 +34,7 @@ export class Manager<T> implements IManager<T> { public readonly store: ITaskManagerStore<T>; public readonly timer: ITimer; - private readonly entities: Record<string, Entity<any>> = {}; + private readonly entities: Record<string, IEntity> = {}; public constructor(params: ManagerParams<T>) { this.context = params.context; @@ -64,7 +61,7 @@ export class Manager<T> implements IManager<T> { this.timer = params.timer; } - public getEntity(name: string): Entity<any> { + public getEntity(name: string): IEntity { if (this.entities[name]) { return this.entities[name]; } @@ -75,17 +72,10 @@ export class Manager<T> implements IManager<T> { })); } - public async read<T>(items: BatchReadItem[]) { + public async read<T>(items: BatchReadItem[]): Promise<T[]> { return await batchReadAll<T>({ table: this.table, items }); } - - public async write(items: BatchWriteItem[]): Promise<BatchWriteResult> { - return await batchWriteAll({ - table: this.table, - items - }); - } } diff --git a/packages/api-elasticsearch-tasks/src/tasks/reindexing/ReindexingTaskRunner.ts b/packages/api-elasticsearch-tasks/src/tasks/reindexing/ReindexingTaskRunner.ts index 908d8d911b7..930eb1b8166 100644 --- a/packages/api-elasticsearch-tasks/src/tasks/reindexing/ReindexingTaskRunner.ts +++ b/packages/api-elasticsearch-tasks/src/tasks/reindexing/ReindexingTaskRunner.ts @@ -6,7 +6,7 @@ import { } from "~/types"; import { ITaskResponse, ITaskResponseResult } from "@webiny/tasks/response/abstractions"; import { scan } from "~/helpers/scan"; -import { BatchWriteItem, ScanResponse } from "@webiny/db-dynamodb"; +import { createTableWriteBatch, ScanResponse } from "@webiny/db-dynamodb"; import { IndexManager } from "~/settings"; import { IIndexManager } from "~/settings/types"; @@ -73,7 +73,10 @@ export class ReindexingTaskRunner { return this.response.done("No more items to process."); } - const batch: BatchWriteItem[] = []; + const tableWriteBatch = createTableWriteBatch({ + table: this.manager.table + }); + for (const item of results.items) { /** * No index defined? Impossible but let's skip if really happens. @@ -110,14 +113,13 @@ export class ReindexingTaskRunner { /** * Reindexing will be triggered by the `putBatch` method. */ - batch.push( - entity.putBatch({ - ...item, - modified: new Date().toISOString() - }) - ); + tableWriteBatch.put(entity.entity, { + ...item, + TYPE: item.TYPE || "unknown", + modified: new Date().toISOString() + }); } - await this.manager.write(batch); + await tableWriteBatch.execute(); /** * We always store the index settings, so we can restore them later. * Also, we always want to store what was the last key we processed, just in case something breaks, so we can continue from this point. diff --git a/packages/api-elasticsearch-tasks/src/types.ts b/packages/api-elasticsearch-tasks/src/types.ts index b2d5b276551..8446398e1ee 100644 --- a/packages/api-elasticsearch-tasks/src/types.ts +++ b/packages/api-elasticsearch-tasks/src/types.ts @@ -1,17 +1,17 @@ -import { ElasticsearchContext } from "@webiny/api-elasticsearch/types"; -import { Entity } from "@webiny/db-dynamodb/toolbox"; -import { +import type { ElasticsearchContext } from "@webiny/api-elasticsearch/types"; +import type { Context as TasksContext, IIsCloseToTimeoutCallable, + ITaskManagerStore, + ITaskResponse, ITaskResponseDoneResultOutput } from "@webiny/tasks/types"; -import { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb"; -import { Client } from "@webiny/api-elasticsearch"; +import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb"; +import type { Client } from "@webiny/api-elasticsearch"; import { createTable } from "~/definitions"; -import { ITaskResponse } from "@webiny/tasks/response/abstractions"; -import { ITaskManagerStore } from "@webiny/tasks/runner/abstractions"; -import { BatchWriteItem, BatchWriteResult } from "@webiny/db-dynamodb"; -import { ITimer } from "@webiny/handler-aws"; +import type { BatchReadItem, IEntity } from "@webiny/db-dynamodb"; +import type { ITimer } from "@webiny/handler-aws"; +import type { GenericRecord } from "@webiny/api/types"; export interface Context extends ElasticsearchContext, TasksContext {} @@ -42,17 +42,18 @@ export interface IElasticsearchIndexingTaskValues { } export interface AugmentedError extends Error { - data?: Record<string, any>; + data?: GenericRecord; [key: string]: any; } export interface IDynamoDbElasticsearchRecord { PK: string; SK: string; + TYPE?: string; index: string; _et?: string; entity: string; - data: Record<string, any>; + data: GenericRecord; modified: string; } @@ -70,7 +71,7 @@ export interface IManager< readonly store: ITaskManagerStore<T>; readonly timer: ITimer; - getEntity: (name: string) => Entity<any>; + getEntity: (name: string) => IEntity; - write: (items: BatchWriteItem[]) => Promise<BatchWriteResult>; + read<T>(items: BatchReadItem[]): Promise<T[]>; } diff --git a/packages/api-file-manager-ddb/src/operations/AliasesStorageOperations.ts b/packages/api-file-manager-ddb/src/operations/AliasesStorageOperations.ts index 47d2ed6280b..af1f8aba00d 100644 --- a/packages/api-file-manager-ddb/src/operations/AliasesStorageOperations.ts +++ b/packages/api-file-manager-ddb/src/operations/AliasesStorageOperations.ts @@ -1,13 +1,12 @@ -import { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb"; -import { Entity, Table } from "@webiny/db-dynamodb/toolbox"; -import { - FileManagerAliasesStorageOperations, +import type { DynamoDBDocument } from "@webiny/aws-sdk/client-dynamodb"; +import type { Entity, Table } from "@webiny/db-dynamodb/toolbox"; +import type { File, - FileAlias + FileAlias, + FileManagerAliasesStorageOperations } from "@webiny/api-file-manager/types"; import { - BatchWriteItem, - batchWriteAll, + createEntityWriteBatch, createStandardEntity, createTable, DbItem, @@ -39,52 +38,49 @@ export class AliasesStorageOperations implements FileManagerAliasesStorageOperat async deleteAliases(file: File): Promise<void> { const aliasItems = await this.getExistingAliases(file); - const items: BatchWriteItem[] = []; - aliasItems.forEach(item => { - items.push( - this.aliasEntity.deleteBatch({ + const batchWrite = createEntityWriteBatch({ + entity: this.aliasEntity, + delete: aliasItems.map(item => { + return { PK: this.createPartitionKey({ id: item.fileId, tenant: item.tenant, locale: item.locale }), SK: `ALIAS#${item.alias}` - }) - ); + }; + }) }); - await batchWriteAll({ table: this.table, items }); + await batchWrite.execute(); } async storeAliases(file: File): Promise<void> { - const items: BatchWriteItem[] = []; const existingAliases = await this.getExistingAliases(file); const newAliases = this.createNewAliasesRecords(file, existingAliases); - newAliases.forEach(alias => { - items.push(this.aliasEntity.putBatch(alias)); + const batchWrite = createEntityWriteBatch({ + entity: this.aliasEntity }); + for (const alias of newAliases) { + batchWrite.put(alias); + } // Delete aliases that are in the DB but are NOT in the file. for (const data of existingAliases) { if (!file.aliases.some(alias => data.alias === alias)) { - items.push( - this.aliasEntity.deleteBatch({ - PK: this.createPartitionKey(file), - SK: `ALIAS#${data.alias}` - }) - ); + batchWrite.delete({ + PK: this.createPartitionKey(file), + SK: `ALIAS#${data.alias}` + }); } } - await batchWriteAll({ - table: this.table, - items - }); + await batchWrite.execute(); } - private async getExistingAliases(file: File) { + private async getExistingAliases(file: File): Promise<FileAlias[]> { const aliases = await queryAll<{ data: FileAlias }>({ entity: this.aliasEntity, partitionKey: this.createPartitionKey(file), diff --git a/packages/api-form-builder-so-ddb-es/src/definitions/elasticsearch.ts b/packages/api-form-builder-so-ddb-es/src/definitions/elasticsearch.ts index 84f28c9538c..9236ce07eee 100644 --- a/packages/api-form-builder-so-ddb-es/src/definitions/elasticsearch.ts +++ b/packages/api-form-builder-so-ddb-es/src/definitions/elasticsearch.ts @@ -28,7 +28,6 @@ export const createElasticsearchEntity = (params: Params) => { TYPE: { type: "string" }, - ...(attributes || {}) } }); diff --git a/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts b/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts index db1e3cf4c05..5ca078d4910 100644 --- a/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts +++ b/packages/api-form-builder-so-ddb-es/src/operations/form/index.ts @@ -17,7 +17,7 @@ import { Entity, Table } from "@webiny/db-dynamodb/toolbox"; import { Client } from "@elastic/elasticsearch"; import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query"; import WebinyError from "@webiny/error"; -import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite"; +import { createEntityWriteBatch, getClean, IPutParamsItem, put } from "@webiny/db-dynamodb"; import { configurations } from "~/configurations"; import { filterItems } from "@webiny/db-dynamodb/utils/filter"; import fields from "./fields"; @@ -28,7 +28,6 @@ import { decodeCursor, encodeCursor } from "@webiny/api-elasticsearch"; import { PluginsContainer } from "@webiny/plugins"; import { FormBuilderFormCreateKeyParams, FormBuilderFormStorageOperations } from "~/types"; import { ElasticsearchSearchResponse } from "@webiny/api-elasticsearch/types"; -import { deleteItem, getClean, put } from "@webiny/db-dynamodb"; export type DbRecord<T = any> = T & { PK: string; @@ -71,7 +70,7 @@ const getESDataForLatestRevision = (form: FbForm): FbFormElastic => ({ export const createFormStorageOperations = ( params: CreateFormStorageOperationsParams ): FormBuilderFormStorageOperations => { - const { entity, esEntity, table, plugins, elasticsearch } = params; + const { entity, esEntity, plugins, elasticsearch } = params; const formDynamoDbFields = fields(); @@ -123,24 +122,24 @@ export const createFormStorageOperations = ( SK: createLatestSortKey() }; - const items = [ - entity.putBatch({ - ...form, - TYPE: createFormType(), - ...revisionKeys - }), - entity.putBatch({ - ...form, - TYPE: createFormLatestType(), - ...latestKeys - }) - ]; + const itemsBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...form, + TYPE: createFormType(), + ...revisionKeys + }, + { + ...form, + TYPE: createFormLatestType(), + ...latestKeys + } + ] + }); try { - await batchWriteAll({ - table, - items - }); + await itemsBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not insert form data into regular table.", @@ -194,24 +193,24 @@ export const createFormStorageOperations = ( SK: createLatestSortKey() }; - const items = [ - entity.putBatch({ - ...form, - ...revisionKeys, - TYPE: createFormType() - }), - entity.putBatch({ - ...form, - ...latestKeys, - TYPE: createFormLatestType() - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...form, + ...revisionKeys, + TYPE: createFormType() + }, + { + ...form, + ...latestKeys, + TYPE: createFormLatestType() + } + ] + }); try { - await batchWriteAll({ - table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || @@ -283,27 +282,26 @@ export const createFormStorageOperations = ( }); const isLatestForm = latestForm ? latestForm.id === form.id : false; - const items = [ - entity.putBatch({ - ...form, - TYPE: createFormType(), - ...revisionKeys - }) - ]; - if (isLatestForm) { - items.push( - entity.putBatch({ + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { ...form, - TYPE: createFormLatestType(), - ...latestKeys - }) - ); + TYPE: createFormType(), + ...revisionKeys + } + ] + }); + + if (isLatestForm) { + entityBatch.put({ + ...form, + TYPE: createFormLatestType(), + ...latestKeys + }); } try { - await batchWriteAll({ - table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not update form data in the regular table.", @@ -547,17 +545,18 @@ export const createFormStorageOperations = ( ); } - const deleteItems = items.map(item => { - return entity.deleteBatch({ - PK: item.PK, - SK: item.SK - }); + const deleteBatch = createEntityWriteBatch({ + entity, + delete: items.map(item => { + return { + PK: item.PK, + SK: item.SK + }; + }) }); + try { - await batchWriteAll({ - table, - items: deleteItems - }); + await deleteBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not delete form and it's submissions.", @@ -569,11 +568,13 @@ export const createFormStorageOperations = ( PK: createFormPartitionKey(form), SK: createLatestSortKey() }; + const deleteEsBatch = createEntityWriteBatch({ + entity: esEntity, + delete: [latestKeys] + }); + try { - await deleteItem({ - entity: esEntity, - keys: latestKeys - }); + await deleteEsBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not delete latest form record from Elasticsearch.", @@ -612,8 +613,12 @@ export const createFormStorageOperations = ( const isLatest = latestForm ? latestForm.id === form.id : false; const isLatestPublished = latestPublishedForm ? latestPublishedForm.id === form.id : false; - const items = [entity.deleteBatch(revisionKeys)]; - let esDataItem = undefined; + const entityBatch = createEntityWriteBatch({ + entity, + delete: [revisionKeys] + }); + + let esDataItem: IPutParamsItem | undefined = undefined; if (isLatest || isLatestPublished) { /** @@ -630,34 +635,28 @@ export const createFormStorageOperations = ( }) .shift(); if (previouslyPublishedForm) { - items.push( - entity.putBatch({ - ...previouslyPublishedForm, - PK: createFormPartitionKey(previouslyPublishedForm), - SK: createLatestPublishedSortKey(), - TYPE: createFormLatestPublishedType() - }) - ); + entityBatch.put({ + ...previouslyPublishedForm, + PK: createFormPartitionKey(previouslyPublishedForm), + SK: createLatestPublishedSortKey(), + TYPE: createFormLatestPublishedType() + }); } else { - items.push( - entity.deleteBatch({ - PK: createFormPartitionKey(form), - SK: createLatestPublishedSortKey() - }) - ); + entityBatch.delete({ + PK: createFormPartitionKey(form), + SK: createLatestPublishedSortKey() + }); } } /** * Sort out the latest record. */ if (isLatest && previous) { - items.push( - entity.putBatch({ - ...previous, - ...latestKeys, - TYPE: createFormLatestType() - }) - ); + entityBatch.put({ + ...previous, + ...latestKeys, + TYPE: createFormLatestType() + }); const { index } = configurations.es({ tenant: previous.tenant, @@ -675,10 +674,7 @@ export const createFormStorageOperations = ( * Now save the batch data. */ try { - await batchWriteAll({ - table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not delete form revision from regular table.", @@ -759,36 +755,35 @@ export const createFormStorageOperations = ( /** * Update revision and latest published records */ - const items = [ - entity.putBatch({ - ...form, - ...revisionKeys, - TYPE: createFormType() - }), - entity.putBatch({ - ...form, - ...latestPublishedKeys, - TYPE: createFormLatestPublishedType() - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...form, + ...revisionKeys, + TYPE: createFormType() + }, + { + ...form, + ...latestPublishedKeys, + TYPE: createFormLatestPublishedType() + } + ] + }); + /** * Update the latest form as well */ if (isLatestForm) { - items.push( - entity.putBatch({ - ...form, - ...latestKeys, - TYPE: createFormLatestType() - }) - ); + entityBatch.put({ + ...form, + ...latestKeys, + TYPE: createFormLatestType() + }); } try { - await batchWriteAll({ - table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not publish form.", @@ -887,14 +882,18 @@ export const createFormStorageOperations = ( const isLatest = latestForm ? latestForm.id === form.id : false; const isLatestPublished = latestPublishedForm ? latestPublishedForm.id === form.id : false; - const items = [ - entity.putBatch({ - ...form, - ...revisionKeys, - TYPE: createFormType() - }) - ]; - let esData: any = undefined; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...form, + ...revisionKeys, + TYPE: createFormType() + } + ] + }); + + let esData: FbFormElastic | undefined = undefined; if (isLatest) { esData = getESDataForLatestRevision(form); } @@ -916,23 +915,18 @@ export const createFormStorageOperations = ( const previouslyPublishedRevision = revisions.shift(); if (previouslyPublishedRevision) { - items.push( - entity.putBatch({ - ...previouslyPublishedRevision, - ...latestPublishedKeys, - TYPE: createFormLatestPublishedType() - }) - ); + entityBatch.put({ + ...previouslyPublishedRevision, + ...latestPublishedKeys, + TYPE: createFormLatestPublishedType() + }); } else { - items.push(entity.deleteBatch(latestPublishedKeys)); + entityBatch.delete(latestPublishedKeys); } } try { - await batchWriteAll({ - table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not unpublish form.", diff --git a/packages/api-form-builder-so-ddb-es/src/operations/submission/index.ts b/packages/api-form-builder-so-ddb-es/src/operations/submission/index.ts index a1e1a00aafa..e93ee28b580 100644 --- a/packages/api-form-builder-so-ddb-es/src/operations/submission/index.ts +++ b/packages/api-form-builder-so-ddb-es/src/operations/submission/index.ts @@ -10,7 +10,7 @@ import { import { Entity, Table } from "@webiny/db-dynamodb/toolbox"; import { Client } from "@elastic/elasticsearch"; import WebinyError from "@webiny/error"; -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { batchReadAll } from "@webiny/db-dynamodb"; import { sortItems } from "@webiny/db-dynamodb/utils/sort"; import { createLimit, decodeCursor, encodeCursor } from "@webiny/api-elasticsearch"; import { diff --git a/packages/api-form-builder-so-ddb/src/operations/form/index.ts b/packages/api-form-builder-so-ddb/src/operations/form/index.ts index 3ade575baa2..c25f60780dc 100644 --- a/packages/api-form-builder-so-ddb/src/operations/form/index.ts +++ b/packages/api-form-builder-so-ddb/src/operations/form/index.ts @@ -1,5 +1,5 @@ import WebinyError from "@webiny/error"; -import { +import type { FbForm, FormBuilderStorageOperationsCreateFormFromParams, FormBuilderStorageOperationsCreateFormParams, @@ -14,14 +14,14 @@ import { FormBuilderStorageOperationsUnpublishFormParams, FormBuilderStorageOperationsUpdateFormParams } from "@webiny/api-form-builder/types"; -import { Entity, Table } from "@webiny/db-dynamodb/toolbox"; +import type { Entity, Table } from "@webiny/db-dynamodb/toolbox"; import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query"; -import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite"; +import { createEntityWriteBatch } from "@webiny/db-dynamodb"; import { filterItems } from "@webiny/db-dynamodb/utils/filter"; import { sortItems } from "@webiny/db-dynamodb/utils/sort"; import { createIdentifier, parseIdentifier } from "@webiny/utils"; -import { PluginsContainer } from "@webiny/plugins"; -import { +import type { PluginsContainer } from "@webiny/plugins"; +import type { FormBuilderFormCreateGSIPartitionKeyParams, FormBuilderFormCreatePartitionKeyParams, FormBuilderFormStorageOperations @@ -60,7 +60,7 @@ export interface CreateFormStorageOperationsParams { export const createFormStorageOperations = ( params: CreateFormStorageOperationsParams ): FormBuilderFormStorageOperations => { - const { entity, table, plugins } = params; + const { entity, plugins } = params; const formDynamoDbFields = plugins.byType<FormDynamoDbFieldPlugin>( FormDynamoDbFieldPlugin.type @@ -123,28 +123,30 @@ export const createFormStorageOperations = ( return "fb.form.latestPublished"; }; - const createRevisionKeys = (form: FbForm): Keys => { + const createRevisionKeys = (form: Pick<FbForm, "id" | "tenant" | "locale">): Keys => { return { PK: createFormPartitionKey(form), SK: createRevisionSortKey(form) }; }; - const createLatestKeys = (form: FbForm): Keys => { + const createLatestKeys = (form: Pick<FbForm, "tenant" | "locale" | "id" | "formId">): Keys => { return { PK: createFormLatestPartitionKey(form), SK: createFormLatestSortKey(form) }; }; - const createLatestPublishedKeys = (form: FbForm): Keys => { + const createLatestPublishedKeys = ( + form: Pick<FbForm, "tenant" | "locale" | "id" | "formId"> + ): Keys => { return { PK: createFormLatestPublishedPartitionKey(form), SK: createLatestPublishedSortKey(form) }; }; - const createGSIKeys = (form: FbForm): GsiKeys => { + const createGSIKeys = (form: Pick<FbForm, "version" | "tenant" | "locale">): GsiKeys => { return { GSI1_PK: createFormGSIPartitionKey(form), GSI1_SK: createGSISortKey(form.version) @@ -160,25 +162,25 @@ export const createFormStorageOperations = ( const latestKeys = createLatestKeys(form); const gsiKeys = createGSIKeys(form); - const items = [ - entity.putBatch({ - ...form, - ...revisionKeys, - ...gsiKeys, - TYPE: createFormType() - }), - entity.putBatch({ - ...form, - ...latestKeys, - TYPE: createFormLatestType() - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...form, + ...revisionKeys, + ...gsiKeys, + TYPE: createFormType() + }, + { + ...form, + ...latestKeys, + TYPE: createFormLatestType() + } + ] + }); try { - await batchWriteAll({ - table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not insert form data into table.", @@ -202,25 +204,25 @@ export const createFormStorageOperations = ( const latestKeys = createLatestKeys(form); const gsiKeys = createGSIKeys(form); - const items = [ - entity.putBatch({ - ...form, - ...revisionKeys, - ...gsiKeys, - TYPE: createFormType() - }), - entity.putBatch({ - ...form, - ...latestKeys, - TYPE: createFormLatestType() - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...form, + ...revisionKeys, + ...gsiKeys, + TYPE: createFormType() + }, + { + ...form, + ...latestKeys, + TYPE: createFormLatestType() + } + ] + }); try { - await batchWriteAll({ - table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not create form data in the table, from existing form.", @@ -259,28 +261,27 @@ export const createFormStorageOperations = ( }); const isLatestForm = latestForm ? latestForm.id === form.id : false; - const items = [ - entity.putBatch({ - ...form, - ...revisionKeys, - ...gsiKeys, - TYPE: createFormType() - }) - ]; - if (isLatestForm) { - items.push( - entity.putBatch({ + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { ...form, - ...latestKeys, - TYPE: createFormLatestType() - }) - ); + ...revisionKeys, + ...gsiKeys, + TYPE: createFormType() + } + ] + }); + + if (isLatestForm) { + entityBatch.put({ + ...form, + ...latestKeys, + TYPE: createFormLatestType() + }); } try { - await batchWriteAll({ - table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not update form data in the table.", @@ -510,29 +511,27 @@ export const createFormStorageOperations = ( } ); } + let latestPublishedKeys: Keys | undefined; + const entityBatch = createEntityWriteBatch({ + entity + }); - let hasLatestPublishedRecord = false; - - const deleteItems = items.map(item => { - if (!hasLatestPublishedRecord && item.published) { - hasLatestPublishedRecord = true; + for (const item of items) { + if (!latestPublishedKeys && item.published) { + latestPublishedKeys = createLatestPublishedKeys(item); } - return entity.deleteBatch({ + entityBatch.delete({ PK: item.PK, SK: item.SK }); - }); - if (hasLatestPublishedRecord) { - deleteItems.push(entity.deleteBatch(createLatestPublishedKeys(items[0]))); } - deleteItems.push(entity.deleteBatch(createLatestKeys(items[0]))); + if (latestPublishedKeys) { + entityBatch.delete(latestPublishedKeys); + } try { - await batchWriteAll({ - table, - items: deleteItems - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not delete form and it's submissions.", @@ -561,7 +560,10 @@ export const createFormStorageOperations = ( const isLatest = latestForm ? latestForm.id === form.id : false; const isLatestPublished = latestPublishedForm ? latestPublishedForm.id === form.id : false; - const items = [entity.deleteBatch(revisionKeys)]; + const entityBatch = createEntityWriteBatch({ + entity, + delete: [revisionKeys] + }); if (isLatest || isLatestPublished) { /** @@ -578,42 +580,36 @@ export const createFormStorageOperations = ( }) .shift(); if (previouslyPublishedForm) { - items.push( - entity.putBatch({ - ...previouslyPublishedForm, - ...createLatestPublishedKeys(previouslyPublishedForm), - GSI1_PK: null, - GSI1_SK: null, - TYPE: createFormLatestPublishedType() - }) - ); + entityBatch.put({ + ...previouslyPublishedForm, + ...createLatestPublishedKeys(previouslyPublishedForm), + GSI1_PK: null, + GSI1_SK: null, + TYPE: createFormLatestPublishedType() + }); } else { - items.push(entity.deleteBatch(createLatestPublishedKeys(form))); + entityBatch.delete(createLatestPublishedKeys(form)); } } /** * Sort out the latest record. */ if (isLatest) { - items.push( - entity.putBatch({ - ...previous, - ...latestKeys, - GSI1_PK: null, - GSI1_SK: null, - TYPE: createFormLatestType() - }) - ); + entityBatch.put({ + ...previous, + ...latestKeys, + GSI1_PK: null, + GSI1_SK: null, + TYPE: createFormLatestType() + }); } } /** * Now save the batch data. */ try { - await batchWriteAll({ - table, - items - }); + await entityBatch.execute(); + return form; } catch (ex) { throw new WebinyError( @@ -667,37 +663,36 @@ export const createFormStorageOperations = ( /** * Update revision and latest published records */ - const items = [ - entity.putBatch({ - ...form, - ...revisionKeys, - ...gsiKeys, - TYPE: createFormType() - }), - entity.putBatch({ - ...form, - ...latestPublishedKeys, - TYPE: createFormLatestPublishedType() - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...form, + ...revisionKeys, + ...gsiKeys, + TYPE: createFormType() + }, + { + ...form, + ...latestPublishedKeys, + TYPE: createFormLatestPublishedType() + } + ] + }); + /** * Update the latest form as well */ if (isLatestForm) { - items.push( - entity.putBatch({ - ...form, - ...latestKeys, - TYPE: createFormLatestType() - }) - ); + entityBatch.put({ + ...form, + ...latestKeys, + TYPE: createFormLatestType() + }); } try { - await batchWriteAll({ - table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not publish form.", @@ -754,17 +749,20 @@ export const createFormStorageOperations = ( const isLatest = latestForm ? latestForm.id === form.id : false; const isLatestPublished = latestPublishedForm ? latestPublishedForm.id === form.id : false; - const items = [ - entity.putBatch({ - ...form, - ...revisionKeys, - ...gsiKeys, - TYPE: createFormType() - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...form, + ...revisionKeys, + ...gsiKeys, + TYPE: createFormType() + } + ] + }); if (isLatest) { - entity.putBatch({ + entityBatch.put({ ...form, ...latestKeys, TYPE: createFormLatestType() @@ -788,23 +786,18 @@ export const createFormStorageOperations = ( const previouslyPublishedRevision = revisions.shift(); if (previouslyPublishedRevision) { - items.push( - entity.putBatch({ - ...previouslyPublishedRevision, - ...latestPublishedKeys, - TYPE: createFormLatestPublishedType() - }) - ); + entityBatch.put({ + ...previouslyPublishedRevision, + ...latestPublishedKeys, + TYPE: createFormLatestPublishedType() + }); } else { - items.push(entity.deleteBatch(latestPublishedKeys)); + entityBatch.delete(latestPublishedKeys); } } try { - await batchWriteAll({ - table, - items - }); + await entityBatch.execute(); return form; } catch (ex) { throw new WebinyError( diff --git a/packages/api-headless-cms-ddb-es/__tests__/__api__/setupFile.js b/packages/api-headless-cms-ddb-es/__tests__/__api__/setupFile.js index e657c72bbb3..a436a3f75d9 100644 --- a/packages/api-headless-cms-ddb-es/__tests__/__api__/setupFile.js +++ b/packages/api-headless-cms-ddb-es/__tests__/__api__/setupFile.js @@ -66,6 +66,9 @@ module.exports = () => { }); }); + createOrRefreshIndexSubscription.name = + "headlessCmsDdbEs.context.createOrRefreshIndexSubscription"; + return { storageOperations: createStorageOperations({ documentClient, diff --git a/packages/api-headless-cms-ddb-es/__tests__/converters/convertersDisabled.test.ts b/packages/api-headless-cms-ddb-es/__tests__/converters/convertersDisabled.test.ts index b3193273d49..dac86634f7c 100644 --- a/packages/api-headless-cms-ddb-es/__tests__/converters/convertersDisabled.test.ts +++ b/packages/api-headless-cms-ddb-es/__tests__/converters/convertersDisabled.test.ts @@ -7,8 +7,6 @@ import { CmsModel } from "@webiny/api-headless-cms/types"; import { get } from "@webiny/db-dynamodb"; import { createPartitionKey } from "~/operations/entry/keys"; -jest.retryTimes(0); - describe("storage field path converters disabled", () => { const { elasticsearch, entryEntity } = useHandler(); diff --git a/packages/api-headless-cms-ddb-es/__tests__/graphql/dummyLocales.ts b/packages/api-headless-cms-ddb-es/__tests__/graphql/dummyLocales.ts index 9424ee10e21..f6b763e697c 100644 --- a/packages/api-headless-cms-ddb-es/__tests__/graphql/dummyLocales.ts +++ b/packages/api-headless-cms-ddb-es/__tests__/graphql/dummyLocales.ts @@ -2,7 +2,7 @@ import { ContextPlugin } from "@webiny/api"; import { CmsContext } from "@webiny/api-headless-cms/types"; export const createDummyLocales = () => { - return new ContextPlugin<CmsContext>(async context => { + const plugin = new ContextPlugin<CmsContext>(async context => { const { i18n, security } = context; await security.withoutAuthorization(async () => { @@ -23,4 +23,7 @@ export const createDummyLocales = () => { }); }); }); + + plugin.name = "headlessCmsDdbEs.context.createDummyLocales"; + return plugin; }; diff --git a/packages/api-headless-cms-ddb-es/src/definitions/entryElasticsearch.ts b/packages/api-headless-cms-ddb-es/src/definitions/entryElasticsearch.ts index 97bffededb1..338b1b42af0 100644 --- a/packages/api-headless-cms-ddb-es/src/definitions/entryElasticsearch.ts +++ b/packages/api-headless-cms-ddb-es/src/definitions/entryElasticsearch.ts @@ -28,6 +28,9 @@ export const createEntryElasticsearchEntity = ( data: { type: "map" }, + TYPE: { + type: "string" + }, ...(attributes || {}) } }); diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts index 1e35ed56305..6338066f712 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts @@ -1,5 +1,5 @@ import DataLoader from "dataloader"; -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { batchReadAll } from "@webiny/db-dynamodb"; import { cleanupItems } from "@webiny/db-dynamodb/utils/cleanup"; import { CmsStorageEntry } from "@webiny/api-headless-cms/types"; import { createBatchScheduleFn } from "./createBatchScheduleFn"; diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts index 5e510a2a714..d6a409bd913 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts @@ -1,5 +1,5 @@ import DataLoader from "dataloader"; -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { batchReadAll } from "@webiny/db-dynamodb"; import { cleanupItems } from "@webiny/db-dynamodb/utils/cleanup"; import { CmsStorageEntry } from "@webiny/api-headless-cms/types"; import { createPartitionKey, createPublishedSortKey } from "~/operations/entry/keys"; diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getRevisionById.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getRevisionById.ts index 8374efbcb72..4458e9a34fc 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getRevisionById.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/dataLoader/getRevisionById.ts @@ -1,5 +1,5 @@ import DataLoader from "dataloader"; -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { batchReadAll } from "@webiny/db-dynamodb"; import { CmsStorageEntry } from "@webiny/api-headless-cms/types"; import { cleanupItems } from "@webiny/db-dynamodb/utils/cleanup"; import { createPartitionKey, createRevisionSortKey } from "~/operations/entry/keys"; diff --git a/packages/api-headless-cms-ddb-es/src/operations/entry/index.ts b/packages/api-headless-cms-ddb-es/src/operations/entry/index.ts index 574423f8119..f5d098b658e 100644 --- a/packages/api-headless-cms-ddb-es/src/operations/entry/index.ts +++ b/packages/api-headless-cms-ddb-es/src/operations/entry/index.ts @@ -1,17 +1,25 @@ import WebinyError from "@webiny/error"; -import { +import type { CmsEntry, CmsModel, CmsStorageEntry, - CONTENT_ENTRY_STATUS, StorageOperationsCmsModel } from "@webiny/api-headless-cms/types"; +import { CONTENT_ENTRY_STATUS } from "@webiny/api-headless-cms/types"; import { extractEntriesFromIndex } from "~/helpers"; import { configurations } from "~/configurations"; -import { Entity } from "@webiny/db-dynamodb/toolbox"; -import { Client } from "@elastic/elasticsearch"; -import { PluginsContainer } from "@webiny/plugins"; -import { batchWriteAll, BatchWriteItem } from "@webiny/db-dynamodb/utils/batchWrite"; +import type { Entity } from "@webiny/db-dynamodb/toolbox"; +import type { Client } from "@elastic/elasticsearch"; +import type { PluginsContainer } from "@webiny/plugins"; +import type { BatchReadItem, QueryAllParams, QueryOneParams } from "@webiny/db-dynamodb"; +import { + batchReadAll, + cleanupItem, + createEntityWriteBatch, + getClean, + queryAll, + queryOne +} from "@webiny/db-dynamodb"; import { DataLoadersHandler } from "./dataLoaders"; import { createLatestSortKey, @@ -19,12 +27,6 @@ import { createPublishedSortKey, createRevisionSortKey } from "./keys"; -import { - queryAll, - QueryAllParams, - queryOne, - QueryOneParams -} from "@webiny/db-dynamodb/utils/query"; import { compress, createLimit, @@ -32,21 +34,17 @@ import { decompress, encodeCursor } from "@webiny/api-elasticsearch"; -import { getClean } from "@webiny/db-dynamodb/utils/get"; import { zeroPad } from "@webiny/utils"; -import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; -import { +import type { ElasticsearchSearchResponse, SearchBody as ElasticsearchSearchBody } from "@webiny/api-elasticsearch/types"; -import { CmsEntryStorageOperations, CmsIndexEntry } from "~/types"; +import type { CmsEntryStorageOperations, CmsIndexEntry } from "~/types"; import { createElasticsearchBody } from "./elasticsearch/body"; import { logIgnoredEsResponseError } from "./elasticsearch/logIgnoredEsResponseError"; import { shouldIgnoreEsResponseError } from "./elasticsearch/shouldIgnoreEsResponseError"; import { createLatestRecordType, createPublishedRecordType, createRecordType } from "./recordType"; import { StorageOperationsCmsModelPlugin } from "@webiny/api-headless-cms"; -import { WriteRequest } from "@webiny/aws-sdk/client-dynamodb"; -import { batchReadAll, BatchReadItem } from "@webiny/db-dynamodb"; import { createTransformer } from "./transformations"; import { convertEntryKeysFromStorage } from "./transformations/convertEntryKeys"; import { @@ -57,6 +55,9 @@ import { } from "@webiny/api-headless-cms/constants"; interface ElasticsearchDbRecord { + PK: string; + SK: string; + TYPE: string; index: string; data: Record<string, any>; } @@ -164,37 +165,35 @@ export const createEntriesStorageOperations = ( SK: createPublishedSortKey() }; - const items = [ - entity.putBatch({ - ...storageEntry, - locked, - ...revisionKeys, - TYPE: createRecordType() - }), - entity.putBatch({ - ...storageEntry, - locked, - ...latestKeys, - TYPE: createLatestRecordType() - }) - ]; - - if (isPublished) { - items.push( - entity.putBatch({ + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { ...storageEntry, locked, - ...publishedKeys, - TYPE: createPublishedRecordType() - }) - ); + ...revisionKeys, + TYPE: createRecordType() + }, + { + ...storageEntry, + locked, + ...latestKeys, + TYPE: createLatestRecordType() + } + ] + }); + + if (isPublished) { + entityBatch.put({ + ...storageEntry, + locked, + ...publishedKeys, + TYPE: createPublishedRecordType() + }); } try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); dataLoaders.clearAll({ model }); @@ -211,29 +210,29 @@ export const createEntriesStorageOperations = ( } const esLatestData = await transformer.getElasticsearchLatestEntryData(); - const esItems: BatchWriteItem[] = [ - esEntity.putBatch({ - ...latestKeys, - index: esIndex, - data: esLatestData - }) - ]; + + const elasticsearchEntityBatch = createEntityWriteBatch({ + entity: esEntity, + put: [ + { + ...latestKeys, + index: esIndex, + data: esLatestData + } + ] + }); + if (isPublished) { const esPublishedData = await transformer.getElasticsearchPublishedEntryData(); - esItems.push( - esEntity.putBatch({ - ...publishedKeys, - index: esIndex, - data: esPublishedData - }) - ); + elasticsearchEntityBatch.put({ + ...publishedKeys, + index: esIndex, + data: esPublishedData + }); } try { - await batchWriteAll({ - table: esEntity.table, - items: esItems - }); + await elasticsearchEntityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not insert entry data into the Elasticsearch DynamoDB table.", @@ -295,27 +294,28 @@ export const createEntriesStorageOperations = ( const esLatestData = await transformer.getElasticsearchLatestEntryData(); - const items = [ - entity.putBatch({ - ...storageEntry, - TYPE: createRecordType(), - ...revisionKeys - }), - entity.putBatch({ - ...storageEntry, - TYPE: createLatestRecordType(), - ...latestKeys - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...storageEntry, + TYPE: createRecordType(), + ...revisionKeys + }, + { + ...storageEntry, + TYPE: createLatestRecordType(), + ...latestKeys + } + ] + }); if (isPublished) { - items.push( - entity.putBatch({ - ...storageEntry, - TYPE: createPublishedRecordType(), - ...publishedKeys - }) - ); + entityBatch.put({ + ...storageEntry, + TYPE: createPublishedRecordType(), + ...publishedKeys + }); // Unpublish previously published revision (if any). const [publishedRevisionStorageEntry] = await dataLoaders.getPublishedRevisionByEntryId( @@ -326,27 +326,23 @@ export const createEntriesStorageOperations = ( ); if (publishedRevisionStorageEntry) { - items.push( - entity.putBatch({ - ...publishedRevisionStorageEntry, - PK: createPartitionKey({ - id: publishedRevisionStorageEntry.id, - locale: model.locale, - tenant: model.tenant - }), - SK: createRevisionSortKey(publishedRevisionStorageEntry), - TYPE: createRecordType(), - status: CONTENT_ENTRY_STATUS.UNPUBLISHED - }) - ); + entityBatch.put({ + ...publishedRevisionStorageEntry, + PK: createPartitionKey({ + id: publishedRevisionStorageEntry.id, + locale: model.locale, + tenant: model.tenant + }), + SK: createRevisionSortKey(publishedRevisionStorageEntry), + TYPE: createRecordType(), + status: CONTENT_ENTRY_STATUS.UNPUBLISHED + }); } } try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); + dataLoaders.clearAll({ model }); @@ -366,30 +362,28 @@ export const createEntriesStorageOperations = ( model }); - const esItems: BatchWriteItem[] = [ - esEntity.putBatch({ - ...latestKeys, - index: esIndex, - data: esLatestData - }) - ]; + const elasticsearchEntityBatch = createEntityWriteBatch({ + entity: esEntity, + put: [ + { + ...latestKeys, + index: esIndex, + data: esLatestData + } + ] + }); if (isPublished) { const esPublishedData = await transformer.getElasticsearchPublishedEntryData(); - esItems.push( - esEntity.putBatch({ - ...publishedKeys, - index: esIndex, - data: esPublishedData - }) - ); + elasticsearchEntityBatch.put({ + ...publishedKeys, + index: esIndex, + data: esPublishedData + }); } try { - await batchWriteAll({ - table: esEntity.table, - items: esItems - }); + await elasticsearchEntityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not update latest entry in the DynamoDB Elasticsearch table.", @@ -461,26 +455,30 @@ export const createEntriesStorageOperations = ( ids: [entry.id] }); - const items = [ - entity.putBatch({ - ...storageEntry, - locked, - ...revisionKeys, - TYPE: createRecordType() - }) - ]; - if (isPublished) { - items.push( - entity.putBatch({ + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { ...storageEntry, locked, - ...publishedKeys, - TYPE: createPublishedRecordType() - }) - ); + ...revisionKeys, + TYPE: createRecordType() + } + ] + }); + + if (isPublished) { + entityBatch.put({ + ...storageEntry, + locked, + ...publishedKeys, + TYPE: createPublishedRecordType() + }); } - const esItems: BatchWriteItem[] = []; + const elasticsearchEntityBatch = createEntityWriteBatch({ + entity: esEntity + }); const { index: esIndex } = configurations.es({ model @@ -495,26 +493,22 @@ export const createEntriesStorageOperations = ( /** * First we update the regular DynamoDB table. */ - items.push( - entity.putBatch({ - ...storageEntry, - ...latestKeys, - TYPE: createLatestRecordType() - }) - ); + entityBatch.put({ + ...storageEntry, + ...latestKeys, + TYPE: createLatestRecordType() + }); /** * And then update the Elasticsearch table to propagate changes to the Elasticsearch */ const elasticsearchLatestData = await transformer.getElasticsearchLatestEntryData(); - esItems.push( - esEntity.putBatch({ - ...latestKeys, - index: esIndex, - data: elasticsearchLatestData - }) - ); + elasticsearchEntityBatch.put({ + ...latestKeys, + index: esIndex, + data: elasticsearchLatestData + }); } else { /** * If not updating latest revision, we still want to update the latest revision's @@ -536,25 +530,21 @@ export const createEntriesStorageOperations = ( * - one for the actual revision record * - one for the latest record */ - items.push( - entity.putBatch({ - ...updatedLatestStorageEntry, - PK: createPartitionKey({ - id: latestStorageEntry.id, - locale: model.locale, - tenant: model.tenant - }), - SK: createRevisionSortKey(latestStorageEntry), - TYPE: createRecordType() - }) - ); + entityBatch.put({ + ...updatedLatestStorageEntry, + PK: createPartitionKey({ + id: latestStorageEntry.id, + locale: model.locale, + tenant: model.tenant + }), + SK: createRevisionSortKey(latestStorageEntry), + TYPE: createRecordType() + }); - items.push( - entity.putBatch({ - ...updatedLatestStorageEntry, - TYPE: createLatestRecordType() - }) - ); + entityBatch.put({ + ...updatedLatestStorageEntry, + TYPE: createLatestRecordType() + }); /** * Update the Elasticsearch table to propagate changes to the Elasticsearch. @@ -575,13 +565,11 @@ export const createEntriesStorageOperations = ( ...updatedEntryLevelMetaFields }); - esItems.push( - esEntity.putBatch({ - ...latestKeys, - index: esIndex, - data: updatedLatestEntry - }) - ); + elasticsearchEntityBatch.put({ + ...latestKeys, + index: esIndex, + data: updatedLatestEntry + }); } } } @@ -589,19 +577,15 @@ export const createEntriesStorageOperations = ( if (isPublished && publishedStorageEntry?.id === entry.id) { const elasticsearchPublishedData = await transformer.getElasticsearchPublishedEntryData(); - esItems.push( - esEntity.putBatch({ - ...publishedKeys, - index: esIndex, - data: elasticsearchPublishedData - }) - ); + elasticsearchEntityBatch.put({ + ...publishedKeys, + index: esIndex, + data: elasticsearchPublishedData + }); } try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); + dataLoaders.clearAll({ model }); @@ -616,15 +600,8 @@ export const createEntriesStorageOperations = ( } ); } - if (esItems.length === 0) { - return initialStorageEntry; - } - try { - await batchWriteAll({ - table: esEntity.table, - items: esItems - }); + await elasticsearchEntityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not update entry DynamoDB Elasticsearch records.", @@ -664,17 +641,19 @@ export const createEntriesStorageOperations = ( */ let latestRecord: CmsEntry | undefined = undefined; let publishedRecord: CmsEntry | undefined = undefined; - const items: BatchWriteItem[] = []; + const entityBatch = createEntityWriteBatch({ + entity + }); + for (const record of records) { - items.push( - entity.putBatch({ - ...record, - location: { - ...record?.location, - folderId - } - }) - ); + entityBatch.put({ + ...record, + location: { + ...record?.location, + folderId + } + }); + /** * We need to get the published and latest records, so we can update the Elasticsearch. */ @@ -685,10 +664,7 @@ export const createEntriesStorageOperations = ( } } try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); dataLoaders.clearAll({ model }); @@ -743,27 +719,27 @@ export const createEntriesStorageOperations = ( if (esItems.length === 0) { return; } - const esUpdateItems: BatchWriteItem[] = []; - for (const item of esItems) { - esUpdateItems.push( - esEntity.putBatch({ - ...item, - data: await compress(plugins, { - ...item.data, - location: { - ...item.data?.location, - folderId - } - }) + + const elasticsearchEntityBatch = createEntityWriteBatch({ + entity: esEntity, + put: await Promise.all( + esItems.map(async item => { + return { + ...item, + data: await compress(plugins, { + ...item.data, + location: { + ...item.data?.location, + folderId + } + }) + }; }) - ); - } + ) + }); try { - await batchWriteAll({ - table: esEntity.table, - items: esUpdateItems - }); + await elasticsearchEntityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not move entry DynamoDB Elasticsearch records.", @@ -820,18 +796,20 @@ export const createEntriesStorageOperations = ( */ let latestRecord: CmsEntry | undefined = undefined; let publishedRecord: CmsEntry | undefined = undefined; - const items: BatchWriteItem[] = []; + + const entityBatch = createEntityWriteBatch({ + entity + }); for (const record of records) { - items.push( - entity.putBatch({ - ...record, - ...updatedEntryMetaFields, - wbyDeleted: storageEntry.wbyDeleted, - location: storageEntry.location, - binOriginalFolderId: storageEntry.binOriginalFolderId - }) - ); + entityBatch.put({ + ...record, + ...updatedEntryMetaFields, + wbyDeleted: storageEntry.wbyDeleted, + location: storageEntry.location, + binOriginalFolderId: storageEntry.binOriginalFolderId + }); + /** * We need to get the published and latest records, so we can update the Elasticsearch. */ @@ -846,10 +824,8 @@ export const createEntriesStorageOperations = ( * We write the records back to the primary DynamoDB table. */ try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); + dataLoaders.clearAll({ model }); @@ -915,30 +891,28 @@ export const createEntriesStorageOperations = ( /** * We update all ES records with data received. */ - const esUpdateItems: BatchWriteItem[] = []; + const elasticsearchEntityBatch = createEntityWriteBatch({ + entity: esEntity + }); + for (const item of esItems) { - esUpdateItems.push( - esEntity.putBatch({ - ...item, - data: await compress(plugins, { - ...item.data, - ...updatedEntryMetaFields, - wbyDeleted: entry.wbyDeleted, - location: entry.location, - binOriginalFolderId: entry.binOriginalFolderId - }) + elasticsearchEntityBatch.put({ + ...item, + data: await compress(plugins, { + ...item.data, + ...updatedEntryMetaFields, + wbyDeleted: entry.wbyDeleted, + location: entry.location, + binOriginalFolderId: entry.binOriginalFolderId }) - ); + }); } /** * We write the records back to the primary DynamoDB Elasticsearch table. */ try { - await batchWriteAll({ - table: esEntity.table, - items: esUpdateItems - }); + await elasticsearchEntityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || @@ -1000,18 +974,20 @@ export const createEntriesStorageOperations = ( */ let latestRecord: CmsEntry | undefined = undefined; let publishedRecord: CmsEntry | undefined = undefined; - const items: BatchWriteItem[] = []; + + const entityBatch = createEntityWriteBatch({ + entity + }); for (const record of records) { - items.push( - entity.putBatch({ - ...record, - ...updatedEntryMetaFields, - wbyDeleted: storageEntry.wbyDeleted, - location: storageEntry.location, - binOriginalFolderId: storageEntry.binOriginalFolderId - }) - ); + entityBatch.put({ + ...record, + ...updatedEntryMetaFields, + wbyDeleted: storageEntry.wbyDeleted, + location: storageEntry.location, + binOriginalFolderId: storageEntry.binOriginalFolderId + }); + /** * We need to get the published and latest records, so we can update the Elasticsearch. */ @@ -1026,10 +1002,8 @@ export const createEntriesStorageOperations = ( * We write the records back to the primary DynamoDB table. */ try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); + dataLoaders.clearAll({ model }); @@ -1092,30 +1066,27 @@ export const createEntriesStorageOperations = ( /** * We update all ES records with data received. */ - const esUpdateItems: BatchWriteItem[] = []; + const elasticsearchEntityBatch = createEntityWriteBatch({ + entity: esEntity + }); for (const item of esItems) { - esUpdateItems.push( - esEntity.putBatch({ - ...item, - data: await compress(plugins, { - ...item.data, - ...updatedEntryMetaFields, - wbyDeleted: entry.wbyDeleted, - location: entry.location, - binOriginalFolderId: entry.binOriginalFolderId - }) + elasticsearchEntityBatch.put({ + ...item, + data: await compress(plugins, { + ...item.data, + ...updatedEntryMetaFields, + wbyDeleted: entry.wbyDeleted, + location: entry.location, + binOriginalFolderId: entry.binOriginalFolderId }) - ); + }); } /** * We write the records back to the primary DynamoDB Elasticsearch table. */ try { - await batchWriteAll({ - table: esEntity.table, - items: esUpdateItems - }); + await elasticsearchEntityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not restore entry records from DynamoDB Elasticsearch table.", @@ -1158,25 +1129,29 @@ export const createEntriesStorageOperations = ( } }); - const deleteItems = items.map(item => { - return entity.deleteBatch({ - PK: item.PK, - SK: item.SK - }); + const entityBatch = createEntityWriteBatch({ + entity, + delete: items.map(item => { + return { + PK: item.PK, + SK: item.SK + }; + }) }); - const deleteEsItems = esItems.map(item => { - return esEntity.deleteBatch({ - PK: item.PK, - SK: item.SK - }); + const elasticsearchEntityBatch = createEntityWriteBatch({ + entity: esEntity, + delete: esItems.map(item => { + return { + PK: item.PK, + SK: item.SK + }; + }) }); try { - await batchWriteAll({ - table: entity.table, - items: deleteItems - }); + await entityBatch.execute(); + dataLoaders.clearAll({ model }); @@ -1192,10 +1167,7 @@ export const createEntriesStorageOperations = ( } try { - await batchWriteAll({ - table: esEntity.table, - items: deleteEsItems - }); + await elasticsearchEntityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not destroy entry records from DynamoDB Elasticsearch table.", @@ -1234,34 +1206,33 @@ export const createEntriesStorageOperations = ( /** * We need to delete all existing records of the given entry revision. */ - const items = [ - /** - * Delete records of given entry revision. - */ - entity.deleteBatch({ - PK: partitionKey, - SK: createRevisionSortKey(entry) - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + delete: [ + { + PK: partitionKey, + SK: createRevisionSortKey(entry) + } + ] + }); - const esItems: BatchWriteItem[] = []; + const elasticsearchEntityBatch = createEntityWriteBatch({ + entity: esEntity + }); /** * If revision we are deleting is the published one as well, we need to delete those records as well. */ if (publishedStorageEntry?.id === entry.id) { - items.push( - entity.deleteBatch({ - PK: partitionKey, - SK: createPublishedSortKey() - }) - ); - esItems.push( - esEntity.deleteBatch({ - PK: partitionKey, - SK: createPublishedSortKey() - }) - ); + entityBatch.delete({ + PK: partitionKey, + SK: createPublishedSortKey() + }); + + elasticsearchEntityBatch.delete({ + PK: partitionKey, + SK: createPublishedSortKey() + }); } if (latestEntry && initialLatestStorageEntry) { @@ -1273,31 +1244,27 @@ export const createEntriesStorageOperations = ( /** * In the end we need to set the new latest entry. */ - items.push( - entity.putBatch({ - ...latestStorageEntry, - PK: partitionKey, - SK: createLatestSortKey(), - TYPE: createLatestRecordType() - }) - ); + entityBatch.put({ + ...latestStorageEntry, + PK: partitionKey, + SK: createLatestSortKey(), + TYPE: createLatestRecordType() + }); /** * Also perform an update on the actual revision. This is needed * because of updates on the entry-level meta fields. */ - items.push( - entity.putBatch({ - ...latestStorageEntry, - PK: createPartitionKey({ - id: initialLatestStorageEntry.id, - locale: model.locale, - tenant: model.tenant - }), - SK: createRevisionSortKey(initialLatestStorageEntry), - TYPE: createRecordType() - }) - ); + entityBatch.put({ + ...latestStorageEntry, + PK: createPartitionKey({ + id: initialLatestStorageEntry.id, + locale: model.locale, + tenant: model.tenant + }), + SK: createRevisionSortKey(initialLatestStorageEntry), + TYPE: createRecordType() + }); const latestTransformer = createTransformer({ plugins, @@ -1307,21 +1274,16 @@ export const createEntriesStorageOperations = ( }); const esLatestData = await latestTransformer.getElasticsearchLatestEntryData(); - esItems.push( - esEntity.putBatch({ - PK: partitionKey, - SK: createLatestSortKey(), - index, - data: esLatestData - }) - ); + elasticsearchEntityBatch.put({ + PK: partitionKey, + SK: createLatestSortKey(), + index, + data: esLatestData + }); } try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); dataLoaders.clearAll({ model @@ -1339,15 +1301,8 @@ export const createEntriesStorageOperations = ( ); } - if (esItems.length === 0) { - return; - } - try { - await batchWriteAll({ - table: esEntity.table, - items: esItems - }); + await elasticsearchEntityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || @@ -1379,82 +1334,74 @@ export const createEntriesStorageOperations = ( /** * Then we need to construct the queries for all the revisions and entries. */ - const items: Record<string, WriteRequest>[] = []; - const esItems: Record<string, WriteRequest>[] = []; + + const entityBatch = createEntityWriteBatch({ + entity + }); + const elasticsearchEntityBatch = createEntityWriteBatch({ + entity: esEntity + }); for (const id of entries) { /** * Latest item. */ - items.push( - entity.deleteBatch({ - PK: createPartitionKey({ - id, - locale: model.locale, - tenant: model.tenant - }), - SK: "L" - }) - ); - esItems.push( - esEntity.deleteBatch({ - PK: createPartitionKey({ - id, - locale: model.locale, - tenant: model.tenant - }), - SK: "L" - }) - ); + entityBatch.delete({ + PK: createPartitionKey({ + id, + locale: model.locale, + tenant: model.tenant + }), + SK: "L" + }); + + elasticsearchEntityBatch.delete({ + PK: createPartitionKey({ + id, + locale: model.locale, + tenant: model.tenant + }), + SK: "L" + }); + /** * Published item. */ - items.push( - entity.deleteBatch({ - PK: createPartitionKey({ - id, - locale: model.locale, - tenant: model.tenant - }), - SK: "P" - }) - ); - esItems.push( - esEntity.deleteBatch({ - PK: createPartitionKey({ - id, - locale: model.locale, - tenant: model.tenant - }), - SK: "P" - }) - ); + entityBatch.delete({ + PK: createPartitionKey({ + id, + locale: model.locale, + tenant: model.tenant + }), + SK: "P" + }); + + elasticsearchEntityBatch.delete({ + PK: createPartitionKey({ + id, + locale: model.locale, + tenant: model.tenant + }), + SK: "P" + }); } /** * Exact revisions of all the entries */ for (const revision of revisions) { - items.push( - entity.deleteBatch({ - PK: createPartitionKey({ - id: revision.id, - locale: model.locale, - tenant: model.tenant - }), - SK: createRevisionSortKey({ - version: revision.version - }) + entityBatch.delete({ + PK: createPartitionKey({ + id: revision.id, + locale: model.locale, + tenant: model.tenant + }), + SK: createRevisionSortKey({ + version: revision.version }) - ); + }); } - await batchWriteAll({ - table: entity.table, - items - }); - await batchWriteAll({ - table: esEntity.table, - items: esItems - }); + await entityBatch.execute(); + await elasticsearchEntityBatch.execute(); }; const list: CmsEntryStorageOperations["list"] = async (initialModel, params) => { @@ -1641,19 +1588,25 @@ export const createEntriesStorageOperations = ( }); // 1. Update REV# and P records with new data. - const items = [ - entity.putBatch({ - ...storageEntry, - ...revisionKeys, - TYPE: createRecordType() - }), - entity.putBatch({ - ...storageEntry, - ...publishedKeys, - TYPE: createPublishedRecordType() - }) - ]; - const esItems: BatchWriteItem[] = []; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...storageEntry, + ...revisionKeys, + TYPE: createRecordType() + }, + { + ...storageEntry, + ...publishedKeys, + TYPE: createPublishedRecordType() + } + ] + }); + + const elasticsearchEntityBatch = createEntityWriteBatch({ + entity: esEntity + }); const { index: esIndex } = configurations.es({ model @@ -1666,12 +1619,10 @@ export const createEntriesStorageOperations = ( if (publishingLatestRevision) { // 2.1 If we're publishing the latest revision, we first need to update the L record. - items.push( - entity.putBatch({ - ...storageEntry, - ...latestKeys - }) - ); + entityBatch.put({ + ...storageEntry, + ...latestKeys + }); // 2.2 Additionally, if we have a previously published entry, we need to mark it as unpublished. // Note that we need to take re-publishing into account (same published revision being @@ -1680,18 +1631,16 @@ export const createEntriesStorageOperations = ( if (publishedStorageEntry) { const isRepublishing = publishedStorageEntry.id === entry.id; if (!isRepublishing) { - items.push( - /** - * Update currently published entry (unpublish it) - */ - entity.putBatch({ - ...publishedStorageEntry, - status: CONTENT_ENTRY_STATUS.UNPUBLISHED, - TYPE: createRecordType(), - PK: createPartitionKey(publishedStorageEntry), - SK: createRevisionSortKey(publishedStorageEntry) - }) - ); + /** + * Update currently published entry (unpublish it) + */ + entityBatch.put({ + ...publishedStorageEntry, + status: CONTENT_ENTRY_STATUS.UNPUBLISHED, + TYPE: createRecordType(), + PK: createPartitionKey(publishedStorageEntry), + SK: createRevisionSortKey(publishedStorageEntry) + }); } } } else { @@ -1716,24 +1665,20 @@ export const createEntriesStorageOperations = ( status: latestRevisionStatus }; - items.push( - entity.putBatch({ - ...latestStorageEntryFields, - PK: createPartitionKey(latestStorageEntry), - SK: createLatestSortKey(), - TYPE: createLatestRecordType() - }) - ); + entityBatch.put({ + ...latestStorageEntryFields, + PK: createPartitionKey(latestStorageEntry), + SK: createLatestSortKey(), + TYPE: createLatestRecordType() + }); // 2.5 Update REV# record. - items.push( - entity.putBatch({ - ...latestStorageEntryFields, - PK: createPartitionKey(latestStorageEntry), - SK: createRevisionSortKey(latestStorageEntry), - TYPE: createRecordType() - }) - ); + entityBatch.put({ + ...latestStorageEntryFields, + PK: createPartitionKey(latestStorageEntry), + SK: createRevisionSortKey(latestStorageEntry), + TYPE: createRecordType() + }); // 2.6 Additionally, if we have a previously published entry, we need to mark it as unpublished. // Note that we need to take re-publishing into account (same published revision being @@ -1745,15 +1690,13 @@ export const createEntriesStorageOperations = ( publishedRevisionId !== latestStorageEntry.id; if (!isRepublishing && publishedRevisionDifferentFromLatest) { - items.push( - entity.putBatch({ - ...publishedStorageEntry, - PK: createPartitionKey(publishedStorageEntry), - SK: createRevisionSortKey(publishedStorageEntry), - TYPE: createRecordType(), - status: CONTENT_ENTRY_STATUS.UNPUBLISHED - }) - ); + entityBatch.put({ + ...publishedStorageEntry, + PK: createPartitionKey(publishedStorageEntry), + SK: createRevisionSortKey(publishedStorageEntry), + TYPE: createRecordType(), + status: CONTENT_ENTRY_STATUS.UNPUBLISHED + }); } } } @@ -1764,13 +1707,11 @@ export const createEntriesStorageOperations = ( * Update the published revision entry in ES. */ const esPublishedData = await transformer.getElasticsearchPublishedEntryData(); - esItems.push( - esEntity.putBatch({ - ...publishedKeys, - index: esIndex, - data: esPublishedData - }) - ); + elasticsearchEntityBatch.put({ + ...publishedKeys, + index: esIndex, + data: esPublishedData + }); /** * Need to decompress the data from Elasticsearch DynamoDB table. @@ -1797,14 +1738,12 @@ export const createEntriesStorageOperations = ( } }); - esItems.push( - esEntity.putBatch({ - index: esIndex, - PK: createPartitionKey(latestEsEntryDataDecompressed), - SK: createLatestSortKey(), - data: await latestTransformer.getElasticsearchLatestEntryData() - }) - ); + elasticsearchEntityBatch.put({ + index: esIndex, + PK: createPartitionKey(latestEsEntryDataDecompressed), + SK: createLatestSortKey(), + data: await latestTransformer.getElasticsearchLatestEntryData() + }); } else { const updatedEntryLevelMetaFields = pickEntryMetaFields( entry, @@ -1836,13 +1775,11 @@ export const createEntriesStorageOperations = ( status: latestRevisionStatus }); - esItems.push( - esEntity.putBatch({ - ...latestKeys, - index: esIndex, - data: updatedLatestEntry - }) - ); + elasticsearchEntityBatch.put({ + ...latestKeys, + index: esIndex, + data: updatedLatestEntry + }); } } @@ -1850,10 +1787,8 @@ export const createEntriesStorageOperations = ( * Finally, execute regular table batch. */ try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); + dataLoaders.clearAll({ model }); @@ -1873,10 +1808,7 @@ export const createEntriesStorageOperations = ( * And Elasticsearch table batch. */ try { - await batchWriteAll({ - table: esEntity.table, - items: esItems - }); + await elasticsearchEntityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || @@ -1919,25 +1851,34 @@ export const createEntriesStorageOperations = ( tenant: model.tenant }); - const items = [ - entity.deleteBatch({ - PK: partitionKey, - SK: createPublishedSortKey() - }), - entity.putBatch({ - ...storageEntry, - PK: partitionKey, - SK: createRevisionSortKey(entry), - TYPE: createRecordType() - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...storageEntry, + PK: partitionKey, + SK: createRevisionSortKey(entry), + TYPE: createRecordType() + } + ], + delete: [ + { + PK: partitionKey, + SK: createPublishedSortKey() + } + ] + }); + + const elasticsearchEntityBatch = createEntityWriteBatch({ + entity: esEntity, + delete: [ + { + PK: partitionKey, + SK: createPublishedSortKey() + } + ] + }); - const esItems: BatchWriteItem[] = [ - esEntity.deleteBatch({ - PK: partitionKey, - SK: createPublishedSortKey() - }) - ]; /** * If we are unpublishing the latest revision, let's also update the latest revision entry's status in both DynamoDB tables. */ @@ -1946,34 +1887,28 @@ export const createEntriesStorageOperations = ( model }); - items.push( - entity.putBatch({ - ...storageEntry, - PK: partitionKey, - SK: createLatestSortKey(), - TYPE: createLatestRecordType() - }) - ); + entityBatch.put({ + ...storageEntry, + PK: partitionKey, + SK: createLatestSortKey(), + TYPE: createLatestRecordType() + }); const esLatestData = await transformer.getElasticsearchLatestEntryData(); - esItems.push( - esEntity.putBatch({ - PK: partitionKey, - SK: createLatestSortKey(), - index, - data: esLatestData - }) - ); + elasticsearchEntityBatch.put({ + PK: partitionKey, + SK: createLatestSortKey(), + index, + data: esLatestData + }); } /** * Finally, execute regular table batch. */ try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); + dataLoaders.clearAll({ model }); @@ -1991,10 +1926,7 @@ export const createEntriesStorageOperations = ( * And Elasticsearch table batch. */ try { - await batchWriteAll({ - table: esEntity.table, - items: esItems - }); + await elasticsearchEntityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || diff --git a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts index b35994d54f1..fc13f4270d4 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getLatestRevisionByEntryId.ts @@ -1,5 +1,5 @@ import DataLoader from "dataloader"; -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { batchReadAll } from "@webiny/db-dynamodb"; import { cleanupItems } from "@webiny/db-dynamodb/utils/cleanup"; import { CmsStorageEntry } from "@webiny/api-headless-cms/types"; import { createBatchScheduleFn } from "./createBatchScheduleFn"; diff --git a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts index 062ebae0557..d3b97b70eae 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getPublishedRevisionByEntryId.ts @@ -1,5 +1,5 @@ import DataLoader from "dataloader"; -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { batchReadAll } from "@webiny/db-dynamodb"; import { cleanupItems } from "@webiny/db-dynamodb/utils/cleanup"; import { CmsStorageEntry } from "@webiny/api-headless-cms/types"; import { createPartitionKey, createPublishedSortKey } from "~/operations/entry/keys"; diff --git a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getRevisionById.ts b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getRevisionById.ts index 7c020d82574..04b5783ace6 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getRevisionById.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/dataLoader/getRevisionById.ts @@ -1,5 +1,5 @@ import DataLoader from "dataloader"; -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { batchReadAll } from "@webiny/db-dynamodb"; import { CmsStorageEntry } from "@webiny/api-headless-cms/types"; import { cleanupItems } from "@webiny/db-dynamodb/utils/cleanup"; import { createPartitionKey, createRevisionSortKey } from "~/operations/entry/keys"; diff --git a/packages/api-headless-cms-ddb/src/operations/entry/index.ts b/packages/api-headless-cms-ddb/src/operations/entry/index.ts index 9c5f84e5b90..5600f97a037 100644 --- a/packages/api-headless-cms-ddb/src/operations/entry/index.ts +++ b/packages/api-headless-cms-ddb/src/operations/entry/index.ts @@ -1,15 +1,15 @@ import WebinyError from "@webiny/error"; import { DataLoadersHandler } from "./dataLoaders"; -import { +import type { CmsEntry, CmsEntryListWhere, CmsEntryUniqueValue, CmsModel, CmsStorageEntry, - CONTENT_ENTRY_STATUS, StorageOperationsCmsModel } from "@webiny/api-headless-cms/types"; -import { Entity } from "@webiny/db-dynamodb/toolbox"; +import { CONTENT_ENTRY_STATUS } from "@webiny/api-headless-cms/types"; +import type { Entity } from "@webiny/db-dynamodb/toolbox"; import { createGSIPartitionKey, createGSISortKey, @@ -18,24 +18,23 @@ import { createPublishedSortKey, createRevisionSortKey } from "~/operations/entry/keys"; -import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite"; import { - DbItem, + cleanupItem, + cleanupItems, + createEntityWriteBatch, queryAll, QueryAllParams, queryOne, QueryOneParams -} from "@webiny/db-dynamodb/utils/query"; -import { cleanupItem, cleanupItems } from "@webiny/db-dynamodb/utils/cleanup"; -import { PluginsContainer } from "@webiny/plugins"; +} from "@webiny/db-dynamodb"; +import type { PluginsContainer } from "@webiny/plugins"; import { decodeCursor, encodeCursor } from "@webiny/utils/cursor"; import { zeroPad } from "@webiny/utils/zeroPad"; import { StorageOperationsCmsModelPlugin, StorageTransformPlugin } from "@webiny/api-headless-cms"; -import { FilterItemFromStorage } from "./filtering/types"; +import type { FilterItemFromStorage } from "./filtering/types"; import { createFields } from "~/operations/entry/filtering/createFields"; import { filter, sort } from "~/operations/entry/filtering"; -import { WriteRequest } from "@webiny/aws-sdk/client-dynamodb"; -import { CmsEntryStorageOperations } from "~/types"; +import type { CmsEntryStorageOperations } from "~/types"; import { isDeletedEntryMetaField, isEntryLevelEntryMetaField, @@ -167,49 +166,47 @@ export const createEntriesStorageOperations = ( * - create new main entry item * - create new or update the latest entry item */ - const items = [ - entity.putBatch({ - ...storageEntry, - locked, - PK: partitionKey, - SK: createRevisionSortKey(entry), - TYPE: createType(), - GSI1_PK: createGSIPartitionKey(model, "A"), - GSI1_SK: createGSISortKey(storageEntry) - }), - entity.putBatch({ - ...storageEntry, - locked, - PK: partitionKey, - SK: createLatestSortKey(), - TYPE: createLatestType(), - GSI1_PK: createGSIPartitionKey(model, "L"), - GSI1_SK: createGSISortKey(storageEntry) - }) - ]; - - /** - * We need to create published entry if - */ - if (isPublished) { - items.push( - entity.putBatch({ + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { ...storageEntry, locked, PK: partitionKey, - SK: createPublishedSortKey(), + SK: createRevisionSortKey(entry), + TYPE: createType(), + GSI1_PK: createGSIPartitionKey(model, "A"), + GSI1_SK: createGSISortKey(storageEntry) + }, + { + ...storageEntry, + locked, + PK: partitionKey, + SK: createLatestSortKey(), TYPE: createLatestType(), - GSI1_PK: createGSIPartitionKey(model, "P"), + GSI1_PK: createGSIPartitionKey(model, "L"), GSI1_SK: createGSISortKey(storageEntry) - }) - ); + } + ] + }); + + /** + * We need to create published entry if + */ + if (isPublished) { + entityBatch.put({ + ...storageEntry, + locked, + PK: partitionKey, + SK: createPublishedSortKey(), + TYPE: createLatestType(), + GSI1_PK: createGSIPartitionKey(model, "P"), + GSI1_SK: createGSISortKey(storageEntry) + }); } try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); dataLoaders.clearAll({ model }); @@ -253,37 +250,38 @@ export const createEntriesStorageOperations = ( * - update the published entry item to the current one * - unpublish previously published revision (if any) */ - const items = [ - entity.putBatch({ - ...storageEntry, - PK: partitionKey, - SK: createRevisionSortKey(storageEntry), - TYPE: createType(), - GSI1_PK: createGSIPartitionKey(model, "A"), - GSI1_SK: createGSISortKey(storageEntry) - }), - entity.putBatch({ - ...storageEntry, - PK: partitionKey, - SK: createLatestSortKey(), - TYPE: createLatestType(), - GSI1_PK: createGSIPartitionKey(model, "L"), - GSI1_SK: createGSISortKey(storageEntry) - }) - ]; - - const isPublished = entry.status === "published"; - if (isPublished) { - items.push( - entity.putBatch({ + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { ...storageEntry, PK: partitionKey, - SK: createPublishedSortKey(), - TYPE: createPublishedType(), - GSI1_PK: createGSIPartitionKey(model, "P"), + SK: createRevisionSortKey(storageEntry), + TYPE: createType(), + GSI1_PK: createGSIPartitionKey(model, "A"), GSI1_SK: createGSISortKey(storageEntry) - }) - ); + }, + { + ...storageEntry, + PK: partitionKey, + SK: createLatestSortKey(), + TYPE: createLatestType(), + GSI1_PK: createGSIPartitionKey(model, "L"), + GSI1_SK: createGSISortKey(storageEntry) + } + ] + }); + + const isPublished = entry.status === "published"; + if (isPublished) { + entityBatch.put({ + ...storageEntry, + PK: partitionKey, + SK: createPublishedSortKey(), + TYPE: createPublishedType(), + GSI1_PK: createGSIPartitionKey(model, "P"), + GSI1_SK: createGSISortKey(storageEntry) + }); // Unpublish previously published revision (if any). const [publishedRevisionStorageEntry] = await dataLoaders.getPublishedRevisionByEntryId( @@ -294,25 +292,20 @@ export const createEntriesStorageOperations = ( ); if (publishedRevisionStorageEntry) { - items.push( - entity.putBatch({ - ...publishedRevisionStorageEntry, - PK: partitionKey, - SK: createRevisionSortKey(publishedRevisionStorageEntry), - TYPE: createType(), - status: CONTENT_ENTRY_STATUS.UNPUBLISHED, - GSI1_PK: createGSIPartitionKey(model, "A"), - GSI1_SK: createGSISortKey(publishedRevisionStorageEntry) - }) - ); + entityBatch.put({ + ...publishedRevisionStorageEntry, + PK: partitionKey, + SK: createRevisionSortKey(publishedRevisionStorageEntry), + TYPE: createType(), + status: CONTENT_ENTRY_STATUS.UNPUBLISHED, + GSI1_PK: createGSIPartitionKey(model, "A"), + GSI1_SK: createGSISortKey(publishedRevisionStorageEntry) + }); } } try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); dataLoaders.clearAll({ model }); @@ -346,8 +339,6 @@ export const createEntriesStorageOperations = ( const isPublished = entry.status === "published"; const locked = isPublished ? true : entry.locked; - const items = []; - const storageEntry = convertToStorageEntry({ model, storageEntry: initialStorageEntry @@ -357,30 +348,32 @@ export const createEntriesStorageOperations = ( * - update the current entry * - update the latest entry if the current entry is the latest one */ - items.push( - entity.putBatch({ - ...storageEntry, - locked, - PK: partitionKey, - SK: createRevisionSortKey(storageEntry), - TYPE: createType(), - GSI1_PK: createGSIPartitionKey(model, "A"), - GSI1_SK: createGSISortKey(storageEntry) - }) - ); - if (isPublished) { - items.push( - entity.putBatch({ + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { ...storageEntry, locked, PK: partitionKey, - SK: createPublishedSortKey(), - TYPE: createPublishedType(), - GSI1_PK: createGSIPartitionKey(model, "P"), + SK: createRevisionSortKey(storageEntry), + TYPE: createType(), + GSI1_PK: createGSIPartitionKey(model, "A"), GSI1_SK: createGSISortKey(storageEntry) - }) - ); + } + ] + }); + + if (isPublished) { + entityBatch.put({ + ...storageEntry, + locked, + PK: partitionKey, + SK: createPublishedSortKey(), + TYPE: createPublishedType(), + GSI1_PK: createGSIPartitionKey(model, "P"), + GSI1_SK: createGSISortKey(storageEntry) + }); } /** @@ -391,17 +384,15 @@ export const createEntriesStorageOperations = ( if (latestStorageEntry) { const updatingLatestRevision = latestStorageEntry.id === entry.id; if (updatingLatestRevision) { - items.push( - entity.putBatch({ - ...storageEntry, - locked, - PK: partitionKey, - SK: createLatestSortKey(), - TYPE: createLatestType(), - GSI1_PK: createGSIPartitionKey(model, "L"), - GSI1_SK: createGSISortKey(entry) - }) - ); + entityBatch.put({ + ...storageEntry, + locked, + PK: partitionKey, + SK: createLatestSortKey(), + TYPE: createLatestType(), + GSI1_PK: createGSIPartitionKey(model, "L"), + GSI1_SK: createGSISortKey(entry) + }); } else { /** * If not updating latest revision, we still want to update the latest revision's @@ -417,37 +408,30 @@ export const createEntriesStorageOperations = ( * - one for the actual revision record * - one for the latest record */ - items.push( - entity.putBatch({ - ...latestStorageEntry, - ...updatedEntryLevelMetaFields, - PK: partitionKey, - SK: createRevisionSortKey(latestStorageEntry), - TYPE: createType(), - GSI1_PK: createGSIPartitionKey(model, "A"), - GSI1_SK: createGSISortKey(latestStorageEntry) - }) - ); + entityBatch.put({ + ...latestStorageEntry, + ...updatedEntryLevelMetaFields, + PK: partitionKey, + SK: createRevisionSortKey(latestStorageEntry), + TYPE: createType(), + GSI1_PK: createGSIPartitionKey(model, "A"), + GSI1_SK: createGSISortKey(latestStorageEntry) + }); - items.push( - entity.putBatch({ - ...latestStorageEntry, - ...updatedEntryLevelMetaFields, - PK: partitionKey, - SK: createLatestSortKey(), - TYPE: createLatestType(), - GSI1_PK: createGSIPartitionKey(model, "L"), - GSI1_SK: createGSISortKey(latestStorageEntry) - }) - ); + entityBatch.put({ + ...latestStorageEntry, + ...updatedEntryLevelMetaFields, + PK: partitionKey, + SK: createLatestSortKey(), + TYPE: createLatestType(), + GSI1_PK: createGSIPartitionKey(model, "L"), + GSI1_SK: createGSISortKey(latestStorageEntry) + }); } } try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); dataLoaders.clearAll({ model }); @@ -490,23 +474,24 @@ export const createEntriesStorageOperations = ( /** * Then create the batch writes for the DynamoDB, with the updated folderId. */ - const items = records.map(item => { - return entity.putBatch({ - ...item, - location: { - ...item.location, - folderId - } - }); + const entityBatch = createEntityWriteBatch({ + entity, + put: records.map(item => { + return { + ...item, + location: { + ...item.location, + folderId + } + }; + }) }); + /** * And finally write it... */ try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); } catch (ex) { throw WebinyError.from(ex, { message: "Could not move records to a new folder.", @@ -518,7 +503,10 @@ export const createEntriesStorageOperations = ( } }; - const moveToBin: CmsEntryStorageOperations["moveToBin"] = async (initialModel, params) => { + const moveToBin: CmsEntryStorageOperations["moveToBin"] = async ( + initialModel, + params + ): Promise<void> => { const { entry, storageEntry: initialStorageEntry } = params; const model = getStorageOperationsModel(initialModel); @@ -537,9 +525,9 @@ export const createEntriesStorageOperations = ( } }; - let records: DbItem<CmsEntry>[] = []; + let records: Awaited<ReturnType<typeof queryAll<CmsEntry>>> = []; try { - records = await queryAll(queryAllParams); + records = await queryAll<CmsEntry>(queryAllParams); } catch (ex) { throw new WebinyError( ex.message || "Could not load all records.", @@ -550,6 +538,9 @@ export const createEntriesStorageOperations = ( } ); } + if (records.length === 0) { + return; + } const storageEntry = convertToStorageEntry({ model, @@ -564,23 +555,23 @@ export const createEntriesStorageOperations = ( /** * Then create the batch writes for the DynamoDB, with the updated data. */ - const items = records.map(record => { - return entity.putBatch({ - ...record, - ...updatedDeletedMetaFields, - wbyDeleted: storageEntry.wbyDeleted, - location: storageEntry.location, - binOriginalFolderId: storageEntry.binOriginalFolderId - }); + const entityBatch = createEntityWriteBatch({ + entity, + put: records.map(record => { + return { + ...record, + ...updatedDeletedMetaFields, + wbyDeleted: storageEntry.wbyDeleted, + location: storageEntry.location, + binOriginalFolderId: storageEntry.binOriginalFolderId + }; + }) }); /** * And finally write it... */ try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not move the entry to the bin.", @@ -611,7 +602,7 @@ export const createEntriesStorageOperations = ( } }; - let records: DbItem<CmsEntry>[] = []; + let records: Awaited<ReturnType<typeof queryAll<CmsEntry>>> = []; try { records = await queryAll(queryAllParams); } catch (ex) { @@ -624,18 +615,19 @@ export const createEntriesStorageOperations = ( } ); } - const items = records.map(item => { - return entity.deleteBatch({ - PK: item.PK, - SK: item.SK - }); + + const entityBatch = createEntityWriteBatch({ + entity, + delete: records.map(item => { + return { + PK: item.PK, + SK: item.SK + }; + }) }); try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); dataLoaders.clearAll({ model }); @@ -655,7 +647,7 @@ export const createEntriesStorageOperations = ( const restoreFromBin: CmsEntryStorageOperations["restoreFromBin"] = async ( initialModel, params - ) => { + ): Promise<CmsStorageEntry> => { const { entry, storageEntry: initialStorageEntry } = params; const model = getStorageOperationsModel(initialModel); @@ -674,9 +666,9 @@ export const createEntriesStorageOperations = ( } }; - let records: DbItem<CmsEntry>[] = []; + let records: Awaited<ReturnType<typeof queryAll<CmsEntry>>> = []; try { - records = await queryAll(queryAllParams); + records = await queryAll<CmsEntry>(queryAllParams); } catch (ex) { throw new WebinyError( ex.message || "Could not load all records.", @@ -687,6 +679,9 @@ export const createEntriesStorageOperations = ( } ); } + if (records.length === 0) { + return initialStorageEntry; + } const storageEntry = convertToStorageEntry({ model, @@ -701,23 +696,24 @@ export const createEntriesStorageOperations = ( isRestoredEntryMetaField ); - const items = records.map(record => { - return entity.putBatch({ - ...record, - ...updatedRestoredMetaFields, - wbyDeleted: storageEntry.wbyDeleted, - location: storageEntry.location, - binOriginalFolderId: storageEntry.binOriginalFolderId - }); + const entityBatch = createEntityWriteBatch({ + entity, + put: records.map(record => { + return { + ...record, + ...updatedRestoredMetaFields, + wbyDeleted: storageEntry.wbyDeleted, + location: storageEntry.location, + binOriginalFolderId: storageEntry.binOriginalFolderId + }; + }) }); + /** * And finally write it... */ try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); dataLoaders.clearAll({ model @@ -751,12 +747,15 @@ export const createEntriesStorageOperations = ( tenant: model.tenant }); - const items = [ - entity.deleteBatch({ - PK: partitionKey, - SK: createRevisionSortKey(entry) - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + delete: [ + { + PK: partitionKey, + SK: createRevisionSortKey(entry) + } + ] + }); const publishedStorageEntry = await getPublishedRevisionByEntryId(model, entry); @@ -764,12 +763,10 @@ export const createEntriesStorageOperations = ( * If revision we are deleting is the published one as well, we need to delete those records as well. */ if (publishedStorageEntry && entry.id === publishedStorageEntry.id) { - items.push( - entity.deleteBatch({ - PK: partitionKey, - SK: createPublishedSortKey() - }) - ); + entityBatch.delete({ + PK: partitionKey, + SK: createPublishedSortKey() + }); } if (initialLatestStorageEntry) { @@ -777,35 +774,29 @@ export const createEntriesStorageOperations = ( storageEntry: initialLatestStorageEntry, model }); - items.push( - entity.putBatch({ - ...latestStorageEntry, - PK: partitionKey, - SK: createLatestSortKey(), - TYPE: createLatestType(), - GSI1_PK: createGSIPartitionKey(model, "L"), - GSI1_SK: createGSISortKey(latestStorageEntry) - }) - ); + entityBatch.put({ + ...latestStorageEntry, + PK: partitionKey, + SK: createLatestSortKey(), + TYPE: createLatestType(), + GSI1_PK: createGSIPartitionKey(model, "L"), + GSI1_SK: createGSISortKey(latestStorageEntry) + }); // Do an update on the latest revision. We need to update the latest revision's // entry-level meta fields to match the previous revision's entry-level meta fields. - items.push( - entity.putBatch({ - ...latestStorageEntry, - PK: partitionKey, - SK: createRevisionSortKey(initialLatestStorageEntry), - TYPE: createType(), - GSI1_PK: createGSIPartitionKey(model, "A"), - GSI1_SK: createGSISortKey(initialLatestStorageEntry) - }) - ); + entityBatch.put({ + ...latestStorageEntry, + PK: partitionKey, + SK: createRevisionSortKey(initialLatestStorageEntry), + TYPE: createType(), + GSI1_PK: createGSIPartitionKey(model, "A"), + GSI1_SK: createGSISortKey(initialLatestStorageEntry) + }); } try { - await batchWriteAll({ - table: entity.table, - items - }); + entityBatch.execute(); + dataLoaders.clearAll({ model }); @@ -834,57 +825,43 @@ export const createEntriesStorageOperations = ( /** * Then we need to construct the queries for all the revisions and entries. */ - const items: Record<string, WriteRequest>[] = []; + + const entityBatch = createEntityWriteBatch({ + entity + }); + for (const id of entries) { - /** - * Latest item. - */ - items.push( - entity.deleteBatch({ - PK: createPartitionKey({ - id, - locale: model.locale, - tenant: model.tenant - }), - SK: "L" - }) - ); - /** - * Published item. - */ - items.push( - entity.deleteBatch({ - PK: createPartitionKey({ - id, - locale: model.locale, - tenant: model.tenant - }), - SK: "P" - }) - ); + const partitionKey = createPartitionKey({ + id, + locale: model.locale, + tenant: model.tenant + }); + entityBatch.delete({ + PK: partitionKey, + SK: "L" + }); + entityBatch.delete({ + PK: partitionKey, + SK: "P" + }); } /** * Exact revisions of all the entries */ for (const revision of revisions) { - items.push( - entity.deleteBatch({ - PK: createPartitionKey({ - id: revision.id, - locale: model.locale, - tenant: model.tenant - }), - SK: createRevisionSortKey({ - version: revision.version - }) + entityBatch.delete({ + PK: createPartitionKey({ + id: revision.id, + locale: model.locale, + tenant: model.tenant + }), + SK: createRevisionSortKey({ + version: revision.version }) - ); + }); } - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); }; const getLatestRevisionByEntryId: CmsEntryStorageOperations["getLatestRevisionByEntryId"] = @@ -1239,24 +1216,27 @@ export const createEntriesStorageOperations = ( }); // 1. Update REV# and P records with new data. - const items = [ - entity.putBatch({ - ...storageEntry, - PK: partitionKey, - SK: createRevisionSortKey(entry), - TYPE: createType(), - GSI1_PK: createGSIPartitionKey(model, "A"), - GSI1_SK: createGSISortKey(entry) - }), - entity.putBatch({ - ...storageEntry, - PK: partitionKey, - SK: createPublishedSortKey(), - TYPE: createPublishedType(), - GSI1_PK: createGSIPartitionKey(model, "P"), - GSI1_SK: createGSISortKey(entry) - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...storageEntry, + PK: partitionKey, + SK: createRevisionSortKey(entry), + TYPE: createType(), + GSI1_PK: createGSIPartitionKey(model, "A"), + GSI1_SK: createGSISortKey(entry) + }, + { + ...storageEntry, + PK: partitionKey, + SK: createPublishedSortKey(), + TYPE: createPublishedType(), + GSI1_PK: createGSIPartitionKey(model, "P"), + GSI1_SK: createGSISortKey(entry) + } + ] + }); // 2. When it comes to the latest record, we need to perform a couple of different // updates, based on whether the entry being published is the latest revision or not. @@ -1265,16 +1245,14 @@ export const createEntriesStorageOperations = ( if (publishingLatestRevision) { // 2.1 If we're publishing the latest revision, we first need to update the L record. - items.push( - entity.putBatch({ - ...storageEntry, - PK: partitionKey, - SK: createLatestSortKey(), - TYPE: createLatestType(), - GSI1_PK: createGSIPartitionKey(model, "L"), - GSI1_SK: createGSISortKey(entry) - }) - ); + entityBatch.put({ + ...storageEntry, + PK: partitionKey, + SK: createLatestSortKey(), + TYPE: createLatestType(), + GSI1_PK: createGSIPartitionKey(model, "L"), + GSI1_SK: createGSISortKey(entry) + }); // 2.2 Additionally, if we have a previously published entry, we need to mark it as unpublished. if (publishedRevisionId && publishedRevisionId !== entry.id) { @@ -1283,17 +1261,15 @@ export const createEntriesStorageOperations = ( model }); - items.push( - entity.putBatch({ - ...publishedStorageEntry, - PK: partitionKey, - SK: createRevisionSortKey(publishedStorageEntry), - TYPE: createType(), - status: CONTENT_ENTRY_STATUS.UNPUBLISHED, - GSI1_PK: createGSIPartitionKey(model, "A"), - GSI1_SK: createGSISortKey(publishedStorageEntry) - }) - ); + entityBatch.put({ + ...publishedStorageEntry, + PK: partitionKey, + SK: createRevisionSortKey(publishedStorageEntry), + TYPE: createType(), + status: CONTENT_ENTRY_STATUS.UNPUBLISHED, + GSI1_PK: createGSIPartitionKey(model, "A"), + GSI1_SK: createGSISortKey(publishedStorageEntry) + }); } } else { // 2.3 If the published revision is not the latest one, the situation is a bit @@ -1322,28 +1298,24 @@ export const createEntriesStorageOperations = ( status: latestRevisionStatus }; - items.push( - entity.putBatch({ - ...latestStorageEntryFields, - PK: partitionKey, - SK: createLatestSortKey(), - TYPE: createLatestType(), - GSI1_PK: createGSIPartitionKey(model, "L"), - GSI1_SK: createGSISortKey(latestStorageEntry) - }) - ); + entityBatch.put({ + ...latestStorageEntryFields, + PK: partitionKey, + SK: createLatestSortKey(), + TYPE: createLatestType(), + GSI1_PK: createGSIPartitionKey(model, "L"), + GSI1_SK: createGSISortKey(latestStorageEntry) + }); // 2.3.2 Update REV# record. - items.push( - entity.putBatch({ - ...latestStorageEntryFields, - PK: partitionKey, - SK: createRevisionSortKey(latestStorageEntry), - TYPE: createType(), - GSI1_PK: createGSIPartitionKey(model, "A"), - GSI1_SK: createGSISortKey(latestStorageEntry) - }) - ); + entityBatch.put({ + ...latestStorageEntryFields, + PK: partitionKey, + SK: createRevisionSortKey(latestStorageEntry), + TYPE: createType(), + GSI1_PK: createGSIPartitionKey(model, "A"), + GSI1_SK: createGSISortKey(latestStorageEntry) + }); // 2.3.3 Finally, if we got a published entry, but it wasn't the latest one, we need to take // an extra step and mark it as unpublished. @@ -1355,25 +1327,20 @@ export const createEntriesStorageOperations = ( model }); - items.push( - entity.putBatch({ - ...publishedStorageEntry, - PK: partitionKey, - SK: createRevisionSortKey(publishedStorageEntry), - TYPE: createType(), - status: CONTENT_ENTRY_STATUS.UNPUBLISHED, - GSI1_PK: createGSIPartitionKey(model, "A"), - GSI1_SK: createGSISortKey(publishedStorageEntry) - }) - ); + entityBatch.put({ + ...publishedStorageEntry, + PK: partitionKey, + SK: createRevisionSortKey(publishedStorageEntry), + TYPE: createType(), + status: CONTENT_ENTRY_STATUS.UNPUBLISHED, + GSI1_PK: createGSIPartitionKey(model, "A"), + GSI1_SK: createGSISortKey(publishedStorageEntry) + }); } } try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); dataLoaders.clearAll({ model }); @@ -1411,20 +1378,25 @@ export const createEntriesStorageOperations = ( * - update current entry revision with new data * - update the latest entry status - if entry being unpublished is latest */ - const items = [ - entity.deleteBatch({ - PK: partitionKey, - SK: createPublishedSortKey() - }), - entity.putBatch({ - ...storageEntry, - PK: partitionKey, - SK: createRevisionSortKey(entry), - TYPE: createType(), - GSI1_PK: createGSIPartitionKey(model, "A"), - GSI1_SK: createGSISortKey(entry) - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + delete: [ + { + PK: partitionKey, + SK: createPublishedSortKey() + } + ], + put: [ + { + ...storageEntry, + PK: partitionKey, + SK: createRevisionSortKey(entry), + TYPE: createType(), + GSI1_PK: createGSIPartitionKey(model, "A"), + GSI1_SK: createGSISortKey(entry) + } + ] + }); /** * We need the latest entry to see if something needs to be updated alongside the unpublishing one. @@ -1434,16 +1406,14 @@ export const createEntriesStorageOperations = ( if (initialLatestStorageEntry) { const unpublishingLatestRevision = entry.id === initialLatestStorageEntry.id; if (unpublishingLatestRevision) { - items.push( - entity.putBatch({ - ...storageEntry, - PK: partitionKey, - SK: createLatestSortKey(), - TYPE: createLatestType(), - GSI1_PK: createGSIPartitionKey(model, "L"), - GSI1_SK: createGSISortKey(entry) - }) - ); + entityBatch.put({ + ...storageEntry, + PK: partitionKey, + SK: createLatestSortKey(), + TYPE: createLatestType(), + GSI1_PK: createGSIPartitionKey(model, "L"), + GSI1_SK: createGSISortKey(entry) + }); } else { const latestStorageEntry = convertToStorageEntry({ storageEntry: initialLatestStorageEntry, @@ -1458,38 +1428,31 @@ export const createEntriesStorageOperations = ( ); // 1. Update actual revision record. - items.push( - entity.putBatch({ - ...latestStorageEntry, - ...updatedEntryLevelMetaFields, - PK: partitionKey, - SK: createRevisionSortKey(latestStorageEntry), - TYPE: createType(), - GSI1_PK: createGSIPartitionKey(model, "A"), - GSI1_SK: createGSISortKey(latestStorageEntry) - }) - ); + entityBatch.put({ + ...latestStorageEntry, + ...updatedEntryLevelMetaFields, + PK: partitionKey, + SK: createRevisionSortKey(latestStorageEntry), + TYPE: createType(), + GSI1_PK: createGSIPartitionKey(model, "A"), + GSI1_SK: createGSISortKey(latestStorageEntry) + }); // 2. Update latest record. - items.push( - entity.putBatch({ - ...latestStorageEntry, - ...updatedEntryLevelMetaFields, - PK: partitionKey, - SK: createLatestSortKey(), - TYPE: createLatestType(), - GSI1_PK: createGSIPartitionKey(model, "L"), - GSI1_SK: createGSISortKey(latestStorageEntry) - }) - ); + entityBatch.put({ + ...latestStorageEntry, + ...updatedEntryLevelMetaFields, + PK: partitionKey, + SK: createLatestSortKey(), + TYPE: createLatestType(), + GSI1_PK: createGSIPartitionKey(model, "L"), + GSI1_SK: createGSISortKey(latestStorageEntry) + }); } } try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); dataLoaders.clearAll({ model }); diff --git a/packages/api-i18n-ddb/src/definitions/localeEntity.ts b/packages/api-i18n-ddb/src/definitions/localeEntity.ts index 4513054a2d7..bdbed1a9747 100644 --- a/packages/api-i18n-ddb/src/definitions/localeEntity.ts +++ b/packages/api-i18n-ddb/src/definitions/localeEntity.ts @@ -1,15 +1,19 @@ -import { Entity, Table } from "@webiny/db-dynamodb/toolbox"; -import { I18NContext } from "@webiny/api-i18n/types"; -import { getExtraAttributes } from "@webiny/db-dynamodb/utils/attributes"; +import type { Table } from "@webiny/db-dynamodb/toolbox"; +import type { I18NContext } from "@webiny/api-i18n/types"; +import { getExtraAttributesFromPlugins } from "@webiny/db-dynamodb/utils/attributes"; +import type { IEntity } from "@webiny/db-dynamodb"; +import { createEntity } from "@webiny/db-dynamodb"; -export default (params: { +export interface ILocaleEntityParams { context: I18NContext; table: Table<string, string, string>; -}): Entity<any> => { +} + +export default (params: ILocaleEntityParams): IEntity => { const { context, table } = params; const entityName = "I18NLocale"; - const attributes = getExtraAttributes(context, entityName); - return new Entity({ + const attributes = getExtraAttributesFromPlugins(context.plugins, entityName); + return createEntity({ name: entityName, table, attributes: { diff --git a/packages/api-i18n-ddb/src/definitions/systemEntity.ts b/packages/api-i18n-ddb/src/definitions/systemEntity.ts index a2e80306a98..44cf24d3800 100644 --- a/packages/api-i18n-ddb/src/definitions/systemEntity.ts +++ b/packages/api-i18n-ddb/src/definitions/systemEntity.ts @@ -1,15 +1,17 @@ -import { Entity, Table } from "@webiny/db-dynamodb/toolbox"; -import { I18NContext } from "@webiny/api-i18n/types"; -import { getExtraAttributes } from "@webiny/db-dynamodb/utils/attributes"; +import type { Table } from "@webiny/db-dynamodb/toolbox"; +import type { I18NContext } from "@webiny/api-i18n/types"; +import { getExtraAttributesFromPlugins } from "@webiny/db-dynamodb/utils/attributes"; +import type { IEntity } from "@webiny/db-dynamodb"; +import { createEntity } from "@webiny/db-dynamodb"; export default (params: { - context: I18NContext; + context: Pick<I18NContext, "plugins">; table: Table<string, string, string>; -}): Entity<any> => { +}): IEntity => { const { context, table } = params; const entityName = "I18NSystem"; - const attributes = getExtraAttributes(context, entityName); - return new Entity({ + const attributes = getExtraAttributesFromPlugins(context.plugins, entityName); + return createEntity({ name: entityName, table, attributes: { diff --git a/packages/api-i18n-ddb/src/operations/locales/LocalesStorageOperations.ts b/packages/api-i18n-ddb/src/operations/locales/LocalesStorageOperations.ts index 96abf44272f..18e344863d6 100644 --- a/packages/api-i18n-ddb/src/operations/locales/LocalesStorageOperations.ts +++ b/packages/api-i18n-ddb/src/operations/locales/LocalesStorageOperations.ts @@ -1,4 +1,4 @@ -import { +import type { I18NContext, I18NLocaleData, I18NLocalesStorageOperations, @@ -11,17 +11,13 @@ import { I18NLocalesStorageOperationsUpdateDefaultParams, I18NLocalesStorageOperationsUpdateParams } from "@webiny/api-i18n/types"; -import { Entity, Table } from "@webiny/db-dynamodb/toolbox"; +import type { Table } from "@webiny/db-dynamodb/toolbox"; import WebinyError from "@webiny/error"; import defineTable from "~/definitions/table"; import defineLocaleEntity from "~/definitions/localeEntity"; -import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query"; -import { filterItems } from "@webiny/db-dynamodb/utils/filter"; -import { sortItems } from "@webiny/db-dynamodb/utils/sort"; -import { createListResponse } from "@webiny/db-dynamodb/utils/listResponse"; -import { cleanupItems } from "@webiny/db-dynamodb/utils/cleanup"; +import type { IEntity, IEntityQueryAllParams } from "@webiny/db-dynamodb"; +import { createListResponse, filterItems, sortItems } from "@webiny/db-dynamodb"; import { LocaleDynamoDbFieldPlugin } from "~/plugins/LocaleDynamoDbFieldPlugin"; -import { deleteItem, getClean, put } from "@webiny/db-dynamodb"; interface ConstructorParams { context: I18NContext; @@ -32,7 +28,7 @@ const DEFAULT_SORT_KEY = "default"; export class LocalesStorageOperations implements I18NLocalesStorageOperations { private readonly context: I18NContext; private readonly table: Table<string, string, string>; - private readonly entity: Entity<any>; + private readonly entity: IEntity; public constructor({ context }: ConstructorParams) { this.context = context; @@ -48,12 +44,9 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations { public async getDefault(params: I18NLocalesStorageOperationsGetDefaultParams) { try { - return await getClean<I18NLocaleData>({ - entity: this.entity, - keys: { - PK: this.createDefaultPartitionKey(params), - SK: DEFAULT_SORT_KEY - } + return this.entity.getClean<I18NLocaleData>({ + PK: this.createDefaultPartitionKey(params), + SK: DEFAULT_SORT_KEY }); } catch (ex) { throw new WebinyError( @@ -65,12 +58,9 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations { public async get(params: I18NLocalesStorageOperationsGetParams) { try { - return await getClean<I18NLocaleData>({ - entity: this.entity, - keys: { - PK: this.createPartitionKey(params), - SK: params.code - } + return this.entity.getClean<I18NLocaleData>({ + PK: this.createPartitionKey(params), + SK: params.code }); } catch (ex) { throw new WebinyError( @@ -89,12 +79,9 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations { }; try { - await put({ - entity: this.entity, - item: { - ...locale, - ...keys - } + await this.entity.put({ + ...locale, + ...keys }); return locale; } catch (ex) { @@ -117,12 +104,9 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations { SK: this.getSortKey(locale) }; try { - await put({ - entity: this.entity, - item: { - ...locale, - ...keys - } + await this.entity.put({ + ...locale, + ...keys }); return locale; } catch (ex) { @@ -144,23 +128,24 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations { /** * Set the locale as the default one. */ - const batch = [ - { - ...locale, - PK: this.createPartitionKey(locale), - SK: this.getSortKey(locale) - }, - { - ...locale, - PK: this.createDefaultPartitionKey(locale), - SK: DEFAULT_SORT_KEY - } - ]; + const entityBatch = this.entity.createEntityWriter(); + + entityBatch.put({ + ...locale, + PK: this.createPartitionKey(locale), + SK: this.getSortKey(locale) + }); + entityBatch.put({ + ...locale, + PK: this.createDefaultPartitionKey(locale), + SK: DEFAULT_SORT_KEY + }); + /** * Set the previous locale not to be default in its data. */ if (previous) { - batch.push({ + entityBatch.put({ ...previous, default: false, PK: this.createPartitionKey(locale), @@ -168,8 +153,10 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations { }); } + const batch = entityBatch.items; + try { - await this.table.batchWrite(batch.map(item => this.entity.putBatch(item))); + await entityBatch.execute(); return locale; } catch (ex) { throw new WebinyError( @@ -190,10 +177,7 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations { SK: this.getSortKey(locale) }; try { - await deleteItem({ - entity: this.entity, - keys - }); + await this.entity.delete(keys); } catch (ex) { throw new WebinyError( ex.message || "Cannot delete I18N locale.", @@ -220,7 +204,7 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations { let results: I18NLocaleData[] = []; try { - results = await queryAll<I18NLocaleData>(queryAllParams); + results = await this.entity.queryAll<I18NLocaleData>(queryAllParams); } catch (ex) { throw new WebinyError( ex.message || "Cannot list I18N locales.", @@ -256,7 +240,7 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations { * Use the common db-dynamodb method to create the required response. */ return createListResponse<I18NLocaleData>({ - items: cleanupItems(this.entity, sortedFiles), + items: sortedFiles, after, totalCount, limit @@ -282,7 +266,7 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations { private createQueryAllParamsOptions( params: I18NLocalesStorageOperationsListParams - ): QueryAllParams { + ): IEntityQueryAllParams { const { where } = params; const tenant = where.tenant; @@ -295,7 +279,6 @@ export class LocalesStorageOperations implements I18NLocalesStorageOperations { delete where.default; } return { - entity: this.entity, partitionKey, options: {} }; diff --git a/packages/api-i18n-ddb/src/operations/system/SystemStorageOperations.ts b/packages/api-i18n-ddb/src/operations/system/SystemStorageOperations.ts index e333f8eef59..b840cf0c0e3 100644 --- a/packages/api-i18n-ddb/src/operations/system/SystemStorageOperations.ts +++ b/packages/api-i18n-ddb/src/operations/system/SystemStorageOperations.ts @@ -1,15 +1,14 @@ -import { +import type { I18NContext, I18NSystem, I18NSystemStorageOperations, I18NSystemStorageOperationsCreate, I18NSystemStorageOperationsUpdate } from "@webiny/api-i18n/types"; -import { Entity } from "@webiny/db-dynamodb/toolbox"; import WebinyError from "@webiny/error"; import defineSystemEntity from "~/definitions/systemEntity"; import defineTable from "~/definitions/table"; -import { getClean, put } from "@webiny/db-dynamodb"; +import type { IEntity } from "@webiny/db-dynamodb"; interface ConstructorParams { context: I18NContext; @@ -19,7 +18,7 @@ const SORT_KEY = "I18N"; export class SystemStorageOperations implements I18NSystemStorageOperations { private readonly _context: I18NContext; - private readonly _entity: Entity<any>; + private readonly entity: IEntity; private get partitionKey(): string { const tenant = this._context.tenancy.getCurrentTenant(); @@ -35,7 +34,7 @@ export class SystemStorageOperations implements I18NSystemStorageOperations { context }); - this._entity = defineSystemEntity({ + this.entity = defineSystemEntity({ context, table }); @@ -48,10 +47,7 @@ export class SystemStorageOperations implements I18NSystemStorageOperations { }; try { - return await getClean<I18NSystem>({ - entity: this._entity, - keys - }); + return await this.entity.getClean<I18NSystem>(keys); } catch (ex) { throw new WebinyError( "Could not load system data from the database.", @@ -67,12 +63,9 @@ export class SystemStorageOperations implements I18NSystemStorageOperations { SK: SORT_KEY }; try { - await put({ - entity: this._entity, - item: { - ...system, - ...keys - } + await this.entity.put({ + ...system, + ...keys }); return system; } catch (ex) { @@ -90,12 +83,9 @@ export class SystemStorageOperations implements I18NSystemStorageOperations { SK: SORT_KEY }; try { - await put({ - entity: this._entity, - item: { - ...system, - ...keys - } + await this.entity.put({ + ...system, + ...keys }); return system; } catch (ex) { diff --git a/packages/api-page-builder-so-ddb-es/src/definitions/pageElasticsearchEntity.ts b/packages/api-page-builder-so-ddb-es/src/definitions/pageElasticsearchEntity.ts index 0d0bf837a21..5d21fc487f1 100644 --- a/packages/api-page-builder-so-ddb-es/src/definitions/pageElasticsearchEntity.ts +++ b/packages/api-page-builder-so-ddb-es/src/definitions/pageElasticsearchEntity.ts @@ -25,6 +25,9 @@ export const createPageElasticsearchEntity = (params: Params): Entity<any> => { data: { type: "map" }, + TYPE: { + type: "string" + }, ...(attributes || {}) } }); diff --git a/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/dataLoader.ts b/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/dataLoader.ts index 5a158ff5aad..1fd149702d9 100644 --- a/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/dataLoader.ts +++ b/packages/api-page-builder-so-ddb-es/src/operations/blockCategory/dataLoader.ts @@ -1,5 +1,5 @@ import DataLoader from "dataloader"; -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { batchReadAll } from "@webiny/db-dynamodb"; import { BlockCategory } from "@webiny/api-page-builder/types"; import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; import { Entity } from "@webiny/db-dynamodb/toolbox"; diff --git a/packages/api-page-builder-so-ddb-es/src/operations/category/dataLoader.ts b/packages/api-page-builder-so-ddb-es/src/operations/category/dataLoader.ts index 50a0d6a8358..913ee4fc3b0 100644 --- a/packages/api-page-builder-so-ddb-es/src/operations/category/dataLoader.ts +++ b/packages/api-page-builder-so-ddb-es/src/operations/category/dataLoader.ts @@ -1,5 +1,5 @@ import DataLoader from "dataloader"; -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { batchReadAll } from "@webiny/db-dynamodb"; import { Category } from "@webiny/api-page-builder/types"; import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; import { Entity } from "@webiny/db-dynamodb/toolbox"; diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/dataLoader.ts b/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/dataLoader.ts index c9505ba7858..b3614d766f6 100644 --- a/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/dataLoader.ts +++ b/packages/api-page-builder-so-ddb-es/src/operations/pageBlock/dataLoader.ts @@ -1,5 +1,5 @@ import DataLoader from "dataloader"; -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { batchReadAll } from "@webiny/db-dynamodb"; import { PageBlock } from "@webiny/api-page-builder/types"; import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; import { Entity } from "@webiny/db-dynamodb/toolbox"; diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pageTemplate/dataLoader.ts b/packages/api-page-builder-so-ddb-es/src/operations/pageTemplate/dataLoader.ts index 32437cbacc9..248b6ffe129 100644 --- a/packages/api-page-builder-so-ddb-es/src/operations/pageTemplate/dataLoader.ts +++ b/packages/api-page-builder-so-ddb-es/src/operations/pageTemplate/dataLoader.ts @@ -1,5 +1,5 @@ import DataLoader from "dataloader"; -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { batchReadAll } from "@webiny/db-dynamodb"; import { PageTemplate } from "@webiny/api-page-builder/types"; import { Entity } from "@webiny/db-dynamodb/toolbox"; import { createPrimaryPK } from "./keys"; diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts b/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts index 2bde41b247c..b523ec37d03 100644 --- a/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts +++ b/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts @@ -1,4 +1,4 @@ -import { +import type { Page, PageStorageOperations, PageStorageOperationsCreateFromParams, @@ -14,12 +14,12 @@ import { PageStorageOperationsUnpublishParams, PageStorageOperationsUpdateParams } from "@webiny/api-page-builder/types"; -import { Entity } from "@webiny/db-dynamodb/toolbox"; +import type { Entity } from "@webiny/db-dynamodb/toolbox"; import omit from "lodash/omit"; import WebinyError from "@webiny/error"; import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; -import { Client } from "@elastic/elasticsearch"; -import { +import type { Client } from "@elastic/elasticsearch"; +import type { ElasticsearchBoolQueryConfig, ElasticsearchSearchResponse } from "@webiny/api-elasticsearch/types"; @@ -28,11 +28,18 @@ import { createLimit, encodeCursor } from "@webiny/api-elasticsearch"; import { createElasticsearchQueryBody } from "./elasticsearchQueryBody"; import { SearchLatestPagesPlugin } from "~/plugins/definitions/SearchLatestPagesPlugin"; import { SearchPublishedPagesPlugin } from "~/plugins/definitions/SearchPublishedPagesPlugin"; -import { DbItem, queryAll, QueryAllParams, queryOne } from "@webiny/db-dynamodb/utils/query"; +import { + createEntityWriteBatch, + getClean, + put, + queryAll, + QueryAllParams, + queryOne, + sortItems +} from "@webiny/db-dynamodb"; import { SearchPagesPlugin } from "~/plugins/definitions/SearchPagesPlugin"; -import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite"; import { getESLatestPageData, getESPublishedPageData } from "./helpers"; -import { PluginsContainer } from "@webiny/plugins"; +import type { PluginsContainer } from "@webiny/plugins"; import { createBasicType, createLatestSortKey, @@ -45,9 +52,7 @@ import { createPublishedType, createSortKey } from "./keys"; -import { sortItems } from "@webiny/db-dynamodb/utils/sort"; import { PageDynamoDbElasticsearchFieldPlugin } from "~/plugins/definitions/PageDynamoDbElasticsearchFieldPlugin"; -import { getClean, put } from "@webiny/db-dynamodb"; import { shouldIgnoreEsResponseError } from "~/operations/pages/shouldIgnoreEsResponseError"; import { logIgnoredEsResponseError } from "~/operations/pages/logIgnoredEsResponseError"; @@ -81,24 +86,26 @@ export const createPageStorageOperations = ( SK: createLatestSortKey() }; - const items = [ - entity.putBatch({ - ...page, - ...versionKeys, - TYPE: createBasicType() - }), - entity.putBatch({ - ...page, - ...latestKeys, - TYPE: createLatestType() - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...page, + ...versionKeys, + TYPE: createBasicType() + }, + { + ...page, + ...latestKeys, + TYPE: createLatestType() + } + ] + }); + const esData = getESLatestPageData(plugins, page, input); try { - await batchWriteAll({ - table: entity.table, - items: items - }); + await entityBatch.execute(); + await put({ entity: esEntity, item: { @@ -133,26 +140,26 @@ export const createPageStorageOperations = ( SK: createLatestSortKey() }; - const items = [ - entity.putBatch({ - ...page, - TYPE: createBasicType(), - ...versionKeys - }), - entity.putBatch({ - ...page, - TYPE: createLatestType(), - ...latestKeys - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...page, + TYPE: createBasicType(), + ...versionKeys + }, + { + ...page, + TYPE: createLatestType(), + ...latestKeys + } + ] + }); const esData = getESLatestPageData(plugins, page); try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); await put({ entity: esEntity, @@ -195,13 +202,16 @@ export const createPageStorageOperations = ( keys: latestKeys }); - const items = [ - entity.putBatch({ - ...page, - TYPE: createBasicType(), - ...keys - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...page, + TYPE: createBasicType(), + ...keys + } + ] + }); const esData = getESLatestPageData(plugins, page, input); @@ -209,22 +219,17 @@ export const createPageStorageOperations = ( /** * We also update the regular record. */ - items.push( - entity.putBatch({ - ...page, - TYPE: createLatestType(), - ...latestKeys - }) - ); + entityBatch.put({ + ...page, + TYPE: createLatestType(), + ...latestKeys + }); } /** * Unfortunately we cannot push regular and es record in the batch write because they are two separate tables. */ try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); await put({ entity: esEntity, @@ -265,32 +270,35 @@ export const createPageStorageOperations = ( const partitionKey = createPartitionKey(page); - const items = [ - entity.deleteBatch({ - PK: partitionKey, - SK: createSortKey(page) - }) - ]; - const esItems = []; - if (publishedPage && publishedPage.id === page.id) { - items.push( - entity.deleteBatch({ - PK: partitionKey, - SK: createPublishedSortKey() - }) - ); - items.push( - entity.deleteBatch({ - PK: createPathPartitionKey(page), - SK: createPathSortKey(page) - }) - ); - esItems.push( - esEntity.deleteBatch({ + const entityBatch = createEntityWriteBatch({ + entity, + delete: [ + { PK: partitionKey, - SK: createPublishedSortKey() - }) - ); + SK: createSortKey(page) + } + ] + }); + + const elasticsearchEntityBatch = createEntityWriteBatch({ + entity: esEntity + }); + + if (publishedPage && publishedPage.id === page.id) { + entityBatch.delete({ + PK: partitionKey, + SK: createPublishedSortKey() + }); + + entityBatch.delete({ + PK: createPathPartitionKey(page), + SK: createPathSortKey(page) + }); + + elasticsearchEntityBatch.delete({ + PK: partitionKey, + SK: createPublishedSortKey() + }); } let previousLatestPage: Page | null = null; if (latestPage && latestPage.id === page.id) { @@ -303,44 +311,34 @@ export const createPageStorageOperations = ( } }); if (previousLatestRecord) { - items.push( - entity.putBatch({ - ...previousLatestRecord, - TYPE: createLatestType(), - PK: partitionKey, - SK: createLatestSortKey() - }) - ); - esItems.push( - esEntity.putBatch({ - PK: partitionKey, - SK: createLatestSortKey(), - index: configurations.es(page).index, - data: getESLatestPageData(plugins, previousLatestRecord) - }) - ); + entityBatch.put({ + ...previousLatestRecord, + TYPE: createLatestType(), + PK: partitionKey, + SK: createLatestSortKey() + }); + + elasticsearchEntityBatch.put({ + PK: partitionKey, + SK: createLatestSortKey(), + index: configurations.es(page).index, + data: getESLatestPageData(plugins, previousLatestRecord) + }); + previousLatestPage = cleanupItem(entity, previousLatestRecord); } } try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not batch write all the page records.", ex.code || "BATCH_WRITE_RECORDS_ERROR" ); } - if (esItems.length === 0) { - return [page, previousLatestPage]; - } + try { - await batchWriteAll({ - table: entity.table, - items: esItems - }); + await elasticsearchEntityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not batch write all the page Elasticsearch records.", @@ -370,7 +368,7 @@ export const createPageStorageOperations = ( gte: " " } }; - let revisions: DbItem<Page>[]; + let revisions: Awaited<ReturnType<typeof queryAll<Page>>>; try { revisions = await queryAll<Page>(queryAllParams); } catch (ex) { @@ -387,48 +385,45 @@ export const createPageStorageOperations = ( * We need to go through all possible entries and delete them. * Also, delete the published entry path record. */ - const items = []; + + const entityBatch = createEntityWriteBatch({ + entity + }); + const elasticsearchEntityBatch = createEntityWriteBatch({ + entity: esEntity + }); + let publishedPathEntryDeleted = false; for (const revision of revisions) { if (revision.status === "published" && !publishedPathEntryDeleted) { publishedPathEntryDeleted = true; - items.push( - entity.deleteBatch({ - PK: createPathPartitionKey(page), - SK: revision.path - }) - ); + entityBatch.delete({ + PK: createPathPartitionKey(page), + SK: revision.path + }); } - items.push( - entity.deleteBatch({ - PK: revision.PK, - SK: revision.SK - }) - ); + entityBatch.delete({ + PK: revision.PK, + SK: revision.SK + }); } - const esItems = [ - esEntity.deleteBatch({ - PK: partitionKey, - SK: createLatestSortKey() - }) - ]; + elasticsearchEntityBatch.delete({ + PK: partitionKey, + SK: createLatestSortKey() + }); + /** * Delete published record if it is published. */ if (publishedPathEntryDeleted) { - esItems.push( - esEntity.deleteBatch({ - PK: partitionKey, - SK: createPublishedSortKey() - }) - ); + elasticsearchEntityBatch.delete({ + PK: partitionKey, + SK: createPublishedSortKey() + }); } try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not delete all the page records.", @@ -436,10 +431,7 @@ export const createPageStorageOperations = ( ); } try { - await batchWriteAll({ - table: entity.table, - items: esItems - }); + await elasticsearchEntityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not delete all the page Elasticsearch records.", @@ -457,118 +449,100 @@ export const createPageStorageOperations = ( /** * Update the given revision of the page. */ - const items = [ - entity.putBatch({ - ...page, - TYPE: createBasicType(), - PK: createPartitionKey(page), - SK: createSortKey(page) - }) - ]; - const esItems = []; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...page, + TYPE: createBasicType(), + PK: createPartitionKey(page), + SK: createSortKey(page) + } + ] + }); + const elasticsearchEntityBatch = createEntityWriteBatch({ + entity: esEntity + }); /** * If we are publishing the latest revision, update the latest revision * status in ES. We also need to update the latest page revision entry in ES. */ if (latestPage.id === page.id) { - items.push( - entity.putBatch({ - ...page, - TYPE: createLatestType(), - PK: createPartitionKey(page), - SK: createLatestSortKey() - }) - ); + entityBatch.put({ + ...page, + TYPE: createLatestType(), + PK: createPartitionKey(page), + SK: createLatestSortKey() + }); - esItems.push( - esEntity.putBatch({ - PK: createPartitionKey(page), - SK: createLatestSortKey(), - index: configurations.es(page).index, - data: getESLatestPageData(plugins, page) - }) - ); + elasticsearchEntityBatch.put({ + PK: createPartitionKey(page), + SK: createLatestSortKey(), + index: configurations.es(page).index, + data: getESLatestPageData(plugins, page) + }); } /** * If we already have a published revision, and it's not the revision being published: * - set the existing published revision to "unpublished" */ if (publishedPage && publishedPage.id !== page.id) { - items.push( - entity.putBatch({ - ...publishedPage, - status: "unpublished", - PK: createPartitionKey(publishedPage), - SK: createSortKey(publishedPage) - }) - ); + entityBatch.put({ + ...publishedPage, + status: "unpublished", + PK: createPartitionKey(publishedPage), + SK: createSortKey(publishedPage) + }); + /** * Remove old published path if required. */ if (publishedPage.path !== page.path) { - items.push( - entity.deleteBatch({ - PK: createPathPartitionKey(page), - SK: publishedPage.path - }) - ); + entityBatch.delete({ + PK: createPathPartitionKey(page), + SK: publishedPage.path + }); } } - esItems.push( - esEntity.putBatch({ - PK: createPartitionKey(page), - SK: createPublishedSortKey(), - index: configurations.es(page).index, - data: getESPublishedPageData(plugins, page) - }) - ); + elasticsearchEntityBatch.put({ + PK: createPartitionKey(page), + SK: createPublishedSortKey(), + index: configurations.es(page).index, + data: getESPublishedPageData(plugins, page) + }); /** * Update or insert published path. */ - items.push( - entity.putBatch({ - ...page, - TYPE: createPublishedPathType(), - PK: createPathPartitionKey(page), - SK: createPathSortKey(page) - }) - ); + entityBatch.put({ + ...page, + TYPE: createPublishedPathType(), + PK: createPathPartitionKey(page), + SK: createPathSortKey(page) + }); + /** * Update or insert published page. */ - items.push( - entity.putBatch({ - ...page, - TYPE: createPublishedType(), - PK: createPartitionKey(page), - SK: createPublishedSortKey() - }) - ); + entityBatch.put({ + ...page, + TYPE: createPublishedType(), + PK: createPartitionKey(page), + SK: createPublishedSortKey() + }); try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not update all the page records when publishing.", ex.code || "UPDATE_RECORDS_ERROR" ); } - /** - * No point in continuing if there are no items in Elasticsearch data - */ - if (esItems.length === 0) { - return page; - } + try { - await batchWriteAll({ - table: esEntity.table, - items: esItems - }); + await elasticsearchEntityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || @@ -584,74 +558,67 @@ export const createPageStorageOperations = ( page.status = "unpublished"; - const items = [ - entity.deleteBatch({ - PK: createPartitionKey(page), - SK: createPublishedSortKey() - }), - entity.deleteBatch({ - PK: createPathPartitionKey(page), - SK: createPathSortKey(page) - }), - entity.putBatch({ - ...page, - TYPE: createBasicType(), - PK: createPartitionKey(page), - SK: createSortKey(page) - }) - ]; - const esItems = []; + const entityBatch = createEntityWriteBatch({ + entity, + delete: [ + { + PK: createPartitionKey(page), + SK: createPublishedSortKey() + }, + { + PK: createPathPartitionKey(page), + SK: createPathSortKey(page) + } + ], + put: [ + { + ...page, + TYPE: createBasicType(), + PK: createPartitionKey(page), + SK: createSortKey(page) + } + ] + }); + + const elasticsearchEntityBatch = createEntityWriteBatch({ + entity: esEntity, + delete: [ + { + PK: createPartitionKey(page), + SK: createPublishedSortKey() + } + ] + }); /* * If we are unpublishing the latest revision, let's also update the latest revision entry's status in ES. */ if (latestPage.id === page.id) { - items.push( - entity.putBatch({ - ...page, - TYPE: createLatestType(), - PK: createPartitionKey(page), - SK: createLatestSortKey() - }) - ); - esItems.push( - esEntity.putBatch({ - PK: createPartitionKey(page), - SK: createLatestSortKey(), - index: configurations.es(page).index, - data: getESLatestPageData(plugins, page) - }) - ); - } + entityBatch.put({ + ...page, + TYPE: createLatestType(), + PK: createPartitionKey(page), + SK: createLatestSortKey() + }); - esItems.push( - esEntity.deleteBatch({ + elasticsearchEntityBatch.put({ PK: createPartitionKey(page), - SK: createPublishedSortKey() - }) - ); + SK: createLatestSortKey(), + index: configurations.es(page).index, + data: getESLatestPageData(plugins, page) + }); + } try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not update all the page records when unpublishing.", ex.code || "UPDATE_RECORDS_ERROR" ); } - /** - * No need to go further if no Elasticsearch items to be applied. - */ - if (esItems.length === 0) { - return page; - } + try { - await batchWriteAll({ - table: esEntity.table, - items: esItems - }); + await elasticsearchEntityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || diff --git a/packages/api-page-builder-so-ddb/package.json b/packages/api-page-builder-so-ddb/package.json index ce0353d12e0..9ceb55a9e92 100644 --- a/packages/api-page-builder-so-ddb/package.json +++ b/packages/api-page-builder-so-ddb/package.json @@ -25,8 +25,7 @@ "@webiny/error": "0.0.0", "@webiny/handler-db": "0.0.0", "@webiny/utils": "0.0.0", - "dataloader": "^2.0.0", - "lodash": "^4.17.21" + "dataloader": "^2.0.0" }, "devDependencies": { "@babel/cli": "^7.23.9", diff --git a/packages/api-page-builder-so-ddb/src/operations/blockCategory/dataLoader.ts b/packages/api-page-builder-so-ddb/src/operations/blockCategory/dataLoader.ts index a8112be7e37..cd030e6a912 100644 --- a/packages/api-page-builder-so-ddb/src/operations/blockCategory/dataLoader.ts +++ b/packages/api-page-builder-so-ddb/src/operations/blockCategory/dataLoader.ts @@ -1,5 +1,5 @@ import DataLoader from "dataloader"; -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { batchReadAll } from "@webiny/db-dynamodb"; import { BlockCategory } from "@webiny/api-page-builder/types"; import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; import { Entity } from "@webiny/db-dynamodb/toolbox"; diff --git a/packages/api-page-builder-so-ddb/src/operations/category/dataLoader.ts b/packages/api-page-builder-so-ddb/src/operations/category/dataLoader.ts index 2c9ebc9c70a..661b32359d6 100644 --- a/packages/api-page-builder-so-ddb/src/operations/category/dataLoader.ts +++ b/packages/api-page-builder-so-ddb/src/operations/category/dataLoader.ts @@ -1,5 +1,5 @@ import DataLoader from "dataloader"; -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { batchReadAll } from "@webiny/db-dynamodb"; import { Category } from "@webiny/api-page-builder/types"; import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; import { Entity } from "@webiny/db-dynamodb/toolbox"; diff --git a/packages/api-page-builder-so-ddb/src/operations/pageBlock/dataLoader.ts b/packages/api-page-builder-so-ddb/src/operations/pageBlock/dataLoader.ts index c9505ba7858..b3614d766f6 100644 --- a/packages/api-page-builder-so-ddb/src/operations/pageBlock/dataLoader.ts +++ b/packages/api-page-builder-so-ddb/src/operations/pageBlock/dataLoader.ts @@ -1,5 +1,5 @@ import DataLoader from "dataloader"; -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { batchReadAll } from "@webiny/db-dynamodb"; import { PageBlock } from "@webiny/api-page-builder/types"; import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; import { Entity } from "@webiny/db-dynamodb/toolbox"; diff --git a/packages/api-page-builder-so-ddb/src/operations/pageTemplate/dataLoader.ts b/packages/api-page-builder-so-ddb/src/operations/pageTemplate/dataLoader.ts index 32437cbacc9..248b6ffe129 100644 --- a/packages/api-page-builder-so-ddb/src/operations/pageTemplate/dataLoader.ts +++ b/packages/api-page-builder-so-ddb/src/operations/pageTemplate/dataLoader.ts @@ -1,5 +1,5 @@ import DataLoader from "dataloader"; -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; +import { batchReadAll } from "@webiny/db-dynamodb"; import { PageTemplate } from "@webiny/api-page-builder/types"; import { Entity } from "@webiny/db-dynamodb/toolbox"; import { createPrimaryPK } from "./keys"; diff --git a/packages/api-page-builder-so-ddb/src/operations/pages/index.ts b/packages/api-page-builder-so-ddb/src/operations/pages/index.ts index f2619b1fdb7..1938bace62b 100644 --- a/packages/api-page-builder-so-ddb/src/operations/pages/index.ts +++ b/packages/api-page-builder-so-ddb/src/operations/pages/index.ts @@ -1,6 +1,5 @@ import WebinyError from "@webiny/error"; -import lodashGet from "lodash/get"; -import { +import type { Page, PageStorageOperations, PageStorageOperationsCreateFromParams, @@ -18,21 +17,21 @@ import { PageStorageOperationsUpdateParams } from "@webiny/api-page-builder/types"; import { getClean } from "@webiny/db-dynamodb/utils/get"; -import { Entity } from "@webiny/db-dynamodb/toolbox"; +import type { Entity } from "@webiny/db-dynamodb/toolbox"; import { cleanupItem } from "@webiny/db-dynamodb/utils/cleanup"; +import type { QueryAllParams } from "@webiny/db-dynamodb"; import { - DbItem, + createEntityWriteBatch, + decodeCursor, + encodeCursor, + filterItems, queryAll, - QueryAllParams, queryOne, - queryOneClean -} from "@webiny/db-dynamodb/utils/query"; -import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite"; -import { filterItems } from "@webiny/db-dynamodb/utils/filter"; -import { sortItems } from "@webiny/db-dynamodb/utils/sort"; -import { decodeCursor, encodeCursor } from "@webiny/db-dynamodb/utils/cursor"; + queryOneClean, + sortItems +} from "@webiny/db-dynamodb"; import { PageDynamoDbFieldPlugin } from "~/plugins/definitions/PageDynamoDbFieldPlugin"; -import { PluginsContainer } from "@webiny/plugins"; +import type { PluginsContainer } from "@webiny/plugins"; import { createLatestPartitionKey, createLatestSortKey, @@ -105,25 +104,26 @@ export const createPageStorageOperations = ( * - latest * - revision */ - const items = [ - entity.putBatch({ - ...page, - titleLC, - ...latestKeys, - TYPE: createLatestType() - }), - entity.putBatch({ - ...page, - titleLC, - ...revisionKeys, - TYPE: createRevisionType() - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...page, + titleLC, + ...latestKeys, + TYPE: createLatestType() + }, + { + ...page, + titleLC, + ...revisionKeys, + TYPE: createRevisionType() + } + ] + }); + try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); return page; } catch (ex) { throw new WebinyError( @@ -154,24 +154,24 @@ export const createPageStorageOperations = ( * - latest * - revision */ - const items = [ - entity.putBatch({ - ...page, - ...latestKeys, - TYPE: createLatestType() - }), - entity.putBatch({ - ...page, - ...revisionKeys, - TYPE: createRevisionType() - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...page, + ...latestKeys, + TYPE: createLatestType() + }, + { + ...page, + ...revisionKeys, + TYPE: createRevisionType() + } + ] + }); try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); return page; } catch (ex) { throw new WebinyError( @@ -211,33 +211,32 @@ export const createPageStorageOperations = ( * - revision * - latest if this is the latest */ - const items = [ - entity.putBatch({ - ...page, - titleLC, - ...revisionKeys, - TYPE: createRevisionType() - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...page, + titleLC, + ...revisionKeys, + TYPE: createRevisionType() + } + ] + }); + /** * Latest if it is the one. */ if (latestPage && latestPage.id === page.id) { - items.push( - entity.putBatch({ - ...page, - titleLC, - ...latestKeys, - TYPE: createLatestType() - }) - ); + entityBatch.put({ + ...page, + titleLC, + ...latestKeys, + TYPE: createLatestType() + }); } try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); return page; } catch (ex) { @@ -280,9 +279,13 @@ export const createPageStorageOperations = ( * We need to update * - latest, if it exists, with previous record */ - const items = [entity.deleteBatch(revisionKeys)]; + const entityBatch = createEntityWriteBatch({ + entity, + delete: [revisionKeys] + }); + if (publishedPage && publishedPage.id === page.id) { - items.push(entity.deleteBatch(publishedKeys)); + entityBatch.delete(publishedKeys); } let previousLatestPage: Page | null = null; if (latestPage && latestPage.id === page.id) { @@ -296,21 +299,17 @@ export const createPageStorageOperations = ( } }); if (previousLatestRecord) { - items.push( - entity.putBatch({ - ...previousLatestRecord, - ...latestKeys, - TYPE: createLatestType() - }) - ); + entityBatch.put({ + ...previousLatestRecord, + ...latestKeys, + TYPE: createLatestType() + }); + previousLatestPage = cleanupItem(entity, previousLatestRecord); } } try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not batch write all the page records.", @@ -347,11 +346,14 @@ export const createPageStorageOperations = ( SK: createPublishedSortKey(page) }; - const items = [entity.deleteBatch(latestKeys)]; + const entityBatch = createEntityWriteBatch({ + entity, + delete: [latestKeys] + }); - let revisions: DbItem<Page>[]; + let revisions: Awaited<ReturnType<typeof queryAll<Page>>> = []; try { - revisions = await queryAll(queryAllParams); + revisions = await queryAll<Page>(queryAllParams); } catch (ex) { throw new WebinyError( ex.message || "Could not query for all revisions of the page.", @@ -369,22 +371,17 @@ export const createPageStorageOperations = ( */ for (const revision of revisions) { if (!deletedPublishedRecord && revision.status === "published") { - items.push(entity.deleteBatch(publishedKeys)); + entityBatch.delete(publishedKeys); deletedPublishedRecord = true; } - items.push( - entity.deleteBatch({ - PK: revision.PK, - SK: revision.SK - }) - ); + entityBatch.delete({ + PK: revision.PK, + SK: revision.SK + }); } try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not delete all the page records.", @@ -420,22 +417,23 @@ export const createPageStorageOperations = ( /** * Update the given revision of the page. */ - const items = [ - entity.putBatch({ - ...page, - ...revisionKeys, - TYPE: createRevisionType() - }) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...page, + ...revisionKeys, + TYPE: createRevisionType() + } + ] + }); if (latestPage.id === page.id) { - items.push( - entity.putBatch({ - ...page, - ...latestKeys, - TYPE: createLatestType() - }) - ); + entityBatch.put({ + ...page, + ...latestKeys, + TYPE: createLatestType() + }); } /** * If we already have a published revision, and it's not the revision being published: @@ -446,31 +444,24 @@ export const createPageStorageOperations = ( PK: createRevisionPartitionKey(publishedPage), SK: createRevisionSortKey(publishedPage) }; - items.push( - entity.putBatch({ - ...publishedPage, - status: "unpublished", - ...publishedRevisionKeys, - TYPE: createRevisionType() - }) - ); + entityBatch.put({ + ...publishedPage, + status: "unpublished", + ...publishedRevisionKeys, + TYPE: createRevisionType() + }); } - items.push( - entity.putBatch({ - ...page, - ...publishedKeys, - GSI1_PK: createPathPartitionKey(page), - GSI1_SK: page.path, - TYPE: createPublishedType() - }) - ); + entityBatch.put({ + ...page, + ...publishedKeys, + GSI1_PK: createPathPartitionKey(page), + GSI1_SK: page.path, + TYPE: createPublishedType() + }); try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not update all the page records when publishing.", @@ -504,30 +495,28 @@ export const createPageStorageOperations = ( SK: createPublishedSortKey(page) }; - const items = [ - entity.putBatch({ - ...page, - ...revisionKeys, - TYPE: createRevisionType() - }), - entity.deleteBatch(publishedKeys) - ]; + const entityBatch = createEntityWriteBatch({ + entity, + put: [ + { + ...page, + ...revisionKeys, + TYPE: createRevisionType() + } + ], + delete: [publishedKeys] + }); if (latestPage.id === page.id) { - items.push( - entity.putBatch({ - ...page, - ...latestKeys, - TYPE: createLatestType() - }) - ); + entityBatch.put({ + ...page, + ...latestKeys, + TYPE: createLatestType() + }); } try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not update all the page records when unpublishing.", @@ -819,7 +808,7 @@ export const createPageStorageOperations = ( options }; - let pages: DbItem<Page>[] = []; + let pages: Awaited<ReturnType<typeof queryAll<Page>>> = []; try { pages = await queryAll<Page>(queryAllParams); } catch (ex) { @@ -833,22 +822,20 @@ export const createPageStorageOperations = ( ); } - const tags = pages.reduce((collection, page) => { - let list: string[] = lodashGet(page, "settings.general.tags") as unknown as string[]; - if (!list || list.length === 0) { - return collection; + const tags = new Set<string>(); + for (const page of pages) { + let tagList = page.settings?.general?.tags; + if (!tagList?.length) { + continue; } else if (where.search) { const re = new RegExp(where.search, "i"); - list = list.filter(t => t.match(re) !== null); + tagList = tagList.filter(tag => !!tag && tag.match(re) !== null); } - - for (const t of list) { - collection[t] = undefined; + for (const tag of tagList) { + tags.add(tag); } - return collection; - }, {} as Record<string, string | undefined>); - - return Object.keys(tags); + } + return Array.from(tags); }; return { diff --git a/packages/api-prerendering-service-so-ddb/src/operations/queueJob.ts b/packages/api-prerendering-service-so-ddb/src/operations/queueJob.ts index 3499661f229..f8371e1c7e5 100644 --- a/packages/api-prerendering-service-so-ddb/src/operations/queueJob.ts +++ b/packages/api-prerendering-service-so-ddb/src/operations/queueJob.ts @@ -1,14 +1,14 @@ import WebinyError from "@webiny/error"; -import { +import type { PrerenderingServiceQueueJobStorageOperations, PrerenderingServiceStorageOperationsCreateQueueJobParams, PrerenderingServiceStorageOperationsDeleteQueueJobsParams, QueueJob } from "@webiny/api-prerendering-service/types"; -import { Entity } from "@webiny/db-dynamodb/toolbox"; -import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite"; -import { queryAllClean, QueryAllParams } from "@webiny/db-dynamodb/utils/query"; -import { put } from "@webiny/db-dynamodb"; +import type { Entity } from "@webiny/db-dynamodb/toolbox"; +import { createEntityWriteBatch, put } from "@webiny/db-dynamodb"; +import type { QueryAllParams } from "@webiny/db-dynamodb/utils/query"; +import { queryAllClean } from "@webiny/db-dynamodb/utils/query"; export interface CreateQueueJobStorageOperationsParams { entity: Entity<any>; @@ -89,18 +89,18 @@ export const createQueueJobStorageOperations = ( ) => { const { queueJobs } = params; - const items = queueJobs.map(job => { - return entity.deleteBatch({ - PK: createQueueJobPartitionKey(), - SK: createQueueJobSortKey(job.id) - }); + const entityBatch = createEntityWriteBatch({ + entity, + delete: queueJobs.map(job => { + return { + PK: createQueueJobPartitionKey(), + SK: createQueueJobSortKey(job.id) + }; + }) }); try { - await batchWriteAll({ - table: entity.table, - items - }); + await entityBatch.execute(); return queueJobs; } catch (ex) { throw new WebinyError( diff --git a/packages/api-prerendering-service-so-ddb/src/operations/render.ts b/packages/api-prerendering-service-so-ddb/src/operations/render.ts index 07565470dc1..b9fb75a89b9 100644 --- a/packages/api-prerendering-service-so-ddb/src/operations/render.ts +++ b/packages/api-prerendering-service-so-ddb/src/operations/render.ts @@ -1,5 +1,5 @@ import WebinyError from "@webiny/error"; -import { +import type { PrerenderingServiceRenderStorageOperations, PrerenderingServiceStorageOperationsCreateRenderParams, PrerenderingServiceStorageOperationsCreateTagPathLinksParams, @@ -12,18 +12,24 @@ import { Tag, TagPathLink } from "@webiny/api-prerendering-service/types"; -import { Entity, EntityQueryOptions } from "@webiny/db-dynamodb/toolbox"; -import { get } from "@webiny/db-dynamodb/utils/get"; -import { queryAll, queryAllClean, QueryAllParams } from "@webiny/db-dynamodb/utils/query"; -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; -import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite"; -import { cleanupItem, cleanupItems } from "@webiny/db-dynamodb/utils/cleanup"; -import { DataContainer } from "~/types"; -import { deleteItem, put } from "@webiny/db-dynamodb"; +import type { Entity, EntityQueryOptions } from "@webiny/db-dynamodb/toolbox"; +import { + batchReadAll, + cleanupItem, + cleanupItems, + createEntityWriteBatch, + deleteItem, + get, + put, + queryAll, + queryAllClean +} from "@webiny/db-dynamodb"; +import type { QueryAllParams } from "@webiny/db-dynamodb"; +import type { DataContainer } from "~/types"; export interface CreateRenderStorageOperationsParams { - entity: Entity<any>; - tagPathLinkEntity: Entity<any>; + entity: Entity; + tagPathLinkEntity: Entity; } export interface CreateTagPathLinkPartitionKeyParams { @@ -276,29 +282,29 @@ export const createRenderStorageOperations = ( ) => { const { tagPathLinks } = params; - const items = tagPathLinks.map(item => { - return tagPathLinkEntity.putBatch({ - data: item, - TYPE: createTagPathLinkType(), - PK: createTagPathLinkPartitionKey({ - tenant: item.tenant, - tag: item, - path: item.path - }), - SK: createTagPathLinkSortKey({ - tag: item, - path: item.path - }), - GSI1_PK: createTagPathLinkGSI1PartitionKey({ tag: item, tenant: item.tenant }), - GSI1_SK: createTagPathLinkGSI1SortKey({ tag: item, path: item.path }) - }); + const tagPathLinksBatch = createEntityWriteBatch({ + entity: tagPathLinkEntity, + put: tagPathLinks.map(item => { + return { + data: item, + TYPE: createTagPathLinkType(), + PK: createTagPathLinkPartitionKey({ + tenant: item.tenant, + tag: item, + path: item.path + }), + SK: createTagPathLinkSortKey({ + tag: item, + path: item.path + }), + GSI1_PK: createTagPathLinkGSI1PartitionKey({ tag: item, tenant: item.tenant }), + GSI1_SK: createTagPathLinkGSI1SortKey({ tag: item, path: item.path }) + }; + }) }); try { - await batchWriteAll({ - table: tagPathLinkEntity.table, - items - }); + await tagPathLinksBatch.execute(); return tagPathLinks; } catch (ex) { throw new WebinyError( @@ -315,25 +321,26 @@ export const createRenderStorageOperations = ( params: PrerenderingServiceStorageOperationsDeleteTagPathLinksParams ): Promise<void> => { const { tenant, tags, path } = params; - const items = tags.map(tag => { - return tagPathLinkEntity.deleteBatch({ - PK: createTagPathLinkPartitionKey({ - tag, - tenant, - path - }), - SK: createTagPathLinkSortKey({ - tag, - path - }) - }); + + const tagPathLinksBatch = createEntityWriteBatch({ + entity: tagPathLinkEntity, + delete: tags.map(tag => { + return { + PK: createTagPathLinkPartitionKey({ + tag, + tenant, + path + }), + SK: createTagPathLinkSortKey({ + tag, + path + }) + }; + }) }); try { - await batchWriteAll({ - table: tagPathLinkEntity.table, - items - }); + await tagPathLinksBatch.execute(); } catch (ex) { throw new WebinyError( ex.message || "Could not delete tagPathLink records.", diff --git a/packages/api-security-so-ddb/src/index.ts b/packages/api-security-so-ddb/src/index.ts index c900f2e9261..628e339a5ce 100644 --- a/packages/api-security-so-ddb/src/index.ts +++ b/packages/api-security-so-ddb/src/index.ts @@ -1,5 +1,5 @@ import { ENTITIES, SecurityStorageParams } from "~/types"; -import { +import type { ApiKey, Group, ListTenantLinksByTypeParams, @@ -18,16 +18,19 @@ import { createTeamEntity, createTenantLinkEntity } from "~/definitions/entities"; -import { cleanupItem, cleanupItems } from "@webiny/db-dynamodb/utils/cleanup"; +import type { QueryOneParams } from "@webiny/db-dynamodb"; import { + cleanupItem, + cleanupItems, + createEntityWriteBatch, + deleteItem, + getClean, + put, queryAll, queryAllClean, queryOneClean, - QueryOneParams -} from "@webiny/db-dynamodb/utils/query"; -import { sortItems } from "@webiny/db-dynamodb/utils/sort"; -import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite"; -import { deleteItem, getClean, put } from "@webiny/db-dynamodb"; + sortItems +} from "@webiny/db-dynamodb"; const reservedFields: string[] = ["PK", "SK", "index", "data"]; @@ -187,17 +190,20 @@ export const createStorageOperations = ( } }, async createTenantLinks(links): Promise<void> { - const items = links.map(link => { - return entities.tenantLinks.putBatch({ - PK: `IDENTITY#${link.identity}`, - SK: `LINK#T#${link.tenant}`, - GSI1_PK: `T#${link.tenant}`, - GSI1_SK: `TYPE#${link.type}#IDENTITY#${link.identity}`, - ...cleanupItem(entities.tenantLinks, link) - }); + const batchWrite = createEntityWriteBatch({ + entity: entities.tenantLinks, + put: links.map(link => { + return { + PK: `IDENTITY#${link.identity}`, + SK: `LINK#T#${link.tenant}`, + GSI1_PK: `T#${link.tenant}`, + GSI1_SK: `TYPE#${link.type}#IDENTITY#${link.identity}`, + ...cleanupItem(entities.tenantLinks, link) + }; + }) }); - await batchWriteAll({ table, items }); + await batchWrite.execute(); }, async deleteApiKey({ apiKey }) { const keys = createApiKeyKeys(apiKey); @@ -248,14 +254,16 @@ export const createStorageOperations = ( } }, async deleteTenantLinks(links): Promise<void> { - const items = links.map(link => { - return entities.tenantLinks.deleteBatch({ - PK: `IDENTITY#${link.identity}`, - SK: `LINK#T#${link.tenant}` - }); + const batchWrite = createEntityWriteBatch({ + entity: entities.tenantLinks, + delete: links.map(link => { + return { + PK: `IDENTITY#${link.identity}`, + SK: `LINK#T#${link.tenant}` + }; + }) }); - - await batchWriteAll({ table, items }); + await batchWrite.execute(); }, async getApiKey({ id, tenant }) { const keys = createApiKeyKeys({ id, tenant }); @@ -586,17 +594,20 @@ export const createStorageOperations = ( } }, async updateTenantLinks(links): Promise<void> { - const items = links.map(link => { - return entities.tenantLinks.putBatch({ - PK: `IDENTITY#${link.identity}`, - SK: `LINK#T#${link.tenant}`, - GSI1_PK: `T#${link.tenant}`, - GSI1_SK: `TYPE#${link.type}#IDENTITY#${link.identity}`, - ...cleanupItem(entities.tenantLinks, link) - }); + const batchWrite = createEntityWriteBatch({ + entity: entities.tenantLinks, + put: links.map(link => { + return { + PK: `IDENTITY#${link.identity}`, + SK: `LINK#T#${link.tenant}`, + GSI1_PK: `T#${link.tenant}`, + GSI1_SK: `TYPE#${link.type}#IDENTITY#${link.identity}`, + ...cleanupItem(entities.tenantLinks, link) + }; + }) }); - await batchWriteAll({ table, items }); + await batchWrite.execute(); } }; }; diff --git a/packages/api-tenancy-so-ddb/src/index.ts b/packages/api-tenancy-so-ddb/src/index.ts index 9e8a3cca180..a81ea07bf68 100644 --- a/packages/api-tenancy-so-ddb/src/index.ts +++ b/packages/api-tenancy-so-ddb/src/index.ts @@ -1,5 +1,10 @@ -import { batchReadAll } from "@webiny/db-dynamodb/utils/batchRead"; -import { batchWriteAll } from "@webiny/db-dynamodb/utils/batchWrite"; +import { + batchReadAll, + createEntityWriteBatch, + createTableWriteBatch, + getClean, + put +} from "@webiny/db-dynamodb"; import { queryAll, QueryAllParams } from "@webiny/db-dynamodb/utils/query"; import WebinyError from "@webiny/error"; import { createTable } from "~/definitions/table"; @@ -7,8 +12,7 @@ import { createTenantEntity } from "~/definitions/tenantEntity"; import { createSystemEntity } from "~/definitions/systemEntity"; import { createDomainEntity } from "~/definitions/domainEntity"; import { CreateTenancyStorageOperations, ENTITIES } from "~/types"; -import { ListTenantsParams, System, Tenant, TenantDomain } from "@webiny/api-tenancy/types"; -import { getClean, put } from "@webiny/db-dynamodb"; +import type { ListTenantsParams, System, Tenant, TenantDomain } from "@webiny/api-tenancy/types"; interface TenantDomainRecord { PK: string; @@ -181,20 +185,24 @@ export const createStorageOperations: CreateTenancyStorageOperations = params => }; try { - const items: any[] = [ - entities.tenants.putBatch({ TYPE: "tenancy.tenant", ...keys, data }) - ]; - const newDomains = createNewDomainsRecords(data); - - newDomains.forEach(record => { - items.push(entities.domains.putBatch(record)); + const tableWrite = createTableWriteBatch({ + table: tableInstance }); - await batchWriteAll({ - table: tableInstance, - items: items + tableWrite.put(entities.tenants, { + TYPE: "tenancy.tenant", + ...keys, + data }); + const newDomains = createNewDomainsRecords(data); + + for (const domain of newDomains) { + tableWrite.put(entities.domains, domain); + } + + await tableWrite.execute(); + return data as TTenant; } catch (err) { throw WebinyError.from(err, { @@ -215,8 +223,6 @@ export const createStorageOperations: CreateTenancyStorageOperations = params => GSI1_SK: `T#${data.parent}#${data.createdOn}` }; - const items: any[] = [entities.tenants.putBatch({ ...keys, data })]; - const existingDomains = await queryAll<TenantDomain>({ entity: entities.domains, partitionKey: "DOMAINS", @@ -228,31 +234,30 @@ export const createStorageOperations: CreateTenancyStorageOperations = params => const newDomains = createNewDomainsRecords(data, existingDomains); + const tableWrite = createTableWriteBatch({ + table: tableInstance + }); + + tableWrite.put(entities.tenants, { ...keys, data }); + // Delete domains that are in the DB but are NOT in the settings. - const deleteDomains = []; + for (const { fqdn } of existingDomains) { - if (!data.settings.domains.find(d => d.fqdn === fqdn)) { - deleteDomains.push({ - PK: `DOMAIN#${fqdn}`, - SK: `A` - }); + if (data.settings.domains.some(d => d.fqdn === fqdn)) { + continue; } - } - - try { - newDomains.forEach(record => { - items.push(entities.domains.putBatch(record)); - }); - - deleteDomains.forEach(item => { - items.push(entities.domains.deleteBatch(item)); + tableWrite.delete(entities.domains, { + PK: `DOMAIN#${fqdn}`, + SK: `A` }); + } - await batchWriteAll({ - table: tableInstance, - items: items - }); + for (const domain of newDomains) { + tableWrite.put(entities.domains, domain); + } + try { + await tableWrite.execute(); return data as TTenant; } catch (err) { throw WebinyError.from(err, { @@ -273,27 +278,25 @@ export const createStorageOperations: CreateTenancyStorageOperations = params => } }); - const items = [ - entities.tenants.deleteBatch({ - PK: `T#${id}`, - SK: "A" - }) - ]; - - existingDomains.forEach(domain => { - items.push( - entities.domains.deleteBatch({ - PK: domain.PK, - SK: domain.SK - }) - ); + const batchWrite = createEntityWriteBatch({ + entity: entities.tenants, + delete: [ + { + PK: `T#${id}`, + SK: "A" + } + ] }); + for (const domain of existingDomains) { + batchWrite.delete({ + PK: domain.PK, + SK: domain.SK + }); + } + // Delete tenant and domain items - await batchWriteAll({ - table: tableInstance, - items - }); + await batchWrite.execute(); } }; }; diff --git a/packages/db-dynamodb/README.md b/packages/db-dynamodb/README.md index 7990665227b..b6bbb743004 100644 --- a/packages/db-dynamodb/README.md +++ b/packages/db-dynamodb/README.md @@ -14,6 +14,86 @@ For more information, please visit yarn add @webiny/db-dynamodb ``` +### Helper classes +We have some classes that ease the use of dynamodb-toolbox. +#### [Table](./src/utils/table/Table.ts) +```typescript +import { createTable } from "@webiny/db-dynamodb"; + +const table = createTable({...params}); +const writer = table.createWriter(); // see TableWriteBatch +const reader = table.createReader(); // see TableReadBatch + +const result = await table.scan({...params}); // see scan +``` + +#### [Entity](./src/utils/entity/Entity.ts) +```typescript + +import { createEntity } from "@webiny/db-dynamodb"; + +const entity = createEntity({...params}); +const writer = entity.createWriter(); // see EntityWriteBatch +const reader = entity.createReader(); // see EntityReadBatch +const tableWriter = entity.createTableWriter(); // see TableWriteBatch + +const getResult = await entity.get({...params}); // see get +const getCleanResult = await entity.getClean({...params}); // see get +const queryAllResult = await entity.queryAll({...params}); // see queryAllClean +const queryOneResult = await entity.queryOne({...params}); // see queryOneClean +const putResult = await entity.put({...params}); // see put + +``` + +#### [EntityWriteBatch](./src/utils/entity/EntityWriteBatch.ts) +```typescript +import { createEntityWriteBatch } from "@webiny/db-dynamodb"; + +const writer = createEntityWriteBatch({...params}); + +writer.put({...item}); +writer.delete({...keys}); +writer.delete({...moreKeys}); + +await writer.execute(); +``` + +#### [EntityReadBatch](./src/utils/entity/EntityReadBatch.ts) +```typescript +import { createEntityReadBatch } from "@webiny/db-dynamodb"; + +const reader = createEntityReadBatch({...params}); + +reader.get({...keys}); +reader.get({...moreKeys}); + +const result = await reader.execute(); +``` +#### [TableWriteBatch](./src/utils/table/TableWriteBatch.ts) +```typescript +import { createTableWriteBatch } from "@webiny/db-dynamodb"; + +const writer = createTableWriteBatch({...params}); + +writer.put(entity, {...item}); +writer.delete(entity, {...keys}); +writer.delete(entity, {...moreKeys}); + +await writer.execute(); +``` +#### [TableReadBatch](./src/utils/table/TableReadBatch.ts) +```typescript +import {createTableReadBatch} from "@webiny/db-dynamodb"; + +const reader = createTableReadBatch({...params}); + +writer.get(entity, {...keys}); +writer.get(entity, {...moreKeys}); + +const result = await reader.execute(); +``` + + ### Helper functions We have a number [helper](./src/utils) functions that ease the use of either dynamodb-toolbox, filtering, sorting or just creating proper response. @@ -64,4 +144,4 @@ This function accepts items (records) to be filtered, a definition of fields to #### [sort](./src/utils/sort.ts) Sort the DynamoDB records by given sort condition. -This function accepts items (records) to be sorted, sort options (eg. createdBy_ASC, id_DESC, etc.) and a definitions of fields to sort by (not required by default if no field modification is required). \ No newline at end of file +This function accepts items (records) to be sorted, sort options (eg. createdBy_ASC, id_DESC, etc.) and a definitions of fields to sort by (not required by default if no field modification is required). diff --git a/packages/db-dynamodb/package.json b/packages/db-dynamodb/package.json index 00ab30f0cfb..f61f4d233ca 100644 --- a/packages/db-dynamodb/package.json +++ b/packages/db-dynamodb/package.json @@ -14,7 +14,6 @@ "@webiny/aws-sdk": "0.0.0", "@webiny/db": "0.0.0", "@webiny/error": "0.0.0", - "@webiny/handler-db": "0.0.0", "@webiny/plugins": "0.0.0", "@webiny/utils": "0.0.0", "date-fns": "^2.22.1", diff --git a/packages/db-dynamodb/src/index.ts b/packages/db-dynamodb/src/index.ts index 3aa680435aa..e56b1e14553 100644 --- a/packages/db-dynamodb/src/index.ts +++ b/packages/db-dynamodb/src/index.ts @@ -1,6 +1,6 @@ import { default as DynamoDbDriver } from "./DynamoDbDriver"; export * from "./utils"; -export { DbItem } from "./types"; +export type { DbItem } from "./types"; export { DynamoDbDriver }; diff --git a/packages/db-dynamodb/src/toolbox.ts b/packages/db-dynamodb/src/toolbox.ts index 22b943165eb..7d90fb1c654 100644 --- a/packages/db-dynamodb/src/toolbox.ts +++ b/packages/db-dynamodb/src/toolbox.ts @@ -6,6 +6,8 @@ export type { TableConstructor } from "dynamodb-toolbox/dist/cjs/classes/Table"; export type { + Readonly, + EntityConstructor, AttributeDefinition, EntityQueryOptions, AttributeDefinitions diff --git a/packages/db-dynamodb/src/utils/batchRead.ts b/packages/db-dynamodb/src/utils/batch/batchRead.ts similarity index 100% rename from packages/db-dynamodb/src/utils/batchRead.ts rename to packages/db-dynamodb/src/utils/batch/batchRead.ts diff --git a/packages/db-dynamodb/src/utils/batchWrite.ts b/packages/db-dynamodb/src/utils/batch/batchWrite.ts similarity index 79% rename from packages/db-dynamodb/src/utils/batchWrite.ts rename to packages/db-dynamodb/src/utils/batch/batchWrite.ts index b65e5afe94a..6c753bd46dc 100644 --- a/packages/db-dynamodb/src/utils/batchWrite.ts +++ b/packages/db-dynamodb/src/utils/batch/batchWrite.ts @@ -1,31 +1,12 @@ import lodashChunk from "lodash/chunk"; import { TableDef } from "~/toolbox"; -import { WriteRequest } from "@webiny/aws-sdk/client-dynamodb"; - -export interface BatchWriteItem { - [key: string]: WriteRequest; -} +import { BatchWriteItem, BatchWriteResponse, BatchWriteResult } from "./types"; export interface BatchWriteParams { table?: TableDef; items: BatchWriteItem[]; } -export interface BatchWriteResponse { - next?: () => Promise<BatchWriteResponse>; - $metadata: { - httpStatusCode: number; - requestId: string; - attempts: number; - totalRetryDelay: number; - }; - UnprocessedItems?: { - [table: string]: WriteRequest[]; - }; -} - -export type BatchWriteResult = BatchWriteResponse[]; - const hasUnprocessedItems = (result: BatchWriteResponse): boolean => { if (typeof result.next !== "function") { return false; diff --git a/packages/db-dynamodb/src/utils/batch/index.ts b/packages/db-dynamodb/src/utils/batch/index.ts new file mode 100644 index 00000000000..cc976f74632 --- /dev/null +++ b/packages/db-dynamodb/src/utils/batch/index.ts @@ -0,0 +1,3 @@ +export * from "./batchRead"; +export * from "./batchWrite"; +export * from "./types"; diff --git a/packages/db-dynamodb/src/utils/batch/types.ts b/packages/db-dynamodb/src/utils/batch/types.ts new file mode 100644 index 00000000000..f7b3ad9984b --- /dev/null +++ b/packages/db-dynamodb/src/utils/batch/types.ts @@ -0,0 +1,30 @@ +import type { WriteRequest } from "@webiny/aws-sdk/client-dynamodb"; + +export interface BatchWriteResponse { + next?: () => Promise<BatchWriteResponse>; + $metadata: { + httpStatusCode: number; + requestId: string; + attempts: number; + totalRetryDelay: number; + }; + UnprocessedItems?: { + [table: string]: WriteRequest[]; + }; +} + +export type BatchWriteResult = BatchWriteResponse[]; + +export interface IDeleteBatchItem { + PK: string; + SK: string; +} + +export type IPutBatchItem<T extends Record<string, any> = Record<string, any>> = { + PK: string; + SK: string; +} & T; + +export interface BatchWriteItem { + [key: string]: WriteRequest; +} diff --git a/packages/db-dynamodb/src/utils/delete.ts b/packages/db-dynamodb/src/utils/delete.ts index 0540e39d57e..b4adc394d08 100644 --- a/packages/db-dynamodb/src/utils/delete.ts +++ b/packages/db-dynamodb/src/utils/delete.ts @@ -1,14 +1,14 @@ import { Entity } from "~/toolbox"; - -interface Params { +export interface IDeleteItemKeys { + PK: string; + SK: string; +} +export interface IDeleteItemParams { entity: Entity; - keys: { - PK: string; - SK: string; - }; + keys: IDeleteItemKeys; } -export const deleteItem = async (params: Params) => { +export const deleteItem = async (params: IDeleteItemParams) => { const { entity, keys } = params; return await entity.delete(keys, { diff --git a/packages/db-dynamodb/src/utils/entity/Entity.ts b/packages/db-dynamodb/src/utils/entity/Entity.ts new file mode 100644 index 00000000000..3e056d2b016 --- /dev/null +++ b/packages/db-dynamodb/src/utils/entity/Entity.ts @@ -0,0 +1,112 @@ +import type { + AttributeDefinitions, + EntityConstructor as BaseEntityConstructor, + Readonly, + TableDef +} from "~/toolbox"; +import { Entity as BaseEntity } from "~/toolbox"; +import type { ITableWriteBatch } from "../table/types"; +import type { + IEntity, + IEntityQueryAllParams, + IEntityQueryOneParams, + IEntityReadBatch, + IEntityWriteBatch +} from "./types"; +import type { IPutParamsItem } from "../put"; +import { put } from "../put"; +import type { GetRecordParamsKeys } from "../get"; +import { get, getClean } from "../get"; +import type { IDeleteItemKeys } from "../delete"; +import { deleteItem } from "../delete"; +import { createEntityReadBatch } from "./EntityReadBatch"; +import { createEntityWriteBatch } from "./EntityWriteBatch"; +import { createTableWriteBatch } from "~/utils/table/TableWriteBatch"; +import { queryAllClean, queryOneClean } from "../query"; + +export type EntityConstructor< + T extends Readonly<AttributeDefinitions> = Readonly<AttributeDefinitions> +> = BaseEntityConstructor< + TableDef, + string, + true, + true, + true, + "created", + "modified", + "entity", + false, + T +>; + +export class Entity implements IEntity { + public readonly entity: BaseEntity; + + public constructor(params: EntityConstructor) { + this.entity = new BaseEntity(params); + } + + public createEntityReader(): IEntityReadBatch { + return createEntityReadBatch({ + entity: this.entity + }); + } + + public createEntityWriter(): IEntityWriteBatch { + return createEntityWriteBatch({ + entity: this.entity + }); + } + + public createTableWriter(): ITableWriteBatch { + return createTableWriteBatch({ + table: this.entity.table as TableDef + }); + } + + public async put(item: IPutParamsItem): ReturnType<typeof put> { + return put({ + entity: this.entity, + item + }); + } + + public async get<T>(keys: GetRecordParamsKeys): ReturnType<typeof get<T>> { + return get<T>({ + entity: this.entity, + keys + }); + } + + public async getClean<T>(keys: GetRecordParamsKeys): ReturnType<typeof getClean<T>> { + return getClean<T>({ + entity: this.entity, + keys + }); + } + + public async delete(keys: IDeleteItemKeys): ReturnType<typeof deleteItem> { + return deleteItem({ + entity: this.entity, + keys + }); + } + + public queryOne<T>(params: IEntityQueryOneParams): Promise<T | null> { + return queryOneClean<T>({ + ...params, + entity: this.entity + }); + } + + public queryAll<T>(params: IEntityQueryAllParams): Promise<T[]> { + return queryAllClean<T>({ + ...params, + entity: this.entity + }); + } +} + +export const createEntity = (params: EntityConstructor): IEntity => { + return new Entity(params); +}; diff --git a/packages/db-dynamodb/src/utils/entity/EntityReadBatch.ts b/packages/db-dynamodb/src/utils/entity/EntityReadBatch.ts new file mode 100644 index 00000000000..c1101bf84b5 --- /dev/null +++ b/packages/db-dynamodb/src/utils/entity/EntityReadBatch.ts @@ -0,0 +1,55 @@ +import type { IPutBatchItem } from "~/utils/batch/types"; +import type { + IEntityReadBatch, + IEntityReadBatchBuilder, + IEntityReadBatchBuilderGetResponse, + IEntityReadBatchKey +} from "./types"; +import type { TableDef } from "~/toolbox"; +import type { Entity as ToolboxEntity } from "~/toolbox"; +import { batchReadAll } from "~/utils/batch/batchRead"; +import { GenericRecord } from "@webiny/api/types"; +import { createEntityReadBatchBuilder } from "./EntityReadBatchBuilder"; +import type { EntityOption } from "./getEntity"; +import { getEntity } from "./getEntity"; + +export interface IEntityReadBatchParams { + entity: EntityOption; + read?: IPutBatchItem[]; +} + +export class EntityReadBatch implements IEntityReadBatch { + private readonly entity: ToolboxEntity; + private readonly builder: IEntityReadBatchBuilder; + private readonly _items: IEntityReadBatchBuilderGetResponse[] = []; + + public constructor(params: IEntityReadBatchParams) { + this.entity = getEntity(params.entity); + this.builder = createEntityReadBatchBuilder(this.entity); + for (const item of params.read || []) { + this.get(item); + } + } + public get(input: IEntityReadBatchKey | IEntityReadBatchKey[]): void { + if (Array.isArray(input)) { + this._items.push( + ...input.map(item => { + return this.builder.get(item); + }) + ); + return; + } + this._items.push(this.builder.get(input)); + } + + public async execute<T = GenericRecord>() { + return await batchReadAll<T>({ + table: this.entity.table as TableDef, + items: this._items + }); + } +} + +export const createEntityReadBatch = (params: IEntityReadBatchParams): IEntityReadBatch => { + return new EntityReadBatch(params); +}; diff --git a/packages/db-dynamodb/src/utils/entity/EntityReadBatchBuilder.ts b/packages/db-dynamodb/src/utils/entity/EntityReadBatchBuilder.ts new file mode 100644 index 00000000000..9b72ca8ee8c --- /dev/null +++ b/packages/db-dynamodb/src/utils/entity/EntityReadBatchBuilder.ts @@ -0,0 +1,34 @@ +import type { Entity as ToolboxEntity } from "~/toolbox"; +import type { + IEntityReadBatchBuilder, + IEntityReadBatchBuilderGetResponse, + IEntityReadBatchKey +} from "./types"; +import { WebinyError } from "@webiny/error"; +import { Entity } from "./Entity"; +import type { EntityOption } from "./getEntity"; +import { getEntity } from "./getEntity"; + +export class EntityReadBatchBuilder implements IEntityReadBatchBuilder { + private readonly entity: ToolboxEntity; + + public constructor(entity: EntityOption) { + this.entity = getEntity(entity); + } + + public get(item: IEntityReadBatchKey): IEntityReadBatchBuilderGetResponse { + const result = this.entity.getBatch(item); + if (!result.Table) { + throw new WebinyError(`No table provided for entity ${this.entity.name}.`); + } else if (!result.Key) { + throw new WebinyError(`No key provided for entity ${this.entity.name}.`); + } + return result as IEntityReadBatchBuilderGetResponse; + } +} + +export const createEntityReadBatchBuilder = ( + entity: ToolboxEntity | Entity +): IEntityReadBatchBuilder => { + return new EntityReadBatchBuilder(entity); +}; diff --git a/packages/db-dynamodb/src/utils/entity/EntityWriteBatch.ts b/packages/db-dynamodb/src/utils/entity/EntityWriteBatch.ts new file mode 100644 index 00000000000..49eef6beaa3 --- /dev/null +++ b/packages/db-dynamodb/src/utils/entity/EntityWriteBatch.ts @@ -0,0 +1,77 @@ +import type { TableDef } from "~/toolbox"; +import type { Entity as ToolboxEntity } from "~/toolbox"; +import { batchWriteAll } from "~/utils/batch/batchWrite"; +import type { + BatchWriteItem, + BatchWriteResult, + IDeleteBatchItem, + IPutBatchItem +} from "~/utils/batch/types"; +import type { IEntityWriteBatch, IEntityWriteBatchBuilder } from "./types"; +import type { ITableWriteBatch } from "~/utils/table/types"; +import { createTableWriteBatch } from "~/utils/table/TableWriteBatch"; +import { createEntityWriteBatchBuilder } from "./EntityWriteBatchBuilder"; +import type { EntityOption } from "./getEntity"; +import { getEntity } from "./getEntity"; + +export interface IEntityWriteBatchParams { + entity: EntityOption; + put?: IPutBatchItem[]; + delete?: IDeleteBatchItem[]; +} + +export class EntityWriteBatch implements IEntityWriteBatch { + private readonly entity: ToolboxEntity; + private readonly _items: BatchWriteItem[] = []; + private readonly builder: IEntityWriteBatchBuilder; + + public get total(): number { + return this._items.length; + } + + public get items(): BatchWriteItem[] { + return Array.from(this._items); + } + + public constructor(params: IEntityWriteBatchParams) { + this.entity = getEntity(params.entity); + this.builder = createEntityWriteBatchBuilder(this.entity); + for (const item of params.put || []) { + this.put(item); + } + for (const item of params.delete || []) { + this.delete(item); + } + } + + public put<T extends Record<string, any>>(item: IPutBatchItem<T>): void { + this._items.push(this.builder.put(item)); + } + + public delete(item: IDeleteBatchItem): void { + this._items.push(this.builder.delete(item)); + } + + public combine(items: BatchWriteItem[]): ITableWriteBatch { + return createTableWriteBatch({ + table: this.entity!.table as TableDef, + items: this._items.concat(items) + }); + } + + public async execute(): Promise<BatchWriteResult> { + if (this._items.length === 0) { + return []; + } + const items = Array.from(this._items); + this._items.length = 0; + return await batchWriteAll({ + items, + table: this.entity.table + }); + } +} + +export const createEntityWriteBatch = (params: IEntityWriteBatchParams): IEntityWriteBatch => { + return new EntityWriteBatch(params); +}; diff --git a/packages/db-dynamodb/src/utils/entity/EntityWriteBatchBuilder.ts b/packages/db-dynamodb/src/utils/entity/EntityWriteBatchBuilder.ts new file mode 100644 index 00000000000..73cb5cff13e --- /dev/null +++ b/packages/db-dynamodb/src/utils/entity/EntityWriteBatchBuilder.ts @@ -0,0 +1,28 @@ +import type { Entity } from "~/toolbox"; +import type { BatchWriteItem, IDeleteBatchItem, IPutBatchItem } from "~/utils/batch/types"; +import type { IEntityWriteBatchBuilder } from "./types"; +import type { EntityOption } from "./getEntity"; +import { getEntity } from "./getEntity"; + +export class EntityWriteBatchBuilder implements IEntityWriteBatchBuilder { + private readonly entity: Entity; + + public constructor(entity: EntityOption) { + this.entity = getEntity(entity); + } + + public put<T extends Record<string, any>>(item: IPutBatchItem<T>): BatchWriteItem { + return this.entity.putBatch(item, { + execute: true, + strictSchemaCheck: false + }); + } + + public delete(item: IDeleteBatchItem): BatchWriteItem { + return this.entity.deleteBatch(item); + } +} + +export const createEntityWriteBatchBuilder = (entity: Entity): IEntityWriteBatchBuilder => { + return new EntityWriteBatchBuilder(entity); +}; diff --git a/packages/db-dynamodb/src/utils/entity/getEntity.ts b/packages/db-dynamodb/src/utils/entity/getEntity.ts new file mode 100644 index 00000000000..5634f6d3b7d --- /dev/null +++ b/packages/db-dynamodb/src/utils/entity/getEntity.ts @@ -0,0 +1,14 @@ +import { Entity as ToolboxEntity } from "~/toolbox"; +import { Entity } from "./Entity"; + +export type EntityOption = ToolboxEntity | Entity; + +export const getEntity = (entity: EntityOption): ToolboxEntity => { + const result = entity instanceof ToolboxEntity ? entity : entity.entity; + if (!result.name) { + throw new Error(`No name provided for entity.`); + } else if (!result.table) { + throw new Error(`No table provided for entity ${result.name}.`); + } + return result; +}; diff --git a/packages/db-dynamodb/src/utils/entity/index.ts b/packages/db-dynamodb/src/utils/entity/index.ts new file mode 100644 index 00000000000..cab547e858b --- /dev/null +++ b/packages/db-dynamodb/src/utils/entity/index.ts @@ -0,0 +1,7 @@ +export * from "./Entity"; +export * from "./EntityReadBatch"; +export * from "./EntityReadBatchBuilder"; +export * from "./EntityWriteBatch"; +export * from "./EntityWriteBatchBuilder"; +export * from "./getEntity"; +export * from "./types"; diff --git a/packages/db-dynamodb/src/utils/entity/types.ts b/packages/db-dynamodb/src/utils/entity/types.ts new file mode 100644 index 00000000000..a3322d22d4e --- /dev/null +++ b/packages/db-dynamodb/src/utils/entity/types.ts @@ -0,0 +1,69 @@ +import type { Entity as BaseEntity } from "dynamodb-toolbox"; +import type { + BatchWriteItem, + BatchWriteResult, + IDeleteBatchItem, + IPutBatchItem +} from "~/utils/batch/types"; +import type { GenericRecord } from "@webiny/api/types"; +import type { TableDef } from "~/toolbox"; +import type { ITableWriteBatch } from "~/utils/table/types"; +import type { IPutParamsItem, put } from "~/utils/put"; +import type { QueryAllParams, QueryOneParams } from "~/utils/query"; +import type { get, getClean, GetRecordParamsKeys } from "~/utils/get"; +import type { deleteItem, IDeleteItemKeys } from "~/utils/delete"; +import type { batchReadAll } from "~/utils/batch/batchRead"; + +export type IEntityQueryOneParams = Omit<QueryOneParams, "entity">; + +export type IEntityQueryAllParams = Omit<QueryAllParams, "entity">; + +export interface IEntity { + readonly entity: BaseEntity; + createEntityReader(): IEntityReadBatch; + createEntityWriter(): IEntityWriteBatch; + createTableWriter(): ITableWriteBatch; + put(item: IPutParamsItem): ReturnType<typeof put>; + get<T>(keys: GetRecordParamsKeys): ReturnType<typeof get<T>>; + getClean<T>(keys: GetRecordParamsKeys): ReturnType<typeof getClean<T>>; + delete(keys: IDeleteItemKeys): ReturnType<typeof deleteItem>; + queryOne<T>(params: IEntityQueryOneParams): Promise<T | null>; + queryAll<T>(params: IEntityQueryAllParams): Promise<T[]>; +} + +export interface IEntityWriteBatchBuilder { + // readonly entity: Entity; + put<T extends Record<string, any>>(item: IPutBatchItem<T>): BatchWriteItem; + delete(item: IDeleteBatchItem): BatchWriteItem; +} + +export interface IEntityWriteBatch { + readonly total: number; + // readonly entity: Entity; + readonly items: BatchWriteItem[]; + // readonly builder: IEntityWriteBatchBuilder; + + put(item: IPutBatchItem): void; + delete(item: IDeleteBatchItem): void; + execute(): Promise<BatchWriteResult>; + combine(items: BatchWriteItem[]): ITableWriteBatch; +} + +export interface IEntityReadBatchKey { + PK: string; + SK: string; +} + +export interface IEntityReadBatch { + get(input: IEntityReadBatchKey | IEntityReadBatchKey[]): void; + execute<T = GenericRecord>(): ReturnType<typeof batchReadAll<T>>; +} + +export interface IEntityReadBatchBuilderGetResponse { + Table: TableDef; + Key: IEntityReadBatchKey; +} + +export interface IEntityReadBatchBuilder { + get(item: IEntityReadBatchKey): IEntityReadBatchBuilderGetResponse; +} diff --git a/packages/db-dynamodb/src/utils/get.ts b/packages/db-dynamodb/src/utils/get.ts index b3e9d0f52e0..b3d29980851 100644 --- a/packages/db-dynamodb/src/utils/get.ts +++ b/packages/db-dynamodb/src/utils/get.ts @@ -1,12 +1,14 @@ import { Entity } from "~/toolbox"; import { cleanupItem } from "~/utils/cleanup"; +export interface GetRecordParamsKeys { + PK: string; + SK: string; +} + export interface GetRecordParams { entity: Entity; - keys: { - PK: string; - SK: string; - }; + keys: GetRecordParamsKeys; } /** diff --git a/packages/db-dynamodb/src/utils/index.ts b/packages/db-dynamodb/src/utils/index.ts index b3823773402..6e5246f3896 100644 --- a/packages/db-dynamodb/src/utils/index.ts +++ b/packages/db-dynamodb/src/utils/index.ts @@ -1,5 +1,3 @@ -export * from "./batchRead"; -export * from "./batchWrite"; export * from "./cleanup"; export * from "./createEntity"; export * from "./createTable"; @@ -13,5 +11,7 @@ export * from "./query"; export * from "./count"; export * from "./scan"; export * from "./sort"; -export * from "./table"; export * from "./update"; +export * from "./batch"; +export * from "./entity"; +export * from "./table"; diff --git a/packages/db-dynamodb/src/utils/put.ts b/packages/db-dynamodb/src/utils/put.ts index 65f1a193552..f426445103c 100644 --- a/packages/db-dynamodb/src/utils/put.ts +++ b/packages/db-dynamodb/src/utils/put.ts @@ -1,18 +1,21 @@ import { Entity } from "~/toolbox"; -interface Params { +export interface IPutParamsItem { + PK: string; + SK: string; + [key: string]: any; +} + +export interface IPutParams { entity: Entity; - item: { - PK: string; - SK: string; - [key: string]: any; - }; + item: IPutParamsItem; } -export const put = async (params: Params) => { +export const put = async (params: IPutParams) => { const { entity, item } = params; return await entity.put(item, { - execute: true + execute: true, + strictSchemaCheck: false }); }; diff --git a/packages/db-dynamodb/src/utils/scan.ts b/packages/db-dynamodb/src/utils/scan.ts index 6feac2268f1..8606f85a41a 100644 --- a/packages/db-dynamodb/src/utils/scan.ts +++ b/packages/db-dynamodb/src/utils/scan.ts @@ -1,8 +1,8 @@ -import { ScanInput, ScanOutput } from "@webiny/aws-sdk/client-dynamodb"; -import { Entity, ScanOptions, Table } from "~/toolbox"; +import type { ScanInput, ScanOutput } from "@webiny/aws-sdk/client-dynamodb"; +import type { Entity, ScanOptions, TableDef } from "~/toolbox"; import { executeWithRetry, ExecuteWithRetryOptions } from "@webiny/utils"; -export type { ScanOptions }; +export type { ScanOptions, ScanInput, ScanOutput }; export interface BaseScanParams { options?: ScanOptions; @@ -10,7 +10,7 @@ export interface BaseScanParams { } export interface ScanWithTable extends BaseScanParams { - table: Table<any, any, any>; + table: TableDef; entity?: never; } diff --git a/packages/db-dynamodb/src/utils/table.ts b/packages/db-dynamodb/src/utils/table.ts deleted file mode 100644 index a435ba992d0..00000000000 --- a/packages/db-dynamodb/src/utils/table.ts +++ /dev/null @@ -1,16 +0,0 @@ -import WebinyError from "@webiny/error"; -import { DbContext } from "@webiny/handler-db/types"; - -/** - * Will be removed in favor of passing the table name directly to the storage operations. - * - * @deprecated - */ -export const getTable = <T extends DbContext>(context: T): string => { - if (!context.db) { - throw new WebinyError("Missing db on context.", "DB_ERROR"); - } else if (!context.db.table) { - throw new WebinyError("Missing table on context.db.", "TABLE_ERROR"); - } - return context.db.table; -}; diff --git a/packages/db-dynamodb/src/utils/table/Table.ts b/packages/db-dynamodb/src/utils/table/Table.ts new file mode 100644 index 00000000000..7d7fba286ed --- /dev/null +++ b/packages/db-dynamodb/src/utils/table/Table.ts @@ -0,0 +1,44 @@ +import type { TableConstructor } from "~/toolbox"; +import { Table as BaseTable } from "~/toolbox"; +import type { + ITable, + ITableReadBatch, + ITableScanParams, + ITableScanResponse, + ITableWriteBatch +} from "./types"; +import { createTableWriteBatch } from "./TableWriteBatch"; +import { createTableReadBatch } from "./TableReadBatch"; +import { scan } from "../scan"; + +export class Table< + Name extends string = string, + PartitionKey extends string = string, + SortKey extends string = string +> implements ITable +{ + public readonly table: BaseTable<Name, PartitionKey, SortKey>; + + public constructor(params: TableConstructor<Name, PartitionKey, SortKey>) { + this.table = new BaseTable(params); + } + + public createWriter(): ITableWriteBatch { + return createTableWriteBatch({ + table: this.table + }); + } + + public createReader(): ITableReadBatch { + return createTableReadBatch({ + table: this.table + }); + } + + public async scan<T>(params: ITableScanParams): Promise<ITableScanResponse<T>> { + return scan<T>({ + ...params, + table: this.table + }); + } +} diff --git a/packages/db-dynamodb/src/utils/table/TableReadBatch.ts b/packages/db-dynamodb/src/utils/table/TableReadBatch.ts new file mode 100644 index 00000000000..826bae5f96d --- /dev/null +++ b/packages/db-dynamodb/src/utils/table/TableReadBatch.ts @@ -0,0 +1,75 @@ +import type { Entity, TableDef } from "~/toolbox"; +import type { + IEntityReadBatchBuilder, + IEntityReadBatchBuilderGetResponse +} from "~/utils/entity/types"; +import { batchReadAll } from "~/utils/batch/batchRead"; +import { createEntityReadBatchBuilder } from "~/utils/entity/EntityReadBatchBuilder"; +import type { GenericRecord } from "@webiny/api/types"; +import { WebinyError } from "@webiny/error"; +import type { ITableReadBatch, ITableReadBatchKey } from "./types"; + +export interface ITableReadBatchParams { + table: TableDef; +} + +export class TableReadBatch implements ITableReadBatch { + private readonly table: TableDef; + + private readonly _items: IEntityReadBatchBuilderGetResponse[] = []; + private readonly builders: Map<string, IEntityReadBatchBuilder> = new Map(); + + public constructor(params: ITableReadBatchParams) { + this.table = params.table; + } + + public get total(): number { + return this._items.length; + } + + public get items(): IEntityReadBatchBuilderGetResponse[] { + return Array.from(this._items); + } + + public get(entity: Entity, input: ITableReadBatchKey): void { + const builder = this.getBuilder(entity); + + const items = Array.isArray(input) ? input : [input]; + for (const item of items) { + /** + * We cannot read from two tables at the same time, so check for that. + */ + if (this.table.name !== entity.table!.name) { + throw new WebinyError(`Cannot read from two different tables at the same time.`); + } + + this._items.push(builder.get(item)); + } + } + + public async execute<T = GenericRecord>(): Promise<T[]> { + if (this._items.length === 0) { + return []; + } + const items = Array.from(this._items); + this._items.length = 0; + return await batchReadAll<T>({ + items, + table: this.table + }); + } + + private getBuilder(entity: Entity): IEntityReadBatchBuilder { + const builder = this.builders.get(entity.name); + if (builder) { + return builder; + } + const newBuilder = createEntityReadBatchBuilder(entity); + this.builders.set(entity.name, newBuilder); + return newBuilder; + } +} + +export const createTableReadBatch = (params: ITableReadBatchParams): ITableReadBatch => { + return new TableReadBatch(params); +}; diff --git a/packages/db-dynamodb/src/utils/table/TableWriteBatch.ts b/packages/db-dynamodb/src/utils/table/TableWriteBatch.ts new file mode 100644 index 00000000000..a8cbf0c5c9b --- /dev/null +++ b/packages/db-dynamodb/src/utils/table/TableWriteBatch.ts @@ -0,0 +1,84 @@ +import type { Entity, TableDef } from "~/toolbox"; +import type { + BatchWriteItem, + BatchWriteResult, + IDeleteBatchItem, + IPutBatchItem +} from "~/utils/batch/types"; +import type { IEntityWriteBatchBuilder } from "~/utils/entity/types"; +import { batchWriteAll } from "~/utils/batch/batchWrite"; +import { createEntityWriteBatchBuilder } from "~/utils/entity/EntityWriteBatchBuilder"; +import type { ITableWriteBatch } from "./types"; + +export interface ITableWriteBatchParams { + table: TableDef; + items?: BatchWriteItem[]; +} + +export class TableWriteBatch implements ITableWriteBatch { + private readonly table: TableDef; + private readonly _items: BatchWriteItem[] = []; + private readonly builders: Map<string, IEntityWriteBatchBuilder> = new Map(); + + public get total(): number { + return this._items.length; + } + + public get items(): BatchWriteItem[] { + return Array.from(this._items); + } + + public constructor(params: ITableWriteBatchParams) { + this.table = params.table; + if (!params.items?.length) { + return; + } + this._items.push(...params.items); + } + + public put(entity: Entity, item: IPutBatchItem): void { + const builder = this.getBuilder(entity); + this._items.push(builder.put(item)); + } + + public delete(entity: Entity, item: IDeleteBatchItem): void { + const builder = this.getBuilder(entity); + this._items.push(builder.delete(item)); + } + + public combine(items: BatchWriteItem[]): ITableWriteBatch { + return createTableWriteBatch({ + table: this.table, + items: this._items.concat(items) + }); + } + + public async execute(): Promise<BatchWriteResult> { + if (this._items.length === 0) { + return []; + } + const items = Array.from(this._items); + this._items.length = 0; + return await batchWriteAll({ + items, + table: this.table + }); + } + + private getBuilder(entity: Entity): IEntityWriteBatchBuilder { + if (!entity.name) { + throw new Error("Entity must have a name."); + } + const builder = this.builders.get(entity.name); + if (builder) { + return builder; + } + const newBuilder = createEntityWriteBatchBuilder(entity); + this.builders.set(entity.name, newBuilder); + return newBuilder; + } +} + +export const createTableWriteBatch = (params: ITableWriteBatchParams): ITableWriteBatch => { + return new TableWriteBatch(params); +}; diff --git a/packages/db-dynamodb/src/utils/table/index.ts b/packages/db-dynamodb/src/utils/table/index.ts new file mode 100644 index 00000000000..fd8e934067a --- /dev/null +++ b/packages/db-dynamodb/src/utils/table/index.ts @@ -0,0 +1,4 @@ +export * from "./Table"; +export * from "./TableReadBatch"; +export * from "./TableWriteBatch"; +export * from "./types"; diff --git a/packages/db-dynamodb/src/utils/table/types.ts b/packages/db-dynamodb/src/utils/table/types.ts new file mode 100644 index 00000000000..c35ce53f1e6 --- /dev/null +++ b/packages/db-dynamodb/src/utils/table/types.ts @@ -0,0 +1,53 @@ +import type { TableDef } from "dynamodb-toolbox/dist/cjs/classes/Table/types"; +import type { + BatchWriteItem, + BatchWriteResult, + IDeleteBatchItem, + IPutBatchItem +} from "~/utils/batch/types"; +import type { BaseScanParams, ScanResponse } from "../scan"; +import type { Entity } from "~/toolbox"; +import type { GenericRecord } from "@webiny/api/types"; + +export type ITableScanParams = BaseScanParams; + +export type ITableScanResponse<T> = ScanResponse<T>; + +export interface ITable { + table: TableDef; + createWriter(): ITableWriteBatch; + createReader(): ITableReadBatch; + scan<T>(params: ITableScanParams): Promise<ITableScanResponse<T>>; +} + +export interface ITableWriteBatch { + readonly total: number; + // readonly table: TableDef; + readonly items: BatchWriteItem[]; + put(entity: Entity, item: IPutBatchItem): void; + delete(entity: Entity, item: IDeleteBatchItem): void; + execute(): Promise<BatchWriteResult>; + combine(items: BatchWriteItem[]): ITableWriteBatch; +} + +export interface ITableReadBatchKey { + PK: string; + SK: string; +} + +export interface ITableReadBatchBuilderGetResponse { + Table: TableDef; + Key: ITableReadBatchKey; +} + +export interface ITableReadBatchKey { + PK: string; + SK: string; +} + +export interface ITableReadBatch { + readonly total: number; + readonly items: ITableReadBatchBuilderGetResponse[]; + get(entity: Entity, input: ITableReadBatchKey | ITableReadBatchKey[]): void; + execute<T = GenericRecord>(): Promise<T[]>; +} diff --git a/packages/db-dynamodb/src/utils/update.ts b/packages/db-dynamodb/src/utils/update.ts index b7fad30da83..c4e563972ab 100644 --- a/packages/db-dynamodb/src/utils/update.ts +++ b/packages/db-dynamodb/src/utils/update.ts @@ -5,6 +5,7 @@ interface Params { item: { PK: string; SK: string; + TYPE?: string; [key: string]: any; }; } @@ -13,6 +14,7 @@ export const update = async (params: Params) => { const { entity, item } = params; return await entity.update(item, { - execute: true + execute: true, + strictSchemaCheck: false }); }; diff --git a/packages/db-dynamodb/tsconfig.build.json b/packages/db-dynamodb/tsconfig.build.json index d14ec0b973f..a1e7b5b5b27 100644 --- a/packages/db-dynamodb/tsconfig.build.json +++ b/packages/db-dynamodb/tsconfig.build.json @@ -6,7 +6,6 @@ { "path": "../aws-sdk/tsconfig.build.json" }, { "path": "../db/tsconfig.build.json" }, { "path": "../error/tsconfig.build.json" }, - { "path": "../handler-db/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, { "path": "../utils/tsconfig.build.json" } ], diff --git a/packages/db-dynamodb/tsconfig.json b/packages/db-dynamodb/tsconfig.json index 13fd84d1241..df4af1353b1 100644 --- a/packages/db-dynamodb/tsconfig.json +++ b/packages/db-dynamodb/tsconfig.json @@ -6,7 +6,6 @@ { "path": "../aws-sdk" }, { "path": "../db" }, { "path": "../error" }, - { "path": "../handler-db" }, { "path": "../plugins" }, { "path": "../utils" } ], @@ -25,8 +24,6 @@ "@webiny/db": ["../db/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], - "@webiny/handler-db/*": ["../handler-db/src/*"], - "@webiny/handler-db": ["../handler-db/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"], "@webiny/utils/*": ["../utils/src/*"], diff --git a/packages/migrations/src/migrations/5.35.0/001/ddb-es/FileDataMigration.ts b/packages/migrations/src/migrations/5.35.0/001/ddb-es/FileDataMigration.ts index 0f2750f9257..b4848b89c70 100644 --- a/packages/migrations/src/migrations/5.35.0/001/ddb-es/FileDataMigration.ts +++ b/packages/migrations/src/migrations/5.35.0/001/ddb-es/FileDataMigration.ts @@ -5,12 +5,12 @@ import { PrimitiveValue } from "@webiny/api-elasticsearch/types"; import { executeWithRetry } from "@webiny/utils"; import { DataMigration, DataMigrationContext } from "@webiny/data-migration"; import { - createStandardEntity, - queryOne, - queryAll, batchWriteAll, + createStandardEntity, + esGetIndexName, esQueryAllWithCallback, - esGetIndexName + queryAll, + queryOne } from "~/utils"; import { createFileEntity, getFileData, legacyAttributes } from "../entities/createFileEntity"; import { createLocaleEntity } from "../entities/createLocaleEntity"; @@ -160,8 +160,11 @@ export class FileManager_5_35_0_001_FileData implements DataMigration<FileMigrat const execute = () => { return Promise.all( - chunk(items, 200).map(fileChunk => { - return batchWriteAll({ + chunk(items, 200).map(async fileChunk => { + /** + * Leave batch write for now. + */ + return await batchWriteAll({ table: this.newFileEntity.table, items: fileChunk }); diff --git a/packages/migrations/src/migrations/5.35.0/003/index.ts b/packages/migrations/src/migrations/5.35.0/003/index.ts index 562a703c3ba..92d7685ea33 100644 --- a/packages/migrations/src/migrations/5.35.0/003/index.ts +++ b/packages/migrations/src/migrations/5.35.0/003/index.ts @@ -1,10 +1,11 @@ import { Table } from "@webiny/db-dynamodb/toolbox"; import { DataMigrationContext, PrimaryDynamoTableSymbol } from "@webiny/data-migration"; -import { queryOne, queryAll, batchWriteAll } from "~/utils"; +import { queryAll, queryOne } from "~/utils"; import { createTenantEntity } from "./createTenantEntity"; import { createLegacyUserEntity, createUserEntity, getUserData } from "./createUserEntity"; -import { makeInjectable, inject } from "@webiny/ioc"; +import { inject, makeInjectable } from "@webiny/ioc"; import { executeWithRetry } from "@webiny/utils"; +import { createEntityWriteBatch } from "@webiny/db-dynamodb"; export class AdminUsers_5_35_0_003 { private readonly newUserEntity: ReturnType<typeof createUserEntity>; @@ -73,24 +74,27 @@ export class AdminUsers_5_35_0_003 { continue; } - const newUsers = users - .filter(user => !user.data) - .map(user => { - return this.newUserEntity.putBatch({ - PK: `T#${tenant.id}#ADMIN_USER#${user.id}`, - SK: "A", - GSI1_PK: `T#${tenant.id}#ADMIN_USERS`, - GSI1_SK: user.email, - TYPE: "adminUsers.user", - ...getUserData(user), - // Move all data to a `data` envelope - data: getUserData(user) - }); - }); + const newUsersEntityBatch = createEntityWriteBatch({ + entity: this.newUserEntity, + put: users + .filter(user => !user.data) + .map(user => { + return { + PK: `T#${tenant.id}#ADMIN_USER#${user.id}`, + SK: "A", + GSI1_PK: `T#${tenant.id}#ADMIN_USERS`, + GSI1_SK: user.email, + TYPE: "adminUsers.user", + ...getUserData(user), + // Move all data to a `data` envelope + data: getUserData(user) + }; + }) + }); - await executeWithRetry(() => - batchWriteAll({ table: this.newUserEntity.table, items: newUsers }) - ); + await executeWithRetry(async () => { + return await newUsersEntityBatch.execute(); + }); } } } diff --git a/packages/migrations/src/migrations/5.35.0/005/index.ts b/packages/migrations/src/migrations/5.35.0/005/index.ts index f02cd1e77c8..6b32acd2168 100644 --- a/packages/migrations/src/migrations/5.35.0/005/index.ts +++ b/packages/migrations/src/migrations/5.35.0/005/index.ts @@ -1,14 +1,15 @@ import { Table } from "@webiny/db-dynamodb/toolbox"; -import { makeInjectable, inject } from "@webiny/ioc"; +import { inject, makeInjectable } from "@webiny/ioc"; import { DataMigrationContext, PrimaryDynamoTableSymbol } from "@webiny/data-migration"; -import { queryAll, batchWriteAll } from "~/utils"; +import { queryAll } from "~/utils"; import { createModelEntity } from "./createModelEntity"; import { createTenantEntity } from "./createTenantEntity"; import { createLocaleEntity } from "./createLocaleEntity"; -import { Tenant, I18NLocale, CmsModel } from "./types"; +import { CmsModel, I18NLocale, Tenant } from "./types"; import pluralize from "pluralize"; import upperFirst from "lodash/upperFirst"; import camelCase from "lodash/camelCase"; +import { createEntityWriteBatch } from "@webiny/db-dynamodb"; const createSingularApiName = (model: CmsModel) => { return upperFirst(camelCase(model.modelId)); @@ -91,22 +92,23 @@ export class CmsModels_5_35_0_005 { return; } - const items = models.map(model => { - return this.modelEntity.putBatch({ - ...model, - /** - * Add singular and plural API names. - */ - singularApiName: createSingularApiName(model), - pluralApiName: createPluralApiName(model) - }); + const entityBatch = createEntityWriteBatch({ + entity: this.modelEntity, + put: models.map(model => { + return { + ...model, + /** + * Add singular and plural API names. + */ + singularApiName: createSingularApiName(model), + pluralApiName: createPluralApiName(model) + }; + }) }); - logger.info(`Updating total of ${items.length} models.`); - await batchWriteAll({ - table: this.modelEntity.table, - items - }); + logger.info(`Updating total of ${entityBatch.total} models.`); + + await entityBatch.execute(); logger.info("Updated all the models."); } diff --git a/packages/migrations/src/migrations/5.37.0/002/ddb-es/index.ts b/packages/migrations/src/migrations/5.37.0/002/ddb-es/index.ts index f673b58b039..429fc900dee 100644 --- a/packages/migrations/src/migrations/5.37.0/002/ddb-es/index.ts +++ b/packages/migrations/src/migrations/5.37.0/002/ddb-es/index.ts @@ -272,15 +272,15 @@ export class CmsEntriesRootFolder_5_37_0_002 ); } - const execute = () => { - return batchWriteAll({ + const execute = async () => { + return await batchWriteAll({ table: this.ddbEntryEntity.table, items: ddbItems }); }; - const executeDdbEs = () => { - return batchWriteAll({ + const executeDdbEs = async () => { + return await batchWriteAll({ table: this.ddbEsEntryEntity.table, items: ddbEsItems }); diff --git a/packages/migrations/src/migrations/5.37.0/003/ddb-es/AcoFolderMigration.ts b/packages/migrations/src/migrations/5.37.0/003/ddb-es/AcoFolderMigration.ts index 9869ada4a64..1cdc644b628 100644 --- a/packages/migrations/src/migrations/5.37.0/003/ddb-es/AcoFolderMigration.ts +++ b/packages/migrations/src/migrations/5.37.0/003/ddb-es/AcoFolderMigration.ts @@ -7,8 +7,6 @@ import { createLocaleEntity } from "../entities/createLocaleEntity"; import { createTenantEntity } from "../entities/createTenantEntity"; import { createDdbEntryEntity, createDdbEsEntryEntity } from "../entities/createEntryEntity"; import { - batchWriteAll, - BatchWriteItem, esFindOne, esGetIndexExist, esGetIndexName, @@ -20,6 +18,7 @@ import { CmsEntryAcoFolder, I18NLocale, ListLocalesParams, Tenant } from "../typ import { ACO_FOLDER_MODEL_ID, ROOT_FOLDER, UPPERCASE_ROOT_FOLDER } from "../constants"; import { getElasticsearchLatestEntryData } from "./latestElasticsearchData"; import { getDecompressedData } from "~/migrations/5.37.0/003/utils/getDecompressedData"; +import { createEntityWriteBatch } from "@webiny/db-dynamodb"; const isGroupMigrationCompleted = ( status: PrimitiveValue[] | boolean | undefined @@ -236,8 +235,12 @@ export class AcoRecords_5_37_0_003_AcoFolder `Processing batch #${batch} in group ${groupId} (${folders.length} folders).` ); - const ddbItems: BatchWriteItem[] = []; - const ddbEsItems: BatchWriteItem[] = []; + const entityBatch = createEntityWriteBatch({ + entity: this.ddbEntryEntity + }); + const elasticsearchEntityBatch = createEntityWriteBatch({ + entity: this.ddbEsEntryEntity + }); for (const folder of folders) { const folderPk = `T#${tenantId}#L#${localeCode}#CMS#CME#${folder.entryId}`; @@ -278,10 +281,8 @@ export class AcoRecords_5_37_0_003_AcoFolder TYPE: "cms.entry" }; - ddbItems.push( - this.ddbEntryEntity.putBatch(latestDdb), - this.ddbEntryEntity.putBatch(revisionDdb) - ); + entityBatch.put(latestDdb); + entityBatch.put(revisionDdb); const esLatestRecord = await get<CmsEntryAcoFolderElasticsearchRecord>({ entity: this.ddbEsEntryEntity, @@ -316,21 +317,15 @@ export class AcoRecords_5_37_0_003_AcoFolder index: foldersIndexName }; - ddbEsItems.push(this.ddbEsEntryEntity.putBatch(latestDdbEs)); + elasticsearchEntityBatch.put(latestDdbEs); } - const executeDdb = () => { - return batchWriteAll({ - table: this.ddbEntryEntity.table, - items: ddbItems - }); + const executeDdb = async () => { + return entityBatch.execute(); }; - const executeDdbEs = () => { - return batchWriteAll({ - table: this.ddbEsEntryEntity.table, - items: ddbEsItems - }); + const executeDdbEs = async () => { + return elasticsearchEntityBatch.execute(); }; await executeWithRetry(executeDdb, { diff --git a/packages/migrations/src/migrations/5.40.0/001/ddb/index.ts b/packages/migrations/src/migrations/5.40.0/001/ddb/index.ts index 47166648883..ee0fa247684 100644 --- a/packages/migrations/src/migrations/5.40.0/001/ddb/index.ts +++ b/packages/migrations/src/migrations/5.40.0/001/ddb/index.ts @@ -4,18 +4,13 @@ import { DataMigrationContext, PrimaryDynamoTableSymbol } from "@webiny/data-migration"; -import { - batchWriteAll, - BatchWriteItem, - count, - ddbQueryAllWithCallback, - forEachTenantLocale -} from "~/utils"; +import { count, ddbQueryAllWithCallback, forEachTenantLocale } from "~/utils"; import { inject, makeInjectable } from "@webiny/ioc"; import { executeWithRetry, generateAlphaNumericId } from "@webiny/utils"; import { createBlockEntity } from "~/migrations/5.40.0/001/ddb/createBlockEntity"; import { ContentElement, PageBlock } from "./types"; import { compress, decompress } from "./compression"; +import { createEntityWriteBatch } from "@webiny/db-dynamodb"; const isGroupMigrationCompleted = (status: boolean | undefined): status is boolean => { return typeof status === "boolean"; @@ -99,6 +94,10 @@ export class PbUniqueBlockElementIds_5_40_0_001 implements DataMigration { `Processing batch #${batch} in group ${groupId} (${blocks.length} blocks).` ); + const entityBatch = createEntityWriteBatch({ + entity: this.blockEntity + }); + const items = await Promise.all( blocks.map(async block => { const newContent = await this.generateElementIds(block); @@ -106,18 +105,17 @@ export class PbUniqueBlockElementIds_5_40_0_001 implements DataMigration { return null; } - return this.blockEntity.putBatch({ + const item = { ...block, content: newContent - }); + }; + entityBatch.put(item); + return item; }) ); - const execute = () => { - return batchWriteAll({ - table: this.blockEntity.table, - items: items.filter(Boolean) as BatchWriteItem[] - }); + const execute = async () => { + return await entityBatch.execute(); }; await executeWithRetry(execute, { diff --git a/packages/migrations/src/migrations/5.41.0/001/index.ts b/packages/migrations/src/migrations/5.41.0/001/index.ts index 6cec0e2917d..070146eb9f6 100644 --- a/packages/migrations/src/migrations/5.41.0/001/index.ts +++ b/packages/migrations/src/migrations/5.41.0/001/index.ts @@ -1,10 +1,11 @@ import { Table } from "@webiny/db-dynamodb/toolbox"; import { DataMigrationContext, PrimaryDynamoTableSymbol } from "@webiny/data-migration"; -import { queryOne, queryAll, batchWriteAll } from "~/utils"; +import { queryAll, queryOne } from "~/utils"; import { createTenantEntity } from "./createTenantEntity"; import { createUserEntity } from "./createUserEntity"; -import { makeInjectable, inject } from "@webiny/ioc"; +import { inject, makeInjectable } from "@webiny/ioc"; import { executeWithRetry } from "@webiny/utils"; +import { createEntityWriteBatch } from "@webiny/db-dynamodb"; export class AdminUsers_5_41_0_001 { private readonly newUserEntity: ReturnType<typeof createUserEntity>; @@ -71,22 +72,25 @@ export class AdminUsers_5_41_0_001 { continue; } - const newUsers = users - .filter(user => !Array.isArray(user.data.groups)) - .map(user => { - return this.newUserEntity.putBatch({ - ...user, - data: { - ...user.data, - groups: [user.data.group].filter(Boolean), - teams: [user.data.team].filter(Boolean) - } - }); - }); + const newUsersEntityBatch = createEntityWriteBatch({ + entity: this.newUserEntity, + put: users + .filter(user => !Array.isArray(user.data.groups)) + .map(user => { + return { + ...user, + data: { + ...user.data, + groups: [user.data.group].filter(Boolean), + teams: [user.data.team].filter(Boolean) + } + }; + }) + }); - await executeWithRetry(() => - batchWriteAll({ table: this.newUserEntity.table, items: newUsers }) - ); + await executeWithRetry(async () => { + return await newUsersEntityBatch.execute(); + }); } } } diff --git a/packages/migrations/src/utils/forEachTenantLocale.ts b/packages/migrations/src/utils/forEachTenantLocale.ts index a88284551a9..bd1f7cb13c7 100644 --- a/packages/migrations/src/utils/forEachTenantLocale.ts +++ b/packages/migrations/src/utils/forEachTenantLocale.ts @@ -1,8 +1,18 @@ import { createLocaleEntity, createTenantEntity, queryAll } from "~/utils"; -import { I18NLocale, Tenant } from "~/migrations/5.37.0/003/types"; import { Table } from "@webiny/db-dynamodb/toolbox"; import { Logger } from "@webiny/logger"; +export interface Tenant { + data: { + id: string; + name: string; + }; +} + +export interface I18NLocale { + code: string; +} + type ForEachTenantLocaleCallback = (params: { tenantId: string; localeCode: string; diff --git a/yarn.lock b/yarn.lock index 9474c8c091f..08a8f9dba39 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14716,7 +14716,6 @@ __metadata: dataloader: ^2.0.0 jest: ^29.7.0 jest-dynalite: ^3.2.0 - lodash: ^4.17.21 rimraf: ^5.0.5 ttypescript: ^1.5.12 typescript: 4.9.5 @@ -17302,7 +17301,6 @@ __metadata: "@webiny/cli": 0.0.0 "@webiny/db": 0.0.0 "@webiny/error": 0.0.0 - "@webiny/handler-db": 0.0.0 "@webiny/plugins": 0.0.0 "@webiny/project-utils": 0.0.0 "@webiny/utils": 0.0.0 From 9b7ddad4a32970dc7d5f655bd7c69177f9a2facf Mon Sep 17 00:00:00 2001 From: adrians5j <adrian@webiny.com> Date: Wed, 18 Dec 2024 13:56:58 +0100 Subject: [PATCH 18/52] ci: add `/jest` command [no ci] --- .github/workflows/pullRequestsCommandJest.yml | 466 ++++++++++++++++++ .../wac/pullRequestsCommandJest.wac.ts | 173 +++++++ 2 files changed, 639 insertions(+) create mode 100644 .github/workflows/pullRequestsCommandJest.yml create mode 100644 .github/workflows/wac/pullRequestsCommandJest.wac.ts diff --git a/.github/workflows/pullRequestsCommandJest.yml b/.github/workflows/pullRequestsCommandJest.yml new file mode 100644 index 00000000000..9a5a2fe264e --- /dev/null +++ b/.github/workflows/pullRequestsCommandJest.yml @@ -0,0 +1,466 @@ +# This file was automatically generated by github-actions-wac. +# DO NOT MODIFY IT BY HAND. Instead, modify the source *.wac.ts file(s) +# and run "github-actions-wac build" (or "ghawac build") to regenerate this file. +# For more information, run "github-actions-wac --help". +name: Pull Requests Command - Jest +"on": issue_comment +env: + NODE_OPTIONS: "--max_old_space_size=4096" + AWS_REGION: eu-central-1 +jobs: + checkComment: + name: Check comment for /jest + if: ${{ github.event.issue.pull_request }} + steps: + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Check for Command + id: command + uses: xt0rted/slash-command-action@v2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + command: jest + reaction: "true" + reaction-type: eyes + allow-edits: "false" + permission-level: write + - name: Create comment + uses: peter-evans/create-or-update-comment@v2 + with: + issue-number: ${{ github.event.issue.number }} + body: >- + Jest tests have been initiated (for more information, click + [here](https://github.com/webiny/webiny-js/actions/runs/${{ + github.run_id }})). :sparkles: + runs-on: ubuntu-latest + env: + NODE_OPTIONS: "--max_old_space_size=4096" + YARN_ENABLE_IMMUTABLE_INSTALLS: false + validateWorkflows: + name: Validate workflows + steps: + - uses: actions/setup-node@v4 + with: + node-version: 20 + - uses: actions/checkout@v4 + - name: Install dependencies + run: yarn --immutable + - name: Validate + run: npx github-actions-wac validate + needs: checkComment + runs-on: ubuntu-latest + env: + NODE_OPTIONS: "--max_old_space_size=4096" + YARN_ENABLE_IMMUTABLE_INSTALLS: false + baseBranch: + needs: checkComment + name: Get base branch + outputs: + base-branch: ${{ steps.base-branch.outputs.base-branch }} + steps: + - uses: actions/setup-node@v4 + with: + node-version: 20 + - uses: actions/checkout@v4 + - name: Get base branch + id: base-branch + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + run: >- + echo "base-branch=$(gh pr view ${{ github.event.issue.number }} --json + baseRefName -q .baseRefName)" >> $GITHUB_OUTPUT + runs-on: ubuntu-latest + env: + NODE_OPTIONS: "--max_old_space_size=4096" + YARN_ENABLE_IMMUTABLE_INSTALLS: false + constants: + needs: baseBranch + name: Create constants + outputs: + global-cache-key: ${{ steps.global-cache-key.outputs.global-cache-key }} + run-cache-key: ${{ steps.run-cache-key.outputs.run-cache-key }} + steps: + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Create global cache key + id: global-cache-key + run: >- + echo "global-cache-key=${{ needs.baseBranch.outputs.base-branch }}-${{ + runner.os }}-$(/bin/date -u "+%m%d")-${{ vars.RANDOM_CACHE_KEY_SUFFIX + }}" >> $GITHUB_OUTPUT + - name: Create workflow run cache key + id: run-cache-key + run: >- + echo "run-cache-key=${{ github.run_id }}-${{ github.run_attempt }}-${{ + vars.RANDOM_CACHE_KEY_SUFFIX }}" >> $GITHUB_OUTPUT + runs-on: ubuntu-latest + env: + NODE_OPTIONS: "--max_old_space_size=4096" + YARN_ENABLE_IMMUTABLE_INSTALLS: false + build: + name: Build + needs: + - baseBranch + - constants + runs-on: webiny-build-packages + steps: + - uses: actions/setup-node@v4 + with: + node-version: 20 + - uses: actions/checkout@v4 + with: + path: ${{ needs.baseBranch.outputs.base-branch }} + - name: Checkout Pull Request + working-directory: ${{ needs.baseBranch.outputs.base-branch }} + run: gh pr checkout ${{ github.event.issue.number }} + env: + GITHUB_TOKEN: ${{ secrets.GH_TOKEN }} + - uses: actions/cache@v4 + with: + path: ${{ needs.baseBranch.outputs.base-branch }}/.yarn/cache + key: yarn-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + - uses: actions/cache@v4 + with: + path: ${{ needs.baseBranch.outputs.base-branch }}/.webiny/cached-packages + key: ${{ needs.constants.outputs.global-cache-key }} + - name: Install dependencies + run: yarn --immutable + working-directory: ${{ needs.baseBranch.outputs.base-branch }} + - name: Build packages + run: yarn build:quick + working-directory: ${{ needs.baseBranch.outputs.base-branch }} + - uses: actions/cache@v4 + with: + path: ${{ needs.baseBranch.outputs.base-branch }}/.webiny/cached-packages + key: ${{ needs.constants.outputs.run-cache-key }} + env: + NODE_OPTIONS: "--max_old_space_size=4096" + YARN_ENABLE_IMMUTABLE_INSTALLS: false + jestTestsNoStorage: + needs: + - constants + - build + name: ${{ matrix.package.cmd }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + node: + - 20 + package: >- + ${{ + fromJson('[{"cmd":"packages/api","packageName":"api","id":"806497aaa729e8d39f59792bcfb12b26"},{"cmd":"packages/api-admin-settings","packageName":"api-admin-settings","id":"31140e7ea9283c9db32ec5f905ce2a1e"},{"cmd":"packages/api-authentication","packageName":"api-authentication","id":"0eaf9f853f122e4ab215bf49d39f3edc"},{"cmd":"packages/api-authentication-cognito","packageName":"api-authentication-cognito","id":"dfb5e1fcea213538a9730314cb5e7d06"},{"cmd":"packages/api-headless-cms-ddb","packageName":"api-headless-cms-ddb","id":"5333e1fe6c2b8f5bbcb101a446419c3e"},{"cmd":"packages/api-record-locking","packageName":"api-record-locking","id":"9340c019a5369ea1aa55f7ed28b09f48"},{"cmd":"packages/api-wcp","packageName":"api-wcp","id":"77ff8a0a075e8d9f7e25001ea64c6c9e"},{"cmd":"packages/api-websockets","packageName":"api-websockets","id":"fd704b97c31f78a886b342babd344d33"},{"cmd":"packages/app-aco","packageName":"app-aco","id":"dddb66beffe2e54804d5bdedd2b423cb"},{"cmd":"packages/app-admin","packageName":"app-admin","id":"53bbef747a26e831904585bcfdd845f7"},{"cmd":"packages/cwp-template-aws","packageName":"cwp-template-aws","id":"846572f41c9427974a577bb95257d019"},{"cmd":"packages/data-migration","packageName":"data-migration","id":"294257fffed0174f169b2c812e16258e"},{"cmd":"packages/db-dynamodb","packageName":"db-dynamodb","id":"5cb733de265d7bbda981fce60f2a8962"},{"cmd":"packages/form","packageName":"form","id":"5707e699d8a4d3b8ee1954c070a50617"},{"cmd":"packages/handler","packageName":"handler","id":"1dad17bbf61657b4308250e8293cb5dd"},{"cmd":"packages/handler-aws","packageName":"handler-aws","id":"2a5bd44c5f2a4290c43f9021bbc705a5"},{"cmd":"packages/handler-graphql","packageName":"handler-graphql","id":"74884166fb2bf383da482fb78b18b704"},{"cmd":"packages/handler-logs","packageName":"handler-logs","id":"ca9a7e2ed32de50aff66c839f0003352"},{"cmd":"packages/ioc","packageName":"ioc","id":"af22b6d7d245321d64d4b714d03ef3e1"},{"cmd":"packages/lexical-converter","packageName":"lexical-converter","id":"52e3bb3ea633bd27d5bab8be976cd16f"},{"cmd":"packages/plugins","packageName":"plugins","id":"c91537eaa40845d816d0d9f39e66018b"},{"cmd":"packages/pubsub","packageName":"pubsub","id":"fc14c28c51c537a7d9edd33d73ae29e2"},{"cmd":"packages/react-composition","packageName":"react-composition","id":"428b8a3187fe275cb76da6bad0ba3918"},{"cmd":"packages/react-properties","packageName":"react-properties","id":"7578e63dcaa1ac66fed4a8dd936a9285"},{"cmd":"packages/react-rich-text-lexical-renderer","packageName":"react-rich-text-lexical-renderer","id":"452451b34eb7e0134e99b0706e5eb076"},{"cmd":"packages/utils","packageName":"utils","id":"696ceb17e38e4a274d4a149d24513b78"},{"cmd":"packages/validation","packageName":"validation","id":"9c68da33792a1214ae45e040a2830cd7"}]') + }} + runs-on: ${{ matrix.os }} + env: + NODE_OPTIONS: "--max_old_space_size=4096" + YARN_ENABLE_IMMUTABLE_INSTALLS: false + AWS_REGION: eu-central-1 + steps: + - uses: actions/setup-node@v4 + with: + node-version: 20 + - uses: actions/checkout@v4 + with: + path: ${{ needs.baseBranch.outputs.base-branch }} + - uses: actions/cache@v4 + with: + path: ${{ needs.baseBranch.outputs.base-branch }}/.yarn/cache + key: yarn-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + - uses: actions/cache@v4 + with: + path: ${{ needs.baseBranch.outputs.base-branch }}/.webiny/cached-packages + key: ${{ needs.constants.outputs.run-cache-key }} + - name: Install dependencies + run: yarn --immutable + working-directory: ${{ needs.baseBranch.outputs.base-branch }} + - name: Build packages + run: yarn build:quick + working-directory: ${{ needs.baseBranch.outputs.base-branch }} + - name: Run tests + run: yarn test ${{ matrix.package.cmd }} + working-directory: ${{ needs.baseBranch.outputs.base-branch }} + jestTestsDdb: + needs: + - constants + - build + name: ${{ matrix.package.cmd }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + node: + - 20 + package: >- + ${{ fromJson('[{"cmd":"packages/api-aco + --storage=ddb","storage":"ddb","packageName":"api-aco","id":"5595b4f3460fb2a019066177bd6489f3"},{"cmd":"packages/api-apw + --storage=ddb","storage":"ddb","packageName":"api-apw","id":"04462239e1f3509b08f511de460971ec"},{"cmd":"packages/api-audit-logs + --storage=ddb","storage":"ddb","packageName":"api-audit-logs","id":"47680aa68a1a3951f1117c736e150e45"},{"cmd":"packages/api-file-manager + --storage=ddb","storage":"ddb","packageName":"api-file-manager","id":"9b6eee1ff7cbf9a3d367818705cc4189"},{"cmd":"packages/api-form-builder + --storage=ddb","storage":"ddb","packageName":"api-form-builder","id":"980a9aebb5ec0cab057422364a60493b"},{"cmd":"packages/api-headless-cms + --storage=ddb + --shard=1/6","storage":"ddb","packageName":"api-headless-cms","id":"70476469f4407a455237133406a37a4b"},{"cmd":"packages/api-headless-cms + --storage=ddb + --shard=2/6","storage":"ddb","packageName":"api-headless-cms","id":"0eba11dcf36fd00e737a630f40567e85"},{"cmd":"packages/api-headless-cms + --storage=ddb + --shard=3/6","storage":"ddb","packageName":"api-headless-cms","id":"8c15e662d10ad6272ac557515e39d4cd"},{"cmd":"packages/api-headless-cms + --storage=ddb + --shard=4/6","storage":"ddb","packageName":"api-headless-cms","id":"3b14c43cd5971ad2945b1f0e87970e20"},{"cmd":"packages/api-headless-cms + --storage=ddb + --shard=5/6","storage":"ddb","packageName":"api-headless-cms","id":"a71716169299cfee9996f4344c84616f"},{"cmd":"packages/api-headless-cms + --storage=ddb + --shard=6/6","storage":"ddb","packageName":"api-headless-cms","id":"26f0b825b771340ca981858d86bd1f42"},{"cmd":"packages/api-headless-cms-aco + --storage=ddb","storage":"ddb","packageName":"api-headless-cms-aco","id":"718c110b004c59ed7d13cbcc875a6b64"},{"cmd":"packages/api-headless-cms-bulk-actions + --storage=ddb","storage":"ddb","packageName":"api-headless-cms-bulk-actions","id":"00c0a57737502f28c304015d2d1ba442"},{"cmd":"packages/api-headless-cms-import-export + --storage=ddb","storage":"ddb","packageName":"api-headless-cms-import-export","id":"e9052e7c40171aeb43ce089fdfbbe3c8"},{"cmd":"packages/api-i18n + --storage=ddb","storage":"ddb","packageName":"api-i18n","id":"943e15fe21c847b164f9413f8baf97b7"},{"cmd":"packages/api-mailer + --storage=ddb","storage":"ddb","packageName":"api-mailer","id":"2cc1dc707a39e72f4e5d9a140677ca39"},{"cmd":"packages/api-page-builder + --storage=ddb + --shard=1/6","storage":"ddb","packageName":"api-page-builder","id":"b2a30dfaf230076ce7120c55eb581d32"},{"cmd":"packages/api-page-builder + --storage=ddb + --shard=2/6","storage":"ddb","packageName":"api-page-builder","id":"c58e2f120653e8bd68475c16de4434c5"},{"cmd":"packages/api-page-builder + --storage=ddb + --shard=3/6","storage":"ddb","packageName":"api-page-builder","id":"808cb2da8e70bf84a24de2ab7ed27c24"},{"cmd":"packages/api-page-builder + --storage=ddb + --shard=4/6","storage":"ddb","packageName":"api-page-builder","id":"6f95134a56bea87da59d4c7d56846d72"},{"cmd":"packages/api-page-builder + --storage=ddb + --shard=5/6","storage":"ddb","packageName":"api-page-builder","id":"918eb8cb9d4046da9d38962b12e8ace6"},{"cmd":"packages/api-page-builder + --storage=ddb + --shard=6/6","storage":"ddb","packageName":"api-page-builder","id":"45bc3d824b38bd2770f1d4ba357387f9"},{"cmd":"packages/api-page-builder-aco + --storage=ddb","storage":"ddb","packageName":"api-page-builder-aco","id":"48281621c024ae9bbd0f79da5f6f4867"},{"cmd":"packages/api-page-builder-import-export + --storage=ddb","storage":"ddb","packageName":"api-page-builder-import-export","id":"8540085b59af85d1fd82b37b9e890704"},{"cmd":"packages/api-prerendering-service + --storage=ddb","storage":"ddb","packageName":"api-prerendering-service","id":"a2831c88465244dc03f188f4a40e4d63"},{"cmd":"packages/api-security + --storage=ddb","storage":"ddb","packageName":"api-security","id":"0a065366763b713fb016c43ce21e77b9"},{"cmd":"packages/api-security-cognito + --storage=ddb","storage":"ddb","packageName":"api-security-cognito","id":"0787967fe56689618106e6c64e784bff"},{"cmd":"packages/api-serverless-cms + --storage=ddb","storage":"ddb","packageName":"api-serverless-cms","id":"b660572a629aa6e9191829fe7bfd33cc"},{"cmd":"packages/api-tenancy + --storage=ddb","storage":"ddb","packageName":"api-tenancy","id":"0c81e56d64e97e6b563965250f04ed34"},{"cmd":"packages/api-tenant-manager + --storage=ddb","storage":"ddb","packageName":"api-tenant-manager","id":"4b93a028b8055553c3443a45b38079e9"},{"cmd":"packages/tasks + --storage=ddb","storage":"ddb","packageName":"tasks","id":"925ba761b5995e8a8b980c0789034b3c"}]') + }} + runs-on: ${{ matrix.os }} + env: + NODE_OPTIONS: "--max_old_space_size=4096" + YARN_ENABLE_IMMUTABLE_INSTALLS: false + AWS_REGION: eu-central-1 + steps: + - uses: actions/setup-node@v4 + with: + node-version: 20 + - uses: actions/checkout@v4 + with: + path: ${{ needs.baseBranch.outputs.base-branch }} + - uses: actions/cache@v4 + with: + path: ${{ needs.baseBranch.outputs.base-branch }}/.yarn/cache + key: yarn-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + - uses: actions/cache@v4 + with: + path: ${{ needs.baseBranch.outputs.base-branch }}/.webiny/cached-packages + key: ${{ needs.constants.outputs.run-cache-key }} + - name: Install dependencies + run: yarn --immutable + working-directory: ${{ needs.baseBranch.outputs.base-branch }} + - name: Build packages + run: yarn build:quick + working-directory: ${{ needs.baseBranch.outputs.base-branch }} + - name: Run tests + run: yarn test ${{ matrix.package.cmd }} + working-directory: ${{ needs.baseBranch.outputs.base-branch }} + jestTestsDdbEs: + needs: + - constants + - build + name: ${{ matrix.package.cmd }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + node: + - 20 + package: >- + ${{ fromJson('[{"cmd":"packages/api-aco + --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-aco","id":"8f23ec33f547aa62236f5c71115688d6"},{"cmd":"packages/api-audit-logs + --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-audit-logs","id":"a292444cd9100f78d8fc196274393ea8"},{"cmd":"packages/api-dynamodb-to-elasticsearch + --storage=ddb-es,ddb","storage":["ddb-es"],"packageName":"api-dynamodb-to-elasticsearch","id":"e2c325f0940ba5fb5a891a8cf74fca61"},{"cmd":"packages/api-elasticsearch + --storage=ddb-es,ddb","storage":["ddb-es"],"packageName":"api-elasticsearch","id":"5963079c60b96202bbaf2a802ad14383"},{"cmd":"packages/api-elasticsearch-tasks + --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-elasticsearch-tasks","id":"d81ad1d024a8746cc440e2e548770f8f"},{"cmd":"packages/api-file-manager + --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-file-manager","id":"d6f293add4a252b96cbd770ab6e80557"},{"cmd":"packages/api-form-builder + --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-form-builder","id":"3753bde0144d808eb15c755b7176386c"},{"cmd":"packages/api-form-builder-so-ddb-es + --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-form-builder-so-ddb-es","id":"be1748722ce53a7383696bdc9aecb36e"},{"cmd":"packages/api-headless-cms + --storage=ddb-es,ddb + --shard=1/6","storage":"ddb-es","packageName":"api-headless-cms","id":"c9e8cf197d213d99f54ae218b027db43"},{"cmd":"packages/api-headless-cms + --storage=ddb-es,ddb + --shard=2/6","storage":"ddb-es","packageName":"api-headless-cms","id":"0db69460c7bcc2bd54f21ae32c2436a0"},{"cmd":"packages/api-headless-cms + --storage=ddb-es,ddb + --shard=3/6","storage":"ddb-es","packageName":"api-headless-cms","id":"13763c404c6788aa580d8b9fa8f52239"},{"cmd":"packages/api-headless-cms + --storage=ddb-es,ddb + --shard=4/6","storage":"ddb-es","packageName":"api-headless-cms","id":"795fb79efa47ed2c7b14b1601b03db21"},{"cmd":"packages/api-headless-cms + --storage=ddb-es,ddb + --shard=5/6","storage":"ddb-es","packageName":"api-headless-cms","id":"775a20e72e2f9e3db4c119b08dca9858"},{"cmd":"packages/api-headless-cms + --storage=ddb-es,ddb + --shard=6/6","storage":"ddb-es","packageName":"api-headless-cms","id":"d9e94bb347222577c3a3c8ea3cc41e47"},{"cmd":"packages/api-headless-cms-aco + --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-headless-cms-aco","id":"873cd623b92712713e58e7dc6ddbe5d9"},{"cmd":"packages/api-headless-cms-bulk-actions + --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-headless-cms-bulk-actions","id":"d57a9e2a64e475f4629a14f4e1130e78"},{"cmd":"packages/api-headless-cms-ddb-es + --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-headless-cms-ddb-es","id":"f64e01fd77d4d1c22803e1523560b07c"},{"cmd":"packages/api-headless-cms-es-tasks + --storage=ddb-es,ddb","storage":["ddb-es"],"packageName":"api-headless-cms-es-tasks","id":"f857b5e4a7381a7f10eadef6ec83d9e0"},{"cmd":"packages/api-headless-cms-import-export + --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-headless-cms-import-export","id":"fa2cbb7997de447c87e3f1b646008711"},{"cmd":"packages/api-mailer + --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-mailer","id":"ccc077215f734fbec817d90fdb04d423"},{"cmd":"packages/api-page-builder + --storage=ddb-es,ddb + --shard=1/6","storage":"ddb-es","packageName":"api-page-builder","id":"a9d5f7851f0b921677df8521ff899f86"},{"cmd":"packages/api-page-builder + --storage=ddb-es,ddb + --shard=2/6","storage":"ddb-es","packageName":"api-page-builder","id":"d6c00270cbcfa826dab79e8c703c9eb5"},{"cmd":"packages/api-page-builder + --storage=ddb-es,ddb + --shard=3/6","storage":"ddb-es","packageName":"api-page-builder","id":"b407ab6f87871e108480b0fa3bc17902"},{"cmd":"packages/api-page-builder + --storage=ddb-es,ddb + --shard=4/6","storage":"ddb-es","packageName":"api-page-builder","id":"9aa4fe8f6e30c49c501003a914b2ca5c"},{"cmd":"packages/api-page-builder + --storage=ddb-es,ddb + --shard=5/6","storage":"ddb-es","packageName":"api-page-builder","id":"a84a7bf736194196387f2959132abfdd"},{"cmd":"packages/api-page-builder + --storage=ddb-es,ddb + --shard=6/6","storage":"ddb-es","packageName":"api-page-builder","id":"02927f20dd60108bec8356b6dae55357"},{"cmd":"packages/api-page-builder-aco + --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-page-builder-aco","id":"d12985ec4dcdb80af419125d236a73d8"},{"cmd":"packages/api-page-builder-so-ddb-es + --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-page-builder-so-ddb-es","id":"911289d4016adf351238298ce5b41ac8"},{"cmd":"packages/api-serverless-cms + --storage=ddb-es,ddb","storage":"ddb-es","packageName":"api-serverless-cms","id":"3d8f52f5b779b9ded3d746716fed019f"},{"cmd":"packages/migrations + --storage=ddb-es,ddb","storage":["ddb-es"],"packageName":"migrations","id":"7262de0ebd8c413fce5cc1428462df1a"},{"cmd":"packages/tasks + --storage=ddb-es,ddb","storage":"ddb-es","packageName":"tasks","id":"0c5cd8395d241e54e3488ffcc1c81c26"}]') + }} + runs-on: ${{ matrix.os }} + env: + NODE_OPTIONS: "--max_old_space_size=4096" + YARN_ENABLE_IMMUTABLE_INSTALLS: false + AWS_REGION: eu-central-1 + AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }} + ELASTIC_SEARCH_ENDPOINT: ${{ secrets.ELASTIC_SEARCH_ENDPOINT }} + ELASTIC_SEARCH_INDEX_PREFIX: ${{ matrix.package.id }} + steps: + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::726952677045:role/GitHubActionsWebinyJs + aws-region: eu-central-1 + - uses: actions/checkout@v4 + with: + path: ${{ needs.baseBranch.outputs.base-branch }} + - uses: actions/cache@v4 + with: + path: ${{ needs.baseBranch.outputs.base-branch }}/.yarn/cache + key: yarn-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + - uses: actions/cache@v4 + with: + path: ${{ needs.baseBranch.outputs.base-branch }}/.webiny/cached-packages + key: ${{ needs.constants.outputs.run-cache-key }} + - name: Install dependencies + run: yarn --immutable + working-directory: ${{ needs.baseBranch.outputs.base-branch }} + - name: Build packages + run: yarn build:quick + working-directory: ${{ needs.baseBranch.outputs.base-branch }} + - name: Run tests + run: yarn test ${{ matrix.package.cmd }} + working-directory: ${{ needs.baseBranch.outputs.base-branch }} + permissions: + id-token: write + jestTestsDdbOs: + needs: + - constants + - build + name: ${{ matrix.package.cmd }} + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + node: + - 20 + package: >- + ${{ fromJson('[{"cmd":"packages/api-aco + --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-aco","id":"e4b1b5ebc172f2657485e41c35ad1cd7"},{"cmd":"packages/api-audit-logs + --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-audit-logs","id":"b36aac5f0e34dc4583e5422ae589f1ed"},{"cmd":"packages/api-dynamodb-to-elasticsearch + --storage=ddb-os,ddb","storage":["ddb-os"],"packageName":"api-dynamodb-to-elasticsearch","id":"6e0b282c3d135703e52b2c55822d4fb0"},{"cmd":"packages/api-elasticsearch + --storage=ddb-os,ddb","storage":["ddb-os"],"packageName":"api-elasticsearch","id":"b0f477d6b209f654714809b318be888e"},{"cmd":"packages/api-elasticsearch-tasks + --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-elasticsearch-tasks","id":"580a9577fdbd4a241034a42e1a47dee5"},{"cmd":"packages/api-file-manager + --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-file-manager","id":"346430a79981d3e214c87254a08e31b2"},{"cmd":"packages/api-form-builder + --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-form-builder","id":"d386cddfd3c366ad9955193dcfe74363"},{"cmd":"packages/api-form-builder-so-ddb-es + --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-form-builder-so-ddb-es","id":"6086ced9d7b4412cc438b9e1aefbb976"},{"cmd":"packages/api-headless-cms + --storage=ddb-os,ddb + --shard=1/6","storage":"ddb-os","packageName":"api-headless-cms","id":"f0851fe3b18a5f4130ae919506f9d68f"},{"cmd":"packages/api-headless-cms + --storage=ddb-os,ddb + --shard=2/6","storage":"ddb-os","packageName":"api-headless-cms","id":"627bf598869494740bdb3ee340398ed5"},{"cmd":"packages/api-headless-cms + --storage=ddb-os,ddb + --shard=3/6","storage":"ddb-os","packageName":"api-headless-cms","id":"49c59082ed1d7a79b742944965adff82"},{"cmd":"packages/api-headless-cms + --storage=ddb-os,ddb + --shard=4/6","storage":"ddb-os","packageName":"api-headless-cms","id":"37865d8ba2366687e25fa61967fe4db9"},{"cmd":"packages/api-headless-cms + --storage=ddb-os,ddb + --shard=5/6","storage":"ddb-os","packageName":"api-headless-cms","id":"19d0191a992c0a5145674dc0b37d96b6"},{"cmd":"packages/api-headless-cms + --storage=ddb-os,ddb + --shard=6/6","storage":"ddb-os","packageName":"api-headless-cms","id":"2aade1f8261eacc7d93cc25fa3457fac"},{"cmd":"packages/api-headless-cms-aco + --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-headless-cms-aco","id":"aa2c8429c2564549a680db23fe963347"},{"cmd":"packages/api-headless-cms-bulk-actions + --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-headless-cms-bulk-actions","id":"a798b4705a7eb9858a51d80b386cf30a"},{"cmd":"packages/api-headless-cms-ddb-es + --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-headless-cms-ddb-es","id":"23bea783bb40390ae069dfa4985f97d2"},{"cmd":"packages/api-headless-cms-es-tasks + --storage=ddb-os,ddb","storage":["ddb-os"],"packageName":"api-headless-cms-es-tasks","id":"ee446fd78ad6294bbfb3c0689ff2602e"},{"cmd":"packages/api-headless-cms-import-export + --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-headless-cms-import-export","id":"6059cf3e78f93525c8ed72ad83b7de1a"},{"cmd":"packages/api-mailer + --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-mailer","id":"0ede859b604febdfa78018cdd1067a77"},{"cmd":"packages/api-page-builder + --storage=ddb-os,ddb + --shard=1/6","storage":"ddb-os","packageName":"api-page-builder","id":"691427cc9c5cb297c68cb2f90d7fcb89"},{"cmd":"packages/api-page-builder + --storage=ddb-os,ddb + --shard=2/6","storage":"ddb-os","packageName":"api-page-builder","id":"66b65733ec32b2010df792151240cca1"},{"cmd":"packages/api-page-builder + --storage=ddb-os,ddb + --shard=3/6","storage":"ddb-os","packageName":"api-page-builder","id":"8cdd1f181701f25f8cf9c3fe45b661bd"},{"cmd":"packages/api-page-builder + --storage=ddb-os,ddb + --shard=4/6","storage":"ddb-os","packageName":"api-page-builder","id":"0956377c7a7550c745e9402b51bdca85"},{"cmd":"packages/api-page-builder + --storage=ddb-os,ddb + --shard=5/6","storage":"ddb-os","packageName":"api-page-builder","id":"cc194759ab43627005bc21ee7c833a01"},{"cmd":"packages/api-page-builder + --storage=ddb-os,ddb + --shard=6/6","storage":"ddb-os","packageName":"api-page-builder","id":"b979f8aa837353847942b60e8f4bc057"},{"cmd":"packages/api-page-builder-aco + --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-page-builder-aco","id":"a1a7c90d43da1678f254bd4331cf4d55"},{"cmd":"packages/api-page-builder-so-ddb-es + --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-page-builder-so-ddb-es","id":"e0236755edb31fc1a6005eb161941bf8"},{"cmd":"packages/api-serverless-cms + --storage=ddb-os,ddb","storage":"ddb-os","packageName":"api-serverless-cms","id":"28f2386bb4be699710cb574f3401d76b"},{"cmd":"packages/migrations + --storage=ddb-os,ddb","storage":["ddb-os"],"packageName":"migrations","id":"3f8965830bbe44499a4bc97baf27e090"},{"cmd":"packages/tasks + --storage=ddb-os,ddb","storage":"ddb-os","packageName":"tasks","id":"5eadfa5cc14ec4e8ba87ac3dfb112580"}]') + }} + runs-on: ${{ matrix.os }} + env: + NODE_OPTIONS: "--max_old_space_size=4096" + YARN_ENABLE_IMMUTABLE_INSTALLS: false + AWS_REGION: eu-central-1 + AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_OPEN_SEARCH_DOMAIN_NAME }} + ELASTIC_SEARCH_ENDPOINT: ${{ secrets.OPEN_SEARCH_ENDPOINT }} + ELASTIC_SEARCH_INDEX_PREFIX: ${{ matrix.package.id }} + steps: + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam::726952677045:role/GitHubActionsWebinyJs + aws-region: eu-central-1 + - uses: actions/checkout@v4 + with: + path: ${{ needs.baseBranch.outputs.base-branch }} + - uses: actions/cache@v4 + with: + path: ${{ needs.baseBranch.outputs.base-branch }}/.yarn/cache + key: yarn-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }} + - uses: actions/cache@v4 + with: + path: ${{ needs.baseBranch.outputs.base-branch }}/.webiny/cached-packages + key: ${{ needs.constants.outputs.run-cache-key }} + - name: Install dependencies + run: yarn --immutable + working-directory: ${{ needs.baseBranch.outputs.base-branch }} + - name: Build packages + run: yarn build:quick + working-directory: ${{ needs.baseBranch.outputs.base-branch }} + - name: Run tests + run: yarn test ${{ matrix.package.cmd }} + working-directory: ${{ needs.baseBranch.outputs.base-branch }} + permissions: + id-token: write diff --git a/.github/workflows/wac/pullRequestsCommandJest.wac.ts b/.github/workflows/wac/pullRequestsCommandJest.wac.ts new file mode 100644 index 00000000000..87f2f5d49c9 --- /dev/null +++ b/.github/workflows/wac/pullRequestsCommandJest.wac.ts @@ -0,0 +1,173 @@ +import { createWorkflow, NormalJob } from "github-actions-wac"; +import { + createGlobalBuildCacheSteps, + createInstallBuildSteps, + createRunBuildCacheSteps, + createYarnCacheSteps, + withCommonParams +} from "./steps"; +import { + AWS_REGION, + BUILD_PACKAGES_RUNNER, + listPackagesWithJestTests, + NODE_OPTIONS, + NODE_VERSION +} from "./utils"; +import { createJob, createValidateWorkflowsJob } from "./jobs"; + +// Will print "next" or "dev". Important for caching (via actions/cache). +const DIR_WEBINY_JS = "${{ needs.baseBranch.outputs.base-branch }}"; + +const installBuildSteps = createInstallBuildSteps({ workingDirectory: DIR_WEBINY_JS }); +const yarnCacheSteps = createYarnCacheSteps({ workingDirectory: DIR_WEBINY_JS }); +const globalBuildCacheSteps = createGlobalBuildCacheSteps({ workingDirectory: DIR_WEBINY_JS }); +const runBuildCacheSteps = createRunBuildCacheSteps({ workingDirectory: DIR_WEBINY_JS }); + +const createCheckoutPrSteps = () => + [ + { + name: "Checkout Pull Request", + "working-directory": DIR_WEBINY_JS, + run: "gh pr checkout ${{ github.event.issue.number }}", + env: { GITHUB_TOKEN: "${{ secrets.GH_TOKEN }}" } + } + ] as NonNullable<NormalJob["steps"]>; + +const createJestTestsJob = (storage: string | null) => { + const env: Record<string, string> = { AWS_REGION }; + + if (storage) { + if (storage === "ddb-es") { + env["AWS_ELASTIC_SEARCH_DOMAIN_NAME"] = "${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }}"; + env["ELASTIC_SEARCH_ENDPOINT"] = "${{ secrets.ELASTIC_SEARCH_ENDPOINT }}"; + env["ELASTIC_SEARCH_INDEX_PREFIX"] = "${{ matrix.package.id }}"; + } else if (storage === "ddb-os") { + // We still use the same environment variables as for "ddb-es" setup, it's + // just that the values are read from different secrets. + env["AWS_ELASTIC_SEARCH_DOMAIN_NAME"] = "${{ secrets.AWS_OPEN_SEARCH_DOMAIN_NAME }}"; + env["ELASTIC_SEARCH_ENDPOINT"] = "${{ secrets.OPEN_SEARCH_ENDPOINT }}"; + env["ELASTIC_SEARCH_INDEX_PREFIX"] = "${{ matrix.package.id }}"; + } + } + + const packages = listPackagesWithJestTests({ storage }); + + return createJob({ + needs: ["constants", "build"], + name: "${{ matrix.package.cmd }}", + strategy: { + "fail-fast": false, + matrix: { + os: ["ubuntu-latest"], + node: [NODE_VERSION], + package: "${{ fromJson('" + JSON.stringify(packages) + "') }}" + } + }, + "runs-on": "${{ matrix.os }}", + env, + awsAuth: storage === "ddb-es" || storage === "ddb-os", + checkout: { path: DIR_WEBINY_JS }, + steps: [ + ...yarnCacheSteps, + ...runBuildCacheSteps, + ...installBuildSteps, + ...withCommonParams( + [{ name: "Run tests", run: "yarn test ${{ matrix.package.cmd }}" }], + { "working-directory": DIR_WEBINY_JS } + ) + ] + }); +}; + +export const pullRequestsCommandJest = createWorkflow({ + name: "Pull Requests Command - Jest", + on: "issue_comment", + env: { + NODE_OPTIONS, + AWS_REGION + }, + jobs: { + checkComment: createJob({ + name: `Check comment for /jest`, + if: "${{ github.event.issue.pull_request }}", + checkout: false, + steps: [ + { + name: "Check for Command", + id: "command", + uses: "xt0rted/slash-command-action@v2", + with: { + "repo-token": "${{ secrets.GITHUB_TOKEN }}", + command: "jest", + reaction: "true", + "reaction-type": "eyes", + "allow-edits": "false", + "permission-level": "write" + } + }, + { + name: "Create comment", + uses: "peter-evans/create-or-update-comment@v2", + with: { + "issue-number": "${{ github.event.issue.number }}", + body: "Jest tests have been initiated (for more information, click [here](https://github.com/webiny/webiny-js/actions/runs/${{ github.run_id }})). :sparkles:" + } + } + ] + }), + validateWorkflows: createValidateWorkflowsJob({ needs: "checkComment" }), + baseBranch: createJob({ + needs: "checkComment", + name: "Get base branch", + outputs: { + "base-branch": "${{ steps.base-branch.outputs.base-branch }}" + }, + steps: [ + { + name: "Get base branch", + id: "base-branch", + env: { GITHUB_TOKEN: "${{ secrets.GH_TOKEN }}" }, + run: 'echo "base-branch=$(gh pr view ${{ github.event.issue.number }} --json baseRefName -q .baseRefName)" >> $GITHUB_OUTPUT' + } + ] + }), + constants: createJob({ + needs: "baseBranch", + name: "Create constants", + outputs: { + "global-cache-key": "${{ steps.global-cache-key.outputs.global-cache-key }}", + "run-cache-key": "${{ steps.run-cache-key.outputs.run-cache-key }}" + }, + checkout: false, + steps: [ + { + name: "Create global cache key", + id: "global-cache-key", + run: `echo "global-cache-key=\${{ needs.baseBranch.outputs.base-branch }}-\${{ runner.os }}-$(/bin/date -u "+%m%d")-\${{ vars.RANDOM_CACHE_KEY_SUFFIX }}" >> $GITHUB_OUTPUT` + }, + { + name: "Create workflow run cache key", + id: "run-cache-key", + run: 'echo "run-cache-key=${{ github.run_id }}-${{ github.run_attempt }}-${{ vars.RANDOM_CACHE_KEY_SUFFIX }}" >> $GITHUB_OUTPUT' + } + ] + }), + build: createJob({ + name: "Build", + needs: ["baseBranch", "constants"], + checkout: { path: DIR_WEBINY_JS }, + "runs-on": BUILD_PACKAGES_RUNNER, + steps: [ + ...createCheckoutPrSteps(), + ...yarnCacheSteps, + ...globalBuildCacheSteps, + ...installBuildSteps, + ...runBuildCacheSteps + ] + }), + jestTestsNoStorage: createJestTestsJob(null), + jestTestsDdb: createJestTestsJob("ddb"), + jestTestsDdbEs: createJestTestsJob("ddb-es"), + jestTestsDdbOs: createJestTestsJob("ddb-os") + } +}); From 4e4de20ce2a657c5436e3679d97dd2f4ec6202fc Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Wed, 18 Dec 2024 15:37:40 +0100 Subject: [PATCH 19/52] fix(app-website): decorate lexical renderers on first mount (#4452) --- .../src/layouts/pages/Static/HeaderMobile.tsx | 1 + .../src/render/PageBuilder.tsx | 10 ++-- .../src/render/lexicalRendererDecorators.tsx | 10 ++++ .../elements/heading/LexicalHeading.tsx | 17 ++++-- .../elements/paragraph/LexicalParagraph.tsx | 17 ++++-- packages/app-website/src/Website.tsx | 6 +- packages/app/src/App.tsx | 14 ++++- .../src/layouts/pages/Static/HeaderMobile.tsx | 1 + packages/react-composition/src/Context.tsx | 58 +++++++++++++------ 9 files changed, 93 insertions(+), 41 deletions(-) create mode 100644 packages/app-page-builder/src/render/lexicalRendererDecorators.tsx diff --git a/extensions/theme/src/layouts/pages/Static/HeaderMobile.tsx b/extensions/theme/src/layouts/pages/Static/HeaderMobile.tsx index e31b913cd0d..ad09d180b06 100644 --- a/extensions/theme/src/layouts/pages/Static/HeaderMobile.tsx +++ b/extensions/theme/src/layouts/pages/Static/HeaderMobile.tsx @@ -98,6 +98,7 @@ const HeaderMobileWrapper = styled.div` } > nav { + display: none; -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; animation: slide-out 0.5s forwards; diff --git a/packages/app-page-builder/src/render/PageBuilder.tsx b/packages/app-page-builder/src/render/PageBuilder.tsx index 8dbe63ff019..984ca63afa0 100644 --- a/packages/app-page-builder/src/render/PageBuilder.tsx +++ b/packages/app-page-builder/src/render/PageBuilder.tsx @@ -2,17 +2,15 @@ import React from "react"; import { AddButtonClickHandlers } from "~/elementDecorators/AddButtonClickHandlers"; import { AddButtonLinkComponent } from "~/elementDecorators/AddButtonLinkComponent"; import { InjectElementVariables } from "~/render/variables/InjectElementVariables"; -import { LexicalParagraphRenderer } from "~/render/plugins/elements/paragraph/LexicalParagraph"; -import { LexicalHeadingRenderer } from "~/render/plugins/elements/heading/LexicalHeading"; -export const PageBuilder = () => { +export const PageBuilder = React.memo(() => { return ( <> <AddButtonLinkComponent /> <AddButtonClickHandlers /> <InjectElementVariables /> - <LexicalParagraphRenderer /> - <LexicalHeadingRenderer /> </> ); -}; +}); + +PageBuilder.displayName = "PageBuilder"; diff --git a/packages/app-page-builder/src/render/lexicalRendererDecorators.tsx b/packages/app-page-builder/src/render/lexicalRendererDecorators.tsx new file mode 100644 index 00000000000..65bc981a453 --- /dev/null +++ b/packages/app-page-builder/src/render/lexicalRendererDecorators.tsx @@ -0,0 +1,10 @@ +import type { DecoratorsCollection } from "@webiny/app"; +import { ParagraphRenderer } from "@webiny/app-page-builder-elements/renderers/paragraph"; +import { HeadingRenderer } from "@webiny/app-page-builder-elements/renderers/heading"; +import { LexicalParagraphDecorator } from "~/render/plugins/elements/paragraph/LexicalParagraph"; +import { LexicalHeadingDecorator } from "~/render/plugins/elements/heading/LexicalHeading"; + +export const lexicalRendererDecorators: DecoratorsCollection = [ + [ParagraphRenderer.Component, [LexicalParagraphDecorator]], + [HeadingRenderer.Component, [LexicalHeadingDecorator]] +]; diff --git a/packages/app-page-builder/src/render/plugins/elements/heading/LexicalHeading.tsx b/packages/app-page-builder/src/render/plugins/elements/heading/LexicalHeading.tsx index 284fea7effd..142dbd414a1 100644 --- a/packages/app-page-builder/src/render/plugins/elements/heading/LexicalHeading.tsx +++ b/packages/app-page-builder/src/render/plugins/elements/heading/LexicalHeading.tsx @@ -1,14 +1,16 @@ import React from "react"; import { - HeadingRenderer, - elementInputs + elementInputs, + HeadingRenderer } from "@webiny/app-page-builder-elements/renderers/heading"; import { usePageElements, useRenderer } from "@webiny/app-page-builder-elements"; import { assignStyles } from "@webiny/app-page-builder-elements/utils"; import { isValidLexicalData, LexicalHtmlRenderer } from "@webiny/lexical-editor"; +import type { ComponentDecorator } from "@webiny/app"; +import type { Renderer } from "@webiny/app-page-builder-elements/types"; -export const LexicalHeadingRenderer = HeadingRenderer.Component.createDecorator(Original => { - return function LexicalHeadingRenderer() { +export const LexicalHeadingDecorator: ComponentDecorator<Renderer> = Original => { + return function LexicalHeadingRenderer(props) { const { theme } = usePageElements(); const { getInputValues } = useRenderer(); const inputs = getInputValues<typeof elementInputs>(); @@ -29,6 +31,9 @@ export const LexicalHeadingRenderer = HeadingRenderer.Component.createDecorator( ); } - return <Original />; + return <Original {...props} />; }; -}); +}; + +export const LexicalHeadingRenderer = + HeadingRenderer.Component.createDecorator(LexicalHeadingDecorator); diff --git a/packages/app-page-builder/src/render/plugins/elements/paragraph/LexicalParagraph.tsx b/packages/app-page-builder/src/render/plugins/elements/paragraph/LexicalParagraph.tsx index fb6bce057d9..357fecc0132 100644 --- a/packages/app-page-builder/src/render/plugins/elements/paragraph/LexicalParagraph.tsx +++ b/packages/app-page-builder/src/render/plugins/elements/paragraph/LexicalParagraph.tsx @@ -1,14 +1,16 @@ import React from "react"; import { - ParagraphRenderer, - elementInputs + elementInputs, + ParagraphRenderer } from "@webiny/app-page-builder-elements/renderers/paragraph"; import { usePageElements, useRenderer } from "@webiny/app-page-builder-elements"; import { assignStyles } from "@webiny/app-page-builder-elements/utils"; import { isValidLexicalData, LexicalHtmlRenderer } from "@webiny/lexical-editor"; +import type { ComponentDecorator } from "@webiny/app"; +import type { Renderer } from "@webiny/app-page-builder-elements/types"; -export const LexicalParagraphRenderer = ParagraphRenderer.Component.createDecorator(Original => { - return function LexicalParagraphRenderer() { +export const LexicalParagraphDecorator: ComponentDecorator<Renderer> = Original => { + return function LexicalParagraphRenderer(props) { const { theme } = usePageElements(); const { getInputValues } = useRenderer(); const inputs = getInputValues<typeof elementInputs>(); @@ -29,6 +31,9 @@ export const LexicalParagraphRenderer = ParagraphRenderer.Component.createDecora ); } - return <Original />; + return <Original {...props} />; }; -}); +}; + +export const LexicalParagraphRenderer = + ParagraphRenderer.Component.createDecorator(LexicalParagraphDecorator); diff --git a/packages/app-website/src/Website.tsx b/packages/app-website/src/Website.tsx index 229798fdfff..27986a23d4a 100644 --- a/packages/app-website/src/Website.tsx +++ b/packages/app-website/src/Website.tsx @@ -2,12 +2,13 @@ import React, { useMemo } from "react"; import { App, AppProps, Decorator, GenericComponent } from "@webiny/app"; import { ApolloProvider } from "@apollo/react-hooks"; import { CacheProvider } from "@emotion/react"; -import { Page } from "./Page"; -import { createApolloClient, createEmotionCache } from "~/utils"; import { ThemeProvider } from "@webiny/app-theme"; import { PageBuilderProvider } from "@webiny/app-page-builder/contexts/PageBuilder"; +import { lexicalRendererDecorators } from "@webiny/app-page-builder/render/lexicalRendererDecorators"; import { PageBuilder } from "@webiny/app-page-builder/render"; import { RouteProps } from "@webiny/react-router"; +import { createApolloClient, createEmotionCache } from "~/utils"; +import { Page } from "./Page"; import { LinkPreload } from "~/LinkPreload"; import { WebsiteLoaderCache } from "~/utils/WebsiteLoaderCache"; @@ -53,6 +54,7 @@ export const Website = ({ children, routes = [], providers = [], ...props }: Web debounceRender={debounceMs} routes={appRoutes} providers={[PageBuilderProviderHOC, ...providers]} + decorators={[...lexicalRendererDecorators]} > <LinkPreload /> <PageBuilder /> diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 2f88dff6229..c4f4f5d5ca5 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -13,7 +13,8 @@ import { GenericComponent, compose, Decorator, - HigherOrderComponent + HigherOrderComponent, + DecoratorsCollection } from "@webiny/react-composition"; import { Routes as SortRoutes } from "./core/Routes"; import { DebounceRender } from "./core/DebounceRender"; @@ -53,6 +54,7 @@ export interface AppProps { debounceRender?: number; routes?: Array<RouteProps>; providers?: Array<Decorator<GenericComponent<ProviderProps>>>; + decorators?: DecoratorsCollection; children?: React.ReactNode | React.ReactNode[]; } @@ -62,7 +64,13 @@ interface ProviderProps { type ComponentWithChildren = React.ComponentType<{ children?: React.ReactNode }>; -export const App = ({ debounceRender = 50, routes = [], providers = [], children }: AppProps) => { +export const App = ({ + debounceRender = 50, + routes = [], + providers = [], + decorators = [], + children +}: AppProps) => { const [state, setState] = useState<State>({ routes: routes.reduce<RoutesByPath>((acc, item) => { return { ...acc, [item.path as string]: <Route {...item} /> }; @@ -129,7 +137,7 @@ export const App = ({ debounceRender = 50, routes = [], providers = [], children return ( <AppContext.Provider value={appContext}> - <CompositionProvider> + <CompositionProvider decorators={decorators}> {children} <BrowserRouter> <Providers> diff --git a/packages/cwp-template-aws/template/common/extensions/theme/src/layouts/pages/Static/HeaderMobile.tsx b/packages/cwp-template-aws/template/common/extensions/theme/src/layouts/pages/Static/HeaderMobile.tsx index c254e2cb9aa..990974bf370 100644 --- a/packages/cwp-template-aws/template/common/extensions/theme/src/layouts/pages/Static/HeaderMobile.tsx +++ b/packages/cwp-template-aws/template/common/extensions/theme/src/layouts/pages/Static/HeaderMobile.tsx @@ -98,6 +98,7 @@ const HeaderMobileWrapper = styled.div` } > nav { + display: none; -moz-osx-font-smoothing: grayscale; -webkit-font-smoothing: antialiased; animation: slide-out 0.5s forwards; diff --git a/packages/react-composition/src/Context.tsx b/packages/react-composition/src/Context.tsx index 27b7fffd4ea..f7c08166a48 100644 --- a/packages/react-composition/src/Context.tsx +++ b/packages/react-composition/src/Context.tsx @@ -10,6 +10,7 @@ import { useCompositionScope } from "~/CompositionScope"; import { ComposedFunction, ComposeWith, + Decoratable, DecoratableComponent, DecoratableHook, Decorator, @@ -70,35 +71,56 @@ interface CompositionContext { const CompositionContext = createContext<CompositionContext | undefined>(undefined); +export type DecoratorsTuple = [Decoratable, Decorator<any>[]]; +export type DecoratorsCollection = Array<DecoratorsTuple>; + interface CompositionProviderProps { + decorators?: DecoratorsCollection; children: React.ReactNode; } -export const CompositionProvider = ({ children }: CompositionProviderProps) => { - const [components, setComponents] = useState<ComponentScopes>(new Map()); +const composeComponents = ( + components: ComponentScopes, + decorators: Array<[GenericComponent | GenericHook, Decorator<any>[]]>, + scope = "*" +) => { + const scopeMap: ComposedComponents = components.get(scope) || new Map(); + for (const [component, hocs] of decorators) { + const recipe = scopeMap.get(component) || { component: null, hocs: [] }; + + const newHocs = [...(recipe.hocs || []), ...hocs] as Decorator< + GenericHook | GenericComponent + >[]; + + scopeMap.set(component, { + component: compose(...[...newHocs].reverse())(component), + hocs: newHocs + }); + + components.set(scope, scopeMap); + } + + return components; +}; + +export const CompositionProvider = ({ decorators = [], children }: CompositionProviderProps) => { + const [components, setComponents] = useState<ComponentScopes>(() => { + return composeComponents( + new Map(), + decorators.map(tuple => { + return [tuple[0].original, tuple[1]]; + }) + ); + }); const composeComponent = useCallback( ( - component: GenericHook | GenericComponent, + component: GenericComponent | GenericHook, hocs: HigherOrderComponent<any, any>[], scope: string | undefined = "*" ) => { setComponents(prevComponents => { - const components = new Map(prevComponents); - const scopeMap: ComposedComponents = components.get(scope) || new Map(); - const recipe = scopeMap.get(component) || { component: null, hocs: [] }; - - const newHocs = [...(recipe.hocs || []), ...hocs] as Decorator< - GenericHook | GenericComponent - >[]; - - scopeMap.set(component, { - component: compose(...[...newHocs].reverse())(component), - hocs: newHocs - }); - - components.set(scope, scopeMap); - return components; + return composeComponents(new Map(prevComponents), [[component, hocs]], scope); }); // Return a function that will remove the added HOCs. From 0d891cd4ac9f5c3c5922210b0132c03c48be722b Mon Sep 17 00:00:00 2001 From: adrians5j <adrian@webiny.com> Date: Wed, 18 Dec 2024 16:41:27 +0100 Subject: [PATCH 20/52] fix: filter out non-version-like names --- .../cli-plugin-extensions/src/downloadAndLinkExtension.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cli-plugin-extensions/src/downloadAndLinkExtension.ts b/packages/cli-plugin-extensions/src/downloadAndLinkExtension.ts index 923e12da8f3..571c0417ee6 100644 --- a/packages/cli-plugin-extensions/src/downloadAndLinkExtension.ts +++ b/packages/cli-plugin-extensions/src/downloadAndLinkExtension.ts @@ -15,15 +15,18 @@ import { CliContext } from "@webiny/cli/types"; import { Ora } from "ora"; const EXTENSIONS_ROOT_FOLDER = "extensions"; - const S3_BUCKET_NAME = "webiny-examples"; const S3_BUCKET_REGION = "us-east-1"; +const FOLDER_NAME_IS_VERSION_REGEX = /^\d+\.\d+\.x$/; const getVersionFromVersionFolders = async ( versionFoldersList: string[], currentWebinyVersion: string ) => { - const availableVersions = versionFoldersList.map(v => v.replace(".x", ".0")).sort(); + const availableVersions = versionFoldersList + .filter(v => v.match(FOLDER_NAME_IS_VERSION_REGEX)) + .map(v => v.replace(".x", ".0")) + .sort(); let versionToUse = ""; From f0224a26a3fde88b3590547622f938a2b03ba191 Mon Sep 17 00:00:00 2001 From: Adrian Smijulj <adrian1358@gmail.com> Date: Thu, 19 Dec 2024 15:56:14 +0100 Subject: [PATCH 21/52] fix: introduce `CmsContentFormRendererPlugin` (#4456) --- packages/app-headless-cms/src/index.tsx | 2 ++ .../src/plugins/CmsContentFormRendererPlugin.ts | 13 +++++++++++++ packages/app-headless-cms/src/plugins/index.ts | 1 + 3 files changed, 16 insertions(+) create mode 100644 packages/app-headless-cms/src/plugins/CmsContentFormRendererPlugin.ts create mode 100644 packages/app-headless-cms/src/plugins/index.ts diff --git a/packages/app-headless-cms/src/index.tsx b/packages/app-headless-cms/src/index.tsx index f2c7899b21e..4207ac6cd29 100644 --- a/packages/app-headless-cms/src/index.tsx +++ b/packages/app-headless-cms/src/index.tsx @@ -36,3 +36,5 @@ export const ContentEntriesViewConfig = Object.assign(LegacyContentEntriesViewCo }); export { ContentEntryEditorConfig } from "./ContentEntryEditorConfig"; + +export * from "./plugins"; diff --git a/packages/app-headless-cms/src/plugins/CmsContentFormRendererPlugin.ts b/packages/app-headless-cms/src/plugins/CmsContentFormRendererPlugin.ts new file mode 100644 index 00000000000..551452b04ae --- /dev/null +++ b/packages/app-headless-cms/src/plugins/CmsContentFormRendererPlugin.ts @@ -0,0 +1,13 @@ +import type { CmsContentFormRendererPlugin as BaseCmsContentFormRendererPlugin } from "~/types"; +import { legacyPluginToReactComponent } from "@webiny/app/utils"; + +export type CmsContentFormRendererPluginProps = Pick< + BaseCmsContentFormRendererPlugin, + "modelId" | "render" +>; + +export const CmsContentFormRendererPlugin = + legacyPluginToReactComponent<CmsContentFormRendererPluginProps>({ + pluginType: "cms-content-form-renderer", + componentDisplayName: "CmsContentFormRendererPlugin" + }); diff --git a/packages/app-headless-cms/src/plugins/index.ts b/packages/app-headless-cms/src/plugins/index.ts new file mode 100644 index 00000000000..e6993879cb2 --- /dev/null +++ b/packages/app-headless-cms/src/plugins/index.ts @@ -0,0 +1 @@ +export * from "./CmsContentFormRendererPlugin"; From 4ee6137c2419d4f955b320f470c7ca3ec1a7e40e Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Thu, 19 Dec 2024 16:02:20 +0100 Subject: [PATCH 22/52] fix(lexical-editor): make font sizes configurable through theme (#4454) --- .../LexicalPresets/HeadingEditorPreset.tsx | 13 +++- .../LexicalPresets/ParagraphEditorPreset.tsx | 14 ++++- .../src/components/Toolbar/Toolbar.css | 4 -- .../ToolbarActions/FontSizeAction.tsx | 63 ++++++++++++++----- packages/theme/src/types.ts | 14 ++++- 5 files changed, 82 insertions(+), 26 deletions(-) diff --git a/packages/lexical-editor-pb-element/src/components/LexicalPresets/HeadingEditorPreset.tsx b/packages/lexical-editor-pb-element/src/components/LexicalPresets/HeadingEditorPreset.tsx index 82525b9aa88..f35b6770f2e 100644 --- a/packages/lexical-editor-pb-element/src/components/LexicalPresets/HeadingEditorPreset.tsx +++ b/packages/lexical-editor-pb-element/src/components/LexicalPresets/HeadingEditorPreset.tsx @@ -15,15 +15,24 @@ import { TypographyPlugin, UnderlineAction, LexicalEditorConfig, - LinkPlugin + LinkPlugin, + useRichTextEditor } from "@webiny/lexical-editor"; const { ToolbarElement, Plugin } = LexicalEditorConfig; +const FontSizeActionWithTheme = () => { + const { theme } = useRichTextEditor(); + + const fontSizes = theme?.styles?.fontSizes?.heading ?? FontSizeAction.FONT_SIZES_FALLBACK; + + return <FontSizeAction fontSizes={fontSizes} />; +}; + export const HeadingEditorPreset = () => { return ( <LexicalEditorConfig> - <ToolbarElement name="fontSize" element={<FontSizeAction />} /> + <ToolbarElement name="fontSize" element={<FontSizeActionWithTheme />} /> <ToolbarElement name="fontColor" element={<FontColorAction />} /> <ToolbarElement name="typography" element={<TypographyAction />} /> <ToolbarElement name="textAlignment" element={<TextAlignmentAction />} /> diff --git a/packages/lexical-editor-pb-element/src/components/LexicalPresets/ParagraphEditorPreset.tsx b/packages/lexical-editor-pb-element/src/components/LexicalPresets/ParagraphEditorPreset.tsx index 5f2e109963c..1ab46d12175 100644 --- a/packages/lexical-editor-pb-element/src/components/LexicalPresets/ParagraphEditorPreset.tsx +++ b/packages/lexical-editor-pb-element/src/components/LexicalPresets/ParagraphEditorPreset.tsx @@ -20,14 +20,24 @@ import { UnderlineAction, ListPlugin, LexicalEditorConfig, - LinkPlugin + LinkPlugin, + useRichTextEditor } from "@webiny/lexical-editor"; const { ToolbarElement, Plugin } = LexicalEditorConfig; + +const FontSizeActionWithTheme = () => { + const { theme } = useRichTextEditor(); + + const fontSizes = theme?.styles?.fontSizes?.paragraph ?? FontSizeAction.FONT_SIZES_FALLBACK; + + return <FontSizeAction fontSizes={fontSizes} />; +}; + export const ParagraphEditorPreset = () => { return ( <LexicalEditorConfig> - <ToolbarElement name="fontSize" element={<FontSizeAction />} /> + <ToolbarElement name="fontSize" element={<FontSizeActionWithTheme />} /> <ToolbarElement name="fontColor" element={<FontColorAction />} /> <ToolbarElement name="typography" element={<TypographyAction />} /> <ToolbarElement name="textAlignment" element={<TextAlignmentAction />} /> diff --git a/packages/lexical-editor/src/components/Toolbar/Toolbar.css b/packages/lexical-editor/src/components/Toolbar/Toolbar.css index 8bc676633ab..93ce67cea77 100644 --- a/packages/lexical-editor/src/components/Toolbar/Toolbar.css +++ b/packages/lexical-editor/src/components/Toolbar/Toolbar.css @@ -326,10 +326,6 @@ i.font-color, margin: 0 4px; } -.toolbar-item.font-size { - width: 70px; -} - .lexical-dropdown-container { position: absolute; bottom: -5px; diff --git a/packages/lexical-editor/src/components/ToolbarActions/FontSizeAction.tsx b/packages/lexical-editor/src/components/ToolbarActions/FontSizeAction.tsx index 4051dbf63eb..8d3eaee1059 100644 --- a/packages/lexical-editor/src/components/ToolbarActions/FontSizeAction.tsx +++ b/packages/lexical-editor/src/components/ToolbarActions/FontSizeAction.tsx @@ -6,7 +6,13 @@ import { DropDown, DropDownItem } from "~/ui/DropDown"; import { useDeriveValueFromSelection } from "~/hooks/useCurrentSelection"; import { useRichTextEditor } from "~/hooks"; -const FONT_SIZE_OPTIONS: string[] = [ +export interface FontSize { + id: string; + name: string; + value: string; +} + +export const FONT_SIZES_FALLBACK: FontSize[] = [ "8px", "9px", "10px", @@ -24,7 +30,18 @@ const FONT_SIZE_OPTIONS: string[] = [ "60px", "72px", "96px" -]; +].map(size => ({ + id: size, + name: size, + value: size, + default: size === "15px" +})); + +const emptyOption: FontSize = { + value: "", + name: "Font Size", + id: "empty" +}; function dropDownActiveClass(active: boolean) { if (active) { @@ -34,21 +51,22 @@ function dropDownActiveClass(active: boolean) { } interface FontSizeDropDownProps { + fontSizes: FontSize[]; editor: LexicalEditor; - value: string; + value: string | undefined; disabled?: boolean; } function FontSizeDropDown(props: FontSizeDropDownProps): JSX.Element { - const { editor, value, disabled = false } = props; + const { editor, value, fontSizes, disabled = false } = props; const handleClick = useCallback( - (option: string) => { + (option: FontSize) => { editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection)) { $patchStyleText(selection, { - ["font-size"]: option + ["font-size"]: option.value }); } }); @@ -56,39 +74,43 @@ function FontSizeDropDown(props: FontSizeDropDownProps): JSX.Element { [editor] ); + const selectedOption = fontSizes.find(opt => opt.value === value) ?? emptyOption; + return ( <DropDown disabled={disabled} buttonClassName="toolbar-item font-size" - buttonLabel={value} + buttonLabel={selectedOption.name} buttonAriaLabel={"Formatting options for font size"} > - {FONT_SIZE_OPTIONS.map(option => ( + {fontSizes.map(option => ( <DropDownItem - className={`item fontsize-item ${dropDownActiveClass(value === option)}`} + className={`item fontsize-item ${dropDownActiveClass(value === option.id)}`} onClick={() => handleClick(option)} - key={option} + key={option.id} > - <span className="text">{option}</span> + <span className="text">{option.name}</span> </DropDownItem> ))} </DropDown> ); } -const defaultSize = "15px"; +interface FontSizeActionProps { + fontSizes?: FontSize[]; +} -export const FontSizeAction = () => { +const FontSize = ({ fontSizes = FONT_SIZES_FALLBACK }: FontSizeActionProps) => { const { editor } = useRichTextEditor(); const [isEditable, setIsEditable] = useState(() => editor.isEditable()); const fontSize = useDeriveValueFromSelection(({ rangeSelection }) => { if (!rangeSelection) { - return defaultSize; + return undefined; } try { - return $getSelectionStyleValueForProperty(rangeSelection, "font-size", "15px"); + return $getSelectionStyleValueForProperty(rangeSelection, "font-size"); } catch { - return defaultSize; + return undefined; } }); @@ -102,7 +124,14 @@ export const FontSizeAction = () => { return ( <> - <FontSizeDropDown disabled={!isEditable} value={fontSize} editor={editor} /> + <FontSizeDropDown + disabled={!isEditable} + value={fontSize} + editor={editor} + fontSizes={fontSizes} + /> </> ); }; + +export const FontSizeAction = Object.assign(FontSize, { FONT_SIZES_FALLBACK }); diff --git a/packages/theme/src/types.ts b/packages/theme/src/types.ts index 0c110ea9396..a5b3539b3a7 100644 --- a/packages/theme/src/types.ts +++ b/packages/theme/src/types.ts @@ -27,8 +27,10 @@ export type ThemeBreakpoints = { /* * Typography section + * We want to allow custom strings as well, thus the (string & {}). */ -export type TypographyType = "headings" | "paragraphs" | "quotes" | "lists" | string; +// eslint-disable-next-line @typescript-eslint/ban-types +export type TypographyType = "headings" | "paragraphs" | "quotes" | "lists" | (string & {}); export type TypographyStyle = { id: string; name: string; @@ -39,8 +41,18 @@ export type TypographyStyle = { export type Typography = Record<TypographyType, Readonly<TypographyStyle[]>>; export type ThemeTypographyStyleItems = TypographyStyle[]; +export interface FontSize { + id: string; + name: string; + value: string; +} + export interface ThemeStyles { colors: Record<string, any>; + fontSizes?: { + heading?: FontSize[]; + paragraph?: FontSize[]; + }; borderRadius?: number; typography: Typography; elements: Record<string, Record<string, any> | StylesObject>; From 485f8701488b4528400fe2df0d90c10b8f3cde89 Mon Sep 17 00:00:00 2001 From: Adrian Smijulj <adrian1358@gmail.com> Date: Thu, 19 Dec 2024 16:20:03 +0100 Subject: [PATCH 23/52] fix: improve Webiny CLI developer-experience (#4455) --- .../commands/newWatch.js | 14 +++++++---- .../commands/newWatch/listPackages.js | 6 +++++ .../src/downloadAndLinkExtension.ts | 8 +++++++ .../src/extensions/AdminExtension.ts | 2 +- .../src/extensions/ApiExtension.ts | 2 +- .../src/extensions/PbElementExtension.ts | 2 +- .../src/generateExtension.ts | 8 +++++++ .../bundling/function/watchFunction.js | 23 +++++++++++++++---- 8 files changed, 52 insertions(+), 13 deletions(-) diff --git a/packages/cli-plugin-deploy-pulumi/commands/newWatch.js b/packages/cli-plugin-deploy-pulumi/commands/newWatch.js index 9230f92ba60..5fe5a1044ed 100644 --- a/packages/cli-plugin-deploy-pulumi/commands/newWatch.js +++ b/packages/cli-plugin-deploy-pulumi/commands/newWatch.js @@ -121,8 +121,10 @@ module.exports = async (inputs, context) => { const learnMoreLink = "https://webiny.link/local-aws-lambda-development"; context.info(`Local AWS Lambda development session started.`); - context.info( - `Note that you should deploy your changes once you're done. To do so, run: %s. Learn more: %s.`, + context.warning( + `Note that once the session is terminated, the %s application will no longer work. To fix this, you %s redeploy it via the %s command. Learn more: %s.`, + projectApplication.name, + "MUST", deployCommand, learnMoreLink ); @@ -141,9 +143,11 @@ module.exports = async (inputs, context) => { console.log(); console.log(); - context.info(`Stopping local AWS Lambda development session.`); - context.info( - `Note that you should deploy your changes. To do so, run: %s. Learn more: %s.`, + context.info(`Terminating local AWS Lambda development session.`); + context.warning( + `Note that once the session is terminated, the %s application will no longer work. To fix this, you %s redeploy it via the %s command. Learn more: %s.`, + projectApplication.name, + "MUST", deployCommand, learnMoreLink ); diff --git a/packages/cli-plugin-deploy-pulumi/commands/newWatch/listPackages.js b/packages/cli-plugin-deploy-pulumi/commands/newWatch/listPackages.js index 8722bb6035e..f4c6117e7bf 100644 --- a/packages/cli-plugin-deploy-pulumi/commands/newWatch/listPackages.js +++ b/packages/cli-plugin-deploy-pulumi/commands/newWatch/listPackages.js @@ -56,6 +56,12 @@ const listPackages = async ({ inputs }) => { ? path.join(root, "webiny.config.ts") : path.join(root, "webiny.config.js"); + // We need this because newly introduced extension + // packages do not have a Webiny config file. + if (!fs.existsSync(configPath)) { + continue; + } + packages.push({ name: packageName, config: require(configPath).default || require(configPath), diff --git a/packages/cli-plugin-extensions/src/downloadAndLinkExtension.ts b/packages/cli-plugin-extensions/src/downloadAndLinkExtension.ts index 571c0417ee6..a32e2f4a1ac 100644 --- a/packages/cli-plugin-extensions/src/downloadAndLinkExtension.ts +++ b/packages/cli-plugin-extensions/src/downloadAndLinkExtension.ts @@ -159,6 +159,14 @@ export const downloadAndLinkExtension = async ({ console.log(` ‣ ${context.success.hl(p)}`); }); } + + console.log(); + console.log(chalk.bold("Additional Notes")); + console.log( + `‣ note that if you already have the ${context.success.hl( + "webiny watch" + )} command running, you'll need to restart it` + ); } catch (e) { switch (e.code) { case "NO_OBJECTS_FOUND": diff --git a/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts b/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts index 87575cfb6f7..bfb4caa770a 100644 --- a/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts +++ b/packages/cli-plugin-extensions/src/extensions/AdminExtension.ts @@ -30,7 +30,7 @@ export class AdminExtension extends AbstractExtension { const indexTsxFilePath = `${extensionsFolderPath}/src/index.tsx`; return [ - `run ${chalk.green(watchCommand)} to start a new local development session`, + `run ${chalk.green(watchCommand)} to start local development`, `open ${chalk.green(indexTsxFilePath)} and start coding`, `to install additional dependencies, run ${chalk.green( `yarn workspace ${this.params.packageName} add <package-name>` diff --git a/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts b/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts index 35e6c269235..8cc85806e4a 100644 --- a/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts +++ b/packages/cli-plugin-extensions/src/extensions/ApiExtension.ts @@ -30,7 +30,7 @@ export class ApiExtension extends AbstractExtension { const indexTsxFilePath = `${extensionsFolderPath}/src/index.ts`; return [ - `run ${chalk.green(watchCommand)} to start a new local development session`, + `run ${chalk.green(watchCommand)} to start local development`, `open ${chalk.green(indexTsxFilePath)} and start coding`, `to install additional dependencies, run ${chalk.green( `yarn workspace ${this.params.packageName} add <package-name>` diff --git a/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts b/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts index 0f7be7d90ce..c49767a149c 100644 --- a/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts +++ b/packages/cli-plugin-extensions/src/extensions/PbElementExtension.ts @@ -37,7 +37,7 @@ export class PbElementExtension extends AbstractExtension { return [ [ - `run the following commands to start local development sessions:`, + `run the following commands to start local development:`, ` ∙ ${chalk.green(watchCommandAdmin)}`, ` ∙ ${chalk.green(watchCommandWebsite)}` ].join("\n"), diff --git a/packages/cli-plugin-extensions/src/generateExtension.ts b/packages/cli-plugin-extensions/src/generateExtension.ts index b405ac5c265..55ece0fe8a3 100644 --- a/packages/cli-plugin-extensions/src/generateExtension.ts +++ b/packages/cli-plugin-extensions/src/generateExtension.ts @@ -158,6 +158,14 @@ export const generateExtension = async ({ input, ora, context }: GenerateExtensi console.log(`‣ ${message}`); }); } + + console.log(); + console.log(chalk.bold("Additional Notes")); + console.log( + `‣ note that if you already have the ${context.success.hl( + "webiny watch" + )} command running, you'll need to restart it` + ); } catch (err) { ora.fail("Could not create extension. Please check the logs below."); console.log(); diff --git a/packages/project-utils/bundling/function/watchFunction.js b/packages/project-utils/bundling/function/watchFunction.js index 22fccbf6f45..0f909ee6907 100644 --- a/packages/project-utils/bundling/function/watchFunction.js +++ b/packages/project-utils/bundling/function/watchFunction.js @@ -31,16 +31,29 @@ module.exports = async options => { } return new Promise(async (resolve, reject) => { - options.logs && console.log("Compiling..."); + let initialCompilation = true; + if (options.logs) { + const message = initialCompilation ? "Initial compilation started..." : "Compiling..."; + console.log(message); + } + return webpack(webpackConfig).watch({}, async (err, stats) => { if (err) { return reject(err); } - if (!stats.hasErrors()) { - options.logs && console.log("Compiled successfully."); - } else { - options.logs && console.log(stats.toString("errors-warnings")); + if (!options.logs) { + return; + } + + if (stats.hasErrors()) { + console.log(stats.toString("errors-warnings")); + return; + } + + if (initialCompilation) { + initialCompilation = false; + console.log("Initial compilation completed. Watching for changes..."); } }); }); From 46a11e9aa15d5c5a7561b98008f8115c0dd410c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= <bruno.zoric@gmail.com> Date: Thu, 19 Dec 2024 14:56:28 +0100 Subject: [PATCH 24/52] fix(api-form-builder): skip create fb on new locale if fb not installed on current locale --- .../src/operations/form/index.ts | 3 ++- packages/api-form-builder/__tests__/forms.test.ts | 10 +++++----- packages/api-form-builder/src/plugins/crud/index.ts | 6 ++++++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/api-form-builder-so-ddb/src/operations/form/index.ts b/packages/api-form-builder-so-ddb/src/operations/form/index.ts index c25f60780dc..8177b0c942a 100644 --- a/packages/api-form-builder-so-ddb/src/operations/form/index.ts +++ b/packages/api-form-builder-so-ddb/src/operations/form/index.ts @@ -513,7 +513,8 @@ export const createFormStorageOperations = ( } let latestPublishedKeys: Keys | undefined; const entityBatch = createEntityWriteBatch({ - entity + entity, + delete: [createLatestKeys(form)] }); for (const item of items) { diff --git a/packages/api-form-builder/__tests__/forms.test.ts b/packages/api-form-builder/__tests__/forms.test.ts index 0b29a520ec1..a2b9fc330b0 100644 --- a/packages/api-form-builder/__tests__/forms.test.ts +++ b/packages/api-form-builder/__tests__/forms.test.ts @@ -40,7 +40,7 @@ describe('Form Builder "Form" Test', () => { } }); - test("should create a form and return it in the list of latest forms", async () => { + it("should create a form and return it in the list of latest forms", async () => { const [create] = await createForm({ data: { name: "contact-us" } }); const { id } = create.data.formBuilder.createForm.data; @@ -70,7 +70,7 @@ describe('Form Builder "Form" Test', () => { expect(data[0].id).toEqual(id); }); - test("should update form and return new data from storage", async () => { + it("should update form and return new data from storage", async () => { const [create] = await createForm({ data: { name: "contact-us" } }); const { id } = create.data.formBuilder.createForm.data; @@ -219,7 +219,7 @@ describe('Form Builder "Form" Test', () => { expect(revisions[0].version).toEqual(2); }); - test("should delete a form and all of its revisions", async () => { + it("should delete a form and all of its revisions", async () => { const [create] = await createForm({ data: { name: "contact-us" } }); const { id } = create.data.formBuilder.createForm.data; @@ -246,7 +246,7 @@ describe('Form Builder "Form" Test', () => { expect(list.data.formBuilder.listForms.data.length).toBe(0); }); - test("should publish, add views and unpublish", async () => { + it("should publish, add views and unpublish", async () => { const [create] = await createForm({ data: { name: "contact-us" } }); const { id } = create.data.formBuilder.createForm.data; @@ -306,7 +306,7 @@ describe('Form Builder "Form" Test', () => { expect(latestPublished3.data.formBuilder.getPublishedForm.data.id).toEqual(id); }); - test("should create, list and export submissions to file", async () => { + it("should create, list and export submissions to file", async () => { const [create] = await createForm({ data: { name: "contact-us" } }); const { id } = create.data.formBuilder.createForm.data; diff --git a/packages/api-form-builder/src/plugins/crud/index.ts b/packages/api-form-builder/src/plugins/crud/index.ts index 5a20f2300b6..0c94f6f367a 100644 --- a/packages/api-form-builder/src/plugins/crud/index.ts +++ b/packages/api-form-builder/src/plugins/crud/index.ts @@ -111,6 +111,12 @@ export default (params: CreateFormBuilderCrudParams) => { // Once a new locale is created, we need to create a new settings entry for it. new ContextPlugin<FormBuilderContext>(async context => { context.i18n.locales.onLocaleAfterCreate.subscribe(async params => { + // We don't want to auto-create the settings entry if Form Builder is not installed. + // This is because the entry will be created by the app's installer. + const fbIsInstalled = Boolean(await context.formBuilder.getSystemVersion()); + if (!fbIsInstalled) { + return; + } const { locale } = params; await context.i18n.withLocale(locale, async () => { return context.formBuilder.createSettings({}); From 1b718530041b47e31fa19eaa35cee8ff96736e7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= <bruno.zoric@gmail.com> Date: Thu, 19 Dec 2024 15:29:45 +0100 Subject: [PATCH 25/52] chore: ghawac build --- .github/workflows/pullRequests.yml | 32 ++++++++--------- .../workflows/pullRequestsCommandCypress.yml | 36 +++++++++---------- .github/workflows/pullRequestsCommandJest.yml | 26 +++++++------- 3 files changed, 47 insertions(+), 47 deletions(-) diff --git a/.github/workflows/pullRequests.yml b/.github/workflows/pullRequests.yml index 07a21d893db..07bef462152 100644 --- a/.github/workflows/pullRequests.yml +++ b/.github/workflows/pullRequests.yml @@ -3,7 +3,7 @@ # and run "github-actions-wac build" (or "ghawac build") to regenerate this file. # For more information, run "github-actions-wac --help". name: Pull Requests -"on": pull_request +'on': pull_request concurrency: group: pr-${{ github.event.pull_request.number }} cancel-in-progress: true @@ -19,7 +19,7 @@ jobs: - uses: webiny/action-conventional-commits@v1.3.0 runs-on: ubuntu-latest env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false validateCommitsDev: name: Validate commit messages (dev branch, 'feat' commits not allowed) @@ -34,7 +34,7 @@ jobs: allowed-commit-types: fix,docs,style,refactor,test,build,perf,ci,chore,revert,merge,wip runs-on: ubuntu-latest env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false constants: name: Create constants @@ -87,7 +87,7 @@ jobs: $GITHUB_OUTPUT runs-on: ubuntu-latest env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false assignMilestone: name: Assign milestone @@ -117,7 +117,7 @@ jobs: milestone: ${{ steps.get-milestone-to-assign.outputs.milestone }} runs-on: ubuntu-latest env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false build: name: Build @@ -149,7 +149,7 @@ jobs: path: ${{ github.base_ref }}/.webiny/cached-packages key: ${{ needs.constants.outputs.run-cache-key }} env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false staticCodeAnalysis: needs: @@ -187,7 +187,7 @@ jobs: working-directory: ${{ github.base_ref }} runs-on: ubuntu-latest env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false staticCodeAnalysisTs: name: Static code analysis (TypeScript) @@ -213,7 +213,7 @@ jobs: run: yarn cy:ts working-directory: ${{ github.base_ref }} env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false jestTestsNoStorageConstants: needs: @@ -241,7 +241,7 @@ jobs: echo '${{ steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}' env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false jestTestsNoStorageRun: needs: @@ -262,7 +262,7 @@ jobs: }} runs-on: ${{ matrix.os }} env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false AWS_REGION: eu-central-1 if: needs.jestTestsNoStorageConstants.outputs.packages-to-jest-test != '[]' @@ -359,7 +359,7 @@ jobs: echo '${{ steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}' env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false jestTestsddbRun: needs: @@ -379,7 +379,7 @@ jobs: fromJson(needs.jestTestsddbConstants.outputs.packages-to-jest-test) }} runs-on: ${{ matrix.os }} env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false AWS_REGION: eu-central-1 if: needs.jestTestsddbConstants.outputs.packages-to-jest-test != '[]' @@ -476,7 +476,7 @@ jobs: echo '${{ steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}' env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false jestTestsddb-esRun: needs: @@ -497,7 +497,7 @@ jobs: }} runs-on: ${{ matrix.os }} env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false AWS_REGION: eu-central-1 AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }} @@ -606,7 +606,7 @@ jobs: echo '${{ steps.list-packages-to-jest-test.outputs.packages-to-jest-test }}' env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false jestTestsddb-osRun: needs: @@ -627,7 +627,7 @@ jobs: }} runs-on: ${{ matrix.os }} env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false AWS_REGION: eu-central-1 AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_OPEN_SEARCH_DOMAIN_NAME }} diff --git a/.github/workflows/pullRequestsCommandCypress.yml b/.github/workflows/pullRequestsCommandCypress.yml index 712e3e661d0..59103fd5f2c 100644 --- a/.github/workflows/pullRequestsCommandCypress.yml +++ b/.github/workflows/pullRequestsCommandCypress.yml @@ -3,9 +3,9 @@ # and run "github-actions-wac build" (or "ghawac build") to regenerate this file. # For more information, run "github-actions-wac --help". name: Pull Requests Command - Cypress -"on": issue_comment +'on': issue_comment env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' AWS_REGION: eu-central-1 jobs: checkComment: @@ -21,9 +21,9 @@ jobs: with: repo-token: ${{ secrets.GITHUB_TOKEN }} command: cypress - reaction: "true" + reaction: 'true' reaction-type: eyes - allow-edits: "false" + allow-edits: 'false' permission-level: write - name: Create comment uses: peter-evans/create-or-update-comment@v2 @@ -35,7 +35,7 @@ jobs: github.run_id }})). :sparkles: runs-on: ubuntu-latest env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false validateWorkflows: name: Validate workflows @@ -51,7 +51,7 @@ jobs: needs: checkComment runs-on: ubuntu-latest env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false baseBranch: needs: checkComment @@ -72,7 +72,7 @@ jobs: baseRefName -q .baseRefName)" >> $GITHUB_OUTPUT runs-on: ubuntu-latest env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false constants: needs: baseBranch @@ -97,7 +97,7 @@ jobs: vars.RANDOM_CACHE_KEY_SUFFIX }}" >> $GITHUB_OUTPUT runs-on: ubuntu-latest env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false build: name: Build @@ -136,7 +136,7 @@ jobs: path: ${{ needs.baseBranch.outputs.base-branch }}/.webiny/cached-packages key: ${{ needs.constants.outputs.run-cache-key }} env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false e2e-wby-cms-ddb-constants: needs: @@ -172,7 +172,7 @@ jobs: github.run_id }}_ddb" >> $GITHUB_OUTPUT runs-on: ubuntu-latest env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false e2e-wby-cms-ddb-project-setup: needs: @@ -184,7 +184,7 @@ jobs: cypress-config: ${{ steps.save-cypress-config.outputs.cypress-config }} environment: next env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} @@ -328,7 +328,7 @@ jobs: }} environment: next env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} @@ -404,7 +404,7 @@ jobs: github.run_id }}_ddb-es" >> $GITHUB_OUTPUT runs-on: ubuntu-latest env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false e2e-wby-cms-ddb-es-project-setup: needs: @@ -416,7 +416,7 @@ jobs: cypress-config: ${{ steps.save-cypress-config.outputs.cypress-config }} environment: next env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} @@ -564,7 +564,7 @@ jobs: }} environment: next env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} @@ -644,7 +644,7 @@ jobs: github.run_id }}_ddb-os" >> $GITHUB_OUTPUT runs-on: ubuntu-latest env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false e2e-wby-cms-ddb-os-project-setup: needs: @@ -656,7 +656,7 @@ jobs: cypress-config: ${{ steps.save-cypress-config.outputs.cypress-config }} environment: next env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} @@ -804,7 +804,7 @@ jobs: }} environment: next env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false CYPRESS_MAILOSAUR_API_KEY: ${{ secrets.CYPRESS_MAILOSAUR_API_KEY }} PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} diff --git a/.github/workflows/pullRequestsCommandJest.yml b/.github/workflows/pullRequestsCommandJest.yml index 9a5a2fe264e..87683a5ce36 100644 --- a/.github/workflows/pullRequestsCommandJest.yml +++ b/.github/workflows/pullRequestsCommandJest.yml @@ -3,9 +3,9 @@ # and run "github-actions-wac build" (or "ghawac build") to regenerate this file. # For more information, run "github-actions-wac --help". name: Pull Requests Command - Jest -"on": issue_comment +'on': issue_comment env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' AWS_REGION: eu-central-1 jobs: checkComment: @@ -21,9 +21,9 @@ jobs: with: repo-token: ${{ secrets.GITHUB_TOKEN }} command: jest - reaction: "true" + reaction: 'true' reaction-type: eyes - allow-edits: "false" + allow-edits: 'false' permission-level: write - name: Create comment uses: peter-evans/create-or-update-comment@v2 @@ -35,7 +35,7 @@ jobs: github.run_id }})). :sparkles: runs-on: ubuntu-latest env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false validateWorkflows: name: Validate workflows @@ -51,7 +51,7 @@ jobs: needs: checkComment runs-on: ubuntu-latest env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false baseBranch: needs: checkComment @@ -72,7 +72,7 @@ jobs: baseRefName -q .baseRefName)" >> $GITHUB_OUTPUT runs-on: ubuntu-latest env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false constants: needs: baseBranch @@ -97,7 +97,7 @@ jobs: vars.RANDOM_CACHE_KEY_SUFFIX }}" >> $GITHUB_OUTPUT runs-on: ubuntu-latest env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false build: name: Build @@ -136,7 +136,7 @@ jobs: path: ${{ needs.baseBranch.outputs.base-branch }}/.webiny/cached-packages key: ${{ needs.constants.outputs.run-cache-key }} env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false jestTestsNoStorage: needs: @@ -156,7 +156,7 @@ jobs: }} runs-on: ${{ matrix.os }} env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false AWS_REGION: eu-central-1 steps: @@ -243,7 +243,7 @@ jobs: }} runs-on: ${{ matrix.os }} env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false AWS_REGION: eu-central-1 steps: @@ -330,7 +330,7 @@ jobs: }} runs-on: ${{ matrix.os }} env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false AWS_REGION: eu-central-1 AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_ELASTIC_SEARCH_DOMAIN_NAME }} @@ -427,7 +427,7 @@ jobs: }} runs-on: ${{ matrix.os }} env: - NODE_OPTIONS: "--max_old_space_size=4096" + NODE_OPTIONS: '--max_old_space_size=4096' YARN_ENABLE_IMMUTABLE_INSTALLS: false AWS_REGION: eu-central-1 AWS_ELASTIC_SEARCH_DOMAIN_NAME: ${{ secrets.AWS_OPEN_SEARCH_DOMAIN_NAME }} From a246137615c7480311e70b14d7e9556f4d68dee4 Mon Sep 17 00:00:00 2001 From: Adrian Smijulj <adrian1358@gmail.com> Date: Fri, 20 Dec 2024 16:52:25 +0100 Subject: [PATCH 26/52] fix: delete settings entry on locale deletion (#4460) --- .../__tests__/graphql/i18n.ts | 16 ++++++ .../__tests__/settings.test.ts | 57 ++++++++++++++++--- .../__tests__/useGqlHandler.ts | 5 +- .../src/plugins/crud/index.ts | 7 +++ 4 files changed, 75 insertions(+), 10 deletions(-) diff --git a/packages/api-form-builder/__tests__/graphql/i18n.ts b/packages/api-form-builder/__tests__/graphql/i18n.ts index f33ffc38db5..86255c3126e 100644 --- a/packages/api-form-builder/__tests__/graphql/i18n.ts +++ b/packages/api-form-builder/__tests__/graphql/i18n.ts @@ -13,3 +13,19 @@ export const CREATE_LOCALE = /* GraphQL */ ` } } `; + +export const DELETE_LOCALE = /* GraphQL */ ` + mutation DeleteI18NLocale($code: String!) { + i18n { + deleteI18NLocale(code: $code) { + data { + code + } + error { + message + code + } + } + } + } +`; diff --git a/packages/api-form-builder/__tests__/settings.test.ts b/packages/api-form-builder/__tests__/settings.test.ts index 48a100cc22d..9addb5a3903 100644 --- a/packages/api-form-builder/__tests__/settings.test.ts +++ b/packages/api-form-builder/__tests__/settings.test.ts @@ -2,9 +2,16 @@ import useGqlHandler from "./useGqlHandler"; import { GET_SETTINGS } from "~tests/graphql/formBuilderSettings"; describe("Settings Test", () => { - const { getSettings, updateSettings, install, createI18NLocale, isInstalled } = useGqlHandler(); - - test(`Should not be able to get & update settings before "install"`, async () => { + const { + getSettings, + updateSettings, + install, + createI18NLocale, + deleteI18NLocale, + isInstalled + } = useGqlHandler(); + + it(`Should not be able to get & update settings before "install"`, async () => { // Should not have any settings without install const [getSettingsResponse] = await getSettings(); @@ -40,7 +47,7 @@ describe("Settings Test", () => { }); }); - test("Should be able to install `Form Builder`", async () => { + it("Should be able to install `Form Builder`", async () => { // "isInstalled" should return false prior "install" const [isInstalledResponse] = await isInstalled(); @@ -78,7 +85,7 @@ describe("Settings Test", () => { }); }); - test(`Should be able to get & update settings after "install"`, async () => { + it(`Should be able to get & update settings after "install"`, async () => { // Let's install the `Form builder` const [installResponse] = await install({ domain: "http://localhost:3001" }); @@ -156,7 +163,7 @@ describe("Settings Test", () => { }); }); - test(`Should be able to get & update settings after in a new locale`, async () => { + it(`Should be able to get & update settings after in a new locale`, async () => { // Let's install the `Form builder` await install({ domain: "http://localhost:3001" }); @@ -168,9 +175,7 @@ describe("Settings Test", () => { // set the locale header. Wasn't easily possible via the `getSettings` helper. const [newLocaleFbSettings] = await invoke({ body: { query: GET_SETTINGS }, - headers: { - "x-i18n-locale": "default:de-DE;content:de-DE;" - } + headers: { "x-i18n-locale": "default:de-DE;content:de-DE;" } }); // Settings should exist in the newly created locale. @@ -192,4 +197,38 @@ describe("Settings Test", () => { } }); }); + + it(`Should be able to create a locale, delete it, and again create it`, async () => { + // Let's install the `Form builder` + await install({ domain: "http://localhost:3001" }); + + await createI18NLocale({ data: { code: "en-US" } }); + await createI18NLocale({ data: { code: "de-DE" } }); + + const [deleteDeLocaleResponse] = await deleteI18NLocale({ code: "de-DE" }); + expect(deleteDeLocaleResponse).toEqual({ + data: { + i18n: { + deleteI18NLocale: { + data: { code: "de-DE" }, + error: null + } + } + } + }); + + const [createDeLocaleResponse] = await createI18NLocale({ data: { code: "de-DE" } }); + expect(createDeLocaleResponse).toEqual({ + data: { + i18n: { + createI18NLocale: { + data: { + code: "de-DE" + }, + error: null + } + } + } + }); + }); }); diff --git a/packages/api-form-builder/__tests__/useGqlHandler.ts b/packages/api-form-builder/__tests__/useGqlHandler.ts index e5fc524dd47..8dbf4b4f1bf 100644 --- a/packages/api-form-builder/__tests__/useGqlHandler.ts +++ b/packages/api-form-builder/__tests__/useGqlHandler.ts @@ -12,7 +12,7 @@ import { createI18NGraphQL } from "@webiny/api-i18n/graphql"; // Graphql import { INSTALL as INSTALL_FILE_MANAGER } from "./graphql/fileManagerSettings"; -import { CREATE_LOCALE } from "./graphql/i18n"; +import { DELETE_LOCALE, CREATE_LOCALE } from "./graphql/i18n"; import { GET_SETTINGS, @@ -228,6 +228,9 @@ export default (params: UseGqlHandlerParams = {}) => { // Locales. async createI18NLocale(variables: Record<string, any>) { return invoke({ body: { query: CREATE_LOCALE, variables } }); + }, + async deleteI18NLocale(variables: Record<string, any>) { + return invoke({ body: { query: DELETE_LOCALE, variables } }); } }; }; diff --git a/packages/api-form-builder/src/plugins/crud/index.ts b/packages/api-form-builder/src/plugins/crud/index.ts index 0c94f6f367a..c0aaf780b26 100644 --- a/packages/api-form-builder/src/plugins/crud/index.ts +++ b/packages/api-form-builder/src/plugins/crud/index.ts @@ -122,6 +122,13 @@ export default (params: CreateFormBuilderCrudParams) => { return context.formBuilder.createSettings({}); }); }); + + context.i18n.locales.onLocaleAfterDelete.subscribe(async params => { + const { locale } = params; + await context.i18n.withLocale(locale, async () => { + return context.formBuilder.deleteSettings(); + }); + }); }) ]; }; From 3cc4b02023ddb4671c333805fc83cd06aa1c49da Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Mon, 23 Dec 2024 18:52:27 +0100 Subject: [PATCH 27/52] fix(app-headless-cms): remove delete callback memoization --- .../admin/components/ContentEntryForm/useBind.tsx | 7 +++++-- .../fieldRenderers/object/multipleObjects.tsx | 15 ++++++--------- .../object/multipleObjectsAccordion.tsx | 15 ++++++--------- 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/useBind.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/useBind.tsx index f1bb62dc02d..772e57b9067 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/useBind.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/useBind.tsx @@ -101,8 +101,11 @@ export function useBind({ Bind, field }: UseBindProps) { if (index < 0) { return; } - let value = bind.value; - value = [...value.slice(0, index), ...value.slice(index + 1)]; + + const value = [ + ...bind.value.slice(0, index), + ...bind.value.slice(index + 1) + ]; bind.onChange(value.length === 0 ? null : value); diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjects.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjects.tsx index 4e33f8cc59e..808a7afc618 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjects.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjects.tsx @@ -68,15 +68,12 @@ const Actions = ({ setHighlightIndex, bind, index }: ActionsProps) => { [moveValueUp, index] ); - const onDelete = useCallback( - (ev: React.BaseSyntheticEvent) => { - ev.stopPropagation(); - showConfirmation(() => { - bind.field.removeValue(index); - }); - }, - [index] - ); + const onDelete = (ev: React.BaseSyntheticEvent) => { + ev.stopPropagation(); + showConfirmation(() => { + bind.field.removeValue(index); + }); + }; return ( <> diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx index 24d35ac9f7f..e0cdc188114 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/object/multipleObjectsAccordion.tsx @@ -70,15 +70,12 @@ const Actions = ({ setHighlightIndex, bind, index }: ActionsProps) => { [moveValueUp, index] ); - const onDelete = useCallback( - (ev: React.BaseSyntheticEvent) => { - ev.stopPropagation(); - showConfirmation(() => { - bind.field.removeValue(index); - }); - }, - [index] - ); + const onDelete = (ev: React.BaseSyntheticEvent) => { + ev.stopPropagation(); + showConfirmation(() => { + bind.field.removeValue(index); + }); + }; return ( <> From 6e1f5ac485e11f2f61f4c29f062712a6d2e40912 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Mon, 23 Dec 2024 20:42:21 +0100 Subject: [PATCH 28/52] fix(app-page-builder): expose a useDeleteElement hook --- .../src/editor/hooks/index.ts | 2 + .../src/editor/hooks/useDeleteElement.ts | 64 +++++++++++++++++++ .../src/editor/hooks/useFindElementBlock.ts | 22 +++++++ .../elementSettings/delete/DeleteAction.ts | 59 ++--------------- 4 files changed, 95 insertions(+), 52 deletions(-) create mode 100644 packages/app-page-builder/src/editor/hooks/useDeleteElement.ts create mode 100644 packages/app-page-builder/src/editor/hooks/useFindElementBlock.ts diff --git a/packages/app-page-builder/src/editor/hooks/index.ts b/packages/app-page-builder/src/editor/hooks/index.ts index 827ad709e08..72385053bf7 100644 --- a/packages/app-page-builder/src/editor/hooks/index.ts +++ b/packages/app-page-builder/src/editor/hooks/index.ts @@ -17,3 +17,5 @@ export { useRootElement } from "./useRootElement"; export { useUI } from "./useUI"; export { useUpdateElement } from "./useUpdateElement"; export { useUpdateHandlers } from "./useUpdateHandlers"; +export { useDeleteElement } from "./useDeleteElement"; +export { useFindElementBlock } from "./useFindElementBlock"; diff --git a/packages/app-page-builder/src/editor/hooks/useDeleteElement.ts b/packages/app-page-builder/src/editor/hooks/useDeleteElement.ts new file mode 100644 index 00000000000..8bf13d2a2c5 --- /dev/null +++ b/packages/app-page-builder/src/editor/hooks/useDeleteElement.ts @@ -0,0 +1,64 @@ +import { useCallback } from "react"; +import { plugins } from "@webiny/plugins"; +import { useEventActionHandler, useFindElementBlock, useUpdateElement } from "~/editor"; +import { DeleteElementActionEvent } from "~/editor/recoil/actions"; +import type { PbBlockVariable, PbEditorElement, PbEditorPageElementPlugin } from "~/types"; + +const removeVariableFromBlock = (block: PbEditorElement, variableId: string) => { + const variables = block.data.variables ?? []; + + const updatedVariables = variables.filter( + (variable: PbBlockVariable) => variable.id.split(".")[0] !== variableId + ); + + return { + ...block, + data: { + ...block.data, + variables: updatedVariables + } + }; +}; + +export const useDeleteElement = () => { + const eventActionHandler = useEventActionHandler(); + const updateElement = useUpdateElement(); + const { findElementBlock } = useFindElementBlock(); + + const canDeleteElement = useCallback((element: PbEditorElement) => { + const plugin = plugins + .byType<PbEditorPageElementPlugin>("pb-editor-page-element") + .find(pl => pl.elementType === element.type); + + if (!plugin) { + return false; + } + + if (typeof plugin.canDelete === "function") { + if (!plugin.canDelete({ element })) { + return false; + } + } + + return true; + }, []); + + const deleteElement = useCallback(async (element: PbEditorElement): Promise<void> => { + const block = await findElementBlock(element.id); + + // We need to remove element variable from block if it exists + if (element.data?.variableId && block) { + const updatedBlock = removeVariableFromBlock(block, element.data.variableId); + + updateElement(updatedBlock); + } + + eventActionHandler.trigger( + new DeleteElementActionEvent({ + element + }) + ); + }, []); + + return { canDeleteElement, deleteElement }; +}; diff --git a/packages/app-page-builder/src/editor/hooks/useFindElementBlock.ts b/packages/app-page-builder/src/editor/hooks/useFindElementBlock.ts new file mode 100644 index 00000000000..194840d422e --- /dev/null +++ b/packages/app-page-builder/src/editor/hooks/useFindElementBlock.ts @@ -0,0 +1,22 @@ +import { useCallback } from "react"; +import { useRecoilCallback } from "recoil"; +import { blockByElementSelector } from "~/editor/hooks/useCurrentBlockElement"; + +/** + * Exposes a getter which traverses the element tree upwards from the given element id, and returns an element + * of type "block", if found. + */ +export const useFindElementBlock = () => { + const findBlock = useRecoilCallback(({ snapshot }) => async (id: string) => { + return await snapshot.getPromise(blockByElementSelector(id)); + }); + + const findElementBlock = useCallback( + async (elementId: string) => { + return findBlock(elementId); + }, + [findBlock] + ); + + return { findElementBlock }; +}; diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/delete/DeleteAction.ts b/packages/app-page-builder/src/editor/plugins/elementSettings/delete/DeleteAction.ts index e57cdc387b6..4a6c06dbca9 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/delete/DeleteAction.ts +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/delete/DeleteAction.ts @@ -1,67 +1,22 @@ import React, { useCallback } from "react"; -import { plugins } from "@webiny/plugins"; -import { useActiveElement, useEventActionHandler } from "~/editor"; -import { PbEditorPageElementPlugin, PbBlockVariable, PbEditorElement } from "~/types"; -import { useUpdateElement } from "~/editor/hooks/useUpdateElement"; -import { useParentBlock } from "~/editor/hooks/useParentBlock"; -import { DeleteElementActionEvent } from "~/editor/recoil/actions"; - -const removeVariableFromBlock = (block: PbEditorElement, variableId: string) => { - const variables = block.data.variables ?? []; - - const updatedVariables = variables.filter( - (variable: PbBlockVariable) => variable.id.split(".")[0] !== variableId - ); - - return { - ...block, - data: { - ...block.data, - variables: updatedVariables - } - }; -}; +import { useActiveElement, useDeleteElement } from "~/editor"; interface DeleteActionPropsType { children: React.ReactElement; } const DeleteAction = ({ children }: DeleteActionPropsType) => { - const eventActionHandler = useEventActionHandler(); const [element] = useActiveElement(); - const block = useParentBlock(); - const updateElement = useUpdateElement(); - - if (!element) { - return null; - } + const { deleteElement, canDeleteElement } = useDeleteElement(); const onClick = useCallback((): void => { - // We need to remove element variable from block if it exists - if (element.data?.variableId && block) { - const updatedBlock = removeVariableFromBlock(block, element.data.variableId); - - updateElement(updatedBlock); + if (!element) { + return; } - eventActionHandler.trigger( - new DeleteElementActionEvent({ - element - }) - ); - }, [element.id]); - - const plugin = plugins - .byType<PbEditorPageElementPlugin>("pb-editor-page-element") - .find(pl => pl.elementType === element.type); - - if (!plugin) { - return null; - } - if (typeof plugin.canDelete === "function") { - if (!plugin.canDelete({ element })) { - return null; + if (canDeleteElement(element)) { + deleteElement(element); } - } + }, [element?.id]); return React.cloneElement(children, { onClick }); }; From a807d100247c49285b694f528821183bfee11943 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Mon, 13 Jan 2025 19:31:31 +0100 Subject: [PATCH 29/52] fix(react-composition): inherit decorators from parent scope (cherry picked from commit bd68f63c1f41d3e124953ec97b5cfecc622dead0) --- packages/react-composition/src/Compose.tsx | 6 +-- packages/react-composition/src/Context.tsx | 47 +++++++++++++++++----- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/react-composition/src/Compose.tsx b/packages/react-composition/src/Compose.tsx index b91a8f7c288..a71529e3bdf 100644 --- a/packages/react-composition/src/Compose.tsx +++ b/packages/react-composition/src/Compose.tsx @@ -27,11 +27,7 @@ export const Compose = (props: ComposeProps) => { } const decorators = Array.isArray(props.with) ? props.with : [props.with]; - return composeComponent( - targetFn.original, - decorators as Enumerable<ComposeWith>, - scope[scope.length - 1] - ); + return composeComponent(targetFn.original, decorators as Enumerable<ComposeWith>, scope); }, [props.with]); return null; diff --git a/packages/react-composition/src/Context.tsx b/packages/react-composition/src/Context.tsx index f7c08166a48..39c9e4995c2 100644 --- a/packages/react-composition/src/Context.tsx +++ b/packages/react-composition/src/Context.tsx @@ -65,7 +65,7 @@ interface CompositionContext { composeComponent( component: ComponentType<unknown>, hocs: Enumerable<ComposeWith>, - scope?: string + scope?: string[] ): void; } @@ -79,25 +79,48 @@ interface CompositionProviderProps { children: React.ReactNode; } +/** + * Scopes are ordered in reverse, to go from child to parent. As we iterate over scopes, we try to find the latest component + * recipe (a "recipe" is a base component + all decorators registered so far). If none exist, we return an empty recipe. + */ +const findComponentRecipe = ( + component: GenericComponent | GenericHook, + lookupScopes: string[], + components: ComponentScopes +) => { + for (const scope of lookupScopes) { + const scopeMap: ComposedComponents = components.get(scope) || new Map(); + const recipe = scopeMap.get(component); + if (recipe) { + return recipe; + } + } + + return { component: null, hocs: [] }; +}; + const composeComponents = ( components: ComponentScopes, decorators: Array<[GenericComponent | GenericHook, Decorator<any>[]]>, - scope = "*" + scopes: string[] = [] ) => { - const scopeMap: ComposedComponents = components.get(scope) || new Map(); + const targetScope = scopes[scopes.length - 1]; + const targetComponents = components.get(targetScope) || new Map(); + const lookupScopes = scopes.reverse(); + for (const [component, hocs] of decorators) { - const recipe = scopeMap.get(component) || { component: null, hocs: [] }; + const recipe = findComponentRecipe(component, lookupScopes, components); const newHocs = [...(recipe.hocs || []), ...hocs] as Decorator< GenericHook | GenericComponent >[]; - scopeMap.set(component, { + targetComponents.set(component, { component: compose(...[...newHocs].reverse())(component), hocs: newHocs }); - components.set(scope, scopeMap); + components.set(targetScope, targetComponents); } return components; @@ -109,7 +132,8 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr new Map(), decorators.map(tuple => { return [tuple[0].original, tuple[1]]; - }) + }), + ["*"] ); }); @@ -117,14 +141,19 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr ( component: GenericComponent | GenericHook, hocs: HigherOrderComponent<any, any>[], - scope: string | undefined = "*" + scopes: string[] = [] ) => { setComponents(prevComponents => { - return composeComponents(new Map(prevComponents), [[component, hocs]], scope); + return composeComponents( + new Map(prevComponents), + [[component, hocs]], + ["*", ...scopes] + ); }); // Return a function that will remove the added HOCs. return () => { + const scope = scopes[scopes.length - 1]; setComponents(prevComponents => { const components = new Map(prevComponents); const scopeMap: ComposedComponents = components.get(scope) || new Map(); From 4bdee89dc8f686e4f832ddc890d6376021525d1b Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Mon, 30 Dec 2024 12:56:04 +0100 Subject: [PATCH 30/52] fix(app-headless-cms): toggle overflow when accordion is expanded (cherry picked from commit 18b604eb0c32b3366fa2febb69c5531dfd91dda6) --- .../plugins/fieldRenderers/Accordion.tsx | 46 ++++++++++++------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/Accordion.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/Accordion.tsx index 9c532a38486..daf386fadab 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/Accordion.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/Accordion.tsx @@ -1,5 +1,6 @@ import React, { ReactElement, useCallback, useState } from "react"; import { css } from "emotion"; +import styled from "@emotion/styled"; import classNames from "classnames"; import { Typography } from "@webiny/ui/Typography"; @@ -69,23 +70,34 @@ const classes = { transform: "translateY(3px) rotate(90deg)" } } - }), - accordionItem: css({ - overflow: "hidden", - transition: "max-height 0.3s cubic-bezier(1, 0, 1, 0)", - height: "auto", - maxHeight: "9999px", - - "&.collapsed": { - maxHeight: 0, - transition: "max-height 0.35s cubic-bezier(0, 1, 0, 1)" - }, - ".accordion-content": { - paddingBottom: 10 - } }) }; +const AccordionItem = styled.div` + @keyframes show-overflow { + to { + overflow: visible; + } + } + overflow: hidden; + transition: max-height 0.35s cubic-bezier(0, 1, 0, 1); + height: auto; + max-height: 0; + + &.expanded { + max-height: 9999px; + transition: max-height 0.3s cubic-bezier(1, 0, 1, 0); + animation-name: show-overflow; + animation-fill-mode: forwards; + animation-duration: 20ms; + animation-delay: 0.3s; + } +`; + +const AccordionContent = styled.div` + padding-bottom: 10; +`; + interface AccordionProps { title: string; action?: ReactElement | null; @@ -116,9 +128,9 @@ const Accordion = ({ title, children, action, icon, defaultValue = false }: Acco <div className={"icon-container"}>{icon}</div> </div> </div> - <div className={classNames(classes.accordionItem, { collapsed: !isOpen })}> - <div className="accordion-content">{children}</div> - </div> + <AccordionItem className={classNames({ expanded: isOpen })}> + <AccordionContent>{children}</AccordionContent> + </AccordionItem> </div> ); }; From daa4faaafec4cd4053c64028d18806ea25b2c583 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Sun, 5 Jan 2025 15:00:02 +0100 Subject: [PATCH 31/52] fix(ui): use CSS variable instead of hardcoded values (cherry picked from commit 70d6ade4d37164a86c22a77227fcd5dfe762964e) --- packages/ui/src/AutoComplete/MultiAutoComplete.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/AutoComplete/MultiAutoComplete.tsx b/packages/ui/src/AutoComplete/MultiAutoComplete.tsx index 1f5874e0ded..5e3f61941dd 100644 --- a/packages/ui/src/AutoComplete/MultiAutoComplete.tsx +++ b/packages/ui/src/AutoComplete/MultiAutoComplete.tsx @@ -37,7 +37,7 @@ const style = { display: "flex", justifyContent: "space-between", alignItems: "center", - borderBottom: "2px solid #fa5723", + borderBottom: "2px solid var(--mdc-theme-primary, #fa5723)", padding: "6px 0" }), pages: css({ @@ -128,7 +128,14 @@ interface MultiAutoCompleteState { } const Spinner = () => { - return <MaterialSpinner size={24} spinnerColor={"#fa5723"} spinnerWidth={2} visible />; + return ( + <MaterialSpinner + size={24} + spinnerColor={"var(--mdc-theme-primary, #fa5723)"} + spinnerWidth={2} + visible + /> + ); }; interface RenderOptionsParams From cb9c54ca0bc1c148329d6735501f7c55b87094f8 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Fri, 17 Jan 2025 11:56:52 +0100 Subject: [PATCH 32/52] fix(react-composition): inherit components from parent scopes (#4494) --- .../FileManagerView/FileManagerViewConfig.tsx | 13 ++++- .../configComponents/Browser/FileAction.tsx | 9 ++-- .../configComponents/Browser/FolderAction.tsx | 9 ++-- .../configComponents/Browser/Table/Column.tsx | 9 ++-- .../LexicalCmsEditor/LexicalCmsEditor.tsx | 7 +-- .../Browser/AdvancedSearch/FieldRenderer.tsx | 9 ++-- .../list/Browser/EntryAction.tsx | 14 +++--- .../list/Browser/FolderAction.tsx | 9 ++-- .../list/Browser/Table/Column.tsx | 14 +++--- .../list/ContentEntryListConfig.tsx | 15 +++++- .../pages/list/Browser/FolderAction.tsx | 9 ++-- .../config/pages/list/Browser/PageAction.tsx | 9 ++-- .../pages/list/Browser/Table/Column.tsx | 13 +++-- .../config/pages/list/PageListConfig.tsx | 15 +++++- .../src/admin/plugins/routes.tsx | 2 +- .../configs/list/Browser/Table/Column.tsx | 9 ++-- .../configs/list/Browser/Table/Sorting.tsx | 9 ++-- .../configs/list/TrashBinListConfig.tsx | 15 +++++- .../lexical-editor-pb-element/src/index.tsx | 12 +++-- packages/react-composition/src/Context.tsx | 49 ++++++++++++------- .../react-composition/src/makeDecoratable.tsx | 4 +- 21 files changed, 139 insertions(+), 115 deletions(-) diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/FileManagerViewConfig.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/FileManagerViewConfig.tsx index 4f6e791627c..4a19b47c1b0 100644 --- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/FileManagerViewConfig.tsx +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/FileManagerViewConfig.tsx @@ -1,11 +1,22 @@ -import { useMemo } from "react"; +import React, { useMemo } from "react"; import { createConfigurableComponent } from "@webiny/react-properties"; import { Browser, BrowserConfig } from "./configComponents/Browser"; import { FileDetails, FileDetailsConfig } from "./configComponents/FileDetails"; import { getThumbnailRenderer } from "./getThumbnailRenderer"; +import { CompositionScope } from "@webiny/react-composition"; const base = createConfigurableComponent<FileManagerViewConfigData>("FileManagerView"); +const ScopedFileManagerViewConfig = ({ children }: { children: React.ReactNode }) => { + return ( + <CompositionScope name={"fm"}> + <base.Config>{children}</base.Config> + </CompositionScope> + ); +}; + +ScopedFileManagerViewConfig.displayName = "FileManagerViewConfig"; + export const FileManagerViewConfig = Object.assign(base.Config, { Browser, FileDetails }); export const FileManagerViewWithConfig = base.WithConfig; diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FileAction.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FileAction.tsx index 9c4b414cb48..ede69cf5f4c 100644 --- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FileAction.tsx +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FileAction.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { CompositionScope } from "@webiny/react-composition"; import { AcoConfig, RecordActionConfig } from "@webiny/app-aco"; const { Record } = AcoConfig; @@ -10,11 +9,9 @@ type FileActionProps = React.ComponentProps<typeof AcoConfig.Record.Action>; const BaseFileAction = (props: FileActionProps) => { return ( - <CompositionScope name={"fm"}> - <AcoConfig> - <Record.Action {...props} /> - </AcoConfig> - </CompositionScope> + <AcoConfig> + <Record.Action {...props} /> + </AcoConfig> ); }; diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FolderAction.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FolderAction.tsx index f019f44b279..7839116b234 100644 --- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FolderAction.tsx +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/FolderAction.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { CompositionScope } from "@webiny/react-composition"; import { AcoConfig, FolderActionConfig } from "@webiny/app-aco"; const { Folder } = AcoConfig; @@ -10,11 +9,9 @@ type FolderActionProps = React.ComponentProps<typeof AcoConfig.Folder.Action>; const BaseFolderAction = (props: FolderActionProps) => { return ( - <CompositionScope name={"fm"}> - <AcoConfig> - <Folder.Action {...props} /> - </AcoConfig> - </CompositionScope> + <AcoConfig> + <Folder.Action {...props} /> + </AcoConfig> ); }; diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/Table/Column.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/Table/Column.tsx index 91c21eb81a5..719bfb45e70 100644 --- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/Table/Column.tsx +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/configComponents/Browser/Table/Column.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { CompositionScope } from "@webiny/react-composition"; import { AcoConfig, TableColumnConfig as ColumnConfig } from "@webiny/app-aco"; import { TableItem } from "~/types"; @@ -11,11 +10,9 @@ type ColumnProps = React.ComponentProps<typeof AcoConfig.Table.Column>; const BaseColumn = (props: ColumnProps) => { return ( - <CompositionScope name={"fm"}> - <AcoConfig> - <Table.Column {...props} /> - </AcoConfig> - </CompositionScope> + <AcoConfig> + <Table.Column {...props} /> + </AcoConfig> ); }; diff --git a/packages/app-headless-cms/src/admin/components/LexicalCmsEditor/LexicalCmsEditor.tsx b/packages/app-headless-cms/src/admin/components/LexicalCmsEditor/LexicalCmsEditor.tsx index 925468df5af..60469ba8715 100644 --- a/packages/app-headless-cms/src/admin/components/LexicalCmsEditor/LexicalCmsEditor.tsx +++ b/packages/app-headless-cms/src/admin/components/LexicalCmsEditor/LexicalCmsEditor.tsx @@ -1,7 +1,6 @@ import React, { useCallback } from "react"; import { StaticToolbar } from "@webiny/lexical-editor"; import { RichTextEditorProps } from "@webiny/lexical-editor/types"; -import { CompositionScope } from "@webiny/react-composition"; import { LexicalEditor } from "@webiny/app-admin/components/LexicalEditor"; const placeholderStyles: React.CSSProperties = { position: "absolute", top: 40, left: 25 }; @@ -20,11 +19,7 @@ const styles: React.CSSProperties = { maxHeight: 350 }; -const toolbar = ( - <CompositionScope name={"cms"}> - <StaticToolbar /> - </CompositionScope> -); +const toolbar = <StaticToolbar />; export const LexicalCmsEditor = (props: Omit<RichTextEditorProps, "theme">) => { const onChange = useCallback( diff --git a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/AdvancedSearch/FieldRenderer.tsx b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/AdvancedSearch/FieldRenderer.tsx index b240d2f8cc7..cb021a5122d 100644 --- a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/AdvancedSearch/FieldRenderer.tsx +++ b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/AdvancedSearch/FieldRenderer.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { CompositionScope } from "@webiny/react-composition"; import { AcoConfig, AdvancedSearchFieldRendererConfig as FieldRendererConfig @@ -23,11 +22,9 @@ const BaseFieldRenderer = ({ modelIds = [], ...props }: FieldRendererProps) => { } return ( - <CompositionScope name={"cms"}> - <AcoConfig> - <AdvancedSearch.FieldRenderer {...props} /> - </AcoConfig> - </CompositionScope> + <AcoConfig> + <AdvancedSearch.FieldRenderer {...props} /> + </AcoConfig> ); }; diff --git a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/EntryAction.tsx b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/EntryAction.tsx index ea5c9c8a216..00bf9ef8f22 100644 --- a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/EntryAction.tsx +++ b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/EntryAction.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { CompositionScope, makeDecoratable } from "@webiny/react-composition"; +import { makeDecoratable } from "@webiny/react-composition"; import { AcoConfig, RecordActionConfig } from "@webiny/app-aco"; import { IsApplicableToCurrentModel } from "~/admin/config/IsApplicableToCurrentModel"; @@ -15,13 +15,11 @@ const BaseEntryAction = makeDecoratable( "EntryAction", ({ modelIds = [], ...props }: EntryActionProps) => { return ( - <CompositionScope name={"cms"}> - <AcoConfig> - <IsApplicableToCurrentModel modelIds={modelIds}> - <Record.Action {...props} /> - </IsApplicableToCurrentModel> - </AcoConfig> - </CompositionScope> + <AcoConfig> + <IsApplicableToCurrentModel modelIds={modelIds}> + <Record.Action {...props} /> + </IsApplicableToCurrentModel> + </AcoConfig> ); } ); diff --git a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/FolderAction.tsx b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/FolderAction.tsx index 53f2c20001b..40d950d578f 100644 --- a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/FolderAction.tsx +++ b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/FolderAction.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { CompositionScope } from "@webiny/react-composition"; import { AcoConfig, FolderActionConfig } from "@webiny/app-aco"; import { useModel } from "~/admin/hooks"; @@ -19,11 +18,9 @@ const BaseFolderAction = ({ modelIds = [], ...props }: FolderActionProps) => { } return ( - <CompositionScope name={"cms"}> - <AcoConfig> - <Folder.Action {...props} /> - </AcoConfig> - </CompositionScope> + <AcoConfig> + <Folder.Action {...props} /> + </AcoConfig> ); }; diff --git a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/Table/Column.tsx b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/Table/Column.tsx index d71110f2f93..e1ebeb81801 100644 --- a/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/Table/Column.tsx +++ b/packages/app-headless-cms/src/admin/config/contentEntries/list/Browser/Table/Column.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { CompositionScope, makeDecoratable } from "@webiny/react-composition"; +import { makeDecoratable } from "@webiny/react-composition"; import { AcoConfig, TableColumnConfig as ColumnConfig } from "@webiny/app-aco"; import { TableItem } from "~/types"; import { IsApplicableToCurrentModel } from "~/admin/config/IsApplicableToCurrentModel"; @@ -14,13 +14,11 @@ export interface ColumnProps extends React.ComponentProps<typeof AcoConfig.Table const BaseColumnComponent = ({ modelIds = [], ...props }: ColumnProps) => { return ( - <CompositionScope name={"cms"}> - <AcoConfig> - <IsApplicableToCurrentModel modelIds={modelIds}> - <Table.Column {...props} /> - </IsApplicableToCurrentModel> - </AcoConfig> - </CompositionScope> + <AcoConfig> + <IsApplicableToCurrentModel modelIds={modelIds}> + <Table.Column {...props} /> + </IsApplicableToCurrentModel> + </AcoConfig> ); }; diff --git a/packages/app-headless-cms/src/admin/config/contentEntries/list/ContentEntryListConfig.tsx b/packages/app-headless-cms/src/admin/config/contentEntries/list/ContentEntryListConfig.tsx index 5a383dc7529..a4d036cc30e 100644 --- a/packages/app-headless-cms/src/admin/config/contentEntries/list/ContentEntryListConfig.tsx +++ b/packages/app-headless-cms/src/admin/config/contentEntries/list/ContentEntryListConfig.tsx @@ -1,10 +1,21 @@ -import { useMemo } from "react"; +import React, { useMemo } from "react"; import { createConfigurableComponent } from "@webiny/react-properties"; import { Browser, BrowserConfig } from "./Browser"; +import { CompositionScope } from "@webiny/react-composition"; const base = createConfigurableComponent<ContentEntryListConfig>("ContentEntryListConfig"); -export const ContentEntryListConfig = Object.assign(base.Config, { Browser }); +const ScopedContentEntryListConfig = ({ children }: { children: React.ReactNode }) => { + return ( + <CompositionScope name={"cms"}> + <base.Config>{children}</base.Config> + </CompositionScope> + ); +}; + +ScopedContentEntryListConfig.displayName = "ContentEntryListConfig"; + +export const ContentEntryListConfig = Object.assign(ScopedContentEntryListConfig, { Browser }); export const ContentEntryListWithConfig = base.WithConfig; interface ContentEntryListConfig { diff --git a/packages/app-page-builder/src/admin/config/pages/list/Browser/FolderAction.tsx b/packages/app-page-builder/src/admin/config/pages/list/Browser/FolderAction.tsx index 4cb11debe14..7839116b234 100644 --- a/packages/app-page-builder/src/admin/config/pages/list/Browser/FolderAction.tsx +++ b/packages/app-page-builder/src/admin/config/pages/list/Browser/FolderAction.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { CompositionScope } from "@webiny/react-composition"; import { AcoConfig, FolderActionConfig } from "@webiny/app-aco"; const { Folder } = AcoConfig; @@ -10,11 +9,9 @@ type FolderActionProps = React.ComponentProps<typeof AcoConfig.Folder.Action>; const BaseFolderAction = (props: FolderActionProps) => { return ( - <CompositionScope name={"pb.page"}> - <AcoConfig> - <Folder.Action {...props} /> - </AcoConfig> - </CompositionScope> + <AcoConfig> + <Folder.Action {...props} /> + </AcoConfig> ); }; diff --git a/packages/app-page-builder/src/admin/config/pages/list/Browser/PageAction.tsx b/packages/app-page-builder/src/admin/config/pages/list/Browser/PageAction.tsx index b30ac8369e5..6769638c4a7 100644 --- a/packages/app-page-builder/src/admin/config/pages/list/Browser/PageAction.tsx +++ b/packages/app-page-builder/src/admin/config/pages/list/Browser/PageAction.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { CompositionScope } from "@webiny/react-composition"; import { AcoConfig, RecordActionConfig } from "@webiny/app-aco"; const { Record } = AcoConfig; @@ -10,11 +9,9 @@ type PageActionProps = React.ComponentProps<typeof AcoConfig.Record.Action>; const BasePageAction = (props: PageActionProps) => { return ( - <CompositionScope name={"pb.page"}> - <AcoConfig> - <Record.Action {...props} /> - </AcoConfig> - </CompositionScope> + <AcoConfig> + <Record.Action {...props} /> + </AcoConfig> ); }; diff --git a/packages/app-page-builder/src/admin/config/pages/list/Browser/Table/Column.tsx b/packages/app-page-builder/src/admin/config/pages/list/Browser/Table/Column.tsx index d43d6e36b8c..a7f89ac404f 100644 --- a/packages/app-page-builder/src/admin/config/pages/list/Browser/Table/Column.tsx +++ b/packages/app-page-builder/src/admin/config/pages/list/Browser/Table/Column.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { CompositionScope } from "@webiny/react-composition"; import { AcoConfig, TableColumnConfig as ColumnConfig } from "@webiny/app-aco"; import { TableItem } from "~/types"; @@ -9,13 +8,11 @@ export { ColumnConfig }; type ColumnProps = React.ComponentProps<typeof AcoConfig.Table.Column>; -const BaseColumn = (props: ColumnProps) => { +const BaseColumn: React.FC<ColumnProps> = props => { return ( - <CompositionScope name={"pb.page"}> - <AcoConfig> - <Table.Column {...props} /> - </AcoConfig> - </CompositionScope> + <AcoConfig> + <Table.Column {...props} /> + </AcoConfig> ); }; @@ -23,3 +20,5 @@ export const Column = Object.assign(BaseColumn, { useTableRow: Table.Column.createUseTableRow<TableItem>(), isFolderRow: Table.Column.isFolderRow }); + +Column["displayName"] = "Column"; diff --git a/packages/app-page-builder/src/admin/config/pages/list/PageListConfig.tsx b/packages/app-page-builder/src/admin/config/pages/list/PageListConfig.tsx index 5a7485790f2..929a98ee9fb 100644 --- a/packages/app-page-builder/src/admin/config/pages/list/PageListConfig.tsx +++ b/packages/app-page-builder/src/admin/config/pages/list/PageListConfig.tsx @@ -1,10 +1,21 @@ -import { useMemo } from "react"; +import React, { useMemo } from "react"; import { createConfigurableComponent } from "@webiny/react-properties"; import { Browser, BrowserConfig } from "./Browser"; +import { CompositionScope } from "@webiny/react-composition"; const base = createConfigurableComponent<PageListConfig>("PageListConfig"); -export const PageListConfig = Object.assign(base.Config, { Browser }); +const ScopedPagesListConfig = ({ children }: { children: React.ReactNode }) => { + return ( + <CompositionScope name={"pb.pages"}> + <base.Config>{children}</base.Config> + </CompositionScope> + ); +}; + +ScopedPagesListConfig.displayName = "PagesListConfig"; + +export const PageListConfig = Object.assign(ScopedPagesListConfig, { Browser }); export const PageListWithConfig = base.WithConfig; interface PageListConfig { diff --git a/packages/app-page-builder/src/admin/plugins/routes.tsx b/packages/app-page-builder/src/admin/plugins/routes.tsx index 8cd18ad6eb2..342d2794bae 100644 --- a/packages/app-page-builder/src/admin/plugins/routes.tsx +++ b/packages/app-page-builder/src/admin/plugins/routes.tsx @@ -71,7 +71,7 @@ const plugins: RoutePlugin[] = [ <RenderPluginsLoader> <AdminLayout> <Helmet title={"Page Builder - Pages"} /> - <CompositionScope name={"pb.page"}> + <CompositionScope name={"pb.pages"}> <Pages /> </CompositionScope> </AdminLayout> diff --git a/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Column.tsx b/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Column.tsx index 14462c1d997..84a5cb0cd8b 100644 --- a/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Column.tsx +++ b/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Column.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { CompositionScope } from "@webiny/react-composition"; import { AcoConfig, TableColumnConfig as ColumnConfig } from "@webiny/app-aco"; import { TrashBinItemDTO } from "~/Domain"; @@ -11,11 +10,9 @@ type ColumnProps = React.ComponentProps<typeof AcoConfig.Table.Column>; const BaseColumn = (props: ColumnProps) => { return ( - <CompositionScope name={"trash"}> - <AcoConfig> - <Table.Column {...props} /> - </AcoConfig> - </CompositionScope> + <AcoConfig> + <Table.Column {...props} /> + </AcoConfig> ); }; diff --git a/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Sorting.tsx b/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Sorting.tsx index cf282678ce9..de3bd4c283d 100644 --- a/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Sorting.tsx +++ b/packages/app-trash-bin/src/Presentation/configs/list/Browser/Table/Sorting.tsx @@ -1,5 +1,4 @@ import React from "react"; -import { CompositionScope } from "@webiny/react-composition"; import { AcoConfig, TableSortingConfig as SortingConfig } from "@webiny/app-aco"; const { Table } = AcoConfig; @@ -10,10 +9,8 @@ type SortingProps = React.ComponentProps<typeof AcoConfig.Table.Sorting>; export const Sorting = (props: SortingProps) => { return ( - <CompositionScope name={"trash"}> - <AcoConfig> - <Table.Sorting {...props} /> - </AcoConfig> - </CompositionScope> + <AcoConfig> + <Table.Sorting {...props} /> + </AcoConfig> ); }; diff --git a/packages/app-trash-bin/src/Presentation/configs/list/TrashBinListConfig.tsx b/packages/app-trash-bin/src/Presentation/configs/list/TrashBinListConfig.tsx index 2960021e3c2..378cf77d120 100644 --- a/packages/app-trash-bin/src/Presentation/configs/list/TrashBinListConfig.tsx +++ b/packages/app-trash-bin/src/Presentation/configs/list/TrashBinListConfig.tsx @@ -1,10 +1,21 @@ -import { useMemo } from "react"; +import React, { useMemo } from "react"; import { createConfigurableComponent } from "@webiny/react-properties"; import { Browser, BrowserConfig } from "./Browser"; +import { CompositionScope } from "@webiny/react-composition"; const base = createConfigurableComponent<TrashBinListConfig>("TrashBinListConfig"); -export const TrashBinListConfig = Object.assign(base.Config, { Browser }); +const ScopedTrashBinListConfig = ({ children }: { children: React.ReactNode }) => { + return ( + <CompositionScope name={"trash"}> + <base.Config>{children}</base.Config> + </CompositionScope> + ); +}; + +ScopedTrashBinListConfig.displayName = "TrashBinListConfig"; + +export const TrashBinListConfig = Object.assign(ScopedTrashBinListConfig, { Browser }); export const TrashBinListWithConfig = base.WithConfig; interface TrashBinListConfig { diff --git a/packages/lexical-editor-pb-element/src/index.tsx b/packages/lexical-editor-pb-element/src/index.tsx index 48a3440f4b4..8449f332b5c 100644 --- a/packages/lexical-editor-pb-element/src/index.tsx +++ b/packages/lexical-editor-pb-element/src/index.tsx @@ -14,11 +14,13 @@ export * from "./LexicalEditor"; export const LexicalEditorPlugin = () => { return ( <> - <CompositionScope name={"pb.paragraph"}> - <ParagraphEditorPreset /> - </CompositionScope> - <CompositionScope name={"pb.heading"}> - <HeadingEditorPreset /> + <CompositionScope name={"pb.pageEditor"}> + <CompositionScope name={"pb.paragraph"}> + <ParagraphEditorPreset /> + </CompositionScope> + <CompositionScope name={"pb.heading"}> + <HeadingEditorPreset /> + </CompositionScope> </CompositionScope> <TypographyAction.TypographyDropDown element={<TypographyDropDown />} /> {/* Block editor variables */} diff --git a/packages/react-composition/src/Context.tsx b/packages/react-composition/src/Context.tsx index 39c9e4995c2..457c887a31f 100644 --- a/packages/react-composition/src/Context.tsx +++ b/packages/react-composition/src/Context.tsx @@ -3,6 +3,7 @@ import React, { createContext, useCallback, useContext, + useEffect, useMemo, useState } from "react"; @@ -79,6 +80,10 @@ interface CompositionProviderProps { children: React.ReactNode; } +const getCacheKey = (scopes: string[]) => { + return scopes.join(";"); +}; + /** * Scopes are ordered in reverse, to go from child to parent. As we iterate over scopes, we try to find the latest component * recipe (a "recipe" is a base component + all decorators registered so far). If none exist, we return an empty recipe. @@ -88,8 +93,9 @@ const findComponentRecipe = ( lookupScopes: string[], components: ComponentScopes ) => { - for (const scope of lookupScopes) { - const scopeMap: ComposedComponents = components.get(scope) || new Map(); + for (let i = lookupScopes.length; i > 0; i--) { + const cacheKey = getCacheKey(lookupScopes.slice(0, i)); + const scopeMap: ComposedComponents = components.get(cacheKey) || new Map(); const recipe = scopeMap.get(component); if (recipe) { return recipe; @@ -104,12 +110,11 @@ const composeComponents = ( decorators: Array<[GenericComponent | GenericHook, Decorator<any>[]]>, scopes: string[] = [] ) => { - const targetScope = scopes[scopes.length - 1]; - const targetComponents = components.get(targetScope) || new Map(); - const lookupScopes = scopes.reverse(); + const cacheKey = getCacheKey(scopes); + const targetComponents: ComposedComponents = components.get(cacheKey) || new Map(); for (const [component, hocs] of decorators) { - const recipe = findComponentRecipe(component, lookupScopes, components); + const recipe = findComponentRecipe(component, scopes, components); const newHocs = [...(recipe.hocs || []), ...hocs] as Decorator< GenericHook | GenericComponent @@ -120,7 +125,7 @@ const composeComponents = ( hocs: newHocs }); - components.set(targetScope, targetComponents); + components.set(cacheKey, targetComponents); } return components; @@ -143,20 +148,18 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr hocs: HigherOrderComponent<any, any>[], scopes: string[] = [] ) => { + const allScopes = ["*", ...scopes]; + setComponents(prevComponents => { - return composeComponents( - new Map(prevComponents), - [[component, hocs]], - ["*", ...scopes] - ); + return composeComponents(new Map(prevComponents), [[component, hocs]], allScopes); }); // Return a function that will remove the added HOCs. return () => { - const scope = scopes[scopes.length - 1]; + const cacheKey = getCacheKey(allScopes); setComponents(prevComponents => { const components = new Map(prevComponents); - const scopeMap: ComposedComponents = components.get(scope) || new Map(); + const scopeMap: ComposedComponents = components.get(cacheKey) || new Map(); const recipe = scopeMap.get(component) || { component: null, hocs: [] @@ -170,7 +173,7 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr hocs: newHOCs }); - components.set(scope, scopeMap); + components.set(cacheKey, scopeMap); return components; }); }; @@ -180,9 +183,10 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr const getComponent = useCallback<CompositionContextGetComponentCallable>( (Component, scope = []) => { - const scopesToResolve = ["*", ...scope].reverse(); - for (const scope of scopesToResolve) { - const scopeMap: ComposedComponents = components.get(scope) || new Map(); + const scopes = ["*", ...scope]; + for (let i = scopes.length; i > 0; i--) { + const cacheKey = getCacheKey(scopes.slice(0, i)); + const scopeMap: ComposedComponents = components.get(cacheKey) || new Map(); const composedComponent = scopeMap.get(Component); if (composedComponent) { return composedComponent.component; @@ -203,6 +207,15 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr [components, composeComponent] ); + useEffect(() => { + if (process.env.NODE_ENV !== "production") { + // @ts-expect-error This is a developers-only utility. + window["debug_printComposedComponents"] = () => { + console.log(components); + }; + } + }, [components]); + return <CompositionContext.Provider value={context}>{children}</CompositionContext.Provider>; }; diff --git a/packages/react-composition/src/makeDecoratable.tsx b/packages/react-composition/src/makeDecoratable.tsx index eeea12f73b7..9310b10463d 100644 --- a/packages/react-composition/src/makeDecoratable.tsx +++ b/packages/react-composition/src/makeDecoratable.tsx @@ -77,7 +77,9 @@ export function makeDecoratable<T extends GenericComponent>( ): ReturnType<typeof makeDecoratableComponent<T>>; export function makeDecoratable(hookOrName: any, Component?: any) { if (Component) { - return makeDecoratableComponent(hookOrName, React.memo(Component)); + const component = makeDecoratableComponent(hookOrName, React.memo(Component)); + component.original.displayName = hookOrName; + return component; } return makeDecoratableHook(hookOrName); From 922a47688dd519ddc10b598d3ae79cb3186e3173 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Fri, 17 Jan 2025 17:15:06 +0100 Subject: [PATCH 33/52] fix(lexical-editor): ensure overlays are within the viewport --- packages/lexical-editor/package.json | 1 - packages/lexical-editor/src/commands/index.ts | 1 + .../lexical-editor/src/commands/toolbar.ts | 3 + .../src/components/Toolbar/Toolbar.tsx | 12 ++ .../FloatingLinkEditorPlugin.tsx | 3 +- .../useFloatingLinkEditor.tsx | 106 ++++++++++++------ .../src/utils/setFloatingElemPosition.ts | 56 +++++---- yarn.lock | 1 - 8 files changed, 116 insertions(+), 67 deletions(-) create mode 100644 packages/lexical-editor/src/commands/toolbar.ts diff --git a/packages/lexical-editor/package.json b/packages/lexical-editor/package.json index 7d3e0b426c1..4915f6851f0 100644 --- a/packages/lexical-editor/package.json +++ b/packages/lexical-editor/package.json @@ -21,7 +21,6 @@ "@webiny/react-properties": "0.0.0", "emotion": "^10.0.17", "lexical": "^0.16.1", - "lodash": "4.17.21", "react": "18.2.0", "react-dom": "18.2.0" }, diff --git a/packages/lexical-editor/src/commands/index.ts b/packages/lexical-editor/src/commands/index.ts index c49e77dead5..8bd94941554 100644 --- a/packages/lexical-editor/src/commands/index.ts +++ b/packages/lexical-editor/src/commands/index.ts @@ -1,3 +1,4 @@ export * from "~/commands/image"; export * from "~/commands/list"; export * from "~/commands/quote"; +export * from "~/commands/toolbar"; diff --git a/packages/lexical-editor/src/commands/toolbar.ts b/packages/lexical-editor/src/commands/toolbar.ts new file mode 100644 index 00000000000..675c35273e6 --- /dev/null +++ b/packages/lexical-editor/src/commands/toolbar.ts @@ -0,0 +1,3 @@ +import { createCommand } from "lexical"; + +export const HIDE_FLOATING_TOOLBAR = createCommand("HIDE_FLOATING_TOOLBAR_COMMAND"); diff --git a/packages/lexical-editor/src/components/Toolbar/Toolbar.tsx b/packages/lexical-editor/src/components/Toolbar/Toolbar.tsx index 93df7cf33a0..481549ba4b5 100644 --- a/packages/lexical-editor/src/components/Toolbar/Toolbar.tsx +++ b/packages/lexical-editor/src/components/Toolbar/Toolbar.tsx @@ -15,6 +15,7 @@ import { useLexicalEditorConfig } from "~/components/LexicalEditorConfig/Lexical import { useDeriveValueFromSelection } from "~/hooks/useCurrentSelection"; import { useRichTextEditor } from "~/hooks"; import { isChildOfFloatingToolbar } from "~/utils/isChildOfFloatingToolbar"; +import { HIDE_FLOATING_TOOLBAR } from "~/commands"; interface FloatingToolbarProps { anchorElem: HTMLElement; @@ -127,6 +128,17 @@ const FloatingToolbar: FC<FloatingToolbarProps> = ({ anchorElem, editor }) => { }); }), + editor.registerCommand( + HIDE_FLOATING_TOOLBAR, + () => { + setTimeout(() => { + setIsVisible(false); + }, 10); + return true; + }, + COMMAND_PRIORITY_LOW + ), + editor.registerCommand( SELECTION_CHANGE_COMMAND, () => { diff --git a/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/FloatingLinkEditorPlugin.tsx b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/FloatingLinkEditorPlugin.tsx index 6bb9bc04bc6..f52d53033b2 100644 --- a/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/FloatingLinkEditorPlugin.tsx +++ b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/FloatingLinkEditorPlugin.tsx @@ -78,6 +78,7 @@ export function FloatingLinkEditor({ editor, isVisible, anchorElem }: FloatingLi const rootElement = editor.getRootElement(); if ( + isVisible && selection !== null && nativeSelection !== null && rootElement !== null && @@ -107,7 +108,7 @@ export function FloatingLinkEditor({ editor, isVisible, anchorElem }: FloatingLi } return true; - }, [anchorElem, editor]); + }, [isVisible, anchorElem, editor]); const removeLink = () => { editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); diff --git a/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/useFloatingLinkEditor.tsx b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/useFloatingLinkEditor.tsx index cad6ec0723d..73e78ee1272 100644 --- a/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/useFloatingLinkEditor.tsx +++ b/packages/lexical-editor/src/plugins/FloatingLinkEditorPlugin/useFloatingLinkEditor.tsx @@ -1,10 +1,10 @@ -import React, { useCallback, useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { useRichTextEditor } from "~/hooks"; import { getSelectedNode } from "~/utils/getSelectedNode"; +import { BaseSelection } from "lexical"; import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from "@webiny/lexical-nodes"; import { isChildOfLinkEditor } from "~/plugins/FloatingLinkEditorPlugin/isChildOfLinkEditor"; -import debounce from "lodash/debounce"; import { $getSelection, $isRangeSelection, @@ -15,55 +15,81 @@ import { } from "lexical"; import { $findMatchingParent, mergeRegister } from "@lexical/utils"; import { FloatingLinkEditor } from "./FloatingLinkEditorPlugin"; +import { HIDE_FLOATING_TOOLBAR } from "~/commands"; -export function useFloatingLinkEditor(anchorElem: HTMLElement): JSX.Element | null { - const { editor } = useRichTextEditor(); - const [isLink, setIsLink] = useState(false); +const isLink = (selection: BaseSelection | null) => { + if (!$isRangeSelection(selection)) { + return; + } - const debounceSetIsLink = useCallback(debounce(setIsLink, 50), []); + const node = getSelectedNode(selection); + const linkParent = $findMatchingParent(node, $isLinkNode); + const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode); + const isLinkOrChildOfLink = Boolean($isLinkNode(node) || linkParent); - const updateToolbar = useCallback(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection)) { - return; - } + if (!isLinkOrChildOfLink) { + return false; + } - const node = getSelectedNode(selection); - const linkParent = $findMatchingParent(node, $isLinkNode); - const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode); - const isLinkOrChildOfLink = Boolean($isLinkNode(node) || linkParent); + return linkParent !== null && autoLinkParent == null; +}; - if (!isLinkOrChildOfLink) { - // When hiding the toolbar, we want to hide immediately. - setIsLink(false); - } +const isSelectionCollapsed = (selection: BaseSelection | null) => { + return $isRangeSelection(selection) && selection.isCollapsed(); +}; + +const isLinkFocused = (selection: BaseSelection | null) => { + return isLink(selection) && isSelectionCollapsed(selection); +}; + +const isLinkSelected = (selection: BaseSelection | null) => { + return isLink(selection) && !isSelectionCollapsed(selection); +}; - if (selection.dirty) { - // We don't want this menu to open for auto links. - if (linkParent != null && autoLinkParent == null) { - // When showing the toolbar, we want to debounce it, because sometimes selection gets updated - // multiple times, and the `selection.dirty` flag goes from true to false multiple times, - // eventually settling on `false`, which we want to set once it has settled. - debounceSetIsLink(true); - } +export function useFloatingLinkEditor(anchorElem: HTMLElement): JSX.Element | null { + const { editor } = useRichTextEditor(); + const [isLinkEditorVisible, setShowLinkEditor] = useState(false); + const newLinkRef = useRef(false); + + const showLinkEditor = (state: boolean) => { + setShowLinkEditor(state); + if (!state) { + newLinkRef.current = false; } - }, []); + }; useEffect(() => { return mergeRegister( editor.registerCommand( SELECTION_CHANGE_COMMAND, () => { - updateToolbar(); + const selection = $getSelection(); + + if (isLinkFocused(selection)) { + showLinkEditor(true); + return false; + } + + if (isLinkSelected(selection) && newLinkRef.current) { + return false; + } + + if (isLinkSelected(selection) && !newLinkRef.current) { + showLinkEditor(false); + return false; + } + + showLinkEditor(false); + return false; }, - COMMAND_PRIORITY_CRITICAL + COMMAND_PRIORITY_LOW ), editor.registerCommand( BLUR_COMMAND, payload => { if (!isChildOfLinkEditor(payload.relatedTarget as HTMLElement)) { - setIsLink(false); + showLinkEditor(false); } return false; @@ -73,16 +99,28 @@ export function useFloatingLinkEditor(anchorElem: HTMLElement): JSX.Element | nu editor.registerCommand( TOGGLE_LINK_COMMAND, payload => { - setIsLink(!!payload); + const addLink = !!payload; + + if (addLink) { + newLinkRef.current = true; + showLinkEditor(true); + editor.dispatchCommand(HIDE_FLOATING_TOOLBAR, {}); + } else { + showLinkEditor(false); + } return false; }, COMMAND_PRIORITY_CRITICAL ) ); - }, [editor, updateToolbar]); + }, [editor]); return createPortal( - <FloatingLinkEditor isVisible={isLink} editor={editor} anchorElem={anchorElem} />, + <FloatingLinkEditor + isVisible={isLinkEditorVisible} + editor={editor} + anchorElem={anchorElem} + />, anchorElem ); } diff --git a/packages/lexical-editor/src/utils/setFloatingElemPosition.ts b/packages/lexical-editor/src/utils/setFloatingElemPosition.ts index 6260d67b88f..a50c9f4b2a8 100644 --- a/packages/lexical-editor/src/utils/setFloatingElemPosition.ts +++ b/packages/lexical-editor/src/utils/setFloatingElemPosition.ts @@ -1,46 +1,42 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ const VERTICAL_GAP = 10; const HORIZONTAL_OFFSET = 5; export function setFloatingElemPosition( - targetRect: ClientRect | null, - floatingElem: HTMLElement, + basePosition: ClientRect | null, + elementToPosition: HTMLElement, anchorElem: HTMLElement, verticalGap: number = VERTICAL_GAP, horizontalOffset: number = HORIZONTAL_OFFSET ): void { - const scrollerElem = anchorElem.parentElement; + // A small timeout gives enough time for DOM to update and provides us with correct bounding rect values. + setTimeout(() => { + const scrollerElem = anchorElem.parentElement; - if (targetRect === null || !scrollerElem) { - floatingElem.style.opacity = "0"; - floatingElem.style.transform = "translate(-10000px, -10000px)"; - return; - } + if (basePosition === null || !scrollerElem) { + elementToPosition.style.opacity = "0"; + elementToPosition.style.transform = "translate(-10000px, -10000px)"; + return; + } - const floatingElemRect = floatingElem.getBoundingClientRect(); - const anchorElementRect = anchorElem.getBoundingClientRect(); - const editorScrollerRect = scrollerElem.getBoundingClientRect(); + const rectToPosition = elementToPosition.getBoundingClientRect(); + const anchorElementRect = anchorElem.getBoundingClientRect(); + const editorScrollerRect = scrollerElem.getBoundingClientRect(); - let top = targetRect.top - floatingElemRect.height - verticalGap; - let left = targetRect.left - horizontalOffset; + let top = basePosition.top - rectToPosition.height - verticalGap; + let left = basePosition.left - horizontalOffset; - if (top < editorScrollerRect.top) { - top += floatingElemRect.height + targetRect.height + verticalGap * 2; - } + if (top < editorScrollerRect.top) { + top += rectToPosition.height + basePosition.height + verticalGap * 2; + } - if (left + floatingElemRect.width > editorScrollerRect.right) { - left = editorScrollerRect.right - floatingElemRect.width - horizontalOffset; - } + if (left + rectToPosition.width > editorScrollerRect.right) { + left = editorScrollerRect.right - rectToPosition.width - horizontalOffset; + } - top -= anchorElementRect.top; - left -= anchorElementRect.left; + top -= anchorElementRect.top; + left -= anchorElementRect.left; - floatingElem.style.opacity = "1"; - floatingElem.style.transform = `translate(${left}px, ${top}px)`; + elementToPosition.style.opacity = "1"; + elementToPosition.style.transform = `translate(${left}px, ${top}px)`; + }, 10); } diff --git a/yarn.lock b/yarn.lock index 08a8f9dba39..8b5e330dfe3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17678,7 +17678,6 @@ __metadata: "@webiny/react-properties": 0.0.0 emotion: ^10.0.17 lexical: ^0.16.1 - lodash: 4.17.21 react: 18.2.0 react-dom: 18.2.0 languageName: unknown From 0c7eb4dc6f7d10e97a22cfcfb0904883810dc895 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Tue, 28 Jan 2025 20:49:17 +0100 Subject: [PATCH 34/52] fix(react-composition): remove scope inheritance (cherry picked from commit 335ce0796c062ba26596414413a6de2a31590440) --- packages/react-composition/src/Compose.tsx | 6 +- packages/react-composition/src/Context.tsx | 70 +++++----------------- 2 files changed, 19 insertions(+), 57 deletions(-) diff --git a/packages/react-composition/src/Compose.tsx b/packages/react-composition/src/Compose.tsx index a71529e3bdf..b91a8f7c288 100644 --- a/packages/react-composition/src/Compose.tsx +++ b/packages/react-composition/src/Compose.tsx @@ -27,7 +27,11 @@ export const Compose = (props: ComposeProps) => { } const decorators = Array.isArray(props.with) ? props.with : [props.with]; - return composeComponent(targetFn.original, decorators as Enumerable<ComposeWith>, scope); + return composeComponent( + targetFn.original, + decorators as Enumerable<ComposeWith>, + scope[scope.length - 1] + ); }, [props.with]); return null; diff --git a/packages/react-composition/src/Context.tsx b/packages/react-composition/src/Context.tsx index 457c887a31f..f7c08166a48 100644 --- a/packages/react-composition/src/Context.tsx +++ b/packages/react-composition/src/Context.tsx @@ -3,7 +3,6 @@ import React, { createContext, useCallback, useContext, - useEffect, useMemo, useState } from "react"; @@ -66,7 +65,7 @@ interface CompositionContext { composeComponent( component: ComponentType<unknown>, hocs: Enumerable<ComposeWith>, - scope?: string[] + scope?: string ): void; } @@ -80,52 +79,25 @@ interface CompositionProviderProps { children: React.ReactNode; } -const getCacheKey = (scopes: string[]) => { - return scopes.join(";"); -}; - -/** - * Scopes are ordered in reverse, to go from child to parent. As we iterate over scopes, we try to find the latest component - * recipe (a "recipe" is a base component + all decorators registered so far). If none exist, we return an empty recipe. - */ -const findComponentRecipe = ( - component: GenericComponent | GenericHook, - lookupScopes: string[], - components: ComponentScopes -) => { - for (let i = lookupScopes.length; i > 0; i--) { - const cacheKey = getCacheKey(lookupScopes.slice(0, i)); - const scopeMap: ComposedComponents = components.get(cacheKey) || new Map(); - const recipe = scopeMap.get(component); - if (recipe) { - return recipe; - } - } - - return { component: null, hocs: [] }; -}; - const composeComponents = ( components: ComponentScopes, decorators: Array<[GenericComponent | GenericHook, Decorator<any>[]]>, - scopes: string[] = [] + scope = "*" ) => { - const cacheKey = getCacheKey(scopes); - const targetComponents: ComposedComponents = components.get(cacheKey) || new Map(); - + const scopeMap: ComposedComponents = components.get(scope) || new Map(); for (const [component, hocs] of decorators) { - const recipe = findComponentRecipe(component, scopes, components); + const recipe = scopeMap.get(component) || { component: null, hocs: [] }; const newHocs = [...(recipe.hocs || []), ...hocs] as Decorator< GenericHook | GenericComponent >[]; - targetComponents.set(component, { + scopeMap.set(component, { component: compose(...[...newHocs].reverse())(component), hocs: newHocs }); - components.set(cacheKey, targetComponents); + components.set(scope, scopeMap); } return components; @@ -137,8 +109,7 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr new Map(), decorators.map(tuple => { return [tuple[0].original, tuple[1]]; - }), - ["*"] + }) ); }); @@ -146,20 +117,17 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr ( component: GenericComponent | GenericHook, hocs: HigherOrderComponent<any, any>[], - scopes: string[] = [] + scope: string | undefined = "*" ) => { - const allScopes = ["*", ...scopes]; - setComponents(prevComponents => { - return composeComponents(new Map(prevComponents), [[component, hocs]], allScopes); + return composeComponents(new Map(prevComponents), [[component, hocs]], scope); }); // Return a function that will remove the added HOCs. return () => { - const cacheKey = getCacheKey(allScopes); setComponents(prevComponents => { const components = new Map(prevComponents); - const scopeMap: ComposedComponents = components.get(cacheKey) || new Map(); + const scopeMap: ComposedComponents = components.get(scope) || new Map(); const recipe = scopeMap.get(component) || { component: null, hocs: [] @@ -173,7 +141,7 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr hocs: newHOCs }); - components.set(cacheKey, scopeMap); + components.set(scope, scopeMap); return components; }); }; @@ -183,10 +151,9 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr const getComponent = useCallback<CompositionContextGetComponentCallable>( (Component, scope = []) => { - const scopes = ["*", ...scope]; - for (let i = scopes.length; i > 0; i--) { - const cacheKey = getCacheKey(scopes.slice(0, i)); - const scopeMap: ComposedComponents = components.get(cacheKey) || new Map(); + const scopesToResolve = ["*", ...scope].reverse(); + for (const scope of scopesToResolve) { + const scopeMap: ComposedComponents = components.get(scope) || new Map(); const composedComponent = scopeMap.get(Component); if (composedComponent) { return composedComponent.component; @@ -207,15 +174,6 @@ export const CompositionProvider = ({ decorators = [], children }: CompositionPr [components, composeComponent] ); - useEffect(() => { - if (process.env.NODE_ENV !== "production") { - // @ts-expect-error This is a developers-only utility. - window["debug_printComposedComponents"] = () => { - console.log(components); - }; - } - }, [components]); - return <CompositionContext.Provider value={context}>{children}</CompositionContext.Provider>; }; From f700f04287fb4f2128da1a207fb0fa486a2dd773 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Mon, 17 Feb 2025 19:08:47 +0100 Subject: [PATCH 35/52] fix(app-page-builder): add support for translatable items array --- .../CollectElementValues.tsx | 33 +++++++++++-------- .../ExtractTranslatableValues.tsx | 13 +++++--- .../src/translations/index.ts | 4 +++ 3 files changed, 32 insertions(+), 18 deletions(-) diff --git a/packages/app-page-builder/src/translations/ExtractTranslatableValues/CollectElementValues.tsx b/packages/app-page-builder/src/translations/ExtractTranslatableValues/CollectElementValues.tsx index 8b1c86ca6a1..45957c92362 100644 --- a/packages/app-page-builder/src/translations/ExtractTranslatableValues/CollectElementValues.tsx +++ b/packages/app-page-builder/src/translations/ExtractTranslatableValues/CollectElementValues.tsx @@ -17,12 +17,15 @@ export interface CreateTranslatableItemParams { }; } -export interface CreateTranslatableItem { - (params: CreateTranslatableItemParams): Omit<TranslatableItem, "collectionId">; +export type NewTranslatableItem = Omit<TranslatableItem, "collectionId"> & + Partial<Pick<TranslatableItem, "collectionId">>; + +export interface CreateTranslatableItems { + (params: CreateTranslatableItemParams): NewTranslatableItem[]; } export const createElementRendererInputsDecorator = ( - createTranslatableItem: CreateTranslatableItem + createTranslatableItems: CreateTranslatableItems ) => { return ElementRendererInputs.createDecorator(Original => { return function CollectElementValues(props) { @@ -40,16 +43,20 @@ export const createElementRendererInputsDecorator = ( return; } - translations.setTranslationItem({ - collectionId: `page:${page.id}`, - ...createTranslatableItem({ - element, - value, - input: { - name: key, - type: inputs[key].getType() - } - }) + const items = createTranslatableItems({ + element, + value, + input: { + name: key, + type: inputs[key].getType() + } + }); + + items.forEach(({ collectionId, ...item }) => { + translations.setTranslationItem({ + collectionId: collectionId ?? `page:${page.id}`, + ...item + }); }); }); }, [element.id, values]); diff --git a/packages/app-page-builder/src/translations/ExtractTranslatableValues/ExtractTranslatableValues.tsx b/packages/app-page-builder/src/translations/ExtractTranslatableValues/ExtractTranslatableValues.tsx index e111f592bd1..6956b9694df 100644 --- a/packages/app-page-builder/src/translations/ExtractTranslatableValues/ExtractTranslatableValues.tsx +++ b/packages/app-page-builder/src/translations/ExtractTranslatableValues/ExtractTranslatableValues.tsx @@ -4,17 +4,20 @@ import { SaveTranslatableValues } from "~/translations/ExtractTranslatableValues import { PageEditorConfig } from "~/pageEditor"; import { createElementRendererInputsDecorator, - CreateTranslatableItem + CreateTranslatableItems, + NewTranslatableItem } from "~/translations/ExtractTranslatableValues/CollectElementValues"; -interface ExtractTranslatableValuesProps { - createTranslatableItem: CreateTranslatableItem; +export interface ExtractTranslatableValuesProps { + createTranslatableItems: CreateTranslatableItems; } +export type { NewTranslatableItem, CreateTranslatableItems }; + export const ExtractTranslatableValues = ({ - createTranslatableItem + createTranslatableItems }: ExtractTranslatableValuesProps) => { - const CollectElementValues = createElementRendererInputsDecorator(createTranslatableItem); + const CollectElementValues = createElementRendererInputsDecorator(createTranslatableItems); return ( <> diff --git a/packages/app-page-builder/src/translations/index.ts b/packages/app-page-builder/src/translations/index.ts index 1ad01c48c4e..9bbdd54650b 100644 --- a/packages/app-page-builder/src/translations/index.ts +++ b/packages/app-page-builder/src/translations/index.ts @@ -4,5 +4,9 @@ export * from "./translatedCollection/getTranslatedCollection/useTranslatedColle export * from "./translatedCollection/saveTranslatedCollection/useSaveTranslatedCollection"; export * from "./ExtractTranslatableValues/ExtractTranslatableValues"; +export type { + NewTranslatableItem, + CreateTranslatableItems +} from "./ExtractTranslatableValues/ExtractTranslatableValues"; export * from "./ListCache"; export * from "./Loading"; From 7828d72ebff452885923ed5e7f1f2afec8a03309 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Zori=C4=87?= <bruno.zoric@gmail.com> Date: Wed, 19 Feb 2025 20:53:18 +0100 Subject: [PATCH 36/52] fix: record locking (#4543) --- .../modelManager/DefaultCmsModelManager.ts | 15 ++-- packages/api-headless-cms/src/types/types.ts | 2 +- .../graphql/requestEntryUnlock.test.ts | 4 +- packages/api-record-locking/src/crud/crud.ts | 32 +++++-- packages/api-record-locking/src/index.ts | 13 ++- packages/api-record-locking/src/types.ts | 19 ++-- .../GetLockRecord/GetLockRecordUseCase.ts | 11 ++- .../GetLockedEntryLockRecordUseCase.ts | 8 +- .../IsEntryLocked/IsEntryLockedUseCase.ts | 8 +- .../KickOutCurrentUserUseCase.ts | 4 +- .../ListAllLockRecordsUseCase.ts | 14 ++- .../ListLockRecordsUseCase.ts | 4 +- .../LockEntryUseCase/LockEntryUseCase.ts | 13 +-- .../UnlockEntryUseCase/UnlockEntryUseCase.ts | 39 +++++--- .../UnlockEntryRequestUseCase.ts | 21 +++-- .../UpdateEntryLock/UpdateEntryLockUseCase.ts | 15 ++-- .../api-record-locking/src/useCases/index.ts | 50 +++++++---- .../api-record-locking/src/useCases/types.ts | 6 ++ .../src/utils/calculateExpiresOn.ts | 8 +- .../src/utils/convertEntryToLockRecord.ts | 22 ++--- .../src/utils/getTimeout.ts | 8 +- .../src/utils/isLockedFactory.ts | 3 +- packages/app-record-locking/package.json | 2 + .../ContentEntryLocker.tsx | 51 ++++++----- .../LockedRecord/LockedRecordForceUnlock.tsx | 4 +- .../src/components/assets/lock.svg | 2 +- .../RecordLockingPermissions.tsx | 88 +++++++++++++++++++ .../components/permissionRenderer/index.tsx | 22 +++++ .../src/hooks/usePermission.ts | 13 ++- packages/app-record-locking/src/index.tsx | 3 + packages/app-record-locking/src/types.ts | 5 ++ .../app-record-locking/tsconfig.build.json | 2 + packages/app-record-locking/tsconfig.json | 6 ++ .../src/WebsocketsContextProvider.tsx | 67 ++++++++++---- .../src/domain/WebsocketsConnection.ts | 11 +++ packages/app-websockets/src/types.ts | 16 ++++ .../pulumi-aws/src/apps/api/ApiGateway.ts | 10 ++- yarn.lock | 2 + 38 files changed, 458 insertions(+), 165 deletions(-) create mode 100644 packages/api-record-locking/src/useCases/types.ts create mode 100644 packages/app-record-locking/src/components/permissionRenderer/RecordLockingPermissions.tsx create mode 100644 packages/app-record-locking/src/components/permissionRenderer/index.tsx diff --git a/packages/api-headless-cms/src/modelManager/DefaultCmsModelManager.ts b/packages/api-headless-cms/src/modelManager/DefaultCmsModelManager.ts index 8a7ac3aafc5..c826de613c3 100644 --- a/packages/api-headless-cms/src/modelManager/DefaultCmsModelManager.ts +++ b/packages/api-headless-cms/src/modelManager/DefaultCmsModelManager.ts @@ -1,12 +1,13 @@ -import { - CmsModelManager, - CmsModel, +import type { CmsContext, + CmsDeleteEntryOptions, CmsEntryListParams, + CmsModel, + CmsModelManager, CreateCmsEntryInput, + CreateCmsEntryOptionsInput, UpdateCmsEntryInput, - UpdateCmsEntryOptionsInput, - CreateCmsEntryOptionsInput + UpdateCmsEntryOptionsInput } from "~/types"; import { parseIdentifier } from "@webiny/utils"; @@ -23,13 +24,13 @@ export class DefaultCmsModelManager implements CmsModelManager { return this._context.cms.createEntry(this.model, data, options); } - public async delete(id: string) { + public async delete(id: string, options?: CmsDeleteEntryOptions) { const { version } = parseIdentifier(id); if (version) { return this._context.cms.deleteEntryRevision(this.model, id); } - return this._context.cms.deleteEntry(this.model, id); + return this._context.cms.deleteEntry(this.model, id, options); } public async get(id: string) { diff --git a/packages/api-headless-cms/src/types/types.ts b/packages/api-headless-cms/src/types/types.ts index 7736379b85f..950033eafa9 100644 --- a/packages/api-headless-cms/src/types/types.ts +++ b/packages/api-headless-cms/src/types/types.ts @@ -733,7 +733,7 @@ export interface CmsModelManager<T = CmsEntryValues> { /** * Delete an entry. */ - delete(id: string): Promise<void>; + delete(id: string, options?: CmsDeleteEntryOptions): Promise<void>; } export type ICmsEntryManager<T = GenericRecord> = CmsModelManager<T>; diff --git a/packages/api-record-locking/__tests__/graphql/requestEntryUnlock.test.ts b/packages/api-record-locking/__tests__/graphql/requestEntryUnlock.test.ts index eda2e1def03..533ff791cb3 100644 --- a/packages/api-record-locking/__tests__/graphql/requestEntryUnlock.test.ts +++ b/packages/api-record-locking/__tests__/graphql/requestEntryUnlock.test.ts @@ -1,4 +1,4 @@ -import { IRecordLockingLockRecordActionType } from "~/types"; +import { RecordLockingLockRecordActionType } from "~/types"; import { useGraphQLHandler } from "~tests/helpers/useGraphQLHandler"; import { createIdentity } from "~tests/helpers/identity"; @@ -67,7 +67,7 @@ describe("request entry unlock", () => { type: "cms#author", actions: [ { - type: IRecordLockingLockRecordActionType.requested, + type: RecordLockingLockRecordActionType.requested, message: null, createdBy: secondIdentity, createdOn: expect.toBeDateString() diff --git a/packages/api-record-locking/src/crud/crud.ts b/packages/api-record-locking/src/crud/crud.ts index e70598f4d36..e91ce123ef0 100644 --- a/packages/api-record-locking/src/crud/crud.ts +++ b/packages/api-record-locking/src/crud/crud.ts @@ -3,7 +3,7 @@ import { Context, IGetIdentity, IGetWebsocketsContextCallable, - IHasFullAccessCallable, + IHasRecordLockingAccessCallable, IRecordLocking, IRecordLockingLockRecordValues, IRecordLockingModelManager, @@ -15,7 +15,8 @@ import { OnEntryBeforeUnlockTopicParams, OnEntryLockErrorTopicParams, OnEntryUnlockErrorTopicParams, - OnEntryUnlockRequestErrorTopicParams + OnEntryUnlockRequestErrorTopicParams, + RecordLockingSecurityPermission } from "~/types"; import { RECORD_LOCKING_MODEL_ID } from "./model"; import { IGetLockRecordUseCaseExecute } from "~/abstractions/IGetLockRecordUseCase"; @@ -29,12 +30,18 @@ import { IListAllLockRecordsUseCaseExecute } from "~/abstractions/IListAllLockRe import { IListLockRecordsUseCaseExecute } from "~/abstractions/IListLockRecordsUseCase"; import { IUpdateEntryLockUseCaseExecute } from "~/abstractions/IUpdateEntryLockUseCase"; import { IGetLockedEntryLockRecordUseCaseExecute } from "~/abstractions/IGetLockedEntryLockRecordUseCase"; +import { getTimeout as baseGetTimeout } from "~/utils/getTimeout"; interface Params { context: Pick<Context, "plugins" | "cms" | "benchmark" | "security" | "websockets">; + timeout?: number; } -export const createRecordLockingCrud = async ({ context }: Params): Promise<IRecordLocking> => { +export const createRecordLockingCrud = async (params: Params): Promise<IRecordLocking> => { + const { context } = params; + const getTimeout = (): number => { + return baseGetTimeout(params.timeout); + }; const getModel = async () => { const model = await context.cms.getModel(RECORD_LOCKING_MODEL_ID); if (model) { @@ -63,8 +70,15 @@ export const createRecordLockingCrud = async ({ context }: Params): Promise<IRec }; }; - const hasFullAccess: IHasFullAccessCallable = async () => { - return await context.security.hasFullAccess(); + const hasRecordLockingAccess: IHasRecordLockingAccessCallable = async () => { + const hasFulLAccess = await context.security.hasFullAccess(); + if (hasFulLAccess) { + return true; + } + const permission = await context.security.getPermission<RecordLockingSecurityPermission>( + "recordLocking" + ); + return permission?.canForceUnlock === "yes"; }; const onEntryBeforeLock = createTopic<OnEntryBeforeLockTopicParams>( @@ -114,8 +128,9 @@ export const createRecordLockingCrud = async ({ context }: Params): Promise<IRec } = createUseCases({ getIdentity, getManager, - hasFullAccess, - getWebsockets + hasRecordLockingAccess, + getWebsockets, + getTimeout }); const listAllLockRecords: IListAllLockRecordsUseCaseExecute = async params => { @@ -242,6 +257,7 @@ export const createRecordLockingCrud = async ({ context }: Params): Promise<IRec lockEntry, updateEntryLock, unlockEntry, - unlockEntryRequest + unlockEntryRequest, + getTimeout }; }; diff --git a/packages/api-record-locking/src/index.ts b/packages/api-record-locking/src/index.ts index 7d94897c264..d2da5b6afeb 100644 --- a/packages/api-record-locking/src/index.ts +++ b/packages/api-record-locking/src/index.ts @@ -5,7 +5,11 @@ import { createRecordLockingCrud } from "~/crud/crud"; import { createLockingModel } from "~/crud/model"; import { isHeadlessCmsReady } from "@webiny/api-headless-cms"; -const createContextPlugin = () => { +export interface ICreateContextPluginParams { + timeout?: number; +} + +const createContextPlugin = (params?: ICreateContextPluginParams) => { const plugin = new ContextPlugin<Context>(async context => { if (!context.wcp.canUseRecordLocking()) { return; @@ -18,7 +22,8 @@ const createContextPlugin = () => { context.plugins.register(createLockingModel()); context.recordLocking = await createRecordLockingCrud({ - context + context, + timeout: params?.timeout }); const graphQlPlugin = await createGraphQLSchema({ context }); @@ -29,6 +34,6 @@ const createContextPlugin = () => { return plugin; }; -export const createRecordLocking = () => { - return [createContextPlugin()]; +export const createRecordLocking = (params?: ICreateContextPluginParams) => { + return [createContextPlugin(params)]; }; diff --git a/packages/api-record-locking/src/types.ts b/packages/api-record-locking/src/types.ts index 99352f8a343..25d3bb3ccf3 100644 --- a/packages/api-record-locking/src/types.ts +++ b/packages/api-record-locking/src/types.ts @@ -13,6 +13,7 @@ import { Context as IWebsocketsContext, IWebsocketsContextObject } from "@webiny/api-websockets/types"; +import { SecurityPermission } from "@webiny/api-security/types"; export { CmsError, CmsEntry }; @@ -22,7 +23,7 @@ export type IRecordLockingModelManager = CmsModelManager<IRecordLockingLockRecor export type IRecordLockingMeta = CmsEntryMeta; -export interface IHasFullAccessCallable { +export interface IHasRecordLockingAccessCallable { (): Promise<boolean>; } @@ -39,28 +40,28 @@ export interface IRecordLockingLockRecordValues { type: IRecordLockingLockRecordEntryType; actions?: IRecordLockingLockRecordAction[]; } -export enum IRecordLockingLockRecordActionType { +export enum RecordLockingLockRecordActionType { requested = "requested", approved = "approved", denied = "denied" } export interface IRecordLockingLockRecordRequestedAction { - type: IRecordLockingLockRecordActionType.requested; + type: RecordLockingLockRecordActionType.requested; message?: string; createdOn: Date; createdBy: IRecordLockingIdentity; } export interface IRecordLockingLockRecordApprovedAction { - type: IRecordLockingLockRecordActionType.approved; + type: RecordLockingLockRecordActionType.approved; message?: string; createdOn: Date; createdBy: IRecordLockingIdentity; } export interface IRecordLockingLockRecordDeniedAction { - type: IRecordLockingLockRecordActionType.denied; + type: RecordLockingLockRecordActionType.denied; message?: string; createdOn: Date; createdBy: IRecordLockingIdentity; @@ -198,6 +199,10 @@ export interface OnEntryUnlockRequestErrorTopicParams { } export interface IRecordLocking { + /** + * In milliseconds. + */ + getTimeout: () => number; onEntryBeforeLock: Topic<OnEntryBeforeLockTopicParams>; onEntryAfterLock: Topic<OnEntryAfterLockTopicParams>; onEntryLockError: Topic<OnEntryLockErrorTopicParams>; @@ -235,3 +240,7 @@ export interface IRecordLocking { export interface Context extends CmsContext, IWebsocketsContext { recordLocking: IRecordLocking; } + +export interface RecordLockingSecurityPermission extends SecurityPermission { + canForceUnlock?: string; +} diff --git a/packages/api-record-locking/src/useCases/GetLockRecord/GetLockRecordUseCase.ts b/packages/api-record-locking/src/useCases/GetLockRecord/GetLockRecordUseCase.ts index bf4a1fcc8b4..9ebe0abe37e 100644 --- a/packages/api-record-locking/src/useCases/GetLockRecord/GetLockRecordUseCase.ts +++ b/packages/api-record-locking/src/useCases/GetLockRecord/GetLockRecordUseCase.ts @@ -1,22 +1,25 @@ -import { +import type { IGetLockRecordUseCase, IGetLockRecordUseCaseExecuteParams } from "~/abstractions/IGetLockRecordUseCase"; -import { IRecordLockingModelManager, IRecordLockingLockRecord } from "~/types"; +import type { IRecordLockingLockRecord, IRecordLockingModelManager } from "~/types"; import { NotFoundError } from "@webiny/handler-graphql"; -import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord"; import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId"; import { createIdentifier } from "@webiny/utils"; +import type { ConvertEntryToLockRecordCb } from "~/useCases/types"; export interface IGetLockRecordUseCaseParams { getManager(): Promise<IRecordLockingModelManager>; + convert: ConvertEntryToLockRecordCb; } export class GetLockRecordUseCase implements IGetLockRecordUseCase { private readonly getManager: IGetLockRecordUseCaseParams["getManager"]; + private readonly convert: ConvertEntryToLockRecordCb; public constructor(params: IGetLockRecordUseCaseParams) { this.getManager = params.getManager; + this.convert = params.convert; } public async execute( @@ -30,7 +33,7 @@ export class GetLockRecordUseCase implements IGetLockRecordUseCase { try { const manager = await this.getManager(); const result = await manager.get(id); - return convertEntryToLockRecord(result); + return this.convert(result); } catch (ex) { if (ex instanceof NotFoundError) { return null; diff --git a/packages/api-record-locking/src/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts b/packages/api-record-locking/src/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts index 28943401a92..90a54a078b8 100644 --- a/packages/api-record-locking/src/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts +++ b/packages/api-record-locking/src/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase.ts @@ -1,10 +1,10 @@ -import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; -import { IGetIdentity, IRecordLockingLockRecord } from "~/types"; -import { +import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; +import type { IGetIdentity, IRecordLockingLockRecord } from "~/types"; +import type { IGetLockedEntryLockRecordUseCase, IGetLockedEntryLockRecordUseCaseExecuteParams } from "~/abstractions/IGetLockedEntryLockRecordUseCase"; -import { IIsLocked } from "~/utils/isLockedFactory"; +import type { IIsLocked } from "~/utils/isLockedFactory"; export interface IGetLockedEntryLockRecordUseCaseParams { getLockRecordUseCase: IGetLockRecordUseCase; diff --git a/packages/api-record-locking/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts b/packages/api-record-locking/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts index be2563be9ff..24c00a4797f 100644 --- a/packages/api-record-locking/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts +++ b/packages/api-record-locking/src/useCases/IsEntryLocked/IsEntryLockedUseCase.ts @@ -1,11 +1,11 @@ -import { +import type { IIsEntryLockedUseCase, IIsEntryLockedUseCaseExecuteParams } from "~/abstractions/IIsEntryLocked"; -import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; +import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; import { NotFoundError } from "@webiny/handler-graphql"; -import { IIsLocked } from "~/utils/isLockedFactory"; -import { IGetIdentity } from "~/types"; +import type { IIsLocked } from "~/utils/isLockedFactory"; +import type { IGetIdentity } from "~/types"; export interface IIsEntryLockedParams { getLockRecordUseCase: IGetLockRecordUseCase; diff --git a/packages/api-record-locking/src/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase.ts b/packages/api-record-locking/src/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase.ts index bd3f38b7f13..eeb099f26ee 100644 --- a/packages/api-record-locking/src/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase.ts +++ b/packages/api-record-locking/src/useCases/KickOutCurrentUser/KickOutCurrentUserUseCase.ts @@ -1,8 +1,8 @@ -import { +import type { IKickOutCurrentUserUseCase, IKickOutCurrentUserUseCaseExecuteParams } from "~/abstractions/IKickOutCurrentUserUseCase"; -import { IGetIdentity, IGetWebsocketsContextCallable } from "~/types"; +import type { IGetIdentity, IGetWebsocketsContextCallable } from "~/types"; import { parseIdentifier } from "@webiny/utils"; export interface IKickOutCurrentUserUseCaseParams { diff --git a/packages/api-record-locking/src/useCases/ListAllLockRecordsUseCase/ListAllLockRecordsUseCase.ts b/packages/api-record-locking/src/useCases/ListAllLockRecordsUseCase/ListAllLockRecordsUseCase.ts index d85eee3b5d9..b6067eeb3c5 100644 --- a/packages/api-record-locking/src/useCases/ListAllLockRecordsUseCase/ListAllLockRecordsUseCase.ts +++ b/packages/api-record-locking/src/useCases/ListAllLockRecordsUseCase/ListAllLockRecordsUseCase.ts @@ -1,20 +1,24 @@ -import { +import type { IListAllLockRecordsUseCase, IListAllLockRecordsUseCaseExecuteParams, IListAllLockRecordsUseCaseExecuteResponse } from "~/abstractions/IListAllLockRecordsUseCase"; -import { IRecordLockingModelManager } from "~/types"; -import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord"; +import type { IRecordLockingModelManager } from "~/types"; import { convertWhereCondition } from "~/utils/convertWhereCondition"; +import type { ConvertEntryToLockRecordCb } from "~/useCases/types"; export interface IListAllLockRecordsUseCaseParams { getManager(): Promise<IRecordLockingModelManager>; + convert: ConvertEntryToLockRecordCb; } export class ListAllLockRecordsUseCase implements IListAllLockRecordsUseCase { private readonly getManager: () => Promise<IRecordLockingModelManager>; + private readonly convert: ConvertEntryToLockRecordCb; + public constructor(params: IListAllLockRecordsUseCaseParams) { this.getManager = params.getManager; + this.convert = params.convert; } public async execute( input: IListAllLockRecordsUseCaseExecuteParams @@ -28,7 +32,9 @@ export class ListAllLockRecordsUseCase implements IListAllLockRecordsUseCase { const [items, meta] = await manager.listLatest(params); return { - items: items.map(convertEntryToLockRecord), + items: items.map(item => { + return this.convert(item); + }), meta }; } catch (ex) { diff --git a/packages/api-record-locking/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts b/packages/api-record-locking/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts index 72ff2df28bd..65147ad6b7f 100644 --- a/packages/api-record-locking/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts +++ b/packages/api-record-locking/src/useCases/ListLockRecordsUseCase/ListLockRecordsUseCase.ts @@ -1,9 +1,9 @@ -import { +import type { IListLockRecordsUseCase, IListLockRecordsUseCaseExecuteParams, IListLockRecordsUseCaseExecuteResponse } from "~/abstractions/IListLockRecordsUseCase"; -import { IGetIdentity } from "~/types"; +import type { IGetIdentity } from "~/types"; export interface IListLockRecordsUseCaseParams { listAllLockRecordsUseCase: IListLockRecordsUseCase; diff --git a/packages/api-record-locking/src/useCases/LockEntryUseCase/LockEntryUseCase.ts b/packages/api-record-locking/src/useCases/LockEntryUseCase/LockEntryUseCase.ts index 75f44685357..869eccc2b92 100644 --- a/packages/api-record-locking/src/useCases/LockEntryUseCase/LockEntryUseCase.ts +++ b/packages/api-record-locking/src/useCases/LockEntryUseCase/LockEntryUseCase.ts @@ -1,30 +1,33 @@ import WebinyError from "@webiny/error"; -import { +import type { ILockEntryUseCase, ILockEntryUseCaseExecuteParams } from "~/abstractions/ILockEntryUseCase"; -import { +import type { IRecordLockingLockRecord, IRecordLockingLockRecordValues, IRecordLockingModelManager } from "~/types"; -import { IIsEntryLockedUseCase } from "~/abstractions/IIsEntryLocked"; -import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord"; +import type { IIsEntryLockedUseCase } from "~/abstractions/IIsEntryLocked"; import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId"; import { NotFoundError } from "@webiny/handler-graphql"; +import type { ConvertEntryToLockRecordCb } from "~/useCases/types"; export interface ILockEntryUseCaseParams { isEntryLockedUseCase: IIsEntryLockedUseCase; getManager(): Promise<IRecordLockingModelManager>; + convert: ConvertEntryToLockRecordCb; } export class LockEntryUseCase implements ILockEntryUseCase { private readonly isEntryLockedUseCase: IIsEntryLockedUseCase; private readonly getManager: () => Promise<IRecordLockingModelManager>; + private readonly convert: ConvertEntryToLockRecordCb; public constructor(params: ILockEntryUseCaseParams) { this.isEntryLockedUseCase = params.isEntryLockedUseCase; this.getManager = params.getManager; + this.convert = params.convert; } public async execute( @@ -54,7 +57,7 @@ export class LockEntryUseCase implements ILockEntryUseCase { type: params.type, actions: [] }); - return convertEntryToLockRecord(entry); + return this.convert(entry); } catch (ex) { throw new WebinyError( `Could not lock entry: ${ex.message}`, diff --git a/packages/api-record-locking/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts b/packages/api-record-locking/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts index 8ea64617b37..4c0f7d111d2 100644 --- a/packages/api-record-locking/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts +++ b/packages/api-record-locking/src/useCases/UnlockEntryUseCase/UnlockEntryUseCase.ts @@ -1,26 +1,27 @@ import WebinyError from "@webiny/error"; -import { +import type { IUnlockEntryUseCase, IUnlockEntryUseCaseExecuteParams } from "~/abstractions/IUnlockEntryUseCase"; -import { +import type { IGetIdentity, - IHasFullAccessCallable, + IHasRecordLockingAccessCallable, IRecordLockingLockRecord, IRecordLockingModelManager } from "~/types"; import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId"; -import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; +import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; import { validateSameIdentity } from "~/utils/validateSameIdentity"; import { NotAuthorizedError } from "@webiny/api-security"; -import { IKickOutCurrentUserUseCase } from "~/abstractions/IKickOutCurrentUserUseCase"; +import type { IKickOutCurrentUserUseCase } from "~/abstractions/IKickOutCurrentUserUseCase"; +import { NotFoundError } from "@webiny/handler-graphql"; export interface IUnlockEntryUseCaseParams { readonly getLockRecordUseCase: IGetLockRecordUseCase; readonly kickOutCurrentUserUseCase: IKickOutCurrentUserUseCase; getManager(): Promise<IRecordLockingModelManager>; getIdentity: IGetIdentity; - hasFullAccess: IHasFullAccessCallable; + hasRecordLockingAccess: IHasRecordLockingAccessCallable; } export class UnlockEntryUseCase implements IUnlockEntryUseCase { @@ -28,14 +29,14 @@ export class UnlockEntryUseCase implements IUnlockEntryUseCase { private readonly kickOutCurrentUserUseCase: IKickOutCurrentUserUseCase; private readonly getManager: () => Promise<IRecordLockingModelManager>; private readonly getIdentity: IGetIdentity; - private readonly hasFullAccess: IHasFullAccessCallable; + private readonly hasRecordLockingAccess: IHasRecordLockingAccessCallable; public constructor(params: IUnlockEntryUseCaseParams) { this.getLockRecordUseCase = params.getLockRecordUseCase; this.kickOutCurrentUserUseCase = params.kickOutCurrentUserUseCase; this.getManager = params.getManager; this.getIdentity = params.getIdentity; - this.hasFullAccess = params.hasFullAccess; + this.hasRecordLockingAccess = params.hasRecordLockingAccess; } public async execute( @@ -43,6 +44,19 @@ export class UnlockEntryUseCase implements IUnlockEntryUseCase { ): Promise<IRecordLockingLockRecord> { const record = await this.getLockRecordUseCase.execute(params); if (!record) { + try { + const manager = await this.getManager(); + await manager.delete(createLockRecordDatabaseId(params.id), { + force: true, + permanently: true + }); + } catch (ex) { + if (ex instanceof NotFoundError === false) { + console.log("Could not forcefully delete lock record."); + console.error(ex); + } + } + throw new WebinyError("Lock Record not found.", "LOCK_RECORD_NOT_FOUND", { ...params }); @@ -64,8 +78,8 @@ export class UnlockEntryUseCase implements IUnlockEntryUseCase { if (!params.force) { throw ex; } - const hasFullAccess = await this.hasFullAccess(); - if (ex instanceof NotAuthorizedError === false || !hasFullAccess) { + const hasAccess = await this.hasRecordLockingAccess(); + if (ex instanceof NotAuthorizedError === false || !hasAccess) { throw ex; } @@ -74,7 +88,10 @@ export class UnlockEntryUseCase implements IUnlockEntryUseCase { try { const manager = await this.getManager(); - await manager.delete(createLockRecordDatabaseId(params.id)); + await manager.delete(createLockRecordDatabaseId(params.id), { + force: true, + permanently: true + }); if (!kickOutCurrentUser) { return record; diff --git a/packages/api-record-locking/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts b/packages/api-record-locking/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts index 05044ba4a56..541a137c335 100644 --- a/packages/api-record-locking/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts +++ b/packages/api-record-locking/src/useCases/UnlockRequestUseCase/UnlockEntryRequestUseCase.ts @@ -1,34 +1,33 @@ import WebinyError from "@webiny/error"; -import { +import type { IUnlockEntryRequestUseCase, IUnlockEntryRequestUseCaseExecuteParams } from "~/abstractions/IUnlockEntryRequestUseCase"; -import { - IGetIdentity, - IRecordLockingLockRecord, - IRecordLockingLockRecordActionType, - IRecordLockingModelManager -} from "~/types"; -import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; +import type { IGetIdentity, IRecordLockingLockRecord, IRecordLockingModelManager } from "~/types"; +import { RecordLockingLockRecordActionType } from "~/types"; +import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId"; import { createIdentifier } from "@webiny/utils"; -import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord"; +import type { ConvertEntryToLockRecordCb } from "~/useCases/types"; export interface IUnlockEntryRequestUseCaseParams { getLockRecordUseCase: IGetLockRecordUseCase; getManager: () => Promise<IRecordLockingModelManager>; getIdentity: IGetIdentity; + convert: ConvertEntryToLockRecordCb; } export class UnlockEntryRequestUseCase implements IUnlockEntryRequestUseCase { private readonly getLockRecordUseCase: IGetLockRecordUseCase; private readonly getManager: () => Promise<IRecordLockingModelManager>; private readonly getIdentity: IGetIdentity; + private readonly convert: ConvertEntryToLockRecordCb; public constructor(params: IUnlockEntryRequestUseCaseParams) { this.getLockRecordUseCase = params.getLockRecordUseCase; this.getManager = params.getManager; this.getIdentity = params.getIdentity; + this.convert = params.convert; } public async execute( @@ -68,7 +67,7 @@ export class UnlockEntryRequestUseCase implements IUnlockEntryRequestUseCase { } record.addAction({ - type: IRecordLockingLockRecordActionType.requested, + type: RecordLockingLockRecordActionType.requested, createdOn: new Date(), createdBy: this.getIdentity() }); @@ -82,7 +81,7 @@ export class UnlockEntryRequestUseCase implements IUnlockEntryRequestUseCase { version: 1 }); const result = await manager.update(id, record.toObject()); - return convertEntryToLockRecord(result); + return this.convert(result); } catch (ex) { throw new WebinyError( "Could not update record with a unlock request.", diff --git a/packages/api-record-locking/src/useCases/UpdateEntryLock/UpdateEntryLockUseCase.ts b/packages/api-record-locking/src/useCases/UpdateEntryLock/UpdateEntryLockUseCase.ts index 052f37b4b1a..7e858ed2641 100644 --- a/packages/api-record-locking/src/useCases/UpdateEntryLock/UpdateEntryLockUseCase.ts +++ b/packages/api-record-locking/src/useCases/UpdateEntryLock/UpdateEntryLockUseCase.ts @@ -1,21 +1,22 @@ -import { +import type { IUpdateEntryLockUseCase, IUpdateEntryLockUseCaseExecuteParams } from "~/abstractions/IUpdateEntryLockUseCase"; -import { IGetIdentity, IRecordLockingLockRecord, IRecordLockingModelManager } from "~/types"; -import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; +import type { IGetIdentity, IRecordLockingLockRecord, IRecordLockingModelManager } from "~/types"; +import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; import { WebinyError } from "@webiny/error"; -import { convertEntryToLockRecord } from "~/utils/convertEntryToLockRecord"; import { createLockRecordDatabaseId } from "~/utils/lockRecordDatabaseId"; import { createIdentifier } from "@webiny/utils"; import { validateSameIdentity } from "~/utils/validateSameIdentity"; -import { ILockEntryUseCase } from "~/abstractions/ILockEntryUseCase"; +import type { ILockEntryUseCase } from "~/abstractions/ILockEntryUseCase"; +import type { ConvertEntryToLockRecordCb } from "~/useCases/types"; export interface IUpdateEntryLockUseCaseParams { readonly getLockRecordUseCase: IGetLockRecordUseCase; readonly lockEntryUseCase: ILockEntryUseCase; getManager(): Promise<IRecordLockingModelManager>; getIdentity: IGetIdentity; + convert: ConvertEntryToLockRecordCb; } export class UpdateEntryLockUseCase implements IUpdateEntryLockUseCase { @@ -23,12 +24,14 @@ export class UpdateEntryLockUseCase implements IUpdateEntryLockUseCase { private readonly lockEntryUseCase: ILockEntryUseCase; private readonly getManager: () => Promise<IRecordLockingModelManager>; private readonly getIdentity: IGetIdentity; + private readonly convert: ConvertEntryToLockRecordCb; public constructor(params: IUpdateEntryLockUseCaseParams) { this.getLockRecordUseCase = params.getLockRecordUseCase; this.lockEntryUseCase = params.lockEntryUseCase; this.getManager = params.getManager; this.getIdentity = params.getIdentity; + this.convert = params.convert; } public async execute( @@ -55,7 +58,7 @@ export class UpdateEntryLockUseCase implements IUpdateEntryLockUseCase { const result = await manager.update(id, { savedOn: new Date().toISOString() }); - return convertEntryToLockRecord(result); + return this.convert(result); } catch (ex) { throw new WebinyError( `Could not update lock entry: ${ex.message}`, diff --git a/packages/api-record-locking/src/useCases/index.ts b/packages/api-record-locking/src/useCases/index.ts index 2198c85ca9e..86d4e614d70 100644 --- a/packages/api-record-locking/src/useCases/index.ts +++ b/packages/api-record-locking/src/useCases/index.ts @@ -1,7 +1,7 @@ -import { +import type { IGetIdentity, IGetWebsocketsContextCallable, - IHasFullAccessCallable, + IHasRecordLockingAccessCallable, IRecordLockingModelManager } from "~/types"; import { GetLockRecordUseCase } from "./GetLockRecord/GetLockRecordUseCase"; @@ -13,23 +13,25 @@ import { ListAllLockRecordsUseCase } from "./ListAllLockRecordsUseCase/ListAllLo import { ListLockRecordsUseCase } from "./ListLockRecordsUseCase/ListLockRecordsUseCase"; import { isLockedFactory } from "~/utils/isLockedFactory"; import { UpdateEntryLockUseCase } from "~/useCases/UpdateEntryLock/UpdateEntryLockUseCase"; -import { getTimeout } from "~/utils/getTimeout"; import { KickOutCurrentUserUseCase } from "./KickOutCurrentUser/KickOutCurrentUserUseCase"; import { GetLockedEntryLockRecordUseCase } from "~/useCases/GetLockedEntryLockRecord/GetLockedEntryLockRecordUseCase"; -import { IListAllLockRecordsUseCase } from "~/abstractions/IListAllLockRecordsUseCase"; -import { IListLockRecordsUseCase } from "~/abstractions/IListLockRecordsUseCase"; -import { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; -import { IIsEntryLockedUseCase } from "~/abstractions/IIsEntryLocked"; -import { IGetLockedEntryLockRecordUseCase } from "~/abstractions/IGetLockedEntryLockRecordUseCase"; -import { ILockEntryUseCase } from "~/abstractions/ILockEntryUseCase"; -import { IUpdateEntryLockUseCase } from "~/abstractions/IUpdateEntryLockUseCase"; -import { IUnlockEntryUseCase } from "~/abstractions/IUnlockEntryUseCase"; -import { IUnlockEntryRequestUseCase } from "~/abstractions/IUnlockEntryRequestUseCase"; +import type { IListAllLockRecordsUseCase } from "~/abstractions/IListAllLockRecordsUseCase"; +import type { IListLockRecordsUseCase } from "~/abstractions/IListLockRecordsUseCase"; +import type { IGetLockRecordUseCase } from "~/abstractions/IGetLockRecordUseCase"; +import type { IIsEntryLockedUseCase } from "~/abstractions/IIsEntryLocked"; +import type { IGetLockedEntryLockRecordUseCase } from "~/abstractions/IGetLockedEntryLockRecordUseCase"; +import type { ILockEntryUseCase } from "~/abstractions/ILockEntryUseCase"; +import type { IUpdateEntryLockUseCase } from "~/abstractions/IUpdateEntryLockUseCase"; +import type { IUnlockEntryUseCase } from "~/abstractions/IUnlockEntryUseCase"; +import type { IUnlockEntryRequestUseCase } from "~/abstractions/IUnlockEntryRequestUseCase"; +import { convertEntryToLockRecord as baseConvertEntryToLockRecord } from "~/utils/convertEntryToLockRecord"; +import { ConvertEntryToLockRecordCb } from "~/useCases/types"; export interface ICreateUseCasesParams { + getTimeout: () => number; getIdentity: IGetIdentity; getManager(): Promise<IRecordLockingModelManager>; - hasFullAccess: IHasFullAccessCallable; + hasRecordLockingAccess: IHasRecordLockingAccessCallable; getWebsockets: IGetWebsocketsContextCallable; } @@ -46,11 +48,17 @@ export interface ICreateUseCasesResponse { } export const createUseCases = (params: ICreateUseCasesParams): ICreateUseCasesResponse => { + const { getTimeout } = params; const timeout = getTimeout(); const isLocked = isLockedFactory(timeout); + const convertEntryToLockRecord: ConvertEntryToLockRecordCb = entry => { + return baseConvertEntryToLockRecord(entry, timeout); + }; + const listAllLockRecordsUseCase = new ListAllLockRecordsUseCase({ - getManager: params.getManager + getManager: params.getManager, + convert: convertEntryToLockRecord }); const listLockRecordsUseCase = new ListLockRecordsUseCase({ @@ -60,7 +68,8 @@ export const createUseCases = (params: ICreateUseCasesParams): ICreateUseCasesRe }); const getLockRecordUseCase = new GetLockRecordUseCase({ - getManager: params.getManager + getManager: params.getManager, + convert: convertEntryToLockRecord }); const isEntryLockedUseCase = new IsEntryLockedUseCase({ @@ -77,14 +86,16 @@ export const createUseCases = (params: ICreateUseCasesParams): ICreateUseCasesRe const lockEntryUseCase = new LockEntryUseCase({ isEntryLockedUseCase, - getManager: params.getManager + getManager: params.getManager, + convert: convertEntryToLockRecord }); const updateEntryLockUseCase = new UpdateEntryLockUseCase({ getLockRecordUseCase, lockEntryUseCase, getManager: params.getManager, - getIdentity: params.getIdentity + getIdentity: params.getIdentity, + convert: convertEntryToLockRecord }); const kickOutCurrentUserUseCase = new KickOutCurrentUserUseCase({ @@ -97,13 +108,14 @@ export const createUseCases = (params: ICreateUseCasesParams): ICreateUseCasesRe kickOutCurrentUserUseCase, getManager: params.getManager, getIdentity: params.getIdentity, - hasFullAccess: params.hasFullAccess + hasRecordLockingAccess: params.hasRecordLockingAccess }); const unlockEntryRequestUseCase = new UnlockEntryRequestUseCase({ getLockRecordUseCase, getIdentity: params.getIdentity, - getManager: params.getManager + getManager: params.getManager, + convert: convertEntryToLockRecord }); return { diff --git a/packages/api-record-locking/src/useCases/types.ts b/packages/api-record-locking/src/useCases/types.ts new file mode 100644 index 00000000000..4e25f994fdc --- /dev/null +++ b/packages/api-record-locking/src/useCases/types.ts @@ -0,0 +1,6 @@ +import type { CmsEntry } from "@webiny/api-headless-cms/types"; +import type { IRecordLockingLockRecord, IRecordLockingLockRecordValues } from "~/types"; + +export interface ConvertEntryToLockRecordCb { + (entry: CmsEntry<IRecordLockingLockRecordValues>): IRecordLockingLockRecord; +} diff --git a/packages/api-record-locking/src/utils/calculateExpiresOn.ts b/packages/api-record-locking/src/utils/calculateExpiresOn.ts index 0897986577b..11c5d60330c 100644 --- a/packages/api-record-locking/src/utils/calculateExpiresOn.ts +++ b/packages/api-record-locking/src/utils/calculateExpiresOn.ts @@ -1,9 +1,9 @@ import { IHeadlessCmsLockRecordParams } from "./convertEntryToLockRecord"; -import { getTimeout } from "./getTimeout"; - -export const calculateExpiresOn = (input: Pick<IHeadlessCmsLockRecordParams, "savedOn">): Date => { - const timeout = getTimeout(); +export const calculateExpiresOn = ( + input: Pick<IHeadlessCmsLockRecordParams, "savedOn">, + timeout: number +): Date => { const savedOn = new Date(input.savedOn); return new Date(savedOn.getTime() + timeout); diff --git a/packages/api-record-locking/src/utils/convertEntryToLockRecord.ts b/packages/api-record-locking/src/utils/convertEntryToLockRecord.ts index d5cc5e746e3..c20a5d3a6d6 100644 --- a/packages/api-record-locking/src/utils/convertEntryToLockRecord.ts +++ b/packages/api-record-locking/src/utils/convertEntryToLockRecord.ts @@ -1,8 +1,8 @@ -import { CmsEntry, IRecordLockingIdentity } from "~/types"; -import { +import type { + CmsEntry, + IRecordLockingIdentity, IRecordLockingLockRecord, IRecordLockingLockRecordAction, - IRecordLockingLockRecordActionType, IRecordLockingLockRecordApprovedAction, IRecordLockingLockRecordDeniedAction, IRecordLockingLockRecordEntryType, @@ -10,13 +10,15 @@ import { IRecordLockingLockRecordRequestedAction, IRecordLockingLockRecordValues } from "~/types"; +import { RecordLockingLockRecordActionType } from "~/types"; import { removeLockRecordDatabasePrefix } from "~/utils/lockRecordDatabaseId"; import { calculateExpiresOn } from "~/utils/calculateExpiresOn"; export const convertEntryToLockRecord = ( - entry: CmsEntry<IRecordLockingLockRecordValues> + entry: CmsEntry<IRecordLockingLockRecordValues>, + timeout: number ): IRecordLockingLockRecord => { - return new HeadlessCmsLockRecord(entry); + return new HeadlessCmsLockRecord(entry, timeout); }; export type IHeadlessCmsLockRecordParams = Pick< @@ -66,14 +68,14 @@ export class HeadlessCmsLockRecord implements IRecordLockingLockRecord { return this._actions; } - public constructor(input: IHeadlessCmsLockRecordParams) { + public constructor(input: IHeadlessCmsLockRecordParams, timeout: number) { this._id = removeLockRecordDatabasePrefix(input.entryId); this._targetId = input.values.targetId; this._type = input.values.type; this._lockedBy = input.createdBy; this._lockedOn = new Date(input.createdOn); this._updatedOn = new Date(input.savedOn); - this._expiresOn = calculateExpiresOn(input); + this._expiresOn = calculateExpiresOn(input, timeout); this._actions = input.values.actions; } @@ -103,7 +105,7 @@ export class HeadlessCmsLockRecord implements IRecordLockingLockRecord { } return this._actions.find( (action): action is IRecordLockingLockRecordRequestedAction => - action.type === IRecordLockingLockRecordActionType.requested + action.type === RecordLockingLockRecordActionType.requested ); } @@ -113,7 +115,7 @@ export class HeadlessCmsLockRecord implements IRecordLockingLockRecord { } return this._actions.find( (action): action is IRecordLockingLockRecordApprovedAction => - action.type === IRecordLockingLockRecordActionType.approved + action.type === RecordLockingLockRecordActionType.approved ); } @@ -123,7 +125,7 @@ export class HeadlessCmsLockRecord implements IRecordLockingLockRecord { } return this._actions.find( (action): action is IRecordLockingLockRecordDeniedAction => - action.type === IRecordLockingLockRecordActionType.denied + action.type === RecordLockingLockRecordActionType.denied ); } } diff --git a/packages/api-record-locking/src/utils/getTimeout.ts b/packages/api-record-locking/src/utils/getTimeout.ts index bd18862a6af..8792a48ca6d 100644 --- a/packages/api-record-locking/src/utils/getTimeout.ts +++ b/packages/api-record-locking/src/utils/getTimeout.ts @@ -1,8 +1,12 @@ const defaultTimeoutInSeconds = 1800; /** - * In milliseconds. + * Input is in seconds. + * Output is milliseconds. */ -export const getTimeout = () => { +export const getTimeout = (input: number | undefined) => { + if (input && input > 0) { + return input * 1000; + } const userDefined = process.env.WEBINY_RECORD_LOCK_TIMEOUT ? parseInt(process.env.WEBINY_RECORD_LOCK_TIMEOUT) : undefined; diff --git a/packages/api-record-locking/src/utils/isLockedFactory.ts b/packages/api-record-locking/src/utils/isLockedFactory.ts index 9d3cc7ddc4c..2acc8343330 100644 --- a/packages/api-record-locking/src/utils/isLockedFactory.ts +++ b/packages/api-record-locking/src/utils/isLockedFactory.ts @@ -4,8 +4,7 @@ export interface IIsLocked { (record?: Pick<IRecordLockingLockRecord, "lockedOn"> | null): boolean; } -export const isLockedFactory = (timeoutInput: number): IIsLocked => { - const timeout = timeoutInput * 1000; +export const isLockedFactory = (timeout: number): IIsLocked => { return record => { if (!record || record.lockedOn instanceof Date === false) { return false; diff --git a/packages/app-record-locking/package.json b/packages/app-record-locking/package.json index f34c50cce96..ea8945071f2 100644 --- a/packages/app-record-locking/package.json +++ b/packages/app-record-locking/package.json @@ -24,6 +24,8 @@ "@webiny/app-wcp": "0.0.0", "@webiny/app-websockets": "0.0.0", "@webiny/error": "0.0.0", + "@webiny/form": "0.0.0", + "@webiny/plugins": "0.0.0", "@webiny/react-router": "0.0.0", "@webiny/ui": "0.0.0", "@webiny/utils": "0.0.0", diff --git a/packages/app-record-locking/src/components/HeadlessCmsContentEntry/ContentEntryLocker.tsx b/packages/app-record-locking/src/components/HeadlessCmsContentEntry/ContentEntryLocker.tsx index d279bcffaf8..5d6e69da902 100644 --- a/packages/app-record-locking/src/components/HeadlessCmsContentEntry/ContentEntryLocker.tsx +++ b/packages/app-record-locking/src/components/HeadlessCmsContentEntry/ContentEntryLocker.tsx @@ -1,11 +1,12 @@ -import React, { useEffect, useRef } from "react"; +import React, { useEffect } from "react"; import { useRecordLocking } from "~/hooks"; -import { IIsRecordLockedParams, IRecordLockingIdentity, IRecordLockingLockRecord } from "~/types"; -import { - IncomingGenericData, - IWebsocketsSubscription, - useWebsockets -} from "@webiny/app-websockets"; +import type { + IIsRecordLockedParams, + IRecordLockingIdentity, + IRecordLockingLockRecord +} from "~/types"; +import type { IncomingGenericData } from "@webiny/app-websockets"; +import { useWebsockets } from "@webiny/app-websockets"; import { parseIdentifier } from "@webiny/utils"; import { useDialogs } from "@webiny/app-admin"; import styled from "@emotion/styled"; @@ -51,31 +52,30 @@ export const ContentEntryLocker = ({ }: IContentEntryLockerProps) => { const { updateEntryLock, unlockEntry, fetchLockedEntryLockRecord, removeEntryLock } = useRecordLocking(); - - const subscription = useRef<IWebsocketsSubscription<any>>(); - const websockets = useWebsockets(); - const { showDialog } = useDialogs(); useEffect(() => { if (!entry.id) { return; - } else if (subscription.current) { - subscription.current.off(); } const { id: entryId } = parseIdentifier(entry.id); - subscription.current = websockets.onMessage<IKickOutWebsocketsMessage>( + const removeEntryLockCb = async () => { + const record: IIsRecordLockedParams = { + id: entryId, + $lockingType: model.modelId + }; + removeEntryLock(record); + await unlockEntry(record); + }; + + let onMessageSub = websockets.onMessage<IKickOutWebsocketsMessage>( `recordLocking.entry.kickOut.${entryId}`, async incoming => { const { user } = incoming.data; - const record: IIsRecordLockedParams = { - id: entryId, - $lockingType: model.modelId - }; - removeEntryLock(record); onDisablePrompt(true); + await removeEntryLockCb(); showDialog({ title: "Entry was forcefully unlocked!", content: <ForceUnlocked user={user} />, @@ -88,10 +88,12 @@ export const ContentEntryLocker = ({ ); return () => { - if (!subscription.current) { - return; - } - subscription.current.off(); + onMessageSub.off(); + /** + * Lets null subscriptions, just in case it... + */ + // @ts-expect-error + onMessageSub = null; }; }, [entry.id, onEntryUnlocked, model.modelId]); @@ -112,7 +114,8 @@ export const ContentEntryLocker = ({ if (result) { return; } - unlockEntry(record); + removeEntryLock(record); + await unlockEntry(record); })(); }; }, [entry.id]); diff --git a/packages/app-record-locking/src/components/LockedRecord/LockedRecordForceUnlock.tsx b/packages/app-record-locking/src/components/LockedRecord/LockedRecordForceUnlock.tsx index f90784ba836..effdf281bd9 100644 --- a/packages/app-record-locking/src/components/LockedRecord/LockedRecordForceUnlock.tsx +++ b/packages/app-record-locking/src/components/LockedRecord/LockedRecordForceUnlock.tsx @@ -82,8 +82,8 @@ export const LockedRecordForceUnlock = (props: ILockedRecordForceUnlockProps) => }); }, [props.id, history, navigateTo]); - const { hasFullAccess } = usePermission(); - if (!hasFullAccess) { + const { canForceUnlock } = usePermission(); + if (!canForceUnlock) { return null; } diff --git a/packages/app-record-locking/src/components/assets/lock.svg b/packages/app-record-locking/src/components/assets/lock.svg index cc0495c35d7..5f649d01e09 100644 --- a/packages/app-record-locking/src/components/assets/lock.svg +++ b/packages/app-record-locking/src/components/assets/lock.svg @@ -7,7 +7,7 @@ <polygon opacity="0.87" points="0 0 24 0 24 24 0 24"/> </g> <path d="M18,8 L17,8 L17,6 C17,3.24 14.76,1 12,1 C9.24,1 7,3.24 7,6 L7,8 L6,8 C4.9,8 4,8.9 4,10 L4,20 C4,21.1 4.9,22 6,22 L18,22 C19.1,22 20,21.1 20,20 L20,10 C20,8.9 19.1,8 18,8 Z M12,17 C10.9,17 10,16.1 10,15 C10,13.9 10.9,13 12,13 C13.1,13 14,13.9 14,15 C14,16.1 13.1,17 12,17 Z M9,8 L9,6 C9,4.34 10.34,3 12,3 C13.66,3 15,4.34 15,6 L15,8 L9,8 Z" - fill="#D8D8D8" fill-rule="nonzero"/> + fill="rgba(0, 0, 0, 0.54)" fill-rule="nonzero"/> </g> </g> </svg> diff --git a/packages/app-record-locking/src/components/permissionRenderer/RecordLockingPermissions.tsx b/packages/app-record-locking/src/components/permissionRenderer/RecordLockingPermissions.tsx new file mode 100644 index 00000000000..b6974f350fc --- /dev/null +++ b/packages/app-record-locking/src/components/permissionRenderer/RecordLockingPermissions.tsx @@ -0,0 +1,88 @@ +import React, { Fragment, useCallback, useMemo } from "react"; +import { Cell, Grid } from "@webiny/ui/Grid"; +import { Select } from "@webiny/ui/Select"; +import { i18n } from "@webiny/app/i18n"; +import { gridNoPaddingClass, PermissionInfo } from "@webiny/app-admin/components/Permissions"; +import { Form } from "@webiny/form"; +import { RecordLockingSecurityPermission } from "~/types"; + +const t = i18n.ns("app-record-locking/components/permissionRenderer"); + +const RECORD_LOCKING_PERMISSION = "recordLocking"; + +export interface RecordLockingPermissionsProps { + value: RecordLockingSecurityPermission[]; + onChange: (value: RecordLockingSecurityPermission[]) => void; +} + +export const RecordLockingPermissions = ({ value, onChange }: RecordLockingPermissionsProps) => { + const onFormChange = useCallback( + (data: RecordLockingSecurityPermission) => { + const newValue = value.filter(p => { + return p.name.startsWith(RECORD_LOCKING_PERMISSION) === false; + }); + + if (!data.canForceUnlock || data.canForceUnlock === "no") { + onChange(newValue); + return; + } + + onChange([ + ...newValue, + { + name: "recordLocking", + canForceUnlock: "yes" + } + ]); + }, + [value] + ); + + const formData = useMemo(() => { + if (!Array.isArray(value)) { + return {}; + } + + const hasFullAccess = value.some(item => item.name === "*"); + + if (hasFullAccess) { + return { + canForceUnlock: "yes" + }; + } + + const permissions = value.filter(item => item.name.startsWith(RECORD_LOCKING_PERMISSION)); + + if (!permissions.length || !permissions.some(item => !!item.canForceUnlock)) { + return {}; + } + + return { + canForceUnlock: "yes" + }; + }, []); + + return ( + <Form<RecordLockingSecurityPermission> data={formData} onChange={onFormChange}> + {({ Bind }) => { + return ( + <Fragment> + <Grid className={gridNoPaddingClass}> + <Cell span={6}> + <PermissionInfo title={t`Advanced Record Locking`} /> + </Cell> + <Cell span={6}> + <Bind name={"canForceUnlock"}> + <Select label={t`Advanced Record Locking`}> + <option value={""}>{t`No Access`}</option> + <option value={"yes"}>{t`Full Access`}</option> + </Select> + </Bind> + </Cell> + </Grid> + </Fragment> + ); + }} + </Form> + ); +}; diff --git a/packages/app-record-locking/src/components/permissionRenderer/index.tsx b/packages/app-record-locking/src/components/permissionRenderer/index.tsx new file mode 100644 index 00000000000..0f48fd6a3b9 --- /dev/null +++ b/packages/app-record-locking/src/components/permissionRenderer/index.tsx @@ -0,0 +1,22 @@ +import React from "react"; +import { AccordionItem } from "@webiny/ui/Accordion"; +import { AdminAppPermissionRendererPlugin } from "@webiny/app-admin/types"; +import { ReactComponent as Icon } from "../assets/lock.svg"; +import { RecordLockingPermissions } from "./RecordLockingPermissions"; + +export const recordLockingPermissionRenderer: AdminAppPermissionRendererPlugin = { + type: "admin-app-permissions-renderer", + name: "admin-app-permissions-renderer-record-locking", + render(props) { + return ( + <AccordionItem + icon={<Icon />} + title={"Record Locking"} + description={"Manage Record Locking Permissions."} + data-testid={"permission.recordLocking"} + > + <RecordLockingPermissions {...props} /> + </AccordionItem> + ); + } +}; diff --git a/packages/app-record-locking/src/hooks/usePermission.ts b/packages/app-record-locking/src/hooks/usePermission.ts index af78d623cb3..5b4992bcee6 100644 --- a/packages/app-record-locking/src/hooks/usePermission.ts +++ b/packages/app-record-locking/src/hooks/usePermission.ts @@ -4,11 +4,16 @@ import { useSecurity } from "@webiny/app-security"; export const usePermission = () => { const { identity, getPermission } = useSecurity(); - const hasFullAccess = useMemo(() => { - return !!getPermission("recordLocking.*"); - }, [identity]); + const canForceUnlock = useMemo(() => { + const hasFullAccess = !!getPermission("recordLocking.*"); + if (hasFullAccess) { + return true; + } + const permission = getPermission("recordLocking"); + return permission?.canForceUnlock === "yes"; + }, [identity?.permissions]); return { - hasFullAccess + canForceUnlock }; }; diff --git a/packages/app-record-locking/src/index.tsx b/packages/app-record-locking/src/index.tsx index b15c5dc9e16..01dfd9c3b72 100644 --- a/packages/app-record-locking/src/index.tsx +++ b/packages/app-record-locking/src/index.tsx @@ -4,6 +4,8 @@ import { RecordLockingProvider as RecordLockingProviderComponent } from "~/compo import { HeadlessCmsActionsAcoCell } from "~/components/HeadlessCmsActionsAcoCell"; import { HeadlessCmsContentEntry } from "~/components/HeadlessCmsContentEntry"; import { useWcp } from "@webiny/app-wcp"; +import { plugins } from "@webiny/plugins"; +import { recordLockingPermissionRenderer } from "~/components/permissionRenderer"; export * from "~/components/RecordLockingProvider"; export * from "~/hooks"; @@ -28,6 +30,7 @@ export const RecordLocking = () => { if (!canUseRecordLocking()) { return null; } + plugins.register(recordLockingPermissionRenderer); return ( <> diff --git a/packages/app-record-locking/src/types.ts b/packages/app-record-locking/src/types.ts index 9a4d679ebf9..f77134d87ed 100644 --- a/packages/app-record-locking/src/types.ts +++ b/packages/app-record-locking/src/types.ts @@ -1,6 +1,7 @@ import { EntryTableItem } from "@webiny/app-headless-cms/types"; import { GenericRecord } from "@webiny/app/types"; import { IRecordLockingUnlockEntryResult } from "~/domain/abstractions/IRecordLockingUnlockEntry"; +import { SecurityPermission } from "@webiny/app-security/types"; export interface IRecordLockingIdentity { id: string; @@ -89,3 +90,7 @@ export interface IRecordLockingError<T = GenericRecord> { code: string; data?: T; } + +export interface RecordLockingSecurityPermission extends SecurityPermission { + canForceUnlock?: string; +} diff --git a/packages/app-record-locking/tsconfig.build.json b/packages/app-record-locking/tsconfig.build.json index bf8abc7f4f3..cc1395982f1 100644 --- a/packages/app-record-locking/tsconfig.build.json +++ b/packages/app-record-locking/tsconfig.build.json @@ -5,6 +5,8 @@ { "path": "../app/tsconfig.build.json" }, { "path": "../app-aco/tsconfig.build.json" }, { "path": "../app-admin/tsconfig.build.json" }, + { "path": "../form/tsconfig.build.json" }, + { "path": "../plugins/tsconfig.build.json" }, { "path": "../app-headless-cms/tsconfig.build.json" }, { "path": "../app-security/tsconfig.build.json" }, { "path": "../app-wcp/tsconfig.build.json" }, diff --git a/packages/app-record-locking/tsconfig.json b/packages/app-record-locking/tsconfig.json index b2bdfb27f1f..db6435a4c90 100644 --- a/packages/app-record-locking/tsconfig.json +++ b/packages/app-record-locking/tsconfig.json @@ -5,6 +5,8 @@ { "path": "../app" }, { "path": "../app-aco" }, { "path": "../app-admin" }, + { "path": "../form" }, + { "path": "../plugins" }, { "path": "../app-headless-cms" }, { "path": "../app-security" }, { "path": "../app-wcp" }, @@ -27,6 +29,10 @@ "@webiny/app-aco": ["../app-aco/src"], "@webiny/app-admin/*": ["../app-admin/src/*"], "@webiny/app-admin": ["../app-admin/src"], + "@webiny/form/*": ["../form/src/*"], + "@webiny/form": ["../form/src"], + "@webiny/plugins/*": ["../plugins/src/*"], + "@webiny/plugins": ["../plugins/src"], "@webiny/app-headless-cms/*": ["../app-headless-cms/src/*"], "@webiny/app-headless-cms": ["../app-headless-cms/src"], "@webiny/app-security/*": ["../app-security/src/*"], diff --git a/packages/app-websockets/src/WebsocketsContextProvider.tsx b/packages/app-websockets/src/WebsocketsContextProvider.tsx index 2fa8d86dae8..b5d453d7ea8 100644 --- a/packages/app-websockets/src/WebsocketsContextProvider.tsx +++ b/packages/app-websockets/src/WebsocketsContextProvider.tsx @@ -6,6 +6,8 @@ import { IncomingGenericData, IWebsocketsContext, IWebsocketsContextSendCallable, + IWebsocketsManagerCloseEvent, + IWebsocketsManagerErrorEvent, WebsocketsCloseCode } from "~/types"; import { @@ -68,6 +70,7 @@ export const WebsocketsContextProvider = (props: IWebsocketsContextProviderProps return manager; }, []); + /** * We need this useEffect to close the websocket connection and remove window focus event in case component is unmounted. * This will, probably, happen only during the development phase. @@ -78,23 +81,35 @@ export const WebsocketsContextProvider = (props: IWebsocketsContextProviderProps /** * We want to add a window event listener which will check if the connection is closed, and if its - it will connect again. */ - const fn = () => { - if (!socketsRef.current) { - return; - } else if (socketsRef.current.isClosed()) { - console.log("Running auto-reconnect on focus."); - socketsRef.current.connect(); - } - }; - window.addEventListener("focus", fn); + const abortController = new AbortController(); - return () => { - window.removeEventListener("focus", fn); - // if (!socketsRef.current) { - // return; - // } + window.addEventListener( + "focus", + () => { + if (!socketsRef.current) { + return; + } else if (socketsRef.current.isClosed()) { + console.log("Running auto-reconnect on focus."); + socketsRef.current.connect(); + } + }, + { signal: abortController.signal } + ); + window.addEventListener( + "close", + () => { + subscriptionManager.triggerOnClose( + new CloseEvent("windowClose", { + code: WebsocketsCloseCode.GOING_AWAY, + reason: "Closing Window or Tab." + }) + ); + }, + { signal: abortController.signal } + ); - // socketsRef.current.close(WebsocketsCloseCode.NORMAL, "Component unmounted."); + return () => { + abortController.abort(); }; }, []); @@ -185,6 +200,24 @@ export const WebsocketsContextProvider = (props: IWebsocketsContextProviderProps [socketsRef.current] ); + const onError = useCallback( + (cb: (data: IWebsocketsManagerErrorEvent) => void) => { + return socketsRef.current!.onError(data => { + return cb(data); + }); + }, + [socketsRef.current] + ); + + const onClose = useCallback( + (cb: (data: IWebsocketsManagerCloseEvent) => void) => { + return socketsRef.current!.onClose(data => { + return cb(data); + }); + }, + [socketsRef.current] + ); + if (!socketsRef.current) { return props.loader || null; } @@ -192,7 +225,9 @@ export const WebsocketsContextProvider = (props: IWebsocketsContextProviderProps const value: IWebsocketsContext = { send, createAction, - onMessage + onMessage, + onError, + onClose }; return <WebsocketsContext.Provider value={value} {...props} />; }; diff --git a/packages/app-websockets/src/domain/WebsocketsConnection.ts b/packages/app-websockets/src/domain/WebsocketsConnection.ts index 45a421f0a0c..c4808024942 100644 --- a/packages/app-websockets/src/domain/WebsocketsConnection.ts +++ b/packages/app-websockets/src/domain/WebsocketsConnection.ts @@ -154,6 +154,17 @@ export class WebsocketsConnection implements IWebsocketsConnection { }); connectionCache.ws.addEventListener("error", event => { console.info(`Error in the Websocket connection.`, event); + /** + * Let's close it if possible. + * It will reopen automatically. + */ + if (connectionCache.ws?.close) { + try { + connectionCache.ws.close(); + } catch (ex) { + console.error(ex); + } + } return this.subscriptionManager.triggerOnError(event); }); diff --git a/packages/app-websockets/src/types.ts b/packages/app-websockets/src/types.ts index 9d6824ef108..70d3a3716a6 100644 --- a/packages/app-websockets/src/types.ts +++ b/packages/app-websockets/src/types.ts @@ -1,6 +1,8 @@ import { IGenericData, IWebsocketsAction, + IWebsocketsManagerCloseEvent, + IWebsocketsManagerErrorEvent, IWebsocketsManagerMessageEvent, IWebsocketsSubscription } from "~/domain/types"; @@ -25,10 +27,24 @@ export interface ISocketsContextOnMessageCallable { ): IWebsocketsSubscription<IWebsocketsManagerMessageEvent<T>>; } +export interface ISocketsContextOnErrorCallable { + ( + cb: (data: IWebsocketsManagerErrorEvent) => void + ): IWebsocketsSubscription<IWebsocketsManagerErrorEvent>; +} + +export interface ISocketsContextOnCloseCallable { + ( + cb: (data: IWebsocketsManagerCloseEvent) => void + ): IWebsocketsSubscription<IWebsocketsManagerCloseEvent>; +} + export interface IWebsocketsContext { send: IWebsocketsContextSendCallable; createAction: IWebsocketsContextCreateActionCallable; onMessage: ISocketsContextOnMessageCallable; + onError: ISocketsContextOnErrorCallable; + onClose: ISocketsContextOnCloseCallable; } export interface IncomingGenericData extends IGenericData { diff --git a/packages/pulumi-aws/src/apps/api/ApiGateway.ts b/packages/pulumi-aws/src/apps/api/ApiGateway.ts index 56eaaa17ad4..c3f74913e19 100644 --- a/packages/pulumi-aws/src/apps/api/ApiGateway.ts +++ b/packages/pulumi-aws/src/apps/api/ApiGateway.ts @@ -25,7 +25,15 @@ export const ApiGateway = createAppModule({ name: "default", config: { apiId: api.output.id, - autoDeploy: true + autoDeploy: true, + defaultRouteSettings: { + // Only enable when debugging. Note that by default, API Gateway does not + // have the required permissions to write logs to CloudWatch logs. More: + // https://coady.tech/aws-cloudwatch-logs-arn/ + // loggingLevel: "INFO", + throttlingBurstLimit: 5000, + throttlingRateLimit: 10000 + } } }); diff --git a/yarn.lock b/yarn.lock index 8b5e330dfe3..3afffa2625a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16332,6 +16332,8 @@ __metadata: "@webiny/app-websockets": 0.0.0 "@webiny/cli": 0.0.0 "@webiny/error": 0.0.0 + "@webiny/form": 0.0.0 + "@webiny/plugins": 0.0.0 "@webiny/project-utils": 0.0.0 "@webiny/react-router": 0.0.0 "@webiny/ui": 0.0.0 From 4931327c2d1dd02b32d5b3f68872a89930441cf5 Mon Sep 17 00:00:00 2001 From: Leonardo Giacone <leonardo@webiny.com> Date: Fri, 28 Mar 2025 10:00:16 +0100 Subject: [PATCH 37/52] fix(api-aco): `extensions` default model field (#4580) --- packages/api-aco/src/folder/folder.model.ts | 20 ++++++++++++++++++- .../src/createFieldsList.ts | 4 ++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/api-aco/src/folder/folder.model.ts b/packages/api-aco/src/folder/folder.model.ts index 073b7837eb6..95e4d809e56 100644 --- a/packages/api-aco/src/folder/folder.model.ts +++ b/packages/api-aco/src/folder/folder.model.ts @@ -113,6 +113,17 @@ const permissionsField = () => } }); +const extensionsField = () => + createModelField({ + label: "Extensions", + fieldId: "extensions", + type: "object", + settings: { + layout: [], + fields: [] + } + }); + export const FOLDER_MODEL_ID = "acoFolder"; export const createFolderModel = () => { @@ -127,6 +138,13 @@ export const createFolderModel = () => { // flp: true }, titleFieldId: "title", - fields: [titleField(), slugField(), typeField(), parentIdField(), permissionsField()] + fields: [ + titleField(), + slugField(), + typeField(), + parentIdField(), + permissionsField(), + extensionsField() + ] }); }; diff --git a/packages/app-headless-cms-common/src/createFieldsList.ts b/packages/app-headless-cms-common/src/createFieldsList.ts index f7228d2385d..bda6855183b 100644 --- a/packages/app-headless-cms-common/src/createFieldsList.ts +++ b/packages/app-headless-cms-common/src/createFieldsList.ts @@ -47,10 +47,10 @@ export function createFieldsList({ }) .filter(Boolean); /** - * If there are no fields, let's always load the `id` field. + * If there are no fields, let's always load the `_empty` field. */ if (fields.length === 0) { - fields.push("id"); + fields.push("_empty"); } return fields.join("\n"); } From fdf4a5e06080e16be06ca5b538fd17ed70603706 Mon Sep 17 00:00:00 2001 From: Adrian Smijulj <adrian1358@gmail.com> Date: Fri, 28 Mar 2025 10:02:54 +0100 Subject: [PATCH 38/52] fix: expand PB / FM APIs to allow GQL API-based data migrations (#4581) --- .../src/plugins/graphqlFileStorageS3.ts | 1 + .../src/operations/pages/index.ts | 40 +++- .../src/operations/pages/index.ts | 16 ++ .../__tests__/graphql/createPage.test.ts | 209 ++++++++++++++++++ .../__tests__/graphql/graphql/pages.ts | 14 ++ .../__tests__/graphql/useGqlHandler.ts | 4 + .../src/graphql/crud/categories.crud.ts | 11 +- .../src/graphql/crud/categories/validation.ts | 10 +- .../src/graphql/crud/menus.crud.ts | 11 +- .../src/graphql/crud/menus/validation.ts | 10 +- .../src/graphql/crud/pages.crud.ts | 163 +++++++++++++- .../src/graphql/crud/utils/formatDate.ts | 12 + .../src/graphql/crud/utils/getDate.ts | 14 ++ .../src/graphql/crud/utils/getIdentity.ts | 16 ++ .../src/graphql/graphql/base.gql.ts | 6 + .../src/graphql/graphql/categories.gql.ts | 2 + .../src/graphql/graphql/menus.gql.ts | 2 + .../src/graphql/graphql/pages.gql.ts | 26 +++ .../api-page-builder/src/graphql/types.ts | 121 ++++++++++ .../prerendering/hooks/afterSettingsUpdate.ts | 57 +---- 20 files changed, 670 insertions(+), 75 deletions(-) create mode 100644 packages/api-page-builder/__tests__/graphql/createPage.test.ts create mode 100644 packages/api-page-builder/src/graphql/crud/utils/formatDate.ts create mode 100644 packages/api-page-builder/src/graphql/crud/utils/getDate.ts create mode 100644 packages/api-page-builder/src/graphql/crud/utils/getIdentity.ts diff --git a/packages/api-file-manager-s3/src/plugins/graphqlFileStorageS3.ts b/packages/api-file-manager-s3/src/plugins/graphqlFileStorageS3.ts index 43ffb13fc85..80fba5e9171 100644 --- a/packages/api-file-manager-s3/src/plugins/graphqlFileStorageS3.ts +++ b/packages/api-file-manager-s3/src/plugins/graphqlFileStorageS3.ts @@ -25,6 +25,7 @@ const plugin: GraphQLSchemaPlugin<FileManagerContext> = { } input PreSignedPostPayloadInput { + id: ID name: String! type: String! size: Long! diff --git a/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts b/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts index b523ec37d03..239b80bef5b 100644 --- a/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts +++ b/packages/api-page-builder-so-ddb-es/src/operations/pages/index.ts @@ -69,6 +69,7 @@ export interface CreatePageStorageOperationsParams { elasticsearch: Client; plugins: PluginsContainer; } + export const createPageStorageOperations = ( params: CreatePageStorageOperationsParams ): PageStorageOperations => { @@ -86,6 +87,11 @@ export const createPageStorageOperations = ( SK: createLatestSortKey() }; + const publishedKeys = { + ...versionKeys, + SK: createPublishedSortKey() + }; + const entityBatch = createEntityWriteBatch({ entity, put: [ @@ -103,17 +109,41 @@ export const createPageStorageOperations = ( }); const esData = getESLatestPageData(plugins, page, input); - try { - await entityBatch.execute(); - await put({ - entity: esEntity, - item: { + const elasticsearchEntityBatch = createEntityWriteBatch({ + entity: esEntity, + put: [ + { index: configurations.es(page).index, data: esData, ...latestKeys } + ] + }); + + if (page.status === "published") { + entityBatch.put({ + ...page, + ...publishedKeys, + TYPE: createPublishedType() + }); + + entityBatch.put({ + ...page, + TYPE: createPublishedPathType(), + PK: createPathPartitionKey(page), + SK: createPathSortKey(page) }); + + elasticsearchEntityBatch.put({ + index: configurations.es(page).index, + data: getESPublishedPageData(plugins, page), + ...publishedKeys + }); + } + try { + await entityBatch.execute(); + await elasticsearchEntityBatch.execute(); return page; } catch (ex) { throw new WebinyError( diff --git a/packages/api-page-builder-so-ddb/src/operations/pages/index.ts b/packages/api-page-builder-so-ddb/src/operations/pages/index.ts index 1938bace62b..75fb2868e0a 100644 --- a/packages/api-page-builder-so-ddb/src/operations/pages/index.ts +++ b/packages/api-page-builder-so-ddb/src/operations/pages/index.ts @@ -81,6 +81,7 @@ export interface CreatePageStorageOperationsParams { entity: Entity<any>; plugins: PluginsContainer; } + export const createPageStorageOperations = ( params: CreatePageStorageOperationsParams ): PageStorageOperations => { @@ -98,6 +99,11 @@ export const createPageStorageOperations = ( SK: createLatestSortKey(page) }; + const publishedKeys = { + PK: createPublishedPartitionKey(page), + SK: createPublishedSortKey(page) + }; + const titleLC = page.title.toLowerCase(); /** * We need to create @@ -122,6 +128,16 @@ export const createPageStorageOperations = ( ] }); + if (page.status === "published") { + entityBatch.put({ + ...page, + ...publishedKeys, + GSI1_PK: createPathPartitionKey(page), + GSI1_SK: page.path, + TYPE: createPublishedType() + }); + } + try { await entityBatch.execute(); return page; diff --git a/packages/api-page-builder/__tests__/graphql/createPage.test.ts b/packages/api-page-builder/__tests__/graphql/createPage.test.ts new file mode 100644 index 00000000000..dea0a692297 --- /dev/null +++ b/packages/api-page-builder/__tests__/graphql/createPage.test.ts @@ -0,0 +1,209 @@ +import useGqlHandler from "./useGqlHandler"; + +jest.setTimeout(100000); + +describe("CRUD Test", () => { + const handler = useGqlHandler(); + + const { createCategory, createPageV2, getPage, getPublishedPage } = handler; + + it("creating pages via the new createPagesV2 mutation", async () => { + await createCategory({ + data: { + slug: `slug`, + name: `name`, + url: `/some-url/`, + layout: `layout` + } + }); + + const page = { + id: "67e15c96026bd2000222d698#0001", + pid: "67e15c96026bd2000222d698", + category: "slug", + version: 1, + title: "Welcome to Webiny", + path: "/welcome-to-webiny", + content: { + id: "Fv1PpPWu-", + type: "document", + data: { + settings: {} + }, + elements: [] + }, + status: "published", + publishedOn: "2025-03-24T13:22:30.918Z", + settings: { + general: { + snippet: null, + tags: null, + layout: "static", + image: null + }, + social: { + meta: [], + title: null, + description: null, + image: null + }, + seo: { + title: null, + description: null, + meta: [] + } + }, + createdOn: "2025-03-24T13:22:30.363Z", + createdBy: { + id: "67e15c7d026bd2000222d67a", + displayName: "ad min", + type: "admin" + } + }; + + // The V2 of the createPage mutation should allow us to create pages with + // predefined `createdOn`, `createdBy`, `id`, and also immediately have the + // page published. + await createPageV2({ data: page }); + + const [getPageResponse] = await getPage({ id: page.id }); + + expect(getPageResponse).toMatchObject({ + data: { + pageBuilder: { + getPage: { + data: { + id: "67e15c96026bd2000222d698#0001", + pid: "67e15c96026bd2000222d698", + editor: "page-builder", + category: { + slug: "slug" + }, + version: 1, + title: "Welcome to Webiny", + path: "/welcome-to-webiny", + url: "https://www.test.com/welcome-to-webiny", + content: { + id: "Fv1PpPWu-", + type: "document", + data: { + settings: {} + }, + elements: [] + }, + savedOn: "2025-03-24T13:22:30.363Z", + status: "published", + locked: true, + publishedOn: "2025-03-24T13:22:30.918Z", + revisions: [ + { + id: "67e15c96026bd2000222d698#0001", + status: "published", + locked: true, + version: 1 + } + ], + settings: { + general: { + snippet: null, + tags: null, + layout: "static", + image: null + }, + social: { + meta: [], + title: null, + description: null, + image: null + }, + seo: { + title: null, + description: null, + meta: [] + } + }, + createdFrom: null, + createdOn: "2025-03-24T13:22:30.363Z", + createdBy: { + id: "67e15c7d026bd2000222d67a", + displayName: "ad min", + type: "admin" + } + }, + error: null + } + } + } + }); + + const [getPublishedPageResponse] = await getPublishedPage({ id: page.id }); + + expect(getPublishedPageResponse).toMatchObject({ + data: { + pageBuilder: { + getPublishedPage: { + data: { + id: "67e15c96026bd2000222d698#0001", + pid: "67e15c96026bd2000222d698", + editor: "page-builder", + category: { + slug: "slug" + }, + version: 1, + title: "Welcome to Webiny", + path: "/welcome-to-webiny", + url: "https://www.test.com/welcome-to-webiny", + content: { + id: "Fv1PpPWu-", + type: "document", + data: { + settings: {} + }, + elements: [] + }, + savedOn: "2025-03-24T13:22:30.363Z", + status: "published", + locked: true, + publishedOn: "2025-03-24T13:22:30.918Z", + revisions: [ + { + id: "67e15c96026bd2000222d698#0001", + status: "published", + locked: true, + version: 1 + } + ], + settings: { + general: { + snippet: null, + tags: null, + layout: "static", + image: null + }, + social: { + meta: [], + title: null, + description: null, + image: null + }, + seo: { + title: null, + description: null, + meta: [] + } + }, + createdFrom: null, + createdOn: "2025-03-24T13:22:30.363Z", + createdBy: { + id: "67e15c7d026bd2000222d67a", + displayName: "ad min", + type: "admin" + } + }, + error: null + } + } + } + }); + }); +}); diff --git a/packages/api-page-builder/__tests__/graphql/graphql/pages.ts b/packages/api-page-builder/__tests__/graphql/graphql/pages.ts index 9e2cfeaa999..34ae1f7c1c0 100644 --- a/packages/api-page-builder/__tests__/graphql/graphql/pages.ts +++ b/packages/api-page-builder/__tests__/graphql/graphql/pages.ts @@ -198,7 +198,21 @@ export const createPageCreateGraphQl = (params: CreateDataFieldsParams = {}) => `; }; +export const createPageCreateV2GraphQl = (params: CreateDataFieldsParams = {}) => { + return /* GraphQL */ ` + mutation CreatePageV2($data: PbCreatePageV2Input!) { + pageBuilder { + createPageV2(data: $data) { + data ${createDataFields(params)} + error ${ERROR_FIELD} + } + } + } + `; +}; + export const CREATE_PAGE = createPageCreateGraphQl(); +export const CREATE_PAGE_V2 = createPageCreateV2GraphQl(); export const createPageUpdateGraphQl = (params: CreateDataFieldsParams = {}) => { return /* GraphQL */ ` diff --git a/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts b/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts index d20f93a6350..7c681647fd4 100644 --- a/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts +++ b/packages/api-page-builder/__tests__/graphql/useGqlHandler.ts @@ -26,6 +26,7 @@ import { } from "./graphql/pageElements"; import { CREATE_PAGE, + CREATE_PAGE_V2, DELETE_PAGE, DUPLICATE_PAGE, GET_PAGE, @@ -241,6 +242,9 @@ export default ({ permissions, identity, plugins }: Params = {}) => { async createPage(variables: Record<string, any>) { return invoke({ body: { query: CREATE_PAGE, variables } }); }, + async createPageV2(variables: Record<string, any>) { + return invoke({ body: { query: CREATE_PAGE_V2, variables } }); + }, async duplicatePage(variables: Record<string, any>) { return invoke({ body: { query: DUPLICATE_PAGE, variables } }); }, diff --git a/packages/api-page-builder/src/graphql/crud/categories.crud.ts b/packages/api-page-builder/src/graphql/crud/categories.crud.ts index 158335ce06e..f94e6e68ac6 100644 --- a/packages/api-page-builder/src/graphql/crud/categories.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/categories.crud.ts @@ -23,6 +23,8 @@ import { import { createZodError, removeUndefinedValues } from "@webiny/utils"; import { CategoriesPermissions } from "~/graphql/crud/permissions/CategoriesPermissions"; import { PagesPermissions } from "~/graphql/crud/permissions/PagesPermissions"; +import { getDate } from "~/graphql/crud/utils/getDate"; +import { getIdentity } from "~/graphql/crud/utils/getIdentity"; export interface CreateCategoriesCrudParams { context: PbContext; @@ -172,17 +174,14 @@ export const createCategoriesCrud = (params: CreateCategoriesCrudParams): Catego } const identity = context.security.getIdentity(); + const currentDateTime = new Date(); const data = validationResult.data; const category: Category = { ...data, - createdOn: new Date().toISOString(), - createdBy: { - id: identity.id, - type: identity.type, - displayName: identity.displayName - }, + createdOn: getDate(input.createdOn, currentDateTime), + createdBy: getIdentity(input.createdBy, identity), tenant: getTenantId(), locale: getLocaleCode() }; diff --git a/packages/api-page-builder/src/graphql/crud/categories/validation.ts b/packages/api-page-builder/src/graphql/crud/categories/validation.ts index 9dd72aee443..bd1b10e31fc 100644 --- a/packages/api-page-builder/src/graphql/crud/categories/validation.ts +++ b/packages/api-page-builder/src/graphql/crud/categories/validation.ts @@ -8,7 +8,15 @@ const baseValidation = zod.object({ export const createCategoryCreateValidation = () => { return baseValidation.extend({ - slug: zod.string().min(1).max(100) + slug: zod.string().min(1).max(100), + createdOn: zod.date().optional(), + createdBy: zod + .object({ + id: zod.string(), + type: zod.string(), + displayName: zod.string().nullable() + }) + .optional() }); }; diff --git a/packages/api-page-builder/src/graphql/crud/menus.crud.ts b/packages/api-page-builder/src/graphql/crud/menus.crud.ts index 8bdb7804c9a..2efa4b84f62 100644 --- a/packages/api-page-builder/src/graphql/crud/menus.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/menus.crud.ts @@ -23,6 +23,8 @@ import { } from "~/graphql/crud/menus/validation"; import { createZodError, removeUndefinedValues } from "@webiny/utils"; import { MenusPermissions } from "~/graphql/crud/permissions/MenusPermissions"; +import { getIdentity } from "./utils/getIdentity"; +import { getDate } from "./utils/getDate"; export interface CreateMenuCrudParams { context: PbContext; @@ -175,16 +177,13 @@ export const createMenuCrud = (params: CreateMenuCrudParams): MenusCrud => { } const identity = context.security.getIdentity(); + const currentDateTime = new Date(); const menu: Menu = { ...data, items: data.items || [], - createdOn: new Date().toISOString(), - createdBy: { - id: identity.id, - type: identity.type, - displayName: identity.displayName - }, + createdOn: getDate(input.createdOn, currentDateTime), + createdBy: getIdentity(input.createdBy, identity), tenant: getTenantId(), locale: getLocaleCode() }; diff --git a/packages/api-page-builder/src/graphql/crud/menus/validation.ts b/packages/api-page-builder/src/graphql/crud/menus/validation.ts index b048c915bef..f150ccf9a29 100644 --- a/packages/api-page-builder/src/graphql/crud/menus/validation.ts +++ b/packages/api-page-builder/src/graphql/crud/menus/validation.ts @@ -8,7 +8,15 @@ const baseValidation = zod.object({ export const createMenuCreateValidation = () => { return baseValidation.extend({ - slug: zod.string().min(1).max(100) + slug: zod.string().min(1).max(100), + createdOn: zod.date().optional(), + createdBy: zod + .object({ + id: zod.string(), + type: zod.string(), + displayName: zod.string().nullable() + }) + .optional() }); }; diff --git a/packages/api-page-builder/src/graphql/crud/pages.crud.ts b/packages/api-page-builder/src/graphql/crud/pages.crud.ts index 7f8fb4a8e84..5105867920c 100644 --- a/packages/api-page-builder/src/graphql/crud/pages.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/pages.crud.ts @@ -52,6 +52,8 @@ import { import { createCompression } from "~/graphql/crud/pages/compression"; import { PagesPermissions } from "./permissions/PagesPermissions"; import { PageContent } from "./pages/PageContent"; +import { getDate } from "~/graphql/crud/utils/getDate"; +import { getIdentity } from "~/graphql/crud/utils/getIdentity"; const STATUS_DRAFT = "draft"; const STATUS_PUBLISHED = "published"; @@ -289,12 +291,12 @@ export const createPageCrud = (params: CreatePageCrudParams): PagesCrud => { async processPageContent(page) { return processPageContent(page, pageElementProcessors); }, - async createPage(this: PageBuilderContextObject, slug, meta): Promise<any> { + async createPage(this: PageBuilderContextObject, categorySlug, meta): Promise<any> { await pagesPermissions.ensure({ rwd: "w" }); - const category = await this.getCategory(slug); + const category = await this.getCategory(categorySlug); if (!category) { - throw new NotFoundError(`Category with slug "${slug}" not found.`); + throw new NotFoundError(`Category with slug "${categorySlug}" not found.`); } const title = "Untitled"; @@ -399,7 +401,160 @@ export const createPageCrud = (params: CreatePageCrudParams): PagesCrud => { await storageOperations.pages.create({ input: { - slug + slug: categorySlug + }, + page: await compressPage(page) + }); + await onPageAfterCreate.publish({ page, meta }); + + return page; + } catch (ex) { + throw new WebinyError( + ex.message || "Could not create new page.", + ex.code || "CREATE_PAGE_ERROR", + { + ...(ex.data || {}), + page + } + ); + } + }, + + async createPageV2(this: PageBuilderContextObject, input, meta): Promise<any> { + await pagesPermissions.ensure({ rwd: "w" }); + + const categorySlug = input.category; + if (!categorySlug) { + throw new WebinyError("Category slug is missing.", "CATEGORY_SLUG_MISSING"); + } + + const category = await this.getCategory(categorySlug); + if (!category) { + throw new NotFoundError(`Category with slug "${categorySlug}" not found.`); + } + + const title = input.title || "Untitled"; + + let pagePath = input.path; + if (!pagePath) { + if (category.slug === "static") { + pagePath = normalizePath("untitled-" + uniqid.time()) as string; + } else { + pagePath = normalizePath( + [category.url, "untitled-" + uniqid.time()].join("/").replace(/\/\//g, "/") + ) as string; + } + } + + const result = await createPageCreateValidation().safeParseAsync({ + category: category.slug + }); + if (!result.success) { + throw createZodError(result.error); + } + + const currentIdentity = context.security.getIdentity(); + const currentDateTime = new Date(); + + let pageId = "", + version = 1; + if (input.id) { + const splitId = input.id.split("#"); + pageId = splitId[0]; + version = Number(splitId[1]); + } else if (input.pid) { + pageId = input.pid; + } else { + pageId = mdbid(); + } + + if (input.version) { + version = input.version; + } + + const id = createIdentifier({ + id: pageId, + version: 1 + }); + + const rawSettings = input.settings || { + general: { + layout: category.layout + }, + social: { + description: null, + image: null, + meta: [], + title: null + }, + seo: { + title: null, + description: null, + meta: [] + } + }; + + const validation = createPageSettingsUpdateValidation(); + const settingsValidationResult = validation.safeParse(rawSettings); + if (!settingsValidationResult.success) { + throw createZodError(settingsValidationResult.error); + } + + const settings = settingsValidationResult.data; + const status = input.status || STATUS_DRAFT; + const locked = status !== STATUS_DRAFT; + + let publishedOn = null; + if (status === STATUS_PUBLISHED) { + publishedOn = getDate(input.publishedOn, currentDateTime); + } + + const page: Page = { + id, + pid: pageId, + locale: getLocaleCode(), + tenant: getTenantId(), + editor: DEFAULT_EDITOR, + category: category.slug, + title, + path: pagePath, + version, + status, + locked, + publishedOn, + createdFrom: null, + settings: { + ...settings, + general: { + ...settings.general, + tags: settings.general?.tags || undefined + }, + social: { + ...settings.social, + meta: settings.social?.meta || [] + }, + seo: { + ...settings.seo, + meta: settings.seo?.meta || [] + } + }, + createdOn: getDate(input.createdOn, currentDateTime), + savedOn: getDate(input.createdOn, currentDateTime), + createdBy: getIdentity(input.createdBy, currentIdentity), + ownedBy: getIdentity(input.createdBy, currentIdentity), + content: input.content || PageContent.createEmpty().getValue(), + webinyVersion: context.WEBINY_VERSION + }; + + try { + await onPageBeforeCreate.publish({ + page, + meta + }); + + await storageOperations.pages.create({ + input: { + slug: categorySlug }, page: await compressPage(page) }); diff --git a/packages/api-page-builder/src/graphql/crud/utils/formatDate.ts b/packages/api-page-builder/src/graphql/crud/utils/formatDate.ts new file mode 100644 index 00000000000..b3063eb352f --- /dev/null +++ b/packages/api-page-builder/src/graphql/crud/utils/formatDate.ts @@ -0,0 +1,12 @@ +/** + * Should not be used by users as method is prone to breaking changes. + * @internal + */ +export const formatDate = (date?: Date | string | null): string | null => { + if (!date) { + return null; + } else if (date instanceof Date) { + return date.toISOString(); + } + return new Date(date).toISOString(); +}; diff --git a/packages/api-page-builder/src/graphql/crud/utils/getDate.ts b/packages/api-page-builder/src/graphql/crud/utils/getDate.ts new file mode 100644 index 00000000000..2d4cd08fc5f --- /dev/null +++ b/packages/api-page-builder/src/graphql/crud/utils/getDate.ts @@ -0,0 +1,14 @@ +import { formatDate } from "./formatDate"; + +export const getDate = <T extends string | null = string | null>( + input?: Date | string | null, + defaultValue?: Date | string | null +): T => { + if (!input) { + return formatDate(defaultValue) as T; + } + if (input instanceof Date) { + return formatDate(input) as T; + } + return formatDate(new Date(input)) as T; +}; diff --git a/packages/api-page-builder/src/graphql/crud/utils/getIdentity.ts b/packages/api-page-builder/src/graphql/crud/utils/getIdentity.ts new file mode 100644 index 00000000000..994e94d0662 --- /dev/null +++ b/packages/api-page-builder/src/graphql/crud/utils/getIdentity.ts @@ -0,0 +1,16 @@ +import { SecurityIdentity } from "@webiny/api-security/types"; + +export const getIdentity = <T extends SecurityIdentity | null>( + input: SecurityIdentity | null | undefined, + defaultValue: T | null = null +): T => { + const identity = input?.id && input?.displayName && input?.type ? input : defaultValue; + if (!identity) { + return null as T; + } + return { + id: identity.id, + displayName: identity.displayName, + type: identity.type + } as T; +}; diff --git a/packages/api-page-builder/src/graphql/graphql/base.gql.ts b/packages/api-page-builder/src/graphql/graphql/base.gql.ts index ad5016f52fc..0175ff29e75 100644 --- a/packages/api-page-builder/src/graphql/graphql/base.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/base.gql.ts @@ -23,6 +23,12 @@ export const createBaseGraphQL = (): GraphQLSchemaPlugin => { type: String } + input PbIdentityInput { + id: ID! + displayName: String! + type: String! + } + type PbError { code: String message: String diff --git a/packages/api-page-builder/src/graphql/graphql/categories.gql.ts b/packages/api-page-builder/src/graphql/graphql/categories.gql.ts index 7f17ec6db47..a729d829931 100644 --- a/packages/api-page-builder/src/graphql/graphql/categories.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/categories.gql.ts @@ -29,6 +29,8 @@ export const createCategoryGraphQL = (): GraphQLSchemaPlugin<PbContext> => { slug: String! url: String! layout: String! + createdBy: PbIdentityInput + createdOn: DateTime } # Response types diff --git a/packages/api-page-builder/src/graphql/graphql/menus.gql.ts b/packages/api-page-builder/src/graphql/graphql/menus.gql.ts index d37aceaaa01..c9c98e21124 100644 --- a/packages/api-page-builder/src/graphql/graphql/menus.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/menus.gql.ts @@ -23,6 +23,8 @@ export const createMenuGraphQL = (): GraphQLSchemaPlugin<PbContext> => { slug: String! description: String items: [JSON] + createdBy: PbIdentityInput + createdOn: DateTime } # Response types diff --git a/packages/api-page-builder/src/graphql/graphql/pages.gql.ts b/packages/api-page-builder/src/graphql/graphql/pages.gql.ts index 5f910fdafab..1c938afc965 100644 --- a/packages/api-page-builder/src/graphql/graphql/pages.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/pages.gql.ts @@ -109,6 +109,24 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin<PbContext> => { dataBindings: [DataBindingInput!] } + input PbCreatePageV2Input { + id: ID + pid: ID + category: ID + title: String + version: Int + path: String + content: JSON + savedOn: DateTime + status: String + publishedOn: DateTime + settings: PbPageSettingsInput + createdOn: DateTime + createdBy: PbIdentityInput + dataSources: [DataSourceInput!] + dataBindings: [DataBindingInput!] + } + input PbPageSettingsInput { _empty: String } @@ -242,6 +260,8 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin<PbContext> => { extend type PbMutation { createPage(from: ID, category: String, meta: JSON): PbPageResponse + createPageV2(data: PbCreatePageV2Input!): PbPageResponse + # Update page by given ID. updatePage(id: ID!, data: PbUpdatePageInput!): PbPageResponse @@ -457,6 +477,12 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin<PbContext> => { return context.pageBuilder.createPage(category as string, meta); }); }, + createPageV2: async (_, args: any, context) => { + return resolve(() => { + const { data } = args; + return context.pageBuilder.createPageV2(data); + }); + }, deletePage: async (_, args: any, context) => { return resolve(async () => { diff --git a/packages/api-page-builder/src/graphql/types.ts b/packages/api-page-builder/src/graphql/types.ts index fc2ad2e23c7..a61f56163ae 100644 --- a/packages/api-page-builder/src/graphql/types.ts +++ b/packages/api-page-builder/src/graphql/types.ts @@ -9,6 +9,7 @@ import { Context as BaseContext } from "@webiny/handler/types"; import { BlockCategory, Category, + CreatedBy, DefaultSettings, DynamicDocument, Menu, @@ -17,6 +18,7 @@ import { PageElement, PageSettings, PageSpecialType, + PageStatus, PageTemplate, PageTemplateInput, Settings, @@ -32,8 +34,10 @@ export interface ListPagesParamsWhere { category?: string; status?: string; tags?: { query: string[]; rule?: "any" | "all" }; + [key: string]: any; } + export interface ListPagesParams { limit?: number; after?: string | null; @@ -75,6 +79,7 @@ export interface OnPageBeforeCreateTopicParams<TPage extends Page = Page> { page: TPage; meta?: Record<string, any>; } + /** * @category Lifecycle events */ @@ -82,6 +87,7 @@ export interface OnPageAfterCreateTopicParams<TPage extends Page = Page> { page: TPage; meta?: Record<string, any>; } + /** * @category Lifecycle events */ @@ -90,6 +96,7 @@ export interface OnPageBeforeUpdateTopicParams<TPage extends Page = Page> { page: TPage; input: Record<string, any>; } + /** * @category Lifecycle events */ @@ -98,6 +105,7 @@ export interface OnPageAfterUpdateTopicParams<TPage extends Page = Page> { page: TPage; input: Record<string, any>; } + /** * @category Lifecycle events */ @@ -105,6 +113,7 @@ export interface OnPageBeforeCreateFromTopicParams<TPage extends Page = Page> { original: TPage; page: TPage; } + /** * @category Lifecycle events */ @@ -112,6 +121,7 @@ export interface OnPageAfterCreateFromTopicParams<TPage extends Page = Page> { original: TPage; page: TPage; } + /** * @category Lifecycle events */ @@ -121,6 +131,7 @@ export interface OnPageBeforeDeleteTopicParams<TPage extends Page = Page> { publishedPage: TPage | null; deleteMethod: "deleteAll" | "delete"; } + /** * @category Lifecycle events */ @@ -130,6 +141,7 @@ export interface OnPageAfterDeleteTopicParams<TPage extends Page = Page> { publishedPage: TPage | null; deleteMethod: "deleteAll" | "delete"; } + /** * @category Lifecycle events */ @@ -138,6 +150,7 @@ export interface OnPageBeforePublishTopicParams<TPage extends Page = Page> { latestPage: TPage; publishedPage: TPage | null; } + /** * @category Lifecycle events */ @@ -146,6 +159,7 @@ export interface OnPageAfterPublishTopicParams<TPage extends Page = Page> { latestPage: TPage; publishedPage: TPage | null; } + /** * @category Lifecycle events */ @@ -153,6 +167,7 @@ export interface OnPageBeforeUnpublishTopicParams<TPage extends Page = Page> { page: TPage; latestPage: TPage; } + /** * @category Lifecycle events */ @@ -193,34 +208,54 @@ export interface PageElementProcessor { */ export interface PagesCrud { addPageElementProcessor(processor: PageElementProcessor): void; + processPageContent(content: Page): Promise<Page>; + getPage<TPage extends Page = Page>(id: string, options?: GetPagesOptions): Promise<TPage>; + listLatestPages<TPage extends Page = Page>( args: ListPagesParams, options?: ListLatestPagesOptions ): Promise<[TPage[], ListMeta]>; + listPublishedPages<TPage extends Page = Page>( args: ListPagesParams ): Promise<[TPage[], ListMeta]>; + listPagesTags(args: { search: { query: string } }): Promise<string[]>; + getPublishedPageById<TPage extends Page = Page>(args: { id: string; preview?: boolean; }): Promise<TPage>; + getPublishedPageByPath<TPage extends Page = Page>(args: { path: string }): Promise<TPage>; + listPageRevisions<TPage extends Page = Page>(id: string): Promise<TPage[]>; + createPage<TPage extends Page = Page>( category: string, meta?: Record<string, any> ): Promise<TPage>; + + createPageV2<TPage extends Page = Page>( + data: PbCreatePageV2Input, + meta?: Record<string, any> + ): Promise<TPage>; + createPageFrom<TPage extends Page = Page>( page: string, meta?: Record<string, any> ): Promise<TPage>; + updatePage<TPage extends Page = Page>(id: string, data: PbUpdatePageInput): Promise<TPage>; + deletePage<TPage extends Page = Page>(id: string): Promise<[TPage, TPage]>; + publishPage<TPage extends Page = Page>(id: string): Promise<TPage>; + unpublishPage<TPage extends Page = Page>(id: string): Promise<TPage>; + prerendering: { render(args: RenderParams): Promise<void>; flush(args: FlushParams): Promise<void>; @@ -249,12 +284,14 @@ export interface ListPageElementsParams { export interface OnPageElementBeforeCreateTopicParams { pageElement: PageElement; } + /** * @category Lifecycle events */ export interface OnPageElementAfterCreateTopicParams { pageElement: PageElement; } + /** * @category Lifecycle events */ @@ -262,6 +299,7 @@ export interface OnPageElementBeforeUpdateTopicParams { original: PageElement; pageElement: PageElement; } + /** * @category Lifecycle events */ @@ -269,12 +307,14 @@ export interface OnPageElementAfterUpdateTopicParams { original: PageElement; pageElement: PageElement; } + /** * @category Lifecycle events */ export interface OnPageElementBeforeDeleteTopicParams { pageElement: PageElement; } + /** * @category Lifecycle events */ @@ -287,10 +327,15 @@ export interface OnPageElementAfterDeleteTopicParams { */ export interface PageElementsCrud { getPageElement(id: string): Promise<PageElement | null>; + listPageElements(params?: ListPageElementsParams): Promise<PageElement[]>; + createPageElement(data: Record<string, any>): Promise<PageElement>; + updatePageElement(id: string, data: Record<string, any>): Promise<PageElement>; + deletePageElement(id: string): Promise<void>; + /** * Lifecycle events */ @@ -308,12 +353,14 @@ export interface PageElementsCrud { export interface OnCategoryBeforeCreateTopicParams { category: Category; } + /** * @category Lifecycle events */ export interface OnCategoryAfterCreateTopicParams { category: Category; } + /** * @category Lifecycle events */ @@ -321,6 +368,7 @@ export interface OnCategoryBeforeUpdateTopicParams { original: Category; category: Category; } + /** * @category Lifecycle events */ @@ -328,12 +376,14 @@ export interface OnCategoryAfterUpdateTopicParams { original: Category; category: Category; } + /** * @category Lifecycle events */ export interface OnCategoryBeforeDeleteTopicParams { category: Category; } + /** * @category Lifecycle events */ @@ -346,10 +396,15 @@ export interface OnCategoryAfterDeleteTopicParams { */ export interface CategoriesCrud { getCategory(slug: string, options?: { auth: boolean }): Promise<Category | null>; + listCategories(): Promise<Category[]>; + createCategory(data: PbCategoryInput): Promise<Category>; + updateCategory(slug: string, data: PbCategoryInput): Promise<Category>; + deleteCategory(slug: string): Promise<Category>; + onCategoryBeforeCreate: Topic<OnCategoryBeforeCreateTopicParams>; onCategoryAfterCreate: Topic<OnCategoryAfterCreateTopicParams>; onCategoryBeforeUpdate: Topic<OnCategoryBeforeUpdateTopicParams>; @@ -373,6 +428,7 @@ export interface OnMenuBeforeCreateTopicParams { menu: Menu; input: Record<string, any>; } + /** * @category Lifecycle events */ @@ -380,6 +436,7 @@ export interface OnMenuAfterCreateTopicParams { menu: Menu; input: Record<string, any>; } + /** * @category Lifecycle events */ @@ -387,6 +444,7 @@ export interface OnMenuBeforeUpdateTopicParams { original: Menu; menu: Menu; } + /** * @category Lifecycle events */ @@ -394,12 +452,14 @@ export interface OnMenuAfterUpdateTopicParams { original: Menu; menu: Menu; } + /** * @category Lifecycle events */ export interface OnMenuBeforeDeleteTopicParams { menu: Menu; } + /** * @category Lifecycle events */ @@ -412,17 +472,26 @@ interface CreateMenuInput { slug: string; description: string; items: any[]; + createdOn?: Date | string; + createdBy?: CreatedBy; } + /** * @category Menu */ export interface MenusCrud { getMenu(slug: string, options?: MenuGetOptions): Promise<Menu | null>; + getPublicMenu(slug: string): Promise<Menu>; + listMenus(params?: ListMenuParams): Promise<Menu[]>; + createMenu(data: CreateMenuInput): Promise<Menu>; + updateMenu(slug: string, data: Record<string, any>): Promise<Menu>; + deleteMenu(slug: string): Promise<Menu>; + onMenuBeforeCreate: Topic<OnMenuBeforeCreateTopicParams>; onMenuAfterCreate: Topic<OnMenuAfterCreateTopicParams>; onMenuBeforeUpdate: Topic<OnMenuBeforeUpdateTopicParams>; @@ -444,6 +513,7 @@ export interface SettingsUpdateTopicMetaParams { pages: [PageSpecialType, string | null | undefined, string, Page][]; }; } + /** * @category Lifecycle events */ @@ -452,6 +522,7 @@ export interface OnSettingsBeforeUpdateTopicParams { settings: Settings; meta: SettingsUpdateTopicMetaParams; } + /** * @category Lifecycle events */ @@ -484,20 +555,26 @@ export interface SettingsCrud { export interface OnSystemBeforeInstallTopicParams { tenant: string; } + /** * @category Lifecycle events */ export interface OnSystemAfterInstallTopicParams { tenant: string; } + /** * @category System */ export interface SystemCrud { getSystem: () => Promise<System | null>; + getSystemVersion(): Promise<string | null>; + setSystemVersion(version: string): Promise<void>; + installSystem(args: { name: string; insertDemoData: boolean }): Promise<void>; + /** * Lifecycle events */ @@ -518,12 +595,14 @@ export interface PbBlockCategoryInput { export interface OnBeforeBlockCategoryCreateTopicParams { blockCategory: BlockCategory; } + /** * @category Lifecycle events */ export interface OnAfterBlockCategoryCreateTopicParams { blockCategory: BlockCategory; } + /** * @category Lifecycle events */ @@ -531,6 +610,7 @@ export interface OnBeforeBlockCategoryUpdateTopicParams { original: BlockCategory; blockCategory: BlockCategory; } + /** * @category Lifecycle events */ @@ -538,12 +618,14 @@ export interface OnAfterBlockCategoryUpdateTopicParams { original: BlockCategory; blockCategory: BlockCategory; } + /** * @category Lifecycle events */ export interface OnBeforeBlockCategoryDeleteTopicParams { blockCategory: BlockCategory; } + /** * @category Lifecycle events */ @@ -556,10 +638,15 @@ export interface OnAfterBlockCategoryDeleteTopicParams { */ export interface BlockCategoriesCrud { getBlockCategory(slug: string, options?: { auth: boolean }): Promise<BlockCategory | null>; + listBlockCategories(): Promise<BlockCategory[]>; + createBlockCategory(data: PbBlockCategoryInput): Promise<BlockCategory>; + updateBlockCategory(slug: string, data: PbBlockCategoryInput): Promise<BlockCategory>; + deleteBlockCategory(slug: string): Promise<BlockCategory>; + /** * Lifecycle events */ @@ -577,6 +664,7 @@ export interface ListPageBlocksParams { blockCategory?: string; }; } + /** * @category Lifecycle events */ @@ -633,10 +721,15 @@ export type PageBlockUpdateInput = Partial<PageBlockCreateInput>; */ export interface PageBlocksCrud { getPageBlock(id: string): Promise<PageBlock | null>; + listPageBlocks(params?: ListPageBlocksParams): Promise<PageBlock[]>; + createPageBlock(data: PageBlockCreateInput): Promise<PageBlock>; + updatePageBlock(id: string, data: PageBlockUpdateInput): Promise<PageBlock>; + deletePageBlock(id: string): Promise<boolean>; + resolvePageBlocks(content: Record<string, any> | null): Promise<any>; /** @@ -653,6 +746,7 @@ export interface PageBlocksCrud { export interface ListPageTemplatesParams { sort?: string[]; } + /** * @category Lifecycle events */ @@ -742,17 +836,25 @@ export interface PageTemplatesCrud { auth: boolean; } ): Promise<PageTemplate | null>; + listPageTemplates(params?: ListPageTemplatesParams): Promise<PageTemplate[]>; + createPageTemplate(data: PageTemplateInput): Promise<PageTemplate>; + createPageFromTemplate(data: CreatePageFromTemplateParams): Promise<Page>; + createTemplateFromPage( pageId: string, data: Pick<PageTemplate, "title" | "description" | "slug"> ): Promise<PageTemplate>; + // Copy relevant data from page template to page instance, by reference. copyTemplateDataToPage(template: PageTemplate, page: Page): void; + updatePageTemplate(id: string, data: Record<string, any>): Promise<PageTemplate>; + deletePageTemplate(id: string): Promise<PageTemplate>; + resolvePageTemplate(content: PageContentWithTemplate): Promise<any>; /** @@ -878,6 +980,7 @@ export interface FlushParams { export interface PrerenderingHandlers { render(args: RenderParams): Promise<void>; + flush(args: FlushParams): Promise<void>; } @@ -886,6 +989,8 @@ export interface PbCategoryInput { slug: string; url: string; layout: string; + createdOn?: Date | string; + createdBy?: CreatedBy; } export interface PbUpdatePageInput extends DynamicDocument { @@ -895,3 +1000,19 @@ export interface PbUpdatePageInput extends DynamicDocument { settings?: PageSettings; content?: Record<string, any> | null; } + +export interface PbCreatePageV2Input extends DynamicDocument { + title?: string; + category?: string; + path?: string; + status?: PageStatus; + publishedOn?: Date | string; + settings?: PageSettings; + content?: Record<string, any> | null; + version?: number; + id?: string; + pid?: string; + createdOn?: Date | string; + createdBy?: CreatedBy; + ownedBy?: CreatedBy; +} diff --git a/packages/api-page-builder/src/prerendering/hooks/afterSettingsUpdate.ts b/packages/api-page-builder/src/prerendering/hooks/afterSettingsUpdate.ts index 3f22b5db150..9a01f0a8ae7 100644 --- a/packages/api-page-builder/src/prerendering/hooks/afterSettingsUpdate.ts +++ b/packages/api-page-builder/src/prerendering/hooks/afterSettingsUpdate.ts @@ -1,63 +1,16 @@ -import { PathItem, PbContext } from "~/graphql/types"; +import { PbContext } from "~/graphql/types"; import { ContextPlugin } from "@webiny/api"; export default () => { return new ContextPlugin<PbContext>(async ({ pageBuilder }) => { - pageBuilder.onSettingsAfterUpdate.subscribe(async params => { - const { settings, meta } = params; - if (!settings) { - return; - } - - /** - * If a change on pages settings (home, notFound) has been made, let's rerender accordingly. - */ - const toRender: PathItem[] = []; - - for (let i = 0; i < meta.diff.pages.length; i++) { - const [type, prevPageId, , page] = meta.diff.pages[i]; - switch (type) { - case "home": - toRender.push({ path: "/" }); - break; - case "notFound": - // Render the new "not found" page and store it into the NOT_FOUND_FOLDER. - toRender.push({ - path: page.path, - tags: [{ key: "notFoundPage", value: true }] - }); - - if (prevPageId) { - // Render the old "not found" page, to remove any notion of the "not found" concept - // from the snapshot, as well as the PS#RENDER record in the database. - const prevPage = await pageBuilder.getPublishedPageById({ - id: prevPageId - }); - - toRender.push({ path: prevPage.path }); - } - - break; - } - } - - // Render homepage/not-found page - if (toRender.length > 0) { - await pageBuilder.prerendering.render({ paths: toRender }); - } - - /** - * TODO: right now, on each update of settings, we trigger a complete site rebuild. - * This is from ideal, and we need to implement better checks if full rerender is necessary. - * Why full site rerender? Settings contain logo, favicon, site title, social stuff, and that's - * used on all pages. - */ + pageBuilder.onSettingsAfterUpdate.subscribe(async () => { + // On every update of settings, we trigger a full website prerendering. + // Might not be ideal for large websites, but it's a simple solution for now. await pageBuilder.prerendering.render({ // This flag is for backwards compatibility with the original custom queue implementation // using the "cron job" type of Lambda worker, executed periodically. queue: true, - // We want to rerender everything, but exclude homepage/not-found page, if they were changed. - paths: [{ path: "*", exclude: toRender.map(task => task.path) }] + paths: [{ path: "*" }] }); }); }); From 8ef22d350742cb0b25d1e946fe0fa76a87f93dbd Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Tue, 1 Apr 2025 11:37:13 +0200 Subject: [PATCH 39/52] fix(api-prerendering-service): add groupId to render event --- .../api-page-builder/src/prerendering/prerenderingHandlers.ts | 1 + .../api-prerendering-service-aws/src/render/subscriber.ts | 2 +- packages/api-prerendering-service/src/render/index.ts | 4 +++- packages/api-prerendering-service/src/types.ts | 2 ++ 4 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/api-page-builder/src/prerendering/prerenderingHandlers.ts b/packages/api-page-builder/src/prerendering/prerenderingHandlers.ts index b53958d60ec..7fd93b47026 100644 --- a/packages/api-page-builder/src/prerendering/prerenderingHandlers.ts +++ b/packages/api-page-builder/src/prerendering/prerenderingHandlers.ts @@ -20,6 +20,7 @@ export const prerenderingHandlers = new ContextPlugin<PbContext>(context => { const render = paths.map<RenderEvent>(item => ({ ...item, tenant, + groupId: tenant, locale: locale.code })); diff --git a/packages/api-prerendering-service-aws/src/render/subscriber.ts b/packages/api-prerendering-service-aws/src/render/subscriber.ts index 2182ee27ffc..aff36e40693 100644 --- a/packages/api-prerendering-service-aws/src/render/subscriber.ts +++ b/packages/api-prerendering-service-aws/src/render/subscriber.ts @@ -114,7 +114,7 @@ export default (params: HandlerConfig) => { * the database. This way we are sure that we don't store obsolete infrastructure information. */ toRender.push({ - groupId: render.tenant, + groupId: render.groupId ?? render.tenant, body: render }); } diff --git a/packages/api-prerendering-service/src/render/index.ts b/packages/api-prerendering-service/src/render/index.ts index a7de5ffbfcc..8981d291a77 100644 --- a/packages/api-prerendering-service/src/render/index.ts +++ b/packages/api-prerendering-service/src/render/index.ts @@ -56,7 +56,8 @@ export default (params: RenderParams) => { const settings = await storageOperations.getSettings(); for (const args of handlerArgs) { - const { tenant, path, locale } = args; + const { tenant, path, locale, groupId } = args; + console.log("Rendering item", args); const bucketRoot = isMultiTenant ? tenant : ""; @@ -118,6 +119,7 @@ export default (params: RenderParams) => { tenant, path, locale, + groupId: groupId ?? tenant, tags: args.tags, files: files.map(item => omit(item, ["body"])) }; diff --git a/packages/api-prerendering-service/src/types.ts b/packages/api-prerendering-service/src/types.ts index d0fd251fa9b..ece29e4f9d2 100644 --- a/packages/api-prerendering-service/src/types.ts +++ b/packages/api-prerendering-service/src/types.ts @@ -36,6 +36,7 @@ export interface Render { path: string; tenant: string; locale: string; + groupId: string; tags?: Tag[]; files: { name: string; @@ -171,6 +172,7 @@ export interface RenderEvent { path: string; tenant: string; locale: string; + groupId: string; exclude?: string[]; tags?: Tag[]; } From 2f2dbebb4bbf728ced489516d9a64296e0e27e23 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Wed, 2 Apr 2025 11:59:54 +0200 Subject: [PATCH 40/52] fix(app-page-builder): add missing render plugin loaders --- .../src/admin/plugins/routes.tsx | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/packages/app-page-builder/src/admin/plugins/routes.tsx b/packages/app-page-builder/src/admin/plugins/routes.tsx index 8cd18ad6eb2..dc3339deb89 100644 --- a/packages/app-page-builder/src/admin/plugins/routes.tsx +++ b/packages/app-page-builder/src/admin/plugins/routes.tsx @@ -90,10 +90,12 @@ const plugins: RoutePlugin[] = [ element={ <SecureRoute permission={ROLE_PB_PAGES}> <EditorPluginsLoader> - <Helmet title={"Page Builder - Edit page"} /> - <CompositionScope name={"pb.pageEditor"}> - <PageEditor /> - </CompositionScope> + <RenderPluginsLoader> + <Helmet title={"Page Builder - Edit page"} /> + <CompositionScope name={"pb.pageEditor"}> + <PageEditor /> + </CompositionScope> + </RenderPluginsLoader> </EditorPluginsLoader> </SecureRoute> } @@ -128,10 +130,12 @@ const plugins: RoutePlugin[] = [ element={ <SecureRoute permission={ROLE_PB_TEMPLATE}> <EditorPluginsLoader> - <Helmet title={"Page Builder - Edit template"} /> - <CompositionScope name={"pb.templateEditor"}> - <TemplateEditor /> - </CompositionScope> + <RenderPluginsLoader> + <Helmet title={"Page Builder - Edit template"} /> + <CompositionScope name={"pb.templateEditor"}> + <TemplateEditor /> + </CompositionScope> + </RenderPluginsLoader> </EditorPluginsLoader> </SecureRoute> } @@ -183,10 +187,12 @@ const plugins: RoutePlugin[] = [ element={ <SecureRoute permission={ROLE_PB_PAGES}> <EditorPluginsLoader> - <Helmet title={"Page Builder - Edit block"} /> - <CompositionScope name={"pb.blockEditor"}> - <BlockEditor /> - </CompositionScope> + <RenderPluginsLoader> + <Helmet title={"Page Builder - Edit block"} /> + <CompositionScope name={"pb.blockEditor"}> + <BlockEditor /> + </CompositionScope> + </RenderPluginsLoader> </EditorPluginsLoader> </SecureRoute> } From ac0a3bb8a57c0ba8962f7faaf8052e52a3e4822b Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Tue, 8 Apr 2025 12:32:01 +0200 Subject: [PATCH 41/52] fix(handler-graphql): add a reusable Error and BooleanResponse graphql types --- packages/handler-graphql/src/createGraphQLSchema.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/handler-graphql/src/createGraphQLSchema.ts b/packages/handler-graphql/src/createGraphQLSchema.ts index 5bb8045bfef..3d9d0df6ac0 100644 --- a/packages/handler-graphql/src/createGraphQLSchema.ts +++ b/packages/handler-graphql/src/createGraphQLSchema.ts @@ -45,6 +45,18 @@ export const createGraphQLSchema = (context: Context) => { # as a way to tell the Prerendering Service whether the GraphQL query needs to be # cached or not. directive @ps(cache: Boolean) on QUERY + + type Error { + code: String + message: String + data: JSON + stack: String + } + + type BooleanResponse { + data: Boolean + error: Error + } ` ]; From 72829f8f2e1d979ab8ad14ac4806f032521ff950 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Tue, 8 Apr 2025 12:33:17 +0200 Subject: [PATCH 42/52] fix(api-page-builder): add missing translations use cases --- ...eleteTranslatableCollectionUseCase.test.ts | 38 +++++ .../DeleteTranslatedCollectionUseCase.test.ts | 131 ++++++++++++++++++ .../src/translations/index.ts | 2 + .../graphql/resolvers.ts | 17 +++ .../translatableCollection/graphql/schema.ts | 4 +- .../DeleteTranslatableCollectionRepository.ts | 32 +++++ ...GetTranslatableCollectionByIdRepository.ts | 18 ++- .../DeleteTranslatableCollectionUseCase.ts | 20 +++ .../translatedCollection/graphql/resolvers.ts | 22 ++- .../translatedCollection/graphql/schema.ts | 2 + .../DeleteTranslatedCollectionRepository.ts | 40 ++++++ .../GetTranslatedCollectionRepository.ts | 20 +-- .../DeleteTranslatedCollectionUseCase.ts | 21 +++ 13 files changed, 349 insertions(+), 18 deletions(-) create mode 100644 packages/api-page-builder/__tests__/translations/translatableCollection/DeleteTranslatableCollectionUseCase.test.ts create mode 100644 packages/api-page-builder/__tests__/translations/translatedCollection/DeleteTranslatedCollectionUseCase.test.ts create mode 100644 packages/api-page-builder/src/translations/translatableCollection/repository/DeleteTranslatableCollectionRepository.ts create mode 100644 packages/api-page-builder/src/translations/translatableCollection/useCases/DeleteTranslatableCollectionUseCase.ts create mode 100644 packages/api-page-builder/src/translations/translatedCollection/repository/DeleteTranslatedCollectionRepository.ts create mode 100644 packages/api-page-builder/src/translations/translatedCollection/useCases/DeleteTranslatedCollectionUseCase.ts diff --git a/packages/api-page-builder/__tests__/translations/translatableCollection/DeleteTranslatableCollectionUseCase.test.ts b/packages/api-page-builder/__tests__/translations/translatableCollection/DeleteTranslatableCollectionUseCase.test.ts new file mode 100644 index 00000000000..b269ea28818 --- /dev/null +++ b/packages/api-page-builder/__tests__/translations/translatableCollection/DeleteTranslatableCollectionUseCase.test.ts @@ -0,0 +1,38 @@ +import { useHandler } from "~tests/translations/useHandler"; +import { + DeleteTranslatableCollectionUseCase, + GetTranslatableCollectionUseCase, + SaveTranslatableCollectionUseCase +} from "~/translations"; + +describe("DeleteTranslatableCollectionUseCase", () => { + it("should delete a collection", async () => { + const { handler } = useHandler(); + const context = await handler(); + + // Setup + const saveTranslatableCollection = new SaveTranslatableCollectionUseCase(context); + const newCollection = await saveTranslatableCollection.execute({ + collectionId: "collection:1", + items: [ + { itemId: "element:1", value: "Value 1" }, + { itemId: "element:2", value: "Value 2" } + ] + }); + + const getTranslatableCollection = new GetTranslatableCollectionUseCase(context); + const collection = await getTranslatableCollection.execute(newCollection.getCollectionId()); + + expect(collection).toBeTruthy(); + expect(collection!.getCollectionId()).toEqual(newCollection.getCollectionId()); + + // Test + const deleteTranslatableCollection = new DeleteTranslatableCollectionUseCase(context); + await deleteTranslatableCollection.execute({ + collectionId: "collection:1" + }); + + const checkCollection = await getTranslatableCollection.execute("collection:1"); + expect(checkCollection).toBeUndefined(); + }); +}); diff --git a/packages/api-page-builder/__tests__/translations/translatedCollection/DeleteTranslatedCollectionUseCase.test.ts b/packages/api-page-builder/__tests__/translations/translatedCollection/DeleteTranslatedCollectionUseCase.test.ts new file mode 100644 index 00000000000..d0192645444 --- /dev/null +++ b/packages/api-page-builder/__tests__/translations/translatedCollection/DeleteTranslatedCollectionUseCase.test.ts @@ -0,0 +1,131 @@ +import { useHandler } from "~tests/translations/useHandler"; +import { + SaveTranslatableCollectionUseCase, + SaveTranslatableCollectionParams, + SaveTranslatedCollectionUseCase, + DeleteTranslatedCollectionUseCase, + GetTranslatedCollectionUseCase +} from "~/translations"; +import { PbContext } from "~/graphql/types"; + +const createTranslatableCollection = async ( + context: PbContext, + params: SaveTranslatableCollectionParams +) => { + const saveCollection = new SaveTranslatableCollectionUseCase(context); + await saveCollection.execute(params); +}; + +describe("DeleteTranslatedCollectionUseCase", () => { + it("should delete an entire collection with all translations", async () => { + const { handler } = useHandler(); + const context = await handler(); + + // Setup + await createTranslatableCollection(context, { + collectionId: "collection:1", + items: [ + { itemId: "element:1", value: "Value 1" }, + { itemId: "element:2", value: "Value 2" }, + { itemId: "element:3", value: "Value 3" } + ] + }); + + const saveTranslatedCollection = new SaveTranslatedCollectionUseCase(context); + await saveTranslatedCollection.execute({ + collectionId: "collection:1", + languageCode: "en", + items: [ + { itemId: "element:1", value: "Translated Value 1 EN" }, + { itemId: "element:2", value: "Translated Value 2 EN" } + ] + }); + + await saveTranslatedCollection.execute({ + collectionId: "collection:1", + languageCode: "de", + items: [ + { itemId: "element:1", value: "Translated Value 1 DE" }, + { itemId: "element:2", value: "Translated Value 2 DE" } + ] + }); + + // Test + const deleteTranslatedCollection = new DeleteTranslatedCollectionUseCase(context); + await deleteTranslatedCollection.execute({ collectionId: "collection:1" }); + + const getTranslatedCollection = new GetTranslatedCollectionUseCase(context); + + await expect( + getTranslatedCollection.execute({ + collectionId: "collection:1", + languageCode: "en" + }) + ).rejects.toThrow("not found"); + + await expect( + getTranslatedCollection.execute({ + collectionId: "collection:1", + languageCode: "de" + }) + ).rejects.toThrow("not found"); + }); + + it("should delete a collection for a given language", async () => { + const { handler } = useHandler(); + const context = await handler(); + + // Setup + await createTranslatableCollection(context, { + collectionId: "collection:1", + items: [ + { itemId: "element:1", value: "Value 1" }, + { itemId: "element:2", value: "Value 2" }, + { itemId: "element:3", value: "Value 3" } + ] + }); + + const saveTranslatedCollection = new SaveTranslatedCollectionUseCase(context); + await saveTranslatedCollection.execute({ + collectionId: "collection:1", + languageCode: "en", + items: [ + { itemId: "element:1", value: "Translated Value 1 EN" }, + { itemId: "element:2", value: "Translated Value 2 EN" } + ] + }); + + await saveTranslatedCollection.execute({ + collectionId: "collection:1", + languageCode: "de", + items: [ + { itemId: "element:1", value: "Translated Value 1 DE" }, + { itemId: "element:2", value: "Translated Value 2 DE" } + ] + }); + + // Test + const deleteTranslatedCollection = new DeleteTranslatedCollectionUseCase(context); + await deleteTranslatedCollection.execute({ + collectionId: "collection:1", + languageCode: "en" + }); + + const getTranslatedCollection = new GetTranslatedCollectionUseCase(context); + + await expect( + getTranslatedCollection.execute({ + collectionId: "collection:1", + languageCode: "en" + }) + ).rejects.toThrow("not found"); + + const deCollection = await getTranslatedCollection.execute({ + collectionId: "collection:1", + languageCode: "de" + }); + + expect(deCollection.getCollectionId()).toBe("collection:1"); + expect(deCollection.getLanguageCode()).toBe("de"); + }); +}); diff --git a/packages/api-page-builder/src/translations/index.ts b/packages/api-page-builder/src/translations/index.ts index b37419f089b..cecc7873775 100644 --- a/packages/api-page-builder/src/translations/index.ts +++ b/packages/api-page-builder/src/translations/index.ts @@ -3,9 +3,11 @@ export * from "./translatableCollection/useCases/GetTranslatableCollectionUseCas export * from "./translatableCollection/useCases/SaveTranslatableCollectionUseCase"; export * from "./translatableCollection/useCases/GetOrCreateTranslatableCollectionUseCase"; export * from "./translatableCollection/useCases/CloneTranslatableCollectionUseCase"; +export * from "./translatableCollection/useCases/DeleteTranslatableCollectionUseCase"; // TranslatedCollection export * from "./translatedCollection/useCases/GetTranslatedCollectionUseCase"; export * from "./translatedCollection/useCases/CloneTranslatedCollectionUseCase"; export * from "./translatedCollection/useCases/SaveTranslatedCollectionUseCase"; export * from "./translatedCollection/useCases/GetOrCreateTranslatedCollectionUseCase"; +export * from "./translatedCollection/useCases/DeleteTranslatedCollectionUseCase"; diff --git a/packages/api-page-builder/src/translations/translatableCollection/graphql/resolvers.ts b/packages/api-page-builder/src/translations/translatableCollection/graphql/resolvers.ts index 4d0268f1fec..6eb878e6acf 100644 --- a/packages/api-page-builder/src/translations/translatableCollection/graphql/resolvers.ts +++ b/packages/api-page-builder/src/translations/translatableCollection/graphql/resolvers.ts @@ -5,12 +5,17 @@ import { SaveTranslatableCollectionUseCase } from "~/translations/translatableCo import type { GqlTranslatableItemDTO } from "~/translations/translatableCollection/graphql/GqlTranslatableItemDTO"; import { GetTranslatableCollectionByIdRepository } from "~/translations/translatableCollection/repository/GetTranslatableCollectionByIdRepository"; import { GqlTranslatableCollectionMapper } from "~/translations/translatableCollection/graphql/GqlTranslatableCollectionMapper"; +import { DeleteTranslatableCollectionUseCase } from "~/translations"; interface UpdateTranslatableCollectionParams { collectionId: string; items: GqlTranslatableItemDTO[]; } +interface DeleteTranslatableCollectionParams { + collectionId: string; +} + export const translatableCollectionResolvers: Resolvers<PbContext> = { TranslationsQuery: { getTranslatableCollection: async (_, args, context) => { @@ -39,6 +44,18 @@ export const translatableCollectionResolvers: Resolvers<PbContext> = { } catch (err) { return new ErrorResponse(err); } + }, + deleteTranslatableCollection: async (_, args, context) => { + const { collectionId } = args as DeleteTranslatableCollectionParams; + + try { + const useCase = new DeleteTranslatableCollectionUseCase(context); + await useCase.execute({ collectionId }); + + return new Response(true); + } catch (err) { + return new ErrorResponse(err); + } } } }; diff --git a/packages/api-page-builder/src/translations/translatableCollection/graphql/schema.ts b/packages/api-page-builder/src/translations/translatableCollection/graphql/schema.ts index debbe433dd4..ff3c271c7a4 100644 --- a/packages/api-page-builder/src/translations/translatableCollection/graphql/schema.ts +++ b/packages/api-page-builder/src/translations/translatableCollection/graphql/schema.ts @@ -28,7 +28,7 @@ export const translatableCollectionSchema = /* GraphQL*/ ` data: TranslatableCollection error: PbError } - + extend type TranslationsQuery { """Get the source collection with all the items that need to be translated.""" getTranslatableCollection(collectionId: ID!): TranslatableCollectionResponse @@ -39,5 +39,7 @@ export const translatableCollectionSchema = /* GraphQL*/ ` collectionId: ID! items: [TranslatableItemInput!]! ): SaveTranslatableCollectionResponse + + deleteTranslatableCollection(collectionId: ID!): BooleanResponse } `; diff --git a/packages/api-page-builder/src/translations/translatableCollection/repository/DeleteTranslatableCollectionRepository.ts b/packages/api-page-builder/src/translations/translatableCollection/repository/DeleteTranslatableCollectionRepository.ts new file mode 100644 index 00000000000..87ff8cfdaf3 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/repository/DeleteTranslatableCollectionRepository.ts @@ -0,0 +1,32 @@ +import { PbContext } from "~/types"; +import { GetModel } from "~/translations/GetModel"; +import { TranslatableCollectionDTO } from "./mappers/TranslatableCollectionDTO"; + +export class DeleteTranslatableCollectionRepository { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute(collectionId: string): Promise<void> { + const model = await GetModel.byModelId(this.context, "translatableCollection"); + + // `cms.getEntry` throws an error if an entry is not found. + try { + const existingEntry = await this.context.cms.getEntry<TranslatableCollectionDTO>( + model, + { + where: { collectionId, latest: true } + } + ); + + await this.context.cms.deleteEntry(model, existingEntry.entryId, { permanently: true }); + } catch { + // If a record doesn't exist, then there's nothing to delete, and we can exit. + console.log( + `[DeleteTranslatableCollectionRepository]: Collection doesn't exist: ${collectionId}` + ); + } + } +} diff --git a/packages/api-page-builder/src/translations/translatableCollection/repository/GetTranslatableCollectionByIdRepository.ts b/packages/api-page-builder/src/translations/translatableCollection/repository/GetTranslatableCollectionByIdRepository.ts index b743baf3787..291ba8304de 100644 --- a/packages/api-page-builder/src/translations/translatableCollection/repository/GetTranslatableCollectionByIdRepository.ts +++ b/packages/api-page-builder/src/translations/translatableCollection/repository/GetTranslatableCollectionByIdRepository.ts @@ -15,17 +15,23 @@ export class GetTranslatableCollectionByIdRepository { async execute(collectionId: string): Promise<TranslatableCollection> { const model = await GetModel.byModelId(this.context, "translatableCollection"); - const existingEntry = await this.context.cms.getEntry<TranslatableCollectionDTO>(model, { - where: { collectionId, latest: true } - }); + try { + const existingEntry = await this.context.cms.getEntry<TranslatableCollectionDTO>( + model, + { + where: { collectionId, latest: true } + } + ); - if (!existingEntry) { + return TranslatableCollectionMapper.fromDTO( + existingEntry.values, + existingEntry.entryId + ); + } catch { throw new WebinyError({ message: `TranslatableCollection "${collectionId}" not found!`, code: "NOT_FOUND" }); } - - return TranslatableCollectionMapper.fromDTO(existingEntry.values, existingEntry.entryId); } } diff --git a/packages/api-page-builder/src/translations/translatableCollection/useCases/DeleteTranslatableCollectionUseCase.ts b/packages/api-page-builder/src/translations/translatableCollection/useCases/DeleteTranslatableCollectionUseCase.ts new file mode 100644 index 00000000000..b5c12fe05a0 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatableCollection/useCases/DeleteTranslatableCollectionUseCase.ts @@ -0,0 +1,20 @@ +import { PbContext } from "~/graphql/types"; +import { DeleteTranslatableCollectionRepository } from "~/translations/translatableCollection/repository/DeleteTranslatableCollectionRepository"; + +export interface DeleteTranslatableCollectionParams { + collectionId: string; +} + +export class DeleteTranslatableCollectionUseCase { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute(params: DeleteTranslatableCollectionParams): Promise<void> { + const deleteRepository = new DeleteTranslatableCollectionRepository(this.context); + + await deleteRepository.execute(params.collectionId); + } +} diff --git a/packages/api-page-builder/src/translations/translatedCollection/graphql/resolvers.ts b/packages/api-page-builder/src/translations/translatedCollection/graphql/resolvers.ts index 0717d5c989a..bb56d13fe5e 100644 --- a/packages/api-page-builder/src/translations/translatedCollection/graphql/resolvers.ts +++ b/packages/api-page-builder/src/translations/translatedCollection/graphql/resolvers.ts @@ -4,13 +4,21 @@ import type { PbContext } from "~/graphql/types"; import { GqlTranslatedCollectionMapper } from "~/translations/translatedCollection/graphql/mappers/GqlTranslatedCollectionMapper"; import { SaveTranslatedCollectionUseCase } from "~/translations/translatedCollection/useCases/SaveTranslatedCollectionUseCase"; import { GetOrCreateTranslatedCollectionUseCase } from "~/translations/translatedCollection/useCases/GetOrCreateTranslatedCollectionUseCase"; -import { GetTranslatableCollectionUseCase } from "~/translations"; +import { + DeleteTranslatedCollectionUseCase, + GetTranslatableCollectionUseCase +} from "~/translations"; interface GetTranslatedCollectionParams { collectionId: string; languageCode: string; } +interface DeleteTranslatedCollectionParams { + collectionId: string; + languageCode?: string; +} + interface UpdateTranslatedCollectionParams { collectionId: string; languageCode: string; @@ -76,6 +84,18 @@ export const translatedCollectionResolvers: Resolvers<PbContext> = { } catch (err) { return new ErrorResponse(err); } + }, + deleteTranslatedCollection: async (_, args, context) => { + const { collectionId, languageCode } = args as DeleteTranslatedCollectionParams; + + try { + const useCase = new DeleteTranslatedCollectionUseCase(context); + await useCase.execute({ collectionId, languageCode }); + + return new Response(true); + } catch (err) { + return new ErrorResponse(err); + } } } }; diff --git a/packages/api-page-builder/src/translations/translatedCollection/graphql/schema.ts b/packages/api-page-builder/src/translations/translatedCollection/graphql/schema.ts index ca4cdc5a48d..d52189ed203 100644 --- a/packages/api-page-builder/src/translations/translatedCollection/graphql/schema.ts +++ b/packages/api-page-builder/src/translations/translatedCollection/graphql/schema.ts @@ -41,5 +41,7 @@ export const translatedCollectionSchema = /* GraphQL*/ ` languageCode: String! items: [TranslatedItemInput!]! ): SaveTranslatedCollectionResponse + + deleteTranslatedCollection(collectionId: ID!, languageCode: String): BooleanResponse } `; diff --git a/packages/api-page-builder/src/translations/translatedCollection/repository/DeleteTranslatedCollectionRepository.ts b/packages/api-page-builder/src/translations/translatedCollection/repository/DeleteTranslatedCollectionRepository.ts new file mode 100644 index 00000000000..18f15b16120 --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/repository/DeleteTranslatedCollectionRepository.ts @@ -0,0 +1,40 @@ +import { PbContext } from "~/types"; +import { GetModel } from "~/translations/GetModel"; +import { TranslatedCollectionDTO } from "~/translations/translatedCollection/repository/mappers/TranslatedCollectionDTO"; + +export interface DeleteTranslatedCollectionParams { + collectionId: string; + languageCode?: string; +} + +export class DeleteTranslatedCollectionRepository { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute(params: DeleteTranslatedCollectionParams): Promise<void> { + const model = await GetModel.byModelId(this.context, "translatedCollection"); + + const filter: DeleteTranslatedCollectionParams = { + collectionId: params.collectionId + }; + + if (params.languageCode) { + filter.languageCode = params.languageCode; + } + + const [entries] = await this.context.cms.listEntries<TranslatedCollectionDTO>(model, { + where: { latest: true, ...filter } + }); + + await Promise.all( + entries.map(entry => { + return this.context.cms.deleteEntry(model, entry.entryId, { + permanently: true + }); + }) + ); + } +} diff --git a/packages/api-page-builder/src/translations/translatedCollection/repository/GetTranslatedCollectionRepository.ts b/packages/api-page-builder/src/translations/translatedCollection/repository/GetTranslatedCollectionRepository.ts index 692271ee71a..43fcca7acde 100644 --- a/packages/api-page-builder/src/translations/translatedCollection/repository/GetTranslatedCollectionRepository.ts +++ b/packages/api-page-builder/src/translations/translatedCollection/repository/GetTranslatedCollectionRepository.ts @@ -20,21 +20,21 @@ export class GetTranslatedCollectionRepository { async execute(params: GetTranslatedCollectionParams): Promise<TranslatedCollection> { const model = await GetModel.byModelId(this.context, "translatedCollection"); - const existingEntry = await this.context.cms.getEntry<TranslatedCollectionDTO>(model, { - where: { - collectionId: params.collectionId, - languageCode: params.languageCode, - latest: true - } - }); + try { + const existingEntry = await this.context.cms.getEntry<TranslatedCollectionDTO>(model, { + where: { + collectionId: params.collectionId, + languageCode: params.languageCode, + latest: true + } + }); - if (!existingEntry) { + return TranslatedCollectionMapper.fromDTO(existingEntry.values, existingEntry.entryId); + } catch { throw new WebinyError({ message: `TranslatedCollection "${params.collectionId}" for language "${params.languageCode}" was not found!`, code: "NOT_FOUND" }); } - - return TranslatedCollectionMapper.fromDTO(existingEntry.values, existingEntry.entryId); } } diff --git a/packages/api-page-builder/src/translations/translatedCollection/useCases/DeleteTranslatedCollectionUseCase.ts b/packages/api-page-builder/src/translations/translatedCollection/useCases/DeleteTranslatedCollectionUseCase.ts new file mode 100644 index 00000000000..3257a89d16a --- /dev/null +++ b/packages/api-page-builder/src/translations/translatedCollection/useCases/DeleteTranslatedCollectionUseCase.ts @@ -0,0 +1,21 @@ +import { PbContext } from "~/graphql/types"; +import { DeleteTranslatedCollectionRepository } from "~/translations/translatedCollection/repository/DeleteTranslatedCollectionRepository"; + +export interface DeleteTranslatedCollectionParams { + collectionId: string; + languageCode?: string; +} + +export class DeleteTranslatedCollectionUseCase { + private readonly context: PbContext; + + constructor(context: PbContext) { + this.context = context; + } + + async execute(params: DeleteTranslatedCollectionParams): Promise<void> { + const deleteRepository = new DeleteTranslatedCollectionRepository(this.context); + + await deleteRepository.execute(params); + } +} From ff32ec70c970d7a06a90b82fc855e78d6cf8cf4d Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Tue, 8 Apr 2025 13:48:50 +0200 Subject: [PATCH 43/52] fix(serverless-cms-aws): add control for full website render --- .../src/createWebsiteApp.ts | 6 +- .../src/enterprise/createWebsiteApp.ts | 6 +- .../src/website/plugins/renderWebsite.ts | 109 ++++++++++-------- 3 files changed, 72 insertions(+), 49 deletions(-) diff --git a/packages/serverless-cms-aws/src/createWebsiteApp.ts b/packages/serverless-cms-aws/src/createWebsiteApp.ts index 0d715106f75..d699d98cc35 100644 --- a/packages/serverless-cms-aws/src/createWebsiteApp.ts +++ b/packages/serverless-cms-aws/src/createWebsiteApp.ts @@ -5,11 +5,13 @@ import { lambdaEdgeWarning, renderWebsite, telemetryNoLongerNewUser, - ensureApiDeployedBeforeBuild + ensureApiDeployedBeforeBuild, + IRenderWebsiteParams } from "./website/plugins"; import { uploadAppToS3 } from "./react/plugins"; export interface CreateWebsiteAppParams extends CreateWebsitePulumiAppParams { + renderWebsiteAfterDeploy?: (params: IRenderWebsiteParams) => boolean; plugins?: PluginCollection; } @@ -18,7 +20,7 @@ export function createWebsiteApp(projectAppParams: CreateWebsiteAppParams = {}) uploadAppToS3({ folder: "apps/website" }), generateCommonHandlers, lambdaEdgeWarning, - renderWebsite, + renderWebsite({ prerender: projectAppParams.renderWebsiteAfterDeploy ?? (() => true) }), telemetryNoLongerNewUser, ensureApiDeployedBeforeBuild ]; diff --git a/packages/serverless-cms-aws/src/enterprise/createWebsiteApp.ts b/packages/serverless-cms-aws/src/enterprise/createWebsiteApp.ts index 792be9a07ce..d8aa9adc225 100644 --- a/packages/serverless-cms-aws/src/enterprise/createWebsiteApp.ts +++ b/packages/serverless-cms-aws/src/enterprise/createWebsiteApp.ts @@ -8,11 +8,13 @@ import { lambdaEdgeWarning, renderWebsite, telemetryNoLongerNewUser, - ensureApiDeployedBeforeBuild + ensureApiDeployedBeforeBuild, + IRenderWebsiteParams } from "~/website/plugins"; import { uploadAppToS3 } from "~/react/plugins"; export interface CreateWebsiteAppParams extends CreateWebsitePulumiAppParams { + renderWebsiteAfterDeploy?: (params: IRenderWebsiteParams) => boolean; plugins?: PluginCollection; } @@ -21,7 +23,7 @@ export function createWebsiteApp(projectAppParams: CreateWebsiteAppParams = {}) uploadAppToS3({ folder: "apps/website" }), generateCommonHandlers, lambdaEdgeWarning, - renderWebsite, + renderWebsite({ prerender: projectAppParams.renderWebsiteAfterDeploy ?? (() => true) }), telemetryNoLongerNewUser, ensureApiDeployedBeforeBuild ]; diff --git a/packages/serverless-cms-aws/src/website/plugins/renderWebsite.ts b/packages/serverless-cms-aws/src/website/plugins/renderWebsite.ts index 1961e1707b5..108f5b0f04c 100644 --- a/packages/serverless-cms-aws/src/website/plugins/renderWebsite.ts +++ b/packages/serverless-cms-aws/src/website/plugins/renderWebsite.ts @@ -2,58 +2,77 @@ import { EventBridgeClient, PutEventsCommand } from "@webiny/aws-sdk/client-even import { CliContext } from "@webiny/cli/types"; import { getStackOutput } from "@webiny/cli-plugin-deploy-pulumi/utils"; +export interface IRenderWebsiteParams { + env: string; + inputs: IRenderWebsiteParamsInputs; +} + +export interface IRenderWebsiteParamsInputs { + preview?: boolean; + build?: boolean; +} + +interface RenderWebsiteParams { + prerender: (params: IRenderWebsiteParams) => boolean; +} + /** * On every deployment of the Website project application, this plugin ensures all pages created * with the Webiny Page Builder application are re-rendered. */ -export const renderWebsite = { - type: "hook-after-deploy", - name: "hook-after-deploy-website-render", - async hook(params: Record<string, any>, context: CliContext) { - if (params.inputs.build === false) { - context.info(`"--no-build" argument detected - skipping Website re-rendering.`); - return; - } +export const renderWebsite = (renderWebsiteParams: RenderWebsiteParams) => { + return { + type: "hook-after-deploy", + name: "hook-after-deploy-website-render", + async hook(params: IRenderWebsiteParams, context: CliContext) { + if (params.inputs.build === false) { + context.info(`"--no-build" argument detected - skipping Website re-rendering.`); + return; + } - // No need to re-render the website if we're doing a preview. - if (params.inputs.preview) { - return; - } + // No need to re-render the website if we're doing a preview. + if (params.inputs.preview) { + return; + } - const coreOutput = getStackOutput({ folder: "apps/core", env: params.env }); - - context.info("Issuing a complete website render job..."); - - try { - const client = new EventBridgeClient({ region: coreOutput["region"] }); - - const result = await client.send( - new PutEventsCommand({ - Entries: [ - { - Source: "webiny-cli", - EventBusName: coreOutput["eventBusArn"], - DetailType: "RenderPages", - Detail: JSON.stringify({ path: "*", tenant: "*" }) - } - ] - }) - ); - - const entry = result.Entries?.[0]; - if (entry?.ErrorMessage) { - throw new Error(entry.ErrorMessage); + if (!renderWebsiteParams.prerender(params)) { + context.info("Skipping complete website rendering."); + return; } - context.success("Website re-render job successfully issued."); - context.info( - "Please note that it can take a couple of minutes for the website to be fully updated." - ); - } catch (e) { - context.error( - `An error occurred while trying to update default Page Builder app's settings!` - ); - console.log(e); + const coreOutput = getStackOutput({ folder: "apps/core", env: params.env }); + + context.info("Issuing a complete website rendering job..."); + + try { + const client = new EventBridgeClient({ region: coreOutput["region"] }); + + const result = await client.send( + new PutEventsCommand({ + Entries: [ + { + Source: "webiny-cli", + EventBusName: coreOutput["eventBusArn"], + DetailType: "RenderPages", + Detail: JSON.stringify({ path: "*", tenant: "*" }) + } + ] + }) + ); + + const entry = result.Entries?.[0]; + if (entry?.ErrorMessage) { + throw new Error(entry.ErrorMessage); + } + + context.success("Website rendering job successfully issued."); + context.info( + "Please note that it can take a couple of minutes for the website to be fully updated." + ); + } catch (e) { + context.error(`An error occurred while issuing a website rendering job!`); + console.log(e); + } } - } + }; }; From ef80a269b14813520c9cb8485461184582fa169c Mon Sep 17 00:00:00 2001 From: Adrian Smijulj <adrian1358@gmail.com> Date: Fri, 11 Apr 2025 06:16:45 +0200 Subject: [PATCH 44/52] fix: add `PbEditorPageElementGroupPlugin` React plugin (#4598) --- .../PbEditorPageElementGroupPlugin.tsx | 26 +++++++++++++++++++ .../app-page-builder/src/plugins/index.ts | 1 + 2 files changed, 27 insertions(+) create mode 100644 packages/app-page-builder/src/plugins/PbEditorPageElementGroupPlugin.tsx diff --git a/packages/app-page-builder/src/plugins/PbEditorPageElementGroupPlugin.tsx b/packages/app-page-builder/src/plugins/PbEditorPageElementGroupPlugin.tsx new file mode 100644 index 00000000000..f00e9f71c1e --- /dev/null +++ b/packages/app-page-builder/src/plugins/PbEditorPageElementGroupPlugin.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { legacyPluginToReactComponent } from "@webiny/app/utils"; + +interface PbEditorPageElementGroupPlugin { + name: string; + title: string; + icon: React.ReactNode; + emptyView?: React.ReactNode; +} + +export const PbEditorPageElementGroupPlugin = + legacyPluginToReactComponent<PbEditorPageElementGroupPlugin>({ + pluginType: "pb-editor-page-element-group", + componentDisplayName: "PbEditorPageElementGroupPlugin", + mapProps: props => { + const { title, icon, emptyView } = props; + return { + ...props, + group: { + title, + icon, + emptyView + } + }; + } + }); diff --git a/packages/app-page-builder/src/plugins/index.ts b/packages/app-page-builder/src/plugins/index.ts index bef3be129d8..a55f7e15f46 100644 --- a/packages/app-page-builder/src/plugins/index.ts +++ b/packages/app-page-builder/src/plugins/index.ts @@ -1,4 +1,5 @@ export * from "./PbPageLayoutPlugin"; export * from "./PbEditorPageElementAdvancedSettingsPlugin"; export * from "./PbRenderElementPlugin"; +export * from "./PbEditorPageElementGroupPlugin"; export * from "./PbEditorPageElementPlugin"; From 4669af7399c67e5dd9039ad83ac1c0cbeef7cc38 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Fri, 11 Apr 2025 11:18:49 +0200 Subject: [PATCH 45/52] fix(lexical-theme): add explicit theme.css imports (#4596) --- packages/api-file-manager-aco/tsconfig.build.json | 2 +- packages/api-file-manager-aco/tsconfig.json | 6 +++--- packages/app-serverless-cms/package.json | 8 ++++++++ packages/app-serverless-cms/src/styles.scss | 3 +++ packages/app-serverless-cms/tsconfig.build.json | 1 + packages/app-serverless-cms/tsconfig.json | 3 +++ packages/app-website/package.json | 2 ++ packages/app-website/src/styles.scss | 1 + packages/app-website/tsconfig.build.json | 1 + packages/app-website/tsconfig.json | 3 +++ packages/lexical-theme/src/createTheme.ts | 2 -- yarn.lock | 2 ++ 12 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/api-file-manager-aco/tsconfig.build.json b/packages/api-file-manager-aco/tsconfig.build.json index 000aa8550e1..513919c0630 100644 --- a/packages/api-file-manager-aco/tsconfig.build.json +++ b/packages/api-file-manager-aco/tsconfig.build.json @@ -2,8 +2,8 @@ "extends": "../../tsconfig.build.json", "include": ["src"], "references": [ - { "path": "../api/tsconfig.build.json" }, { "path": "../api-aco/tsconfig.build.json" }, + { "path": "../api/tsconfig.build.json" }, { "path": "../api-admin-users/tsconfig.build.json" }, { "path": "../api-headless-cms/tsconfig.build.json" }, { "path": "../api-i18n/tsconfig.build.json" }, diff --git a/packages/api-file-manager-aco/tsconfig.json b/packages/api-file-manager-aco/tsconfig.json index 54c861d5290..cbea3636d1d 100644 --- a/packages/api-file-manager-aco/tsconfig.json +++ b/packages/api-file-manager-aco/tsconfig.json @@ -2,8 +2,8 @@ "extends": "../../tsconfig.json", "include": ["src", "__tests__"], "references": [ - { "path": "../api" }, { "path": "../api-aco" }, + { "path": "../api" }, { "path": "../api-admin-users" }, { "path": "../api-headless-cms" }, { "path": "../api-i18n" }, @@ -23,10 +23,10 @@ "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"], - "@webiny/api/*": ["../api/src/*"], - "@webiny/api": ["../api/src"], "@webiny/api-aco/*": ["../api-aco/src/*"], "@webiny/api-aco": ["../api-aco/src"], + "@webiny/api/*": ["../api/src/*"], + "@webiny/api": ["../api/src"], "@webiny/api-admin-users/*": ["../api-admin-users/src/*"], "@webiny/api-admin-users": ["../api-admin-users/src"], "@webiny/api-headless-cms/*": ["../api-headless-cms/src/*"], diff --git a/packages/app-serverless-cms/package.json b/packages/app-serverless-cms/package.json index 82af93bcb86..4d6673c6fe8 100644 --- a/packages/app-serverless-cms/package.json +++ b/packages/app-serverless-cms/package.json @@ -35,6 +35,7 @@ "@webiny/app-websockets": "0.0.0", "@webiny/lexical-editor-actions": "0.0.0", "@webiny/lexical-editor-pb-element": "0.0.0", + "@webiny/lexical-theme": "0.0.0", "@webiny/plugins": "0.0.0", "apollo-cache": "^1.3.5", "apollo-client": "^2.6.10", @@ -64,5 +65,12 @@ "plugins": { "removeViewBox": false } + }, + "adio": { + "ignore": { + "dependencies": [ + "@webiny/lexical-theme" + ] + } } } diff --git a/packages/app-serverless-cms/src/styles.scss b/packages/app-serverless-cms/src/styles.scss index d567de7b819..7e5427e0aa0 100644 --- a/packages/app-serverless-cms/src/styles.scss +++ b/packages/app-serverless-cms/src/styles.scss @@ -7,3 +7,6 @@ @import "~@webiny/app-admin/components/RichTextEditor/tools/paragraph/styles.scss"; @import "~@webiny/app-admin/components/RichTextEditor/tools/textColor/styles.scss"; @import "~@webiny/app-admin/components/RichTextEditor/tools/image/styles.scss"; + +// Import Lexical theme styles +@import "~@webiny/lexical-theme/theme.css"; diff --git a/packages/app-serverless-cms/tsconfig.build.json b/packages/app-serverless-cms/tsconfig.build.json index 421e090f48e..586718b1737 100644 --- a/packages/app-serverless-cms/tsconfig.build.json +++ b/packages/app-serverless-cms/tsconfig.build.json @@ -27,6 +27,7 @@ { "path": "../app-websockets/tsconfig.build.json" }, { "path": "../lexical-editor-actions/tsconfig.build.json" }, { "path": "../lexical-editor-pb-element/tsconfig.build.json" }, + { "path": "../lexical-theme/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" } ], "compilerOptions": { diff --git a/packages/app-serverless-cms/tsconfig.json b/packages/app-serverless-cms/tsconfig.json index 3838a1c5e85..4602cd7c4a0 100644 --- a/packages/app-serverless-cms/tsconfig.json +++ b/packages/app-serverless-cms/tsconfig.json @@ -27,6 +27,7 @@ { "path": "../app-websockets" }, { "path": "../lexical-editor-actions" }, { "path": "../lexical-editor-pb-element" }, + { "path": "../lexical-theme" }, { "path": "../plugins" } ], "compilerOptions": { @@ -86,6 +87,8 @@ "@webiny/lexical-editor-actions": ["../lexical-editor-actions/src"], "@webiny/lexical-editor-pb-element/*": ["../lexical-editor-pb-element/src/*"], "@webiny/lexical-editor-pb-element": ["../lexical-editor-pb-element/src"], + "@webiny/lexical-theme/*": ["../lexical-theme/src/*"], + "@webiny/lexical-theme": ["../lexical-theme/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"] }, diff --git a/packages/app-website/package.json b/packages/app-website/package.json index 8069015539e..eab92000de3 100644 --- a/packages/app-website/package.json +++ b/packages/app-website/package.json @@ -19,6 +19,7 @@ "@webiny/app-page-builder": "0.0.0", "@webiny/app-page-builder-elements": "0.0.0", "@webiny/app-theme": "0.0.0", + "@webiny/lexical-theme": "0.0.0", "@webiny/plugins": "0.0.0", "@webiny/react-router": "0.0.0", "apollo-client": "^2.6.10", @@ -48,6 +49,7 @@ "adio": { "ignore": { "dependencies": [ + "@webiny/lexical-theme", "reset-css" ] } diff --git a/packages/app-website/src/styles.scss b/packages/app-website/src/styles.scss index 974f4c0e466..3ee8c038df9 100644 --- a/packages/app-website/src/styles.scss +++ b/packages/app-website/src/styles.scss @@ -1 +1,2 @@ @import "./styles/reset"; +@import "~@webiny/lexical-theme/theme.css"; diff --git a/packages/app-website/tsconfig.build.json b/packages/app-website/tsconfig.build.json index 64363676bf8..05fbee7c027 100644 --- a/packages/app-website/tsconfig.build.json +++ b/packages/app-website/tsconfig.build.json @@ -6,6 +6,7 @@ { "path": "../app-page-builder/tsconfig.build.json" }, { "path": "../app-page-builder-elements/tsconfig.build.json" }, { "path": "../app-theme/tsconfig.build.json" }, + { "path": "../lexical-theme/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, { "path": "../react-router/tsconfig.build.json" } ], diff --git a/packages/app-website/tsconfig.json b/packages/app-website/tsconfig.json index 345217cbf85..d9c74336f3f 100644 --- a/packages/app-website/tsconfig.json +++ b/packages/app-website/tsconfig.json @@ -6,6 +6,7 @@ { "path": "../app-page-builder" }, { "path": "../app-page-builder-elements" }, { "path": "../app-theme" }, + { "path": "../lexical-theme" }, { "path": "../plugins" }, { "path": "../react-router" } ], @@ -24,6 +25,8 @@ "@webiny/app-page-builder-elements": ["../app-page-builder-elements/src"], "@webiny/app-theme/*": ["../app-theme/src/*"], "@webiny/app-theme": ["../app-theme/src"], + "@webiny/lexical-theme/*": ["../lexical-theme/src/*"], + "@webiny/lexical-theme": ["../lexical-theme/src"], "@webiny/plugins/*": ["../plugins/src/*"], "@webiny/plugins": ["../plugins/src"], "@webiny/react-router/*": ["../react-router/src/*"], diff --git a/packages/lexical-theme/src/createTheme.ts b/packages/lexical-theme/src/createTheme.ts index 450a03f1364..c81ff17c79e 100644 --- a/packages/lexical-theme/src/createTheme.ts +++ b/packages/lexical-theme/src/createTheme.ts @@ -1,6 +1,4 @@ import type { EditorThemeClasses } from "lexical"; - -import "./theme.css"; import { ThemeEmotionMap } from "~/types"; export type EditorTheme = { diff --git a/yarn.lock b/yarn.lock index 213d069bb90..88b6d414ff2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14738,6 +14738,7 @@ __metadata: "@webiny/app-websockets": "npm:0.0.0" "@webiny/lexical-editor-actions": "npm:0.0.0" "@webiny/lexical-editor-pb-element": "npm:0.0.0" + "@webiny/lexical-theme": "npm:0.0.0" "@webiny/plugins": "npm:0.0.0" "@webiny/project-utils": "npm:0.0.0" apollo-cache: "npm:^1.3.5" @@ -14940,6 +14941,7 @@ __metadata: "@webiny/app-page-builder": "npm:0.0.0" "@webiny/app-page-builder-elements": "npm:0.0.0" "@webiny/app-theme": "npm:0.0.0" + "@webiny/lexical-theme": "npm:0.0.0" "@webiny/plugins": "npm:0.0.0" "@webiny/project-utils": "npm:0.0.0" "@webiny/react-router": "npm:0.0.0" From ed8f5057569ace71c0cae1b9f854f096578800fb Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Thu, 10 Apr 2025 16:34:19 +0200 Subject: [PATCH 46/52] fix(ui): forward "autocomplete" prop --- packages/ui/src/Input/Input.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/Input/Input.tsx b/packages/ui/src/Input/Input.tsx index 4ad8615df6d..28f80b5cdb3 100644 --- a/packages/ui/src/Input/Input.tsx +++ b/packages/ui/src/Input/Input.tsx @@ -75,7 +75,8 @@ const rmwcProps = [ "inputRef", "className", "maxLength", - "characterCount" + "characterCount", + "autoComplete" ]; export const Input = (props: InputProps) => { From 9684aa437130a123dc89e2b284ca280be122c77e Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Thu, 10 Apr 2025 16:36:29 +0200 Subject: [PATCH 47/52] fix(app-admin-cognito): add autocomplete=off to password inputs --- packages/app-admin-cognito/src/views/RequireNewPassword.tsx | 1 + packages/app-admin-cognito/src/views/SetNewPassword.tsx | 1 + packages/app-admin-cognito/src/views/SignIn.tsx | 2 +- packages/app-admin/src/ui/elements/form/PasswordElement.tsx | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app-admin-cognito/src/views/RequireNewPassword.tsx b/packages/app-admin-cognito/src/views/RequireNewPassword.tsx index 0d1034921dc..b8d9ce0c6da 100644 --- a/packages/app-admin-cognito/src/views/RequireNewPassword.tsx +++ b/packages/app-admin-cognito/src/views/RequireNewPassword.tsx @@ -39,6 +39,7 @@ export const RequireNewPassword = () => { type={"password"} label={"New password"} outlined={true} + autoComplete={"off"} /> </Bind> </Cell> diff --git a/packages/app-admin-cognito/src/views/SetNewPassword.tsx b/packages/app-admin-cognito/src/views/SetNewPassword.tsx index e901f846e7a..4f7fa4b00ce 100644 --- a/packages/app-admin-cognito/src/views/SetNewPassword.tsx +++ b/packages/app-admin-cognito/src/views/SetNewPassword.tsx @@ -78,6 +78,7 @@ export const SetNewPassword = () => { ]} > <Input + autoComplete={"off"} type={"password"} label={"Retype password"} outlined={true} diff --git a/packages/app-admin-cognito/src/views/SignIn.tsx b/packages/app-admin-cognito/src/views/SignIn.tsx index 7de9022360a..456aeb50043 100644 --- a/packages/app-admin-cognito/src/views/SignIn.tsx +++ b/packages/app-admin-cognito/src/views/SignIn.tsx @@ -84,7 +84,7 @@ const DefaultContent = (props: SignInDefaultContentProps) => { </Cell> <Cell span={12}> <Bind name="password" validators={validation.create("required")}> - <Input type={"password"} label={"Your password"} /> + <Input type={"password"} label={"Your password"} autoComplete={"off"} /> </Bind> </Cell> <Cell span={12} className={alignRight}> diff --git a/packages/app-admin/src/ui/elements/form/PasswordElement.tsx b/packages/app-admin/src/ui/elements/form/PasswordElement.tsx index d569eccab1d..30e736b0bb5 100644 --- a/packages/app-admin/src/ui/elements/form/PasswordElement.tsx +++ b/packages/app-admin/src/ui/elements/form/PasswordElement.tsx @@ -21,6 +21,7 @@ export class PasswordElement extends InputElement { > <Input type={"password"} + autoComplete={"off"} label={this.getLabel(props)} placeholder={this.getPlaceholder(props)} disabled={this.getIsDisabled(props)} From 5259c929d2c227dcc19932f0747d08da8e475ac7 Mon Sep 17 00:00:00 2001 From: Swapnil M Mane <swapnilmmane@gmail.com> Date: Wed, 16 Apr 2025 18:22:00 +0530 Subject: [PATCH 48/52] fix: handle notification error during deployment on Apple M4 The deployment was successful, but the CLI displayed an error caused by the notifier. Since notification errors are non-critical and do not affect the deployment outcome, they are now caught and safely ignored. --- packages/cli-plugin-deploy-pulumi/utils/notify.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/cli-plugin-deploy-pulumi/utils/notify.js b/packages/cli-plugin-deploy-pulumi/utils/notify.js index 23e7fda2628..bed793dd5c0 100644 --- a/packages/cli-plugin-deploy-pulumi/utils/notify.js +++ b/packages/cli-plugin-deploy-pulumi/utils/notify.js @@ -12,5 +12,8 @@ module.exports = ({ message }) => { }); setTimeout(resolve, 100); + }).catch(() => { + // Suppress any unexpected promise rejections to ensure smooth user experience. + // Notification errors are non-critical and can be safely ignored. }); }; From 467f5e4e86806460ce4457e8dcfa161819c4b559 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Thu, 17 Apr 2025 15:39:13 +0200 Subject: [PATCH 49/52] fix(ui): add support for accordion action tooltip --- .../ui/src/Accordion/AccordionItemActions.tsx | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/Accordion/AccordionItemActions.tsx b/packages/ui/src/Accordion/AccordionItemActions.tsx index d62af463c22..14f11b364b5 100644 --- a/packages/ui/src/Accordion/AccordionItemActions.tsx +++ b/packages/ui/src/Accordion/AccordionItemActions.tsx @@ -1,5 +1,6 @@ import React from "react"; import { IconButton } from "~/Button"; +import { Tooltip } from "~/Tooltip"; interface AccordionItemActionsProps { children: React.ReactNode; @@ -12,11 +13,17 @@ export const AccordionItemActions = ({ children }: AccordionItemActionsProps) => export interface AccordionItemActionProps { icon: JSX.Element; onClick: () => void; + tooltip?: string; disabled?: boolean; } -export const AccordionItemAction = ({ icon, onClick, disabled }: AccordionItemActionProps) => { - return ( +export const AccordionItemAction = ({ + icon, + onClick, + tooltip, + disabled +}: AccordionItemActionProps) => { + const iconButton = ( <IconButton disabled={disabled} icon={icon} @@ -26,6 +33,16 @@ export const AccordionItemAction = ({ icon, onClick, disabled }: AccordionItemAc }} /> ); + + if (!tooltip) { + return iconButton; + } + + return ( + <Tooltip placement={"bottom"} content={tooltip}> + {iconButton} + </Tooltip> + ); }; export interface AccordionItemElementProps { From d10517be7bf6313cc17059ed68b1154d02a75580 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Thu, 17 Apr 2025 15:39:47 +0200 Subject: [PATCH 50/52] fix(app-headless-cms): add support for custom DZ actions --- .../src/ContentEntryEditorConfig.ts | 9 ++- .../dynamicZone/MultiValueDynamicZone.tsx | 11 +++- .../dynamicZone/SingleValueDynamicZone.tsx | 65 +++++++++++++++---- .../dynamicZone/dynamicZoneRenderer.tsx | 2 + 4 files changed, 68 insertions(+), 19 deletions(-) diff --git a/packages/app-headless-cms/src/ContentEntryEditorConfig.ts b/packages/app-headless-cms/src/ContentEntryEditorConfig.ts index b69d9838ec2..166a604e7f8 100644 --- a/packages/app-headless-cms/src/ContentEntryEditorConfig.ts +++ b/packages/app-headless-cms/src/ContentEntryEditorConfig.ts @@ -14,6 +14,7 @@ import { ContentEntryFormMeta, ContentEntryFormTitle } from "~/admin/views/contentEntries/ContentEntry/FullScreenContentEntry/FullScreenContentEntryHeaderLeft"; +import { SingleValueItemContainer } from "~/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone"; export const ContentEntryEditorConfig = Object.assign(BaseContentEntryEditorConfig, { ContentEntry: Object.assign(ContentEntry, { @@ -42,11 +43,9 @@ export const ContentEntryEditorConfig = Object.assign(BaseContentEntryEditorConf useTemplate: DzField.useTemplate }, Container: DzField.DynamicZoneContainer, - // SingleValue: { - // Container: null, - // ItemContainer: null, - // Item: null - // }, + SingleValue: { + ItemContainer: SingleValueItemContainer + }, MultiValue: { Container: DzField.MultiValueContainer, ItemContainer: DzField.MultiValueItemContainer, diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx index d897dde29bc..a566aa2fe47 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/MultiValueDynamicZone.tsx @@ -42,6 +42,7 @@ export interface MultiValueItemContainerProps { title: React.ReactNode; description: string; icon: JSX.Element; + actions?: JSX.Element; template: CmsDynamicZoneTemplate; children: React.ReactNode; } @@ -53,20 +54,28 @@ export const MultiValueItemContainer = makeDecoratable( <AccordionItem.Actions> <AccordionItem.Action icon={<ArrowUpIcon />} + tooltip={"Move up"} onClick={props.onMoveUp} disabled={props.isFirst} /> <AccordionItem.Action icon={<ArrowDownIcon />} + tooltip={"Move down"} onClick={props.onMoveDown} disabled={props.isLast} /> <AccordionItem.Divider /> + {props.actions ? <>{props.actions}</> : null} <AccordionItem.Action + tooltip={"Duplicate"} icon={<CloneIcon />} onClick={() => props.onClone(props.value)} /> - <AccordionItem.Action icon={<DeleteIcon />} onClick={props.onDelete} /> + <AccordionItem.Action + icon={<DeleteIcon />} + onClick={props.onDelete} + tooltip={"Delete"} + /> </AccordionItem.Actions> ); diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx index aabf0891b7f..9e5167eb44a 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/SingleValueDynamicZone.tsx @@ -18,7 +18,7 @@ import { ParentValueIndexProvider, ModelFieldProvider } from "~/admin/components/ModelFieldProvider"; -import { useConfirmationDialog } from "@webiny/app-admin"; +import { makeDecoratable, useConfirmationDialog } from "@webiny/app-admin"; type GetBind = CmsModelFieldRendererProps["getBind"]; @@ -29,6 +29,51 @@ interface SingleValueDynamicZoneProps { getBind: GetBind; } +interface TemplateValue { + _templateId: string; + [key: string]: any; +} + +export interface SingleValueItemContainerProps { + value: TemplateValue; + contentModel: CmsModel; + onDelete: () => void; + title: React.ReactNode; + description: string; + icon: JSX.Element; + actions?: JSX.Element; + template: CmsDynamicZoneTemplate; + children: React.ReactNode; +} + +export const SingleValueItemContainer = makeDecoratable( + "SingleValueItemContainer", + (props: SingleValueItemContainerProps) => { + const { template, actions, children } = props; + return ( + <AccordionItem + title={template.name} + description={template.description} + icon={<TemplateIcon icon={template.icon} />} + open={true} + interactive={false} + actions={ + <AccordionItem.Actions> + {actions ?? null} + <AccordionItem.Action + icon={<DeleteIcon />} + onClick={props.onDelete} + tooltip={"Delete"} + /> + </AccordionItem.Actions> + } + > + {children} + </AccordionItem> + ); + } +); + export const SingleValueDynamicZone = ({ field, bind, @@ -66,20 +111,14 @@ export const SingleValueDynamicZone = ({ <ParentValueIndexProvider index={-1}> <ModelFieldProvider field={field}> <Accordion> - <AccordionItem + <SingleValueItemContainer + template={template} + value={bind.value} + contentModel={contentModel} + onDelete={unsetValue} title={template.name} description={template.description} icon={<TemplateIcon icon={template.icon} />} - open={true} - interactive={false} - actions={ - <AccordionItem.Actions> - <AccordionItem.Action - icon={<DeleteIcon />} - onClick={unsetValue} - /> - </AccordionItem.Actions> - } > <TemplateProvider template={template}> <Fields @@ -89,7 +128,7 @@ export const SingleValueDynamicZone = ({ Bind={Bind} /> </TemplateProvider> - </AccordionItem> + </SingleValueItemContainer> </Accordion> </ModelFieldProvider> </ParentValueIndexProvider> diff --git a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/dynamicZoneRenderer.tsx b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/dynamicZoneRenderer.tsx index bde4c288fc7..1893f10b6ee 100644 --- a/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/dynamicZoneRenderer.tsx +++ b/packages/app-headless-cms/src/admin/plugins/fieldRenderers/dynamicZone/dynamicZoneRenderer.tsx @@ -30,6 +30,7 @@ export type DynamicZoneContainerProps = { title?: string; description?: string; className?: string; + actions?: JSX.Element; }; export const DynamicZoneContainer = makeDecoratable( @@ -57,6 +58,7 @@ export const DynamicZoneContainer = makeDecoratable( description={description} className={className || defaultClassName} open={open} + actions={props.actions ?? null} > {children} </AccordionItem> From 654ed2075d852b43693f03bf54b937de3f912fe9 Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Fri, 18 Apr 2025 11:06:51 +0200 Subject: [PATCH 51/52] fix(app-page-builder): use the correct composition scope name --- packages/app-page-builder/src/admin/plugins/routes.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app-page-builder/src/admin/plugins/routes.tsx b/packages/app-page-builder/src/admin/plugins/routes.tsx index dc3339deb89..40c77f28aad 100644 --- a/packages/app-page-builder/src/admin/plugins/routes.tsx +++ b/packages/app-page-builder/src/admin/plugins/routes.tsx @@ -71,7 +71,7 @@ const plugins: RoutePlugin[] = [ <RenderPluginsLoader> <AdminLayout> <Helmet title={"Page Builder - Pages"} /> - <CompositionScope name={"pb.page"}> + <CompositionScope name={"pb.pages"}> <Pages /> </CompositionScope> </AdminLayout> From c291af090954e0155e0ab9eb4ba8dd40ddf0e57c Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk <pavel@webiny.com> Date: Fri, 18 Apr 2025 11:13:01 +0200 Subject: [PATCH 52/52] fix(app-page-builder): render null if element is not available --- .../ElementControlHorizontalDropZones.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/ElementControlHorizontalDropZones.tsx b/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/ElementControlHorizontalDropZones.tsx index 48473ed9987..81a840e0f8c 100644 --- a/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/ElementControlHorizontalDropZones.tsx +++ b/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/ElementControlHorizontalDropZones.tsx @@ -89,8 +89,6 @@ export const ElementControlHorizontalDropZones = () => { const { showSnackbar } = useSnackbar(); const parentType = meta.parentElement.type; - const { type } = element; - const canDrop = (item: DragObjectWithTypeWithTarget) => { if (!item) { return false; @@ -129,7 +127,7 @@ export const ElementControlHorizontalDropZones = () => { ); }; - if (!isDragging) { + if (!isDragging || !element) { return null; } @@ -142,7 +140,7 @@ export const ElementControlHorizontalDropZones = () => { <Droppable isVisible={({ item }) => canDrop(item)} onDrop={source => dropElementAction(source, meta.elementIndex)} - type={type} + type={element.type} > {({ drop, isOver }) => ( <WrapperDroppable ref={drop} below={false} zIndex={zIndex}> @@ -156,7 +154,7 @@ export const ElementControlHorizontalDropZones = () => { <Droppable isVisible={({ item }) => canDrop(item)} onDrop={source => dropElementAction(source, meta.elementIndex + 1)} - type={type} + type={element.type} > {({ drop, isOver }) => ( <WrapperDroppable ref={drop} below zIndex={zIndex}>