From 592175ec2b3bcab6876b1272b7710fb438e4cbba Mon Sep 17 00:00:00 2001 From: Pavel Denisjuk Date: Tue, 7 Jan 2025 13:00:00 +0100 Subject: [PATCH] feat: introduce page builder data sources and data bindings (#4469) --- .../src/utils/getAuditConfig.ts | 16 +- .../src/graphql/generateSchema.ts | 12 +- .../process/exporters/PageTemplateExporter.ts | 12 +- .../src/export/utils.ts | 22 +- .../process/templates/templatesHandler.ts | 5 +- .../__tests__/pages/customField.test.ts | 12 + .../src/definitions/pageBlockEntity.ts | 8 + .../src/definitions/pageEntity.ts | 8 + .../src/definitions/pageBlockEntity.ts | 8 + .../src/definitions/pageEntity.ts | 8 + packages/api-page-builder/package.json | 2 + .../src/dataSources/DataLoader.ts | 28 ++ .../src/dataSources/DataLoaderRequest.ts | 34 ++ .../cmsDataSources/CmsEntriesDataSource.ts | 51 +++ .../cmsDataSources/CmsEntryDataSource.ts | 51 +++ .../cmsDataSources/ModelGetQuery.ts | 25 ++ .../cmsDataSources/ModelListQuery.ts | 25 ++ .../converter/ParsedPath.test.ts | 57 +++ .../cmsDataSources/converter/ParsedPath.ts | 28 ++ .../converter/PathsParser.test.ts | 64 +++ .../cmsDataSources/converter/PathsParser.ts | 57 +++ .../converter/SelectionFormatter.test.ts | 68 +++ .../converter/SelectionFormatter.ts | 52 +++ .../cmsDataSources/converter/types.ts | 10 + .../dataSources/context/DataSourcesContext.ts | 14 + .../context/createDataSourcesContext.ts | 14 + .../graphql/createDataSourcesSchema.ts | 14 + .../src/dataSources/graphql/resolvers.ts | 43 ++ .../src/dataSources/graphql/schema.ts | 21 + .../api-page-builder/src/dataSources/index.ts | 2 + .../api-page-builder/src/dataSources/types.ts | 30 ++ packages/api-page-builder/src/graphql/crud.ts | 9 +- .../graphql/crud/dynamicData.validation.ts | 22 + .../src/graphql/crud/pageBlocks/validation.ts | 4 +- .../src/graphql/crud/pageTemplates.crud.ts | 12 +- .../src/graphql/crud/pages/validation.ts | 4 +- .../api-page-builder/src/graphql/graphql.ts | 2 + .../src/graphql/graphql/base.gql.ts | 4 +- .../src/graphql/graphql/dynamicData.gql.ts | 31 ++ .../src/graphql/graphql/pageBlocks.gql.ts | 4 + .../src/graphql/graphql/pageTemplates.gql.ts | 9 +- .../src/graphql/graphql/pages.gql.ts | 4 + .../api-page-builder/src/graphql/index.ts | 6 +- .../api-page-builder/src/graphql/types.ts | 7 +- packages/api-page-builder/src/types.ts | 43 +- packages/api-page-builder/tsconfig.build.json | 1 + packages/api-page-builder/tsconfig.json | 3 + packages/app-dynamic-pages/.babelrc.js | 1 + packages/app-dynamic-pages/LICENSE | 21 + packages/app-dynamic-pages/README.md | 20 + packages/app-dynamic-pages/package.json | 55 +++ .../admin/ContentEntryForm/AddPreviewPane.tsx | 44 ++ .../PassEntryToDataSource.tsx | 78 ++++ .../admin/ContentEntryForm/PreviewPane.tsx | 57 +++ .../src/admin/Extensions.tsx | 18 + .../CreateTemplateDialog.tsx | 225 ++++++++++ .../PageTemplateDialog/PageTemplateDialog.tsx | 43 ++ .../src/admin/SetupDynamicPages.tsx | 43 ++ .../src/admin/elements/Elements.tsx | 20 + .../src/admin/elements/entriesList.tsx | 37 ++ .../src/admin/elements/entriesSearch.tsx | 33 ++ .../eventHandlers/ContentTraverser.ts | 18 + .../admin/elements/renderers/DynamicGrid.tsx | 59 +++ .../admin/elements/renderers/EntriesList.tsx | 27 ++ .../src/admin/elements/renderers/Repeater.tsx | 25 ++ .../src/admin/elements/repeater.tsx | 36 ++ packages/app-dynamic-pages/src/admin/index.ts | 1 + .../pageEditor/DynamicPageEditorConfig.tsx | 17 + .../admin/pageEditor/ElementEventHandlers.tsx | 140 +++++++ .../DynamicTemplateEditorConfig.tsx | 38 ++ .../templateEditor/ElementEventHandlers.tsx | 140 +++++++ .../EntrySelector/EntrySelector.tsx | 27 ++ .../templateEditor/EntrySelector/index.ts | 1 + .../AddEntriesListDataSourceContext.tsx | 29 ++ .../editor/DisableGridDelete.tsx | 23 ++ .../editor/ElementDataSettings.tsx | 26 ++ .../editor/HideIfChildOfEntriesList.tsx | 17 + .../HideIfEntriesListGridWithDataSource.tsx | 24 ++ .../editor/SetupElementDataSettings.tsx | 13 + .../renderers/DynamicElementRenderers.tsx | 20 + .../dataInjection/renderers/DynamicGrid.tsx | 53 +++ .../dataInjection/renderers/EntriesList.tsx | 57 +++ .../dataInjection/renderers/EntriesSearch.tsx | 29 ++ .../src/dataInjection/renderers/Repeater.tsx | 57 +++ .../app-dynamic-pages/src/features/index.ts | 3 + .../useCreateDynamicTemplate.ts | 66 +++ .../pageTemplate/hasMainDataSource.ts | 5 + .../useListDynamicTemplates.ts | 12 + .../app-dynamic-pages/tsconfig.build.json | 21 + packages/app-dynamic-pages/tsconfig.json | 40 ++ packages/app-dynamic-pages/webiny.config.js | 8 + .../FileManagerView/index.tsx | 11 +- .../FileManagerViewContext.tsx | 11 +- .../ContentEntryForm/ContentEntryForm.tsx | 9 +- .../ContentEntryFormProvider.tsx | 12 +- .../src/components/Element.tsx | 2 +- .../src/components/Elements.tsx | 54 ++- .../app-page-builder-elements/src/index.ts | 1 + .../src/inputs/ElementInput.ts | 6 +- .../src/renderers/block.tsx | 15 +- .../src/renderers/grid.tsx | 36 +- .../src/renderers/heading.tsx | 12 +- .../src/renderers/image.tsx | 217 +++++----- .../src/renderers/isHtml.ts | 7 + .../src/renderers/isJson.ts | 12 + .../src/renderers/paragraph.tsx | 23 +- .../app-page-builder-elements/src/types.ts | 2 +- packages/app-page-builder/package.json | 1 + .../src/IfDynamicPagesEnabled.tsx | 10 + packages/app-page-builder/src/PageBuilder.tsx | 11 + .../src/admin/graphql/pages.ts | 10 + .../pageOptionsMenu/PageOptionsMenu.tsx | 27 +- ...PagePreview.tsx => PageContentPreview.tsx} | 15 +- .../pageDetails/previewContent/index.tsx | 4 +- .../src/admin/utils/createElementPlugin.tsx | 1 - .../CreatePageTemplateDialog.tsx | 129 +++--- .../PageTemplateContentPreview.tsx | 26 ++ .../PageTemplates/PageTemplateDetails.tsx | 43 +- .../views/PageTemplates/PageTemplates.tsx | 63 ++- .../PageTemplates/PageTemplatesDataList.tsx | 23 +- .../src/admin/views/PageTemplates/graphql.ts | 185 --------- .../admin/views/Pages/PageTemplatesDialog.tsx | 23 +- .../src/blockEditor/Editor.tsx | 4 +- .../src/blockEditor/graphql.ts | 90 ---- .../src/blockEditor/hooks/index.ts | 1 + .../hooks/useElementRendererInputs.ts | 23 ++ .../src/blockEditor/state/blockAtom.ts | 4 +- .../src/dataInjection/AddBindingContext.tsx | 39 ++ .../src/dataInjection/BindingProvider.tsx | 13 + .../src/dataInjection/ContentTraverser.ts | 18 + .../dataInjection/DataSourceDataProvider.tsx | 19 + .../src/dataInjection/DataSourceProvider.tsx | 45 ++ .../dataInjection/DynamicDocumentProvider.tsx | 56 +++ .../src/dataInjection/ElementInputBinding.ts | 34 ++ .../src/dataInjection/InjectDynamicValues.tsx | 12 + .../editor/DataSourceConfigAndBindings.tsx | 36 ++ .../DataSourceConfigInput.tsx | 86 ++++ .../DataSourceProperties/ElementBinding.tsx | 63 +++ .../DataSourceProperties/ElementInputs.tsx | 19 + .../DataSourceProperties/OnElementType.tsx | 17 + .../useGetElementDataSource.ts | 58 +++ .../DataSourceProperties/useInputBinding.ts | 66 +++ .../editor/DeveloperUtilities.tsx | 45 ++ .../editor/InjectDynamicValues.tsx | 21 + .../editor/SetupDynamicDataInEditor.tsx | 40 ++ .../editor/useGetElementDataSource.ts | 41 ++ .../src/dataInjection/index.ts | 15 + .../presets/WebsiteDataInjection.tsx | 13 + .../preview/PageTemplatesPreview.tsx | 27 ++ .../dataInjection/preview/PagesPreview.tsx | 20 + .../src/dataInjection/useBindElementInputs.ts | 73 ++++ .../src/dataInjection/useBindingContext.ts | 19 + .../src/dataInjection/useDataSource.ts | 14 + .../dataInjection/useDocumentDataSource.ts | 65 +++ .../src/dataInjection/useDynamicDocument.ts | 11 + .../src/dataInjection/useElementBindings.ts | 9 + .../app-page-builder/src/editor/Editor.tsx | 4 +- .../src/editor/PrepareEditorContent.tsx | 6 +- .../Sidebar/ScrollableContainer.tsx | 0 .../src/editor/config/Sidebar/Sidebar.tsx | 2 + .../ElementControlHorizontalDropZones.tsx | 66 ++- .../getElementTitle.ts | 20 +- .../contexts/EventActionHandlerProvider.tsx | 16 +- .../Content/Breadcrumbs/Breadcrumbs.tsx | 2 +- .../Content/Breadcrumbs/styles.ts | 2 +- .../defaultConfig/DefaultEditorConfig.tsx | 3 + .../ElementSettings/ElementSettingsGroup.tsx | 2 +- .../StyleSettings/StyleSettingsGroup.tsx | 2 +- .../Toolbar/Navigator/NavigatorDrawer.tsx | 4 +- .../Toolbar/Navigator/TreeView.tsx | 12 +- .../app-page-builder/src/editor/helpers.ts | 15 +- .../src/editor/hooks/index.ts | 3 + .../editor/hooks/useElementWithChildren.ts | 12 + .../src/editor/hooks/useGetElement.ts | 8 + .../editor/hooks/useIsElementChildOfType.ts | 16 + .../src/editor/hooks/useUpdateElement.ts | 7 +- .../advanced/elementSettingsAction.ts | 10 +- .../plugins/elementSettings/grid/GridSize.tsx | 12 +- .../editor/plugins/elements/block/Block.tsx | 23 +- .../editor/plugins/elements/block/index.tsx | 1 + .../plugins/elements/cell/EmptyCell.tsx | 10 +- .../src/editor/plugins/elements/grid/Grid.tsx | 2 +- .../editor/plugins/elements/grid/PeGrid.tsx | 4 +- .../editor/plugins/elements/grid/index.tsx | 5 +- .../editor/plugins/elements/heading/index.tsx | 7 +- .../editor/plugins/elements/image/Image.tsx | 15 - .../editor/plugins/elements/image/PeImage.tsx | 30 +- .../image/imageCreatedEditorAction.ts | 10 +- .../editor/plugins/elements/image/index.tsx | 7 +- .../editor/plugins/elements/list/index.tsx | 5 +- .../plugins/elements/paragraph/index.tsx | 7 +- .../editor/plugins/elements/quote/index.tsx | 5 +- .../utils/oembed/createEmbedPlugin.tsx | 2 +- .../recoil/actions/afterDropElement/action.ts | 6 +- .../recoil/actions/createElement/types.ts | 4 +- .../recoil/actions/dropElement/action.ts | 2 +- .../recoil/actions/updateElement/types.ts | 4 +- .../AddImageLinkComponent.tsx | 25 ++ .../src/features/ListCache.ts | 74 ++++ .../dataSource/loadDataSource/Checksum.ts | 19 + .../IResolveDataSourceGateway.ts | 5 + .../IResolveDataSourceRepository.ts | 56 +++ .../ResolveDataSourceGqlGateway.ts | 81 ++++ .../ResolveDataSourceMockGateway.ts | 229 +++++++++++ .../ResolveDataSourceRepository.ts | 70 ++++ .../loadDataSource/dataSourceCache.ts | 13 + .../loadDataSource/useLoadDataSource.ts | 94 +++++ .../app-page-builder/src/features/index.ts | 7 + .../CreatePageTemplateGqlGateway.ts | 98 +++++ .../CreatePageTemplateRepository.ts | 34 ++ .../ICreatePageTemplateGateway.ts | 6 + .../ICreatePageTemplateRepository.ts | 6 + .../PageTemplateInputDto.ts | 13 + .../useCreatePageTemplate.ts | 25 ++ .../CreatePageTemplateFromPageGqlGateway.ts | 92 +++++ .../CreatePageTemplateFromPageRepository.ts | 29 ++ .../ICreatePageTemplateFromPageGateway.ts | 9 + .../ICreatePageTemplateFromPageRepository.ts | 6 + .../PageTemplateInputDto.ts | 5 + .../useCreatePageTemplateFromPage.ts | 25 ++ .../DeletePageTemplateGqlGateway.ts | 67 +++ .../DeletePageTemplateRepository.ts | 20 + .../IDeletePageTemplateGateway.ts | 3 + .../IDeletePageTemplateRepository.ts | 3 + .../useDeletePageTemplate.ts | 24 ++ .../IListPageTemplatesGateway.ts | 5 + .../IListPageTemplatesRepository.ts | 7 + .../ListPageTemplatesGqlGateway.ts | 101 +++++ .../ListPageTemplatesRepository.ts | 63 +++ .../listPageTemplates/useListPageTemplates.ts | 52 +++ .../pageTemplate/pageTemplateCache.ts | 4 + .../IRefreshPageTemplatesRepository.ts | 3 + .../RefreshPageTemplatesRepository.ts | 42 ++ .../useRefreshPageTemplates.ts | 37 ++ .../IUpdatePageTemplateGateway.ts | 5 + .../IUpdatePageTemplateRepository.ts | 5 + .../updatePageTemplate/PageTemplateDto.ts | 12 + .../UpdatePageTemplateDto.ts | 13 + .../UpdatePageTemplateGqlGateway.ts | 100 +++++ .../UpdatePageTemplateRepository.ts | 33 ++ .../useUpdatePageTemplate.ts | 25 ++ .../src/pageEditor/Editor.tsx | 2 +- .../config/DefaultPageEditorConfig.tsx | 2 + .../config/SetupDynamicDocument.tsx | 51 +++ .../config/Sidebar/TemplateMode.tsx | 2 +- .../InjectVariableValuesIntoElement.ts | 8 +- .../config/Toolbar/UnlinkPageFromTemplate.ts | 6 +- .../pageEditor/config/TopBar/Title/Title.tsx | 12 +- .../EventActionHandlerDecorator.tsx | 10 +- .../saveRevision/saveRevisionAction.ts | 21 +- .../config/eventActions/updatePageAction.ts | 1 + .../src/pageEditor/graphql.ts | 10 + .../src/pageEditor/state/page/pageAtom.ts | 8 +- .../src/render/PageBuilder.tsx | 2 + .../render/plugins/elements/grid/index.tsx | 4 +- .../render/plugins/elements/image/index.tsx | 15 +- .../variables/InjectElementVariables.tsx | 1 + .../src/templateEditor/Editor.tsx | 120 +----- .../config/Content/BlocksBrowser/AddBlock.tsx | 2 +- .../config/DefaultTemplateEditorConfig.tsx | 2 + .../config/SetupDynamicDocument.tsx | 31 ++ .../SaveTemplateButton/SaveTemplateButton.tsx | 3 - .../TemplateSettingsModal.tsx | 143 +++---- .../config/TopBar/Title/Title.tsx | 18 +- .../EventActionHandlerDecorator.tsx | 15 +- .../eventActions/EventActionHandlers.tsx | 9 +- .../saveTemplate/saveTemplateAction.ts | 91 ++--- .../templateEditor/createStateInitializer.ts | 7 +- .../src/templateEditor/graphql.ts | 56 --- .../src/templateEditor/hooks/index.ts | 1 + .../hooks/useDocumentDataSource.ts | 75 ++++ .../prepareEditor/useBlockCategories.ts | 24 ++ .../prepareEditor/useSavedElements.ts | 29 ++ .../src/templateEditor/state/templateAtom.ts | 30 +- .../src/templateEditor/types.ts | 5 +- .../src/templateEditor/usePrepareEditor.ts | 47 +++ .../SaveTranslatableValues.ts | 11 +- packages/app-page-builder/src/types.ts | 58 ++- packages/app-page-builder/tsconfig.build.json | 1 + packages/app-page-builder/tsconfig.json | 3 + packages/app-serverless-cms/package.json | 1 + packages/app-serverless-cms/src/Admin.tsx | 2 + .../app-serverless-cms/tsconfig.build.json | 1 + packages/app-serverless-cms/tsconfig.json | 3 + .../template/ddb-es/dependencies.json | 2 +- .../template/ddb-os/dependencies.json | 2 +- .../template/ddb/dependencies.json | 2 +- packages/feature-flags/src/index.ts | 1 + .../src/createGraphQLSchema.ts | 10 +- packages/project-utils/package.json | 2 +- packages/react-router/src/Link.tsx | 4 +- webiny.project.ts | 3 +- yarn.lock | 386 +++++++----------- 293 files changed, 6658 insertions(+), 1525 deletions(-) create mode 100644 packages/api-page-builder/src/dataSources/DataLoader.ts create mode 100644 packages/api-page-builder/src/dataSources/DataLoaderRequest.ts create mode 100644 packages/api-page-builder/src/dataSources/cmsDataSources/CmsEntriesDataSource.ts create mode 100644 packages/api-page-builder/src/dataSources/cmsDataSources/CmsEntryDataSource.ts create mode 100644 packages/api-page-builder/src/dataSources/cmsDataSources/ModelGetQuery.ts create mode 100644 packages/api-page-builder/src/dataSources/cmsDataSources/ModelListQuery.ts create mode 100644 packages/api-page-builder/src/dataSources/cmsDataSources/converter/ParsedPath.test.ts create mode 100644 packages/api-page-builder/src/dataSources/cmsDataSources/converter/ParsedPath.ts create mode 100644 packages/api-page-builder/src/dataSources/cmsDataSources/converter/PathsParser.test.ts create mode 100644 packages/api-page-builder/src/dataSources/cmsDataSources/converter/PathsParser.ts create mode 100644 packages/api-page-builder/src/dataSources/cmsDataSources/converter/SelectionFormatter.test.ts create mode 100644 packages/api-page-builder/src/dataSources/cmsDataSources/converter/SelectionFormatter.ts create mode 100644 packages/api-page-builder/src/dataSources/cmsDataSources/converter/types.ts create mode 100644 packages/api-page-builder/src/dataSources/context/DataSourcesContext.ts create mode 100644 packages/api-page-builder/src/dataSources/context/createDataSourcesContext.ts create mode 100644 packages/api-page-builder/src/dataSources/graphql/createDataSourcesSchema.ts create mode 100644 packages/api-page-builder/src/dataSources/graphql/resolvers.ts create mode 100644 packages/api-page-builder/src/dataSources/graphql/schema.ts create mode 100644 packages/api-page-builder/src/dataSources/index.ts create mode 100644 packages/api-page-builder/src/dataSources/types.ts create mode 100644 packages/api-page-builder/src/graphql/crud/dynamicData.validation.ts create mode 100644 packages/api-page-builder/src/graphql/graphql/dynamicData.gql.ts create mode 100644 packages/app-dynamic-pages/.babelrc.js create mode 100644 packages/app-dynamic-pages/LICENSE create mode 100644 packages/app-dynamic-pages/README.md create mode 100644 packages/app-dynamic-pages/package.json create mode 100644 packages/app-dynamic-pages/src/admin/ContentEntryForm/AddPreviewPane.tsx create mode 100644 packages/app-dynamic-pages/src/admin/ContentEntryForm/PassEntryToDataSource.tsx create mode 100644 packages/app-dynamic-pages/src/admin/ContentEntryForm/PreviewPane.tsx create mode 100644 packages/app-dynamic-pages/src/admin/Extensions.tsx create mode 100644 packages/app-dynamic-pages/src/admin/PageTemplateDialog/CreateTemplateDialog.tsx create mode 100644 packages/app-dynamic-pages/src/admin/PageTemplateDialog/PageTemplateDialog.tsx create mode 100644 packages/app-dynamic-pages/src/admin/SetupDynamicPages.tsx create mode 100644 packages/app-dynamic-pages/src/admin/elements/Elements.tsx create mode 100644 packages/app-dynamic-pages/src/admin/elements/entriesList.tsx create mode 100644 packages/app-dynamic-pages/src/admin/elements/entriesSearch.tsx create mode 100644 packages/app-dynamic-pages/src/admin/elements/eventHandlers/ContentTraverser.ts create mode 100644 packages/app-dynamic-pages/src/admin/elements/renderers/DynamicGrid.tsx create mode 100644 packages/app-dynamic-pages/src/admin/elements/renderers/EntriesList.tsx create mode 100644 packages/app-dynamic-pages/src/admin/elements/renderers/Repeater.tsx create mode 100644 packages/app-dynamic-pages/src/admin/elements/repeater.tsx create mode 100644 packages/app-dynamic-pages/src/admin/index.ts create mode 100644 packages/app-dynamic-pages/src/admin/pageEditor/DynamicPageEditorConfig.tsx create mode 100644 packages/app-dynamic-pages/src/admin/pageEditor/ElementEventHandlers.tsx create mode 100644 packages/app-dynamic-pages/src/admin/templateEditor/DynamicTemplateEditorConfig.tsx create mode 100644 packages/app-dynamic-pages/src/admin/templateEditor/ElementEventHandlers.tsx create mode 100644 packages/app-dynamic-pages/src/admin/templateEditor/EntrySelector/EntrySelector.tsx create mode 100644 packages/app-dynamic-pages/src/admin/templateEditor/EntrySelector/index.ts create mode 100644 packages/app-dynamic-pages/src/dataInjection/AddEntriesListDataSourceContext.tsx create mode 100644 packages/app-dynamic-pages/src/dataInjection/editor/DisableGridDelete.tsx create mode 100644 packages/app-dynamic-pages/src/dataInjection/editor/ElementDataSettings.tsx create mode 100644 packages/app-dynamic-pages/src/dataInjection/editor/HideIfChildOfEntriesList.tsx create mode 100644 packages/app-dynamic-pages/src/dataInjection/editor/HideIfEntriesListGridWithDataSource.tsx create mode 100644 packages/app-dynamic-pages/src/dataInjection/editor/SetupElementDataSettings.tsx create mode 100644 packages/app-dynamic-pages/src/dataInjection/renderers/DynamicElementRenderers.tsx create mode 100644 packages/app-dynamic-pages/src/dataInjection/renderers/DynamicGrid.tsx create mode 100644 packages/app-dynamic-pages/src/dataInjection/renderers/EntriesList.tsx create mode 100644 packages/app-dynamic-pages/src/dataInjection/renderers/EntriesSearch.tsx create mode 100644 packages/app-dynamic-pages/src/dataInjection/renderers/Repeater.tsx create mode 100644 packages/app-dynamic-pages/src/features/index.ts create mode 100644 packages/app-dynamic-pages/src/features/pageTemplate/createDynamicTemplate/useCreateDynamicTemplate.ts create mode 100644 packages/app-dynamic-pages/src/features/pageTemplate/hasMainDataSource.ts create mode 100644 packages/app-dynamic-pages/src/features/pageTemplate/listDynamicTemplates/useListDynamicTemplates.ts create mode 100644 packages/app-dynamic-pages/tsconfig.build.json create mode 100644 packages/app-dynamic-pages/tsconfig.json create mode 100644 packages/app-dynamic-pages/webiny.config.js create mode 100644 packages/app-page-builder-elements/src/renderers/isHtml.ts create mode 100644 packages/app-page-builder-elements/src/renderers/isJson.ts create mode 100644 packages/app-page-builder/src/IfDynamicPagesEnabled.tsx rename packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/{PagePreview.tsx => PageContentPreview.tsx} (87%) create mode 100644 packages/app-page-builder/src/admin/views/PageTemplates/PageTemplateContentPreview.tsx delete mode 100644 packages/app-page-builder/src/admin/views/PageTemplates/graphql.ts delete mode 100644 packages/app-page-builder/src/blockEditor/graphql.ts create mode 100644 packages/app-page-builder/src/blockEditor/hooks/useElementRendererInputs.ts create mode 100644 packages/app-page-builder/src/dataInjection/AddBindingContext.tsx create mode 100644 packages/app-page-builder/src/dataInjection/BindingProvider.tsx create mode 100644 packages/app-page-builder/src/dataInjection/ContentTraverser.ts create mode 100644 packages/app-page-builder/src/dataInjection/DataSourceDataProvider.tsx create mode 100644 packages/app-page-builder/src/dataInjection/DataSourceProvider.tsx create mode 100644 packages/app-page-builder/src/dataInjection/DynamicDocumentProvider.tsx create mode 100644 packages/app-page-builder/src/dataInjection/ElementInputBinding.ts create mode 100644 packages/app-page-builder/src/dataInjection/InjectDynamicValues.tsx create mode 100644 packages/app-page-builder/src/dataInjection/editor/DataSourceConfigAndBindings.tsx create mode 100644 packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/DataSourceConfigInput.tsx create mode 100644 packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/ElementBinding.tsx create mode 100644 packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/ElementInputs.tsx create mode 100644 packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/OnElementType.tsx create mode 100644 packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/useGetElementDataSource.ts create mode 100644 packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/useInputBinding.ts create mode 100644 packages/app-page-builder/src/dataInjection/editor/DeveloperUtilities.tsx create mode 100644 packages/app-page-builder/src/dataInjection/editor/InjectDynamicValues.tsx create mode 100644 packages/app-page-builder/src/dataInjection/editor/SetupDynamicDataInEditor.tsx create mode 100644 packages/app-page-builder/src/dataInjection/editor/useGetElementDataSource.ts create mode 100644 packages/app-page-builder/src/dataInjection/index.ts create mode 100644 packages/app-page-builder/src/dataInjection/presets/WebsiteDataInjection.tsx create mode 100644 packages/app-page-builder/src/dataInjection/preview/PageTemplatesPreview.tsx create mode 100644 packages/app-page-builder/src/dataInjection/preview/PagesPreview.tsx create mode 100644 packages/app-page-builder/src/dataInjection/useBindElementInputs.ts create mode 100644 packages/app-page-builder/src/dataInjection/useBindingContext.ts create mode 100644 packages/app-page-builder/src/dataInjection/useDataSource.ts create mode 100644 packages/app-page-builder/src/dataInjection/useDocumentDataSource.ts create mode 100644 packages/app-page-builder/src/dataInjection/useDynamicDocument.ts create mode 100644 packages/app-page-builder/src/dataInjection/useElementBindings.ts rename packages/app-page-builder/src/editor/{defaultConfig => config}/Sidebar/ScrollableContainer.tsx (100%) create mode 100644 packages/app-page-builder/src/editor/hooks/useElementWithChildren.ts create mode 100644 packages/app-page-builder/src/editor/hooks/useGetElement.ts create mode 100644 packages/app-page-builder/src/editor/hooks/useIsElementChildOfType.ts delete mode 100644 packages/app-page-builder/src/editor/plugins/elements/image/Image.tsx create mode 100644 packages/app-page-builder/src/elementDecorators/AddImageLinkComponent.tsx create mode 100644 packages/app-page-builder/src/features/ListCache.ts create mode 100644 packages/app-page-builder/src/features/dataSource/loadDataSource/Checksum.ts create mode 100644 packages/app-page-builder/src/features/dataSource/loadDataSource/IResolveDataSourceGateway.ts create mode 100644 packages/app-page-builder/src/features/dataSource/loadDataSource/IResolveDataSourceRepository.ts create mode 100644 packages/app-page-builder/src/features/dataSource/loadDataSource/ResolveDataSourceGqlGateway.ts create mode 100644 packages/app-page-builder/src/features/dataSource/loadDataSource/ResolveDataSourceMockGateway.ts create mode 100644 packages/app-page-builder/src/features/dataSource/loadDataSource/ResolveDataSourceRepository.ts create mode 100644 packages/app-page-builder/src/features/dataSource/loadDataSource/dataSourceCache.ts create mode 100644 packages/app-page-builder/src/features/dataSource/loadDataSource/useLoadDataSource.ts create mode 100644 packages/app-page-builder/src/features/index.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/createPageTemplate/CreatePageTemplateGqlGateway.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/createPageTemplate/CreatePageTemplateRepository.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/createPageTemplate/ICreatePageTemplateGateway.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/createPageTemplate/ICreatePageTemplateRepository.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/createPageTemplate/PageTemplateInputDto.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/createPageTemplate/useCreatePageTemplate.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/CreatePageTemplateFromPageGqlGateway.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/CreatePageTemplateFromPageRepository.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/ICreatePageTemplateFromPageGateway.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/ICreatePageTemplateFromPageRepository.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/PageTemplateInputDto.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/useCreatePageTemplateFromPage.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/DeletePageTemplateGqlGateway.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/DeletePageTemplateRepository.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/IDeletePageTemplateGateway.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/IDeletePageTemplateRepository.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/useDeletePageTemplate.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/listPageTemplates/IListPageTemplatesGateway.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/listPageTemplates/IListPageTemplatesRepository.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/listPageTemplates/ListPageTemplatesGqlGateway.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/listPageTemplates/ListPageTemplatesRepository.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/listPageTemplates/useListPageTemplates.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/pageTemplateCache.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/refreshPageTemplates/IRefreshPageTemplatesRepository.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/refreshPageTemplates/RefreshPageTemplatesRepository.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/refreshPageTemplates/useRefreshPageTemplates.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/IUpdatePageTemplateGateway.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/IUpdatePageTemplateRepository.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/PageTemplateDto.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/UpdatePageTemplateDto.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/UpdatePageTemplateGqlGateway.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/UpdatePageTemplateRepository.ts create mode 100644 packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/useUpdatePageTemplate.ts create mode 100644 packages/app-page-builder/src/pageEditor/config/SetupDynamicDocument.tsx create mode 100644 packages/app-page-builder/src/templateEditor/config/SetupDynamicDocument.tsx delete mode 100644 packages/app-page-builder/src/templateEditor/graphql.ts create mode 100644 packages/app-page-builder/src/templateEditor/hooks/useDocumentDataSource.ts create mode 100644 packages/app-page-builder/src/templateEditor/prepareEditor/useBlockCategories.ts create mode 100644 packages/app-page-builder/src/templateEditor/prepareEditor/useSavedElements.ts create mode 100644 packages/app-page-builder/src/templateEditor/usePrepareEditor.ts diff --git a/packages/api-audit-logs/src/utils/getAuditConfig.ts b/packages/api-audit-logs/src/utils/getAuditConfig.ts index 401150f72dd..dfd73fd6113 100644 --- a/packages/api-audit-logs/src/utils/getAuditConfig.ts +++ b/packages/api-audit-logs/src/utils/getAuditConfig.ts @@ -141,11 +141,17 @@ export const getAuditConfig = (audit: AuditAction) => { // Check if there is delay on audit log creation for this action. if (delay) { - return await createOrMergeAuditLog({ - app, - payload: auditLogPayload, - delay - }); + try { + return await createOrMergeAuditLog({ + app, + payload: auditLogPayload, + delay + }); + } catch { + // Don't care at this point! + } finally { + return JSON.stringify({}); + } } return await createAuditLog({ app, diff --git a/packages/api-headless-cms/src/graphql/generateSchema.ts b/packages/api-headless-cms/src/graphql/generateSchema.ts index 8420a722b79..eddaab96f3a 100644 --- a/packages/api-headless-cms/src/graphql/generateSchema.ts +++ b/packages/api-headless-cms/src/graphql/generateSchema.ts @@ -21,9 +21,15 @@ export const generateSchema = async (params: GenerateSchemaParams): Promise( - CmsGraphQLSchemaPlugin.type - ); + const schemaPlugins = context.plugins + .byType(CmsGraphQLSchemaPlugin.type) + .filter(pl => { + if (typeof pl.isApplicable === "function") { + return pl.isApplicable(context); + } + return true; + }); + return createExecutableSchema({ plugins: schemaPlugins }); diff --git a/packages/api-page-builder-import-export/src/export/process/exporters/PageTemplateExporter.ts b/packages/api-page-builder-import-export/src/export/process/exporters/PageTemplateExporter.ts index c5a1cf66d70..4b1deef7597 100644 --- a/packages/api-page-builder-import-export/src/export/process/exporters/PageTemplateExporter.ts +++ b/packages/api-page-builder-import-export/src/export/process/exporters/PageTemplateExporter.ts @@ -6,7 +6,14 @@ import { extractFilesFromData } from "~/export/utils"; export interface ExportedTemplateData { template: Pick< PageTemplate, - "title" | "slug" | "tags" | "description" | "content" | "layout" | "pageCategory" + | "title" + | "slug" + | "tags" + | "description" + | "content" + | "layout" + | "dataBindings" + | "dataSources" >; files: File[]; } @@ -38,7 +45,8 @@ export class PageTemplateExporter { description: template.description, content: template.content, layout: template.layout, - pageCategory: template.pageCategory + dataBindings: template.dataBindings, + dataSources: template.dataSources }, files: imageFilesData }; diff --git a/packages/api-page-builder-import-export/src/export/utils.ts b/packages/api-page-builder-import-export/src/export/utils.ts index c4aca57c3dc..baadf9ba00d 100644 --- a/packages/api-page-builder-import-export/src/export/utils.ts +++ b/packages/api-page-builder-import-export/src/export/utils.ts @@ -1,5 +1,11 @@ import { CompleteMultipartUploadOutput } from "@webiny/aws-sdk/client-s3"; -import { BlockCategory, Page, PageBlock, PageTemplate } from "@webiny/api-page-builder/types"; +import { + BlockCategory, + Page, + PageBlock, + PageTemplate, + PageTemplateInput +} from "@webiny/api-page-builder/types"; import { FileManagerContext, File } from "@webiny/api-file-manager/types"; import get from "lodash/get"; import Zipper from "./zipper"; @@ -114,7 +120,14 @@ export async function exportBlock( export interface ExportedTemplateData { template: Pick< PageTemplate, - "title" | "slug" | "tags" | "description" | "content" | "layout" | "pageCategory" + | "title" + | "slug" + | "tags" + | "description" + | "content" + | "layout" + | "dataSources" + | "dataBindings" >; files: File[]; } @@ -135,7 +148,7 @@ export async function exportTemplate( } // Extract the template data in a json file and upload it to S3 - const templateData = { + const templateData: { template: PageTemplateInput; files: File[] } = { template: { title: template.title, slug: template.slug, @@ -143,7 +156,8 @@ export async function exportTemplate( description: template.description, content: template.content, layout: template.layout, - pageCategory: template.pageCategory + dataSources: template.dataSources, + dataBindings: template.dataBindings }, files: imageFilesData }; diff --git a/packages/api-page-builder-import-export/src/import/process/templates/templatesHandler.ts b/packages/api-page-builder-import-export/src/import/process/templates/templatesHandler.ts index a189bb5f8dc..581f437951a 100644 --- a/packages/api-page-builder-import-export/src/import/process/templates/templatesHandler.ts +++ b/packages/api-page-builder-import-export/src/import/process/templates/templatesHandler.ts @@ -77,9 +77,10 @@ export const templatesHandler = async ( slug: template.slug, tags: template.tags, layout: template.layout, - pageCategory: template.pageCategory, description: template.description, - content: template.content + content: template.content, + dataBindings: template.dataBindings, + dataSources: template.dataSources }); // Update task record in DB diff --git a/packages/api-page-builder-so-ddb-es/__tests__/pages/customField.test.ts b/packages/api-page-builder-so-ddb-es/__tests__/pages/customField.test.ts index 21a74fc421a..ff0fdba0bc6 100644 --- a/packages/api-page-builder-so-ddb-es/__tests__/pages/customField.test.ts +++ b/packages/api-page-builder-so-ddb-es/__tests__/pages/customField.test.ts @@ -96,6 +96,12 @@ describe("page custom field", () => { { name: "revisions" }, + { + name: "dataSources" + }, + { + name: "dataBindings" + }, { name: "customViews" }, @@ -201,6 +207,12 @@ describe("page custom field", () => { { name: "content" }, + { + name: "dataSources" + }, + { + name: "dataBindings" + }, { name: "customViews" } diff --git a/packages/api-page-builder-so-ddb-es/src/definitions/pageBlockEntity.ts b/packages/api-page-builder-so-ddb-es/src/definitions/pageBlockEntity.ts index 9af5f7ba6e8..34c2073a9dd 100644 --- a/packages/api-page-builder-so-ddb-es/src/definitions/pageBlockEntity.ts +++ b/packages/api-page-builder-so-ddb-es/src/definitions/pageBlockEntity.ts @@ -55,6 +55,14 @@ export const createPageBlockEntity = (params: Params): Entity => { locale: { type: "string" }, + dataSources: { + type: "list", + default: [] + }, + dataBindings: { + type: "list", + default: [] + }, ...(attributes || {}) } }); diff --git a/packages/api-page-builder-so-ddb-es/src/definitions/pageEntity.ts b/packages/api-page-builder-so-ddb-es/src/definitions/pageEntity.ts index 0062d28b66b..5a88a0c6740 100644 --- a/packages/api-page-builder-so-ddb-es/src/definitions/pageEntity.ts +++ b/packages/api-page-builder-so-ddb-es/src/definitions/pageEntity.ts @@ -82,6 +82,14 @@ export const createPageEntity = (params: Params): Entity => { webinyVersion: { type: "string" }, + dataSources: { + type: "list", + default: [] + }, + dataBindings: { + type: "list", + default: [] + }, ...(attributes || {}) } }); diff --git a/packages/api-page-builder-so-ddb/src/definitions/pageBlockEntity.ts b/packages/api-page-builder-so-ddb/src/definitions/pageBlockEntity.ts index 9af5f7ba6e8..34c2073a9dd 100644 --- a/packages/api-page-builder-so-ddb/src/definitions/pageBlockEntity.ts +++ b/packages/api-page-builder-so-ddb/src/definitions/pageBlockEntity.ts @@ -55,6 +55,14 @@ export const createPageBlockEntity = (params: Params): Entity => { locale: { type: "string" }, + dataSources: { + type: "list", + default: [] + }, + dataBindings: { + type: "list", + default: [] + }, ...(attributes || {}) } }); diff --git a/packages/api-page-builder-so-ddb/src/definitions/pageEntity.ts b/packages/api-page-builder-so-ddb/src/definitions/pageEntity.ts index 62939c0e82b..83de4961a04 100644 --- a/packages/api-page-builder-so-ddb/src/definitions/pageEntity.ts +++ b/packages/api-page-builder-so-ddb/src/definitions/pageEntity.ts @@ -91,6 +91,14 @@ export const createPageEntity = (params: Params): Entity => { webinyVersion: { type: "string" }, + dataSources: { + type: "list", + default: [] + }, + dataBindings: { + type: "list", + default: [] + }, ...(attributes || {}) } }); diff --git a/packages/api-page-builder/package.json b/packages/api-page-builder/package.json index eb41d2f126c..5cd040ad02e 100644 --- a/packages/api-page-builder/package.json +++ b/packages/api-page-builder/package.json @@ -22,6 +22,7 @@ "@webiny/api-tenancy": "0.0.0", "@webiny/aws-sdk": "0.0.0", "@webiny/error": "0.0.0", + "@webiny/feature-flags": "0.0.0", "@webiny/handler": "0.0.0", "@webiny/handler-aws": "0.0.0", "@webiny/handler-db": "0.0.0", @@ -51,6 +52,7 @@ "@webiny/project-utils": "0.0.0", "bytes": "^3.1.2", "jest": "^29.7.0", + "prettier": "^2.8.8", "rimraf": "^6.0.1", "ttypescript": "^1.5.15", "typescript": "4.9.5" diff --git a/packages/api-page-builder/src/dataSources/DataLoader.ts b/packages/api-page-builder/src/dataSources/DataLoader.ts new file mode 100644 index 00000000000..cb2cdc40260 --- /dev/null +++ b/packages/api-page-builder/src/dataSources/DataLoader.ts @@ -0,0 +1,28 @@ +import { createZodError } from "@webiny/utils"; +import type { DataLoaderResult, IDataLoader, IDataSource } from "~/dataSources/types"; +import type { DataLoaderRequest } from "~/dataSources/DataLoaderRequest"; + +export class DataLoader implements IDataLoader { + private readonly dataSources: IDataSource[]; + + constructor(dataSources: IDataSource[]) { + this.dataSources = dataSources; + } + + async load(request: DataLoaderRequest): Promise { + const type = request.getType(); + const dataSource = this.dataSources.find(ds => ds.getType() === type); + + if (!dataSource) { + throw new Error(`Can't find dataSource ${type}`); + } + + const configSchema = dataSource.getConfigSchema(); + const result = await configSchema.safeParseAsync(request.getConfig()); + if (!result.success) { + throw createZodError(result.error); + } + + return dataSource.load(request.withConfig(result.data)); + } +} diff --git a/packages/api-page-builder/src/dataSources/DataLoaderRequest.ts b/packages/api-page-builder/src/dataSources/DataLoaderRequest.ts new file mode 100644 index 00000000000..ad441ed305c --- /dev/null +++ b/packages/api-page-builder/src/dataSources/DataLoaderRequest.ts @@ -0,0 +1,34 @@ +import type { GenericRecord } from "@webiny/api/types"; +import type { RequestDto } from "~/dataSources/types"; + +export class DataLoaderRequest { + private readonly type: string; + private readonly config: TConfig; + private readonly paths: string[]; + + protected constructor(type: string, config: TConfig, paths: string[]) { + this.type = type; + this.config = config; + this.paths = paths; + } + + static create(params: RequestDto) { + return new DataLoaderRequest(params.type, params.config, params.paths); + } + + getType() { + return this.type; + } + + getConfig() { + return this.config; + } + + getPaths() { + return this.paths || []; + } + + withConfig(config: TConfig) { + return new DataLoaderRequest(this.type, config, this.paths); + } +} diff --git a/packages/api-page-builder/src/dataSources/cmsDataSources/CmsEntriesDataSource.ts b/packages/api-page-builder/src/dataSources/cmsDataSources/CmsEntriesDataSource.ts new file mode 100644 index 00000000000..c53e6e896fd --- /dev/null +++ b/packages/api-page-builder/src/dataSources/cmsDataSources/CmsEntriesDataSource.ts @@ -0,0 +1,51 @@ +import zod from "zod"; +import type { CmsContext } from "@webiny/api-headless-cms/types"; +import type { DataLoaderRequest, DataLoaderResult, IDataSource } from "~/dataSources"; +import { ModelListQuery } from "~/dataSources/cmsDataSources/ModelListQuery"; + +export interface CmsEntriesDataSourceConfig { + modelId: string; + limit: number; +} + +export class CmsEntriesDataSource implements IDataSource { + private cms: CmsContext["cms"]; + + constructor(cms: CmsContext["cms"]) { + this.cms = cms; + } + + getType() { + return "cms.entries"; + } + + getConfigSchema(): zod.Schema { + return zod.object({ + modelId: zod.string(), + limit: zod.number() + }); + } + + async load(request: DataLoaderRequest): Promise { + const requestedPaths = request.getPaths(); + const queryPaths = !requestedPaths || requestedPaths.length === 0 ? ["id"] : requestedPaths; + + const config = request.getConfig(); + const schemaClient = await this.cms.getExecutableSchema("preview"); + const model = await this.cms.getModel(config.modelId); + + const listQuery = new ModelListQuery(); + const query = listQuery.getQuery(model, queryPaths); + + const response = await schemaClient({ + query, + operationName: "ListEntries", + variables: { + limit: config.limit || 10 + } + }); + + // @ts-expect-error Naive return for the time being. + return response.data.entries.data; + } +} diff --git a/packages/api-page-builder/src/dataSources/cmsDataSources/CmsEntryDataSource.ts b/packages/api-page-builder/src/dataSources/cmsDataSources/CmsEntryDataSource.ts new file mode 100644 index 00000000000..3fc383d1778 --- /dev/null +++ b/packages/api-page-builder/src/dataSources/cmsDataSources/CmsEntryDataSource.ts @@ -0,0 +1,51 @@ +import zod from "zod"; +import type { CmsContext } from "@webiny/api-headless-cms/types"; +import type { DataLoaderRequest, DataLoaderResult, IDataSource } from "~/dataSources"; +import { ModelGetQuery } from "~/dataSources/cmsDataSources/ModelGetQuery"; + +export interface CmsEntryDataSourceConfig { + modelId: string; + entryId: string; +} + +export class CmsEntryDataSource implements IDataSource { + private cms: CmsContext["cms"]; + + constructor(cms: CmsContext["cms"]) { + this.cms = cms; + } + + getType() { + return "cms.entry"; + } + + getConfigSchema(): zod.Schema { + return zod.object({ + modelId: zod.string(), + entryId: zod.string() + }); + } + + async load(request: DataLoaderRequest): Promise { + const requestedPaths = request.getPaths(); + const queryPaths = !requestedPaths || requestedPaths.length === 0 ? ["id"] : requestedPaths; + + const config = request.getConfig(); + const schemaClient = await this.cms.getExecutableSchema("preview"); + const model = await this.cms.getModel(config.modelId); + + const listQuery = new ModelGetQuery(); + const query = listQuery.getQuery(model, queryPaths); + + const response = await schemaClient({ + query, + operationName: "GetEntry", + variables: { + entryId: config.entryId + } + }); + + // @ts-expect-error Naive return for the time being. + return response.data.entry.data; + } +} diff --git a/packages/api-page-builder/src/dataSources/cmsDataSources/ModelGetQuery.ts b/packages/api-page-builder/src/dataSources/cmsDataSources/ModelGetQuery.ts new file mode 100644 index 00000000000..9abd7e1dcbc --- /dev/null +++ b/packages/api-page-builder/src/dataSources/cmsDataSources/ModelGetQuery.ts @@ -0,0 +1,25 @@ +import { CmsModel } from "@webiny/api-headless-cms/types"; +import { PathsParser } from "./converter/PathsParser"; +import { SelectionFormatter } from "./converter/SelectionFormatter"; + +export class ModelGetQuery { + getQuery(model: CmsModel, paths: string[]) { + const parser = new PathsParser(); + const formatter = new SelectionFormatter(); + + const selection = formatter.formatSelection(parser.parse(paths)); + + return /* GraphQL */ ` + query GetEntry($entryId: String!) { + entry: get${model.singularApiName}(where: { entryId: $entryId }) { + data ${selection} + error { + code + message + data + } + } + } + `; + } +} diff --git a/packages/api-page-builder/src/dataSources/cmsDataSources/ModelListQuery.ts b/packages/api-page-builder/src/dataSources/cmsDataSources/ModelListQuery.ts new file mode 100644 index 00000000000..1ff7dd8fa12 --- /dev/null +++ b/packages/api-page-builder/src/dataSources/cmsDataSources/ModelListQuery.ts @@ -0,0 +1,25 @@ +import { CmsModel } from "@webiny/api-headless-cms/types"; +import { PathsParser } from "./converter/PathsParser"; +import { SelectionFormatter } from "./converter/SelectionFormatter"; + +export class ModelListQuery { + getQuery(model: CmsModel, paths: string[]) { + const parser = new PathsParser(); + const formatter = new SelectionFormatter(); + + const selection = formatter.formatSelection(parser.parse(paths)); + + return /* GraphQL */ ` + query ListEntries($limit: Int) { + entries: list${model.pluralApiName}(limit: $limit) { + data ${selection} + error { + code + message + data + } + } + } + `; + } +} diff --git a/packages/api-page-builder/src/dataSources/cmsDataSources/converter/ParsedPath.test.ts b/packages/api-page-builder/src/dataSources/cmsDataSources/converter/ParsedPath.test.ts new file mode 100644 index 00000000000..65146b38ebe --- /dev/null +++ b/packages/api-page-builder/src/dataSources/cmsDataSources/converter/ParsedPath.test.ts @@ -0,0 +1,57 @@ +import { ParsedPath } from "./ParsedPath"; + +describe("Path Parser", () => { + it("should parse simple field", () => { + const result = ParsedPath.create("title"); + expect(result).toEqual({ + key: "title", + fragment: undefined, + remaining: "" + }); + }); + + it("should parse nested field", () => { + const result = ParsedPath.create("user.name"); + expect(result).toEqual({ + key: "user", + fragment: undefined, + remaining: "name" + }); + }); + + it("should parse fragment field", () => { + const result = ParsedPath.create("content.[Hero].title"); + expect(result).toEqual({ + key: "content", + fragment: "Hero", + remaining: "title" + }); + }); + + it("should parse nested fragment field", () => { + const result = ParsedPath.create("sections.[Hero].content.[Text].body"); + expect(result).toEqual({ + key: "sections", + fragment: "Hero", + remaining: "content.[Text].body" + }); + }); + + it("should handle field with no remaining path", () => { + const result = ParsedPath.create("user"); + expect(result).toEqual({ + key: "user", + fragment: undefined, + remaining: "" + }); + }); + + it("should handle fragment with no remaining path", () => { + const result = ParsedPath.create("content.[Hero]"); + expect(result).toEqual({ + key: "content", + fragment: "Hero", + remaining: "" + }); + }); +}); diff --git a/packages/api-page-builder/src/dataSources/cmsDataSources/converter/ParsedPath.ts b/packages/api-page-builder/src/dataSources/cmsDataSources/converter/ParsedPath.ts new file mode 100644 index 00000000000..82957c9d8b6 --- /dev/null +++ b/packages/api-page-builder/src/dataSources/cmsDataSources/converter/ParsedPath.ts @@ -0,0 +1,28 @@ +import { SelectionPath } from "./types"; + +export class ParsedPath { + public readonly key: string; + public readonly fragment: string | undefined; + public readonly remaining: string; + + private constructor(path: SelectionPath) { + // Match pattern: key.[Type].remaining, or key.remaining, or just key + const fragmentMatch = path.match(/^([^.\[]+)(?:\.(?:\[([^\]]+)\]|([^.\[]+)))?(.*)$/); + + this.key = path as string; + this.remaining = ""; + + if (fragmentMatch) { + const [, key, fragment, normalField, remaining] = fragmentMatch; + this.key = key; + this.fragment = fragment || undefined; + this.remaining = normalField + ? normalField + (remaining || "") + : (remaining || "").replace(/^\./, ""); // Remove leading dot if present + } + } + + static create(path: SelectionPath) { + return new ParsedPath(path); + } +} diff --git a/packages/api-page-builder/src/dataSources/cmsDataSources/converter/PathsParser.test.ts b/packages/api-page-builder/src/dataSources/cmsDataSources/converter/PathsParser.test.ts new file mode 100644 index 00000000000..c4a47d072b0 --- /dev/null +++ b/packages/api-page-builder/src/dataSources/cmsDataSources/converter/PathsParser.test.ts @@ -0,0 +1,64 @@ +import { PathsParser } from "./PathsParser"; + +describe("PathsParser", () => { + it("should generate a simple selection", () => { + const paths = ["title", "content"]; + const result = new PathsParser().parse(paths); + expect(result).toEqual({ + title: true, + content: true + }); + }); + + it("should generate nested selections", () => { + const paths = ["title", "cta.link", "cta.label"]; + const result = new PathsParser().parse(paths); + expect(result).toEqual({ + title: true, + cta: { + link: true, + label: true + } + }); + }); + + it("should handle array fields with nested properties", () => { + const paths = ["testimonials", "testimonials.name", "testimonials.text"]; + const result = new PathsParser().parse(paths); + expect(result).toEqual({ + testimonials: { + name: true, + text: true + } + }); + }); + + it("should handle fields that appear both as leaf and parent nodes", () => { + const paths = ["user", "user.id", "user.profile", "user.profile.name"]; + const result = new PathsParser().parse(paths); + expect(result).toEqual({ + user: { + id: true, + profile: { + name: true + } + } + }); + }); + + it("should handle multiple nested levels", () => { + const paths = ["data.user.profile.name", "data.user.profile.email", "data.user.settings"]; + const result = new PathsParser().parse(paths); + expect(result).toEqual({ + data: { + user: { + profile: { + name: true, + email: true + }, + settings: true + } + } + }); + }); +}); diff --git a/packages/api-page-builder/src/dataSources/cmsDataSources/converter/PathsParser.ts b/packages/api-page-builder/src/dataSources/cmsDataSources/converter/PathsParser.ts new file mode 100644 index 00000000000..f0a69de5c4b --- /dev/null +++ b/packages/api-page-builder/src/dataSources/cmsDataSources/converter/PathsParser.ts @@ -0,0 +1,57 @@ +import { ParsedPath } from "./ParsedPath"; +import type { Fragment, GraphQLSelection } from "./types"; + +export class PathsParser { + parse(paths: string[]) { + const selection: GraphQLSelection = {}; + + // Sort paths to ensure parent paths come after child paths + const sortedPaths = [...paths].sort((a, b) => b.length - a.length); + + sortedPaths.forEach(path => { + let current = selection; + let remainingPath = path; + + while (remainingPath) { + const { key, fragment, remaining } = ParsedPath.create(remainingPath); + remainingPath = remaining; + + if (!fragment) { + // Handle regular field + if (!remainingPath) { + // Don't overwrite existing object with true + if (typeof current[key] !== "object") { + current[key] = true; + } + } else { + if (!current[key] || current[key] === true) { + current[key] = {}; + } + current = current[key] as GraphQLSelection; + } + } else { + // Handle fragment + if (!current[key] || current[key] === true) { + current[key] = {}; + } + + const fragmentContainer = current[key] as GraphQLSelection; + const fragmentKey = `fragment_${fragment}`; + + if (!fragmentContainer[fragmentKey]) { + fragmentContainer[fragmentKey] = { + type: fragment, + fields: {} + } as Fragment; + } + + if (remaining) { + current = (fragmentContainer[fragmentKey] as Fragment).fields; + } + } + } + }); + + return selection; + } +} diff --git a/packages/api-page-builder/src/dataSources/cmsDataSources/converter/SelectionFormatter.test.ts b/packages/api-page-builder/src/dataSources/cmsDataSources/converter/SelectionFormatter.test.ts new file mode 100644 index 00000000000..53f70d9955e --- /dev/null +++ b/packages/api-page-builder/src/dataSources/cmsDataSources/converter/SelectionFormatter.test.ts @@ -0,0 +1,68 @@ +import prettier from "prettier"; +import { PathsParser } from "./PathsParser"; +import { SelectionFormatter } from "./SelectionFormatter"; + +const prettyGql = (value: string) => { + return prettier.format(value.trim(), { parser: "graphql" }); +}; + +describe("SelectionFormatter", () => { + const formatPaths = (paths: string[]) => { + const parser = new PathsParser(); + const formatter = new SelectionFormatter(); + + const selection = parser.parse(paths); + + return formatter.formatSelection(selection); + }; + + it("should handle fragments with new syntax", () => { + const paths = [ + "content.[Hero].title", + "content.[Hero].subtitle", + "content.[Feature].name", + "content.[Feature].description" + ]; + const formatted = formatPaths(paths); + const snapshot = /* GraphQL */ ` + { + content { + ... on Feature { + description + name + } + ... on Hero { + subtitle + title + } + } + } + `; + expect(prettyGql(formatted)).toBe(prettyGql(snapshot)); + }); + + it("should handle nested fragments with new syntax", () => { + const paths = [ + "sections.[Hero].content.[Image].url", + "sections.[Hero].content.[Text].body" + ]; + const formatted = formatPaths(paths); + const snapshot = /* GraphQL */ ` + { + sections { + ... on Hero { + content { + ... on Image { + url + } + ... on Text { + body + } + } + } + } + } + `; + expect(prettyGql(formatted)).toBe(prettyGql(snapshot)); + }); +}); diff --git a/packages/api-page-builder/src/dataSources/cmsDataSources/converter/SelectionFormatter.ts b/packages/api-page-builder/src/dataSources/cmsDataSources/converter/SelectionFormatter.ts new file mode 100644 index 00000000000..83091120041 --- /dev/null +++ b/packages/api-page-builder/src/dataSources/cmsDataSources/converter/SelectionFormatter.ts @@ -0,0 +1,52 @@ +import { Fragment, GraphQLSelection } from "./types"; + +export class SelectionFormatter { + /** + * Converts a GraphQL selection object into a formatted GraphQL query string + * @param selection GraphQL selection object + * @param indent Indentation level + * @returns Formatted GraphQL query string + */ + formatSelection(selection: GraphQLSelection, indent = 0): string { + const lines: string[] = ["{"]; + + Object.entries(selection).forEach(([key, value]) => { + if (value === true) { + lines.push(key); + } else if ("type" in value && "fields" in value) { + // Handle fragment + const fragment = value as Fragment; + lines.push( + `... on ${fragment.type} ${this.formatSelection(fragment.fields, indent + 1)}` + ); + } else { + // Handle regular nested fields + const fragments = Object.entries(value as GraphQLSelection) + .filter(([k]) => k.startsWith("fragment_")) + .map(([, v]) => v as Fragment); + + const regularFields = Object.entries(value as GraphQLSelection) + .filter(([k]) => !k.startsWith("fragment_")) + .reduce((acc, [k, v]) => ({ ...acc, [k]: v }), {}); + + if (Object.keys(regularFields).length > 0) { + lines.push(`${key} ${this.formatSelection(regularFields, indent + 1)}`); + } else if (fragments.length > 0) { + lines.push(`${key} {`); + fragments.forEach(fragment => { + lines.push( + `... on ${fragment.type} ${this.formatSelection( + fragment.fields, + indent + 2 + )}` + ); + }); + lines.push(`}`); + } + } + }); + + lines.push(`}`); + return lines.join("\n"); + } +} diff --git a/packages/api-page-builder/src/dataSources/cmsDataSources/converter/types.ts b/packages/api-page-builder/src/dataSources/cmsDataSources/converter/types.ts new file mode 100644 index 00000000000..8159ee71e9f --- /dev/null +++ b/packages/api-page-builder/src/dataSources/cmsDataSources/converter/types.ts @@ -0,0 +1,10 @@ +export type SelectionPath = string; + +export interface Fragment { + type: string; + fields: GraphQLSelection; +} + +export type GraphQLSelection = { + [key: string]: GraphQLSelection | Fragment | true; +}; diff --git a/packages/api-page-builder/src/dataSources/context/DataSourcesContext.ts b/packages/api-page-builder/src/dataSources/context/DataSourcesContext.ts new file mode 100644 index 00000000000..fbad21cb3d1 --- /dev/null +++ b/packages/api-page-builder/src/dataSources/context/DataSourcesContext.ts @@ -0,0 +1,14 @@ +import { IDataLoader, IDataSource, IDataSourceContext } from "~/dataSources/types"; +import { DataLoader } from "~/dataSources/DataLoader"; + +export class DataSourcesContext implements IDataSourceContext { + private dataSources: IDataSource[] = []; + + addDataSource(dataSource: IDataSource): void { + this.dataSources.push(dataSource); + } + + getLoader(): IDataLoader { + return new DataLoader(this.dataSources); + } +} diff --git a/packages/api-page-builder/src/dataSources/context/createDataSourcesContext.ts b/packages/api-page-builder/src/dataSources/context/createDataSourcesContext.ts new file mode 100644 index 00000000000..beabd0ae0c7 --- /dev/null +++ b/packages/api-page-builder/src/dataSources/context/createDataSourcesContext.ts @@ -0,0 +1,14 @@ +import { createContextPlugin } from "@webiny/api"; +import type { PbContext } from "~/graphql/types"; +import { DataSourcesContext } from "./DataSourcesContext"; +import { CmsEntryDataSource } from "~/dataSources/cmsDataSources/CmsEntryDataSource"; +import { CmsEntriesDataSource } from "~/dataSources/cmsDataSources/CmsEntriesDataSource"; + +export const createDataSourcesContext = () => { + return createContextPlugin(context => { + context.dataSources = new DataSourcesContext(); + + context.dataSources.addDataSource(new CmsEntryDataSource(context.cms)); + context.dataSources.addDataSource(new CmsEntriesDataSource(context.cms)); + }); +}; diff --git a/packages/api-page-builder/src/dataSources/graphql/createDataSourcesSchema.ts b/packages/api-page-builder/src/dataSources/graphql/createDataSourcesSchema.ts new file mode 100644 index 00000000000..dcf8e0d0d44 --- /dev/null +++ b/packages/api-page-builder/src/dataSources/graphql/createDataSourcesSchema.ts @@ -0,0 +1,14 @@ +import { featureFlags } from "@webiny/feature-flags"; +import { createGraphQLSchemaPlugin } from "@webiny/handler-graphql"; +import { dataSourcesSchema } from "~/dataSources/graphql/schema"; +import { dataSourcesResolvers } from "~/dataSources/graphql/resolvers"; + +export const createDataSourcesSchema = () => { + return createGraphQLSchemaPlugin({ + typeDefs: dataSourcesSchema, + resolvers: dataSourcesResolvers, + isApplicable: () => { + return featureFlags.experimentalDynamicPages === true; + } + }); +}; diff --git a/packages/api-page-builder/src/dataSources/graphql/resolvers.ts b/packages/api-page-builder/src/dataSources/graphql/resolvers.ts new file mode 100644 index 00000000000..ad34911fd62 --- /dev/null +++ b/packages/api-page-builder/src/dataSources/graphql/resolvers.ts @@ -0,0 +1,43 @@ +import { ErrorResponse, Response } from "@webiny/handler-graphql"; +import type { Resolvers } from "@webiny/handler-graphql/types"; +import type { DataSourcesContext } from "~/dataSources/types"; +import { DataLoaderRequest } from "~/dataSources"; + +export const dataSourcesResolvers: Resolvers = { + Query: { + dataSources: () => ({}) + }, + DataSourcesQuery: { + loadDataSource: async (_, args, context) => { + const loader = context.dataSources.getLoader(); + + try { + const data = await loader.load( + DataLoaderRequest.create({ + type: args.type, + config: args.config, + paths: args.paths || [] + }) + ); + + if (data === undefined) { + return new ErrorResponse({ + code: "NO_APPLICABLE_DATA_SOURCES", + message: "No data sources were able to handle the request!" + }); + } + + return new Response(data); + } catch (e) { + if (e.code === "VALIDATION_FAILED_INVALID_FIELDS") { + return new ErrorResponse({ + code: "DATA_SOURCE_CONFIG_VALIDATION", + message: "Data source config is invalid.", + data: e.data + }); + } + return new ErrorResponse(e); + } + } + } +}; diff --git a/packages/api-page-builder/src/dataSources/graphql/schema.ts b/packages/api-page-builder/src/dataSources/graphql/schema.ts new file mode 100644 index 00000000000..b162897e954 --- /dev/null +++ b/packages/api-page-builder/src/dataSources/graphql/schema.ts @@ -0,0 +1,21 @@ +export const dataSourcesSchema = /* GraphQL */ ` + type DataSourceError { + code: String + message: String + data: JSON + stack: String + } + + type DataSourceResponse { + data: JSON + error: DataSourceError + } + + type DataSourcesQuery { + loadDataSource(type: String!, config: JSON!, paths: [String!]): DataSourceResponse + } + + extend type Query { + dataSources: DataSourcesQuery + } +`; diff --git a/packages/api-page-builder/src/dataSources/index.ts b/packages/api-page-builder/src/dataSources/index.ts new file mode 100644 index 00000000000..c10b01c9b53 --- /dev/null +++ b/packages/api-page-builder/src/dataSources/index.ts @@ -0,0 +1,2 @@ +export * from "./DataLoaderRequest"; +export * from "./types"; diff --git a/packages/api-page-builder/src/dataSources/types.ts b/packages/api-page-builder/src/dataSources/types.ts new file mode 100644 index 00000000000..ac8ec02e863 --- /dev/null +++ b/packages/api-page-builder/src/dataSources/types.ts @@ -0,0 +1,30 @@ +import zod from "zod"; +import { GenericRecord } from "@webiny/api/types"; +import type { DataLoaderRequest } from "./DataLoaderRequest"; + +export type DataLoaderResult = GenericRecord | GenericRecord[] | undefined; + +export interface IDataLoader { + load(request: DataLoaderRequest): Promise; +} + +export interface IDataSource { + getType(): string; + getConfigSchema(): zod.Schema; + load(request: DataLoaderRequest): Promise; +} + +export interface IDataSourceContext { + addDataSource: (dataSource: IDataSource) => void; + getLoader: () => IDataLoader; +} + +export interface DataSourcesContext { + dataSources: IDataSourceContext; +} + +export interface RequestDto { + type: string; + config: GenericRecord; + paths: string[]; +} diff --git a/packages/api-page-builder/src/graphql/crud.ts b/packages/api-page-builder/src/graphql/crud.ts index e1d18147161..51f391f4f99 100644 --- a/packages/api-page-builder/src/graphql/crud.ts +++ b/packages/api-page-builder/src/graphql/crud.ts @@ -20,6 +20,7 @@ import { BlockCategoriesPermissions } from "./crud/permissions/BlockCategoriesPe import { PageTemplatesPermissions } from "~/graphql/crud/permissions/PageTemplatesPermissions"; import { PageBlocksPermissions } from "~/graphql/crud/permissions/PageBlocksPermissions"; import { GzipContentCompressionPlugin, JsonpackContentCompressionPlugin } from "~/plugins"; +import { createDataSourcesContext } from "~/dataSources/context/createDataSourcesContext"; export interface CreateCrudParams { storageOperations: PageBuilderStorageOperations; @@ -234,8 +235,12 @@ export const createCrud = (params: CreateCrudParams) => { * Maybe figure out some other way of registering the plugins. */ /** - * Add validation + * Add validation. */ - createPageValidation() + createPageValidation(), + /** + * Add DataSources context. + */ + createDataSourcesContext() ]; }; diff --git a/packages/api-page-builder/src/graphql/crud/dynamicData.validation.ts b/packages/api-page-builder/src/graphql/crud/dynamicData.validation.ts new file mode 100644 index 00000000000..6f5900f2fab --- /dev/null +++ b/packages/api-page-builder/src/graphql/crud/dynamicData.validation.ts @@ -0,0 +1,22 @@ +import zod from "zod"; + +export const dynamicData = { + dataSources: zod + .array( + zod.object({ + name: zod.string(), + type: zod.string(), + config: zod.object({}).passthrough() + }) + ) + .optional(), + dataBindings: zod + .array( + zod.object({ + dataSource: zod.string(), + bindFrom: zod.string(), + bindTo: zod.string() + }) + ) + .optional() +}; diff --git a/packages/api-page-builder/src/graphql/crud/pageBlocks/validation.ts b/packages/api-page-builder/src/graphql/crud/pageBlocks/validation.ts index 61eda80ac6b..e7d9898c871 100644 --- a/packages/api-page-builder/src/graphql/crud/pageBlocks/validation.ts +++ b/packages/api-page-builder/src/graphql/crud/pageBlocks/validation.ts @@ -1,4 +1,5 @@ import zod from "zod"; +import { dynamicData } from "~/graphql/crud/dynamicData.validation"; const refineValidation = (value?: string) => { if (!value) { @@ -23,7 +24,8 @@ const refineValidationMessage = (value?: string) => { const baseValidation = zod.object({ name: zod.string().min(1).max(100), - content: zod.union([zod.object({}).passthrough(), zod.array(zod.object({}).passthrough())]) + content: zod.union([zod.object({}).passthrough(), zod.array(zod.object({}).passthrough())]), + ...dynamicData }); export const createPageBlocksCreateValidation = () => { diff --git a/packages/api-page-builder/src/graphql/crud/pageTemplates.crud.ts b/packages/api-page-builder/src/graphql/crud/pageTemplates.crud.ts index 46897db0c7f..a685cbea5de 100644 --- a/packages/api-page-builder/src/graphql/crud/pageTemplates.crud.ts +++ b/packages/api-page-builder/src/graphql/crud/pageTemplates.crud.ts @@ -25,6 +25,7 @@ import WebinyError from "@webiny/error"; import { createTopic } from "@webiny/pubsub"; import { mdbid } from "@webiny/utils"; import { PageTemplatesPermissions } from "~/graphql/crud/permissions/PageTemplatesPermissions"; +import { dynamicData } from "~/graphql/crud/dynamicData.validation"; const createSchema = zod.object({ title: zod.string().max(100), @@ -32,8 +33,8 @@ const createSchema = zod.object({ tags: zod.string().array(), description: zod.string().max(100), layout: zod.string().max(100).optional(), - pageCategory: zod.string().max(100), - content: zod.any() + content: zod.any(), + ...dynamicData }); const updateSchema = zod.object({ @@ -42,8 +43,8 @@ const updateSchema = zod.object({ tags: zod.string().array().optional(), description: zod.string().max(100).optional(), layout: zod.string().max(100).optional(), - pageCategory: zod.string().max(100).optional(), - content: zod.any() + content: zod.any(), + ...dynamicData }); const getDefaultContent = () => { @@ -377,7 +378,7 @@ export const createPageTemplatesCrud = ( if (!template) { throw new NotFoundError(`Page template "${id || slug}" was not found!`); } - const page = await context.pageBuilder.createPage(template.pageCategory, meta); + const page = await context.pageBuilder.createPage("static", meta); this.copyTemplateDataToPage(template, page); await context.pageBuilder.updatePage(page.id, { @@ -422,7 +423,6 @@ export const createPageTemplatesCrud = ( description: data.description, tags: page.settings.general?.tags || [], layout: page.settings.general?.layout || "static", - pageCategory: page.category, content: { ...page.content, data: { diff --git a/packages/api-page-builder/src/graphql/crud/pages/validation.ts b/packages/api-page-builder/src/graphql/crud/pages/validation.ts index 595f8c28391..0fa784a31da 100644 --- a/packages/api-page-builder/src/graphql/crud/pages/validation.ts +++ b/packages/api-page-builder/src/graphql/crud/pages/validation.ts @@ -1,5 +1,6 @@ import normalizePath from "./normalizePath"; import zod from "zod"; +import { dynamicData } from "~/graphql/crud/dynamicData.validation"; export const createPageValidation = zod.object({ category: zod.string().max(500) @@ -111,7 +112,8 @@ export const updatePageValidation = zod content: zod .union([zod.object({}).passthrough(), zod.array(zod.object({}).passthrough())]) .optional() - .nullish() + .nullish(), + ...dynamicData }) .partial(); diff --git a/packages/api-page-builder/src/graphql/graphql.ts b/packages/api-page-builder/src/graphql/graphql.ts index 63d76ed9c36..46b24f1025b 100644 --- a/packages/api-page-builder/src/graphql/graphql.ts +++ b/packages/api-page-builder/src/graphql/graphql.ts @@ -10,10 +10,12 @@ import { createPageBlockGraphQL } from "./graphql/pageBlocks.gql"; import { createPageTemplateGraphQL } from "./graphql/pageTemplates.gql"; import { GraphQLSchemaPlugin } from "@webiny/handler-graphql/types"; +import { createDynamicDataSchema } from "~/graphql/graphql/dynamicData.gql"; export default () => { return [ createBaseGraphQL(), + createDynamicDataSchema(), createMenuGraphQL(), createCategoryGraphQL(), createPageGraphQL(), 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 8a2ccce9295..ad5016f52fc 100644 --- a/packages/api-page-builder/src/graphql/graphql/base.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/base.gql.ts @@ -36,11 +36,11 @@ export const createBaseGraphQL = (): GraphQLSchemaPlugin => { } type PbQuery { - pageBuilder: PbQuery + _empty: String } type PbMutation { - pageBuilder: PbMutation + _empty: String } extend type Query { diff --git a/packages/api-page-builder/src/graphql/graphql/dynamicData.gql.ts b/packages/api-page-builder/src/graphql/graphql/dynamicData.gql.ts new file mode 100644 index 00000000000..e718735df5b --- /dev/null +++ b/packages/api-page-builder/src/graphql/graphql/dynamicData.gql.ts @@ -0,0 +1,31 @@ +import { GraphQLSchemaPlugin } from "@webiny/handler-graphql"; + +export const createDynamicDataSchema = () => { + return new GraphQLSchemaPlugin({ + typeDefs: /* GraphQL */ ` + type DataSource { + name: String! + type: String! + config: JSON! + } + + type DataBinding { + dataSource: String + bindFrom: String + bindTo: String + } + + input DataBindingInput { + dataSource: String! + bindFrom: String! + bindTo: String! + } + + input DataSourceInput { + name: String! + type: String! + config: JSON! + } + ` + }); +}; diff --git a/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts b/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts index 073e3641db5..cb2e956f0ac 100644 --- a/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/pageBlocks.gql.ts @@ -14,6 +14,8 @@ export const createPageBlockGraphQL = new GraphQLSchemaPlugin({ name: String blockCategory: String content: JSON + dataSources: [DataSource!] + dataBindings: [DataBinding!] } input PbCreatePageBlockInput { @@ -26,6 +28,8 @@ export const createPageBlockGraphQL = new GraphQLSchemaPlugin({ name: String blockCategory: String content: JSON + dataSources: [DataSourceInput!] + dataBindings: [DataBindingInput!] } input PbListPageBlocksWhereInput { diff --git a/packages/api-page-builder/src/graphql/graphql/pageTemplates.gql.ts b/packages/api-page-builder/src/graphql/graphql/pageTemplates.gql.ts index 49cd63974ce..7d244de1fc5 100644 --- a/packages/api-page-builder/src/graphql/graphql/pageTemplates.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/pageTemplates.gql.ts @@ -20,11 +20,12 @@ export const createPageTemplateGraphQL = new GraphQLSchemaPlugin({ description: String! tags: [String!] content: JSON! + dataSources: [DataSource!] + dataBindings: [DataBinding!] createdOn: DateTime! savedOn: DateTime! createdBy: PbIdentity! layout: String - pageCategory: String } input PbCreatePageTemplateInput { @@ -33,8 +34,9 @@ export const createPageTemplateGraphQL = new GraphQLSchemaPlugin({ slug: String! tags: [String!] layout: String - pageCategory: String content: JSON + dataSources: [DataSourceInput!] + dataBindings: [DataBindingInput!] } input PbUpdatePageTemplateInput { @@ -42,9 +44,10 @@ export const createPageTemplateGraphQL = new GraphQLSchemaPlugin({ slug: String description: String layout: String - pageCategory: String content: JSON tags: [String!] + dataSources: [DataSourceInput!] + dataBindings: [DataBindingInput!] } input PbCreateTemplateFromPageInput { 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 89a4fb86a14..5f910fdafab 100644 --- a/packages/api-page-builder/src/graphql/graphql/pages.gql.ts +++ b/packages/api-page-builder/src/graphql/graphql/pages.gql.ts @@ -48,6 +48,8 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin => { settings: PbPageSettings content: JSON revisions: [PbPageRevision] + dataSources: [DataSource!] + dataBindings: [DataBinding!] } type PbPageRevision { @@ -103,6 +105,8 @@ const createBasePageGraphQL = (): GraphQLSchemaPlugin => { path: String settings: PbPageSettingsInput content: JSON + dataSources: [DataSourceInput!] + dataBindings: [DataBindingInput!] } input PbPageSettingsInput { diff --git a/packages/api-page-builder/src/graphql/index.ts b/packages/api-page-builder/src/graphql/index.ts index b0e3692a1d8..2aa49f4ec73 100644 --- a/packages/api-page-builder/src/graphql/index.ts +++ b/packages/api-page-builder/src/graphql/index.ts @@ -2,14 +2,16 @@ import { createCrud, CreateCrudParams } from "./crud"; import graphql from "./graphql"; import { createTranslations, createTranslationsGraphQl } from "~/translations/createTranslations"; import { PluginCollection } from "@webiny/plugins/types"; +import { createDataSourcesContext } from "~/dataSources/context/createDataSourcesContext"; +import { createDataSourcesSchema } from "~/dataSources/graphql/createDataSourcesSchema"; export const createPageBuilderGraphQL = (): PluginCollection => { - return [...graphql(), ...createTranslationsGraphQl()]; + return [...graphql(), ...createTranslationsGraphQl(), createDataSourcesSchema()]; }; export type ContextParams = CreateCrudParams; export const createPageBuilderContext = (params: ContextParams) => { - return [createCrud(params), ...createTranslations()]; + return [createCrud(params), ...createTranslations(), createDataSourcesContext()]; }; export * from "./crud/pages/PageContent"; diff --git a/packages/api-page-builder/src/graphql/types.ts b/packages/api-page-builder/src/graphql/types.ts index 99d11b1a79e..fc2ad2e23c7 100644 --- a/packages/api-page-builder/src/graphql/types.ts +++ b/packages/api-page-builder/src/graphql/types.ts @@ -10,6 +10,7 @@ import { BlockCategory, Category, DefaultSettings, + DynamicDocument, Menu, Page, PageBlock, @@ -23,6 +24,7 @@ import { } from "~/types"; import { PrerenderingServiceClientContext } from "@webiny/api-prerendering-service/client/types"; import { FileManagerContext } from "@webiny/api-file-manager/types"; +import { DataSourcesContext } from "~/dataSources/types"; // CRUD types. export interface ListPagesParamsWhere { @@ -792,7 +794,8 @@ export interface PbContext SecurityContext, TenancyContext, FileManagerContext, - PrerenderingServiceClientContext { + PrerenderingServiceClientContext, + DataSourcesContext { pageBuilder: PageBuilderContextObject; } @@ -885,7 +888,7 @@ export interface PbCategoryInput { layout: string; } -export interface PbUpdatePageInput { +export interface PbUpdatePageInput extends DynamicDocument { title?: string; category?: string; path?: string; diff --git a/packages/api-page-builder/src/types.ts b/packages/api-page-builder/src/types.ts index 580cb2159fb..e7e86799b9e 100644 --- a/packages/api-page-builder/src/types.ts +++ b/packages/api-page-builder/src/types.ts @@ -1,4 +1,5 @@ import { DefaultSettingsCrudOptions, PbContext } from "~/graphql/types"; +import { GenericRecord } from "@webiny/api/types"; export * from "./graphql/types"; @@ -80,7 +81,7 @@ export interface PageSettings { */ [key: string]: any; } -export interface Page | null> { +export interface Page | null> extends DynamicDocument { id: string; pid: string; locale: string; @@ -795,7 +796,7 @@ export interface BlockCategoryStorageOperations { /** * @category RecordModel */ -export interface PageBlock { +export interface PageBlock extends DynamicDocument { id: string; name: string; blockCategory: string; @@ -888,17 +889,41 @@ export interface PageBlockStorageOperations { delete(params: PageBlockStorageOperationsDeleteParams): Promise; } +export interface DataSource { + name: string; + type: string; + config: GenericRecord; +} + +export interface DataBinding { + dataSource: string; + bindFrom: string; + bindTo: string; +} + +export interface BlockVariable { + blockId: string; + elementId: string; + label: string; + inputName: string; +} + +export interface DynamicDocument { + dataSources?: DataSource[]; + dataBindings?: DataBinding[]; + blockVariables?: BlockVariable[]; +} + /** * @category RecordModel */ -export interface PageTemplate { +export interface PageTemplate extends DynamicDocument { id: string; title: string; slug: string; tags: string[]; description: string; layout?: string; - pageCategory: string; content?: any; createdOn: string; savedOn: string; @@ -909,7 +934,15 @@ export interface PageTemplate { export type PageTemplateInput = Pick< PageTemplate, - "title" | "description" | "content" | "slug" | "tags" | "layout" | "pageCategory" + | "title" + | "description" + | "content" + | "slug" + | "tags" + | "layout" + | "dataBindings" + | "dataSources" + | "blockVariables" > & { id?: string }; /** diff --git a/packages/api-page-builder/tsconfig.build.json b/packages/api-page-builder/tsconfig.build.json index 6c477340163..7c9d19576db 100644 --- a/packages/api-page-builder/tsconfig.build.json +++ b/packages/api-page-builder/tsconfig.build.json @@ -10,6 +10,7 @@ { "path": "../api-tenancy/tsconfig.build.json" }, { "path": "../aws-sdk/tsconfig.build.json" }, { "path": "../error/tsconfig.build.json" }, + { "path": "../feature-flags/tsconfig.build.json" }, { "path": "../handler/tsconfig.build.json" }, { "path": "../handler-aws/tsconfig.build.json" }, { "path": "../handler-db/tsconfig.build.json" }, diff --git a/packages/api-page-builder/tsconfig.json b/packages/api-page-builder/tsconfig.json index c40cdedaadb..ad0fc653d48 100644 --- a/packages/api-page-builder/tsconfig.json +++ b/packages/api-page-builder/tsconfig.json @@ -10,6 +10,7 @@ { "path": "../api-tenancy" }, { "path": "../aws-sdk" }, { "path": "../error" }, + { "path": "../feature-flags" }, { "path": "../handler" }, { "path": "../handler-aws" }, { "path": "../handler-db" }, @@ -43,6 +44,8 @@ "@webiny/aws-sdk": ["../aws-sdk/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], + "@webiny/feature-flags/*": ["../feature-flags/src/*"], + "@webiny/feature-flags": ["../feature-flags/src"], "@webiny/handler/*": ["../handler/src/*"], "@webiny/handler": ["../handler/src"], "@webiny/handler-aws/*": ["../handler-aws/src/*"], diff --git a/packages/app-dynamic-pages/.babelrc.js b/packages/app-dynamic-pages/.babelrc.js new file mode 100644 index 00000000000..bec58b263bd --- /dev/null +++ b/packages/app-dynamic-pages/.babelrc.js @@ -0,0 +1 @@ +module.exports = require("@webiny/project-utils").createBabelConfigForReact({ path: __dirname }); diff --git a/packages/app-dynamic-pages/LICENSE b/packages/app-dynamic-pages/LICENSE new file mode 100644 index 00000000000..f772d04d4db --- /dev/null +++ b/packages/app-dynamic-pages/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Webiny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/app-dynamic-pages/README.md b/packages/app-dynamic-pages/README.md new file mode 100644 index 00000000000..1828cefc0d0 --- /dev/null +++ b/packages/app-dynamic-pages/README.md @@ -0,0 +1,20 @@ +# @webiny/app-dynamic-pages + +[![](https://img.shields.io/npm/dw/@webiny/app-dynamic-pages.svg)](https://www.npmjs.com/package/@webiny/app-dynamic-pages) +[![](https://img.shields.io/npm/v/@webiny/app-dynamic-pages.svg)](https://www.npmjs.com/package/@webiny/app-dynamic-pages) +[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) + +An app for connecting dynamic data from Headless CMS with the Page Builder. + +## Install + +``` +npm install --save @webiny/app-dynamic-pages +``` + +Or if you prefer yarn: + +``` +yarn add @webiny/app-dynamic-pages +``` diff --git a/packages/app-dynamic-pages/package.json b/packages/app-dynamic-pages/package.json new file mode 100644 index 00000000000..ccebf194c39 --- /dev/null +++ b/packages/app-dynamic-pages/package.json @@ -0,0 +1,55 @@ +{ + "name": "@webiny/app-dynamic-pages", + "version": "0.0.0", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git" + }, + "author": "Webiny Ltd", + "license": "MIT", + "dependencies": { + "@emotion/react": "^11.10.6", + "@emotion/styled": "^11.10.6", + "@fortawesome/fontawesome-svg-core": "^1.3.0", + "@fortawesome/react-fontawesome": "^0.1.17", + "@material-design-icons/svg": "^0.14.3", + "@types/react": "18.2.79", + "@webiny/app": "0.0.0", + "@webiny/app-admin": "0.0.0", + "@webiny/app-headless-cms": "0.0.0", + "@webiny/app-page-builder": "0.0.0", + "@webiny/app-page-builder-elements": "0.0.0", + "@webiny/plugins": "0.0.0", + "@webiny/react-router": "0.0.0", + "@webiny/ui": "0.0.0", + "apollo-client": "^2.6.10", + "emotion": "10.0.27", + "graphql": "^15.7.2", + "mobx": "^6.9.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "slugify": "^1.6.6" + }, + "devDependencies": { + "@emotion/babel-plugin": "^11.11.0", + "@webiny/cli": "0.0.0", + "@webiny/project-utils": "0.0.0", + "rimraf": "^5.0.5", + "ttypescript": "^1.5.12", + "typescript": "4.9.5" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "scripts": { + "build": "yarn webiny run build", + "watch": "yarn webiny run watch" + }, + "svgo": { + "plugins": { + "removeViewBox": false + } + } +} diff --git a/packages/app-dynamic-pages/src/admin/ContentEntryForm/AddPreviewPane.tsx b/packages/app-dynamic-pages/src/admin/ContentEntryForm/AddPreviewPane.tsx new file mode 100644 index 00000000000..c2ceaec0836 --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/ContentEntryForm/AddPreviewPane.tsx @@ -0,0 +1,44 @@ +import React, { useMemo } from "react"; +import styled from "@emotion/styled"; +import { useModel } from "@webiny/app-headless-cms"; +import { ContentEntryEditorConfig } from "@webiny/app-headless-cms"; +import { useListPageTemplates } from "@webiny/app-page-builder/features"; +import { PreviewPane } from "~/admin/ContentEntryForm/PreviewPane"; + +const { ContentEntry } = ContentEntryEditorConfig; + +const SplitView = styled.div` + display: flex; + > div { + flex: 1; + } +`; + +export const AddPreviewPane = ContentEntry.ContentEntryForm.createDecorator(Original => { + return function ContentEntryForm(props) { + const { model } = useModel(); + + const { pageTemplates } = useListPageTemplates(); + + const modelTemplate = useMemo(() => { + return pageTemplates.find(template => + template.dataSources.some(ds => { + return ds.name === "main" && ds.config.modelId === model.modelId; + }) + ); + }, [pageTemplates]); + + if (!modelTemplate) { + return ; + } + + return ( + + +
+ +
+
+ ); + }; +}); diff --git a/packages/app-dynamic-pages/src/admin/ContentEntryForm/PassEntryToDataSource.tsx b/packages/app-dynamic-pages/src/admin/ContentEntryForm/PassEntryToDataSource.tsx new file mode 100644 index 00000000000..ceced7d0e73 --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/ContentEntryForm/PassEntryToDataSource.tsx @@ -0,0 +1,78 @@ +import React, { useCallback, useEffect, useMemo } from "react"; +import { makeAutoObservable } from "mobx"; +import { ContentEntryEditorConfig } from "@webiny/app-headless-cms"; +import { CmsContentEntry } from "@webiny/app-headless-cms/types"; +import { useLoadDataSource } from "@webiny/app-page-builder/features"; +import { + DataRequest, + DataSourceData, + IResolveDataSourceRepository +} from "@webiny/app-page-builder/features/dataSource/loadDataSource/IResolveDataSourceRepository"; + +type OnChange = NonNullable["onChange"]>; + +const { + ContentEntry: { ContentEntryForm } +} = ContentEntryEditorConfig; + +class WithLocalData implements IResolveDataSourceRepository { + private decoratee: IResolveDataSourceRepository; + private entryContainer: EntryContainer; + + constructor(entryContainer: EntryContainer, decoratee: IResolveDataSourceRepository) { + this.entryContainer = entryContainer; + this.decoratee = decoratee; + makeAutoObservable(this); + } + + getData(key: string): DataSourceData | undefined { + if (key.startsWith("main:")) { + return this.entryContainer.getEntry(); + } + + return this.decoratee.getData(key); + } + + async resolveData(request: DataRequest): Promise { + if (request.getName() === "main") { + return; + } + + return this.decoratee.resolveData(request); + } +} + +class EntryContainer { + private entry: Partial; + + constructor(entry: Partial) { + this.entry = entry; + makeAutoObservable(this); + } + + setEntry(entry: Partial) { + this.entry = entry; + } + + getEntry() { + return this.entry; + } +} + +export const PassEntryToDataSource = ContentEntryForm.createDecorator(Original => { + return function PassEntryToDataSource(props) { + const entryContainer = useMemo(() => new EntryContainer(props.entry), []); + + const onEntryChange = useCallback(entry => { + entryContainer.setEntry(entry); + }, []); + + useEffect(() => { + return useLoadDataSource.decorateRepository(repository => { + return new WithLocalData(entryContainer, repository); + }); + }, []); + + return ; + }; +}); diff --git a/packages/app-dynamic-pages/src/admin/ContentEntryForm/PreviewPane.tsx b/packages/app-dynamic-pages/src/admin/ContentEntryForm/PreviewPane.tsx new file mode 100644 index 00000000000..ded139dd176 --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/ContentEntryForm/PreviewPane.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import styled from "@emotion/styled"; +import { PbPageTemplateWithContent } from "@webiny/app-page-builder/types"; +import { RenderPluginsLoader } from "@webiny/app-page-builder/admin"; +import { Content } from "@webiny/app-page-builder-elements"; +import { + DataSourceProvider, + DynamicDocumentProvider +} from "@webiny/app-page-builder/dataInjection"; +import { RefreshIcon } from "@webiny/ui/List/DataList/icons"; +import { useRefreshPageTemplates } from "@webiny/app-page-builder/features"; + +const LivePreviewContainer = styled.div` + position: relative; + display: flex; + flex-direction: column; + border-right: 1px solid var(--mdc-theme-on-background); + height: calc(100vh - 260px); + overflow: auto; +`; + +const Header = styled.div` + display: flex; + padding: 15px; + justify-content: space-between; + border-bottom: 1px solid var(--mdc-theme-on-background); + font-size: 24px; + align-items: center; +`; + +export interface PreviewPaneProps { + template: PbPageTemplateWithContent; +} + +export const PreviewPane = ({ template }: PreviewPaneProps) => { + const mainDataSource = template.dataSources.find(ds => ds.name === "main"); + const { refreshPageTemplates } = useRefreshPageTemplates(); + + return ( + + +
+ {template.title} + refreshPageTemplates()} /> +
+ + + + + +
+
+ ); +}; diff --git a/packages/app-dynamic-pages/src/admin/Extensions.tsx b/packages/app-dynamic-pages/src/admin/Extensions.tsx new file mode 100644 index 00000000000..65934268c07 --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/Extensions.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import { IfDynamicPagesEnabled } from "@webiny/app-page-builder/IfDynamicPagesEnabled"; + +const SetupDynamicPages = React.lazy(() => { + return import(/* webpackChunkName: "experimentalDynamicPages" */ "./SetupDynamicPages").then( + m => ({ default: m.SetupDynamicPages }) + ); +}); + +export const Extensions = () => { + return ( + + + + + + ); +}; diff --git a/packages/app-dynamic-pages/src/admin/PageTemplateDialog/CreateTemplateDialog.tsx b/packages/app-dynamic-pages/src/admin/PageTemplateDialog/CreateTemplateDialog.tsx new file mode 100644 index 00000000000..734e7e7fc87 --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/PageTemplateDialog/CreateTemplateDialog.tsx @@ -0,0 +1,225 @@ +import React, { useState } from "react"; +import { css } from "emotion"; +import styled from "@emotion/styled"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { IconProp } from "@fortawesome/fontawesome-svg-core"; + +import { Icon } from "@webiny/ui/Icon"; +import { ButtonDefault } from "@webiny/ui/Button"; +import { List, ListItem } from "@webiny/ui/List"; +import { Dialog, DialogTitle, DialogContent, DialogActions, DialogCancel } from "@webiny/ui/Dialog"; +import { CircularProgress } from "@webiny/ui/Progress"; +import { useModels } from "@webiny/app-headless-cms"; +import { CmsModel } from "@webiny/app-headless-cms/types"; + +import { ReactComponent as ArrowRightIcon } from "@material-design-icons/svg/round/keyboard_arrow_right.svg"; +import { ReactComponent as InfoIcon } from "@material-design-icons/svg/outlined/info.svg"; +import { ReactComponent as ArticleIcon } from "@material-design-icons/svg/outlined/article.svg"; +import { ReactComponent as DatabaseIcon } from "@material-design-icons/svg/outlined/dashboard.svg"; + +const dialogStyles = css` + .mdc-dialog__surface { + width: 600px; + min-width: 600px; + } + + .mdc-dialog__content { + padding-top: 0 !important; + } + + .content-wrapper:focus-visible { + outline: none; + } +`; + +const Info = styled.div` + display: flex; + align-items: center; + padding: 12px; + margin-top: 12px; + background: var(--mdc-theme-background); + color: var(--mdc-theme-text-secondary-on-background); + fill: var(--mdc-theme-text-secondary-on-background); + cursor: pointer; + + & svg { + margin-right: 12px; + } +`; + +const ListItemStyled = styled(ListItem)` + padding-top: 8px; + padding-bottom: 8px; + height: auto; + min-height: 64px; + color: var(--mdc-theme-text-secondary-on-background); + fill: var(--mdc-theme-text-secondary-on-background); + + & svg { + flex-shrink: 0; + } + + .arrow-right { + margin-left: auto; + } +`; + +const ListItemContent = styled.div` + display: grid; + padding-left: 21px; + padding-right: 21px; +`; + +const Title = styled.div` + font-size: 20px; +`; + +const Highlight = styled.div` + color: var(--mdc-theme-primary); +`; + +const DynamicTemplatesInfo = styled.div` + padding: 24px; + font-size: 20px; + line-height: 24px; + color: var(--mdc-theme-text-secondary-on-background); +`; + +const leftButton = css` + margin-right: auto; +`; + +interface ModelIconProps { + model: CmsModel; +} +const ModelIcon: React.FC = ({ model }) => { + return ( + + ); +}; + +type HeadlessPageTemplateItemProps = { + icon: React.ReactElement; + name: string; + description?: string; + highlight?: string; + onClick: () => void; + hasNestedFields?: boolean; +}; + +const HeadlessPageTemplateItem: React.FC = ({ + icon, + name, + description, + highlight, + onClick, + hasNestedFields +}) => { + return ( + + + + {name} + {description} + {highlight} + + {hasNestedFields && } + + ); +}; + +type CreateTemplateDialogProps = { + open: boolean; + onClose: () => void; + onDynamicTemplateSelect: (model: CmsModel) => void; + onStaticTemplateSelect: () => void; + existingDynamicTemplateModelIds: string[]; +}; + +export const CreateTemplateDialog: React.FC = ({ + open, + onClose, + onDynamicTemplateSelect, + onStaticTemplateSelect, + existingDynamicTemplateModelIds +}) => { + const [dynamicTemplateSelect, setDynamicTemplateSelect] = useState(false); + const { models, loading } = useModels(); + + return ( + + What type of a template you wish to create? + + Select a Headless Page Template for which you want to create a dynamic page + template: + + +
+ {dynamicTemplateSelect ? ( + <> + {loading && } + + {models.map((model, index) => ( + } + name={model.name} + description={model.description} + highlight={ + existingDynamicTemplateModelIds.some( + id => id === model.modelId + ) + ? "Template already exists, click to edit." + : undefined + } + onClick={() => onDynamicTemplateSelect(model)} + /> + ))} + + + ) : ( + <> + } + name={"Static template"} + description={"Used for creating new Page Builder pages."} + onClick={onStaticTemplateSelect} + /> + } + name={"Dynamic template"} + description={ + "Used for auto-generating pages from Headless CMS entries." + } + onClick={() => setDynamicTemplateSelect(true)} + hasNestedFields + /> + + + Click here to learn about different template types. + + + )} +
+
+ + {dynamicTemplateSelect && ( + { + setDynamicTemplateSelect(false); + }} + className={leftButton} + > + < Go back + + )} + Cancel + +
+ ); +}; diff --git a/packages/app-dynamic-pages/src/admin/PageTemplateDialog/PageTemplateDialog.tsx b/packages/app-dynamic-pages/src/admin/PageTemplateDialog/PageTemplateDialog.tsx new file mode 100644 index 00000000000..de1bac43e0c --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/PageTemplateDialog/PageTemplateDialog.tsx @@ -0,0 +1,43 @@ +import React, { useState, useCallback } from "react"; +import { useSnackbar } from "@webiny/app-admin"; +import { useRouter } from "@webiny/react-router"; +import { CmsModel } from "@webiny/app-headless-cms/types"; +import { CreatePageTemplateDialog } from "@webiny/app-page-builder/admin/views/PageTemplates/CreatePageTemplateDialog"; +import { CreateTemplateDialog } from "~/admin/PageTemplateDialog/CreateTemplateDialog"; +import { useCreateDynamicPageTemplate } from "~/features/pageTemplate/createDynamicTemplate/useCreateDynamicTemplate"; + +export const PageTemplateDialog = CreatePageTemplateDialog.createDecorator(Original => { + return function CreatePageTemplateDialog(props) { + const { history } = useRouter(); + const { showSnackbar } = useSnackbar(); + const { createDynamicPageTemplate } = useCreateDynamicPageTemplate(); + const [showStaticTemplateDialog, setShowStaticTemplateDialog] = useState(false); + + const createTemplate = useCallback(async (model: CmsModel) => { + try { + const template = await createDynamicPageTemplate(model); + history.push(`/page-builder/template-editor/${template.id}`); + } catch (error) { + showSnackbar(error.message); + } + }, []); + + const onClose = useCallback(() => { + setShowStaticTemplateDialog(false); + props.onClose(); + }, [props.onClose]); + + return ( + <> + + setShowStaticTemplateDialog(true)} + existingDynamicTemplateModelIds={[]} + /> + + ); + }; +}); diff --git a/packages/app-dynamic-pages/src/admin/SetupDynamicPages.tsx b/packages/app-dynamic-pages/src/admin/SetupDynamicPages.tsx new file mode 100644 index 00000000000..6068fd296ab --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/SetupDynamicPages.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { PageTemplateDialog } from "~/admin/PageTemplateDialog/PageTemplateDialog"; +import { DynamicTemplateEditorConfig } from "~/admin/templateEditor/DynamicTemplateEditorConfig"; +import { AddPreviewPane } from "~/admin/ContentEntryForm/AddPreviewPane"; +import { PassEntryToDataSource } from "~/admin/ContentEntryForm/PassEntryToDataSource"; +import { Elements } from "~/admin/elements/Elements"; +import { DynamicPageEditorConfig } from "~/admin/pageEditor/DynamicPageEditorConfig"; +import { DynamicElementRenderers } from "~/dataInjection/renderers/DynamicElementRenderers"; +import { ContentEntryEditorConfig } from "@webiny/app-headless-cms"; +import { WebsiteDataInjection } from "@webiny/app-page-builder/dataInjection/presets/WebsiteDataInjection"; +import { AddEntriesListDataSourceContext } from "~/dataInjection/AddEntriesListDataSourceContext"; + +export const SetupDynamicPages = () => { + return ( + <> + {/* Register editor elements plugins. */} + + + {/* Decorate page template dialog. */} + + + {/* Configure Template editor. */} + + + {/* Configure Page editor. */} + + + {/* Enable live preview in the CMS entry form. */} + + + + + {/* Register element renderers and decorators. */} + + + {/* Add website-style data binding to page preview. */} + + + + + + ); +}; diff --git a/packages/app-dynamic-pages/src/admin/elements/Elements.tsx b/packages/app-dynamic-pages/src/admin/elements/Elements.tsx new file mode 100644 index 00000000000..a228b722184 --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/elements/Elements.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { plugins } from "@webiny/plugins"; +import { createRepeaterElement } from "~/admin/elements/repeater"; +import { createEntriesListElement } from "~/admin/elements/entriesList"; +import { DynamicGrid } from "./renderers/DynamicGrid"; +import { createEntriesSearchElement } from "~/admin/elements/entriesSearch"; + +export const Elements = React.memo(function Elements() { + plugins.register( + createRepeaterElement(), + createEntriesListElement(), + createEntriesSearchElement() + ); + + return ( + <> + + + ); +}); diff --git a/packages/app-dynamic-pages/src/admin/elements/entriesList.tsx b/packages/app-dynamic-pages/src/admin/elements/entriesList.tsx new file mode 100644 index 00000000000..f44666a925e --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/elements/entriesList.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { ReactComponent as RepeatIcon } from "@material-design-icons/svg/round/repeat.svg"; +import { PbEditorPageElementPlugin, PbElement } from "@webiny/app-page-builder/types"; +import { AdminEntriesListRenderer } from "./renderers/EntriesList"; +import { createElement } from "@webiny/app-page-builder/editor/helpers"; + +export const createEntriesListElement = (): PbEditorPageElementPlugin => { + return { + name: `pb-editor-page-element-entries-list`, + type: "pb-editor-page-element", + elementType: "entries-list", + canReceiveChildren: true, + toolbar: { + title: "Entries List", + group: "pb-editor-element-group-basic", + preview() { + return ; + } + }, + settings: [ + "pb-editor-page-element-settings-clone", + "pb-editor-page-element-settings-delete" + ], + target: ["cell", "block"], + create() { + return { + type: this.elementType, + elements: [createElement("grid")], + data: {} + }; + }, + + render(props) { + return ; + } + }; +}; diff --git a/packages/app-dynamic-pages/src/admin/elements/entriesSearch.tsx b/packages/app-dynamic-pages/src/admin/elements/entriesSearch.tsx new file mode 100644 index 00000000000..9c89369db96 --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/elements/entriesSearch.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { ReactComponent as RepeatIcon } from "@material-design-icons/svg/round/repeat.svg"; +import { PbEditorPageElementPlugin, PbElement } from "@webiny/app-page-builder/types"; +import { EntriesSearchRenderer } from "~/dataInjection/renderers/EntriesSearch"; + +export const createEntriesSearchElement = (): PbEditorPageElementPlugin => { + return { + name: `pb-editor-page-element-entries-search`, + type: "pb-editor-page-element", + elementType: "entries-search", + canReceiveChildren: false, + toolbar: { + title: "Entries Search", + group: "pb-editor-element-group-basic", + preview() { + return ; + } + }, + settings: ["pb-editor-page-element-settings-delete"], + target: ["cell"], + create() { + return { + type: this.elementType, + elements: [], + data: {} + }; + }, + + render(props) { + return ; + } + }; +}; diff --git a/packages/app-dynamic-pages/src/admin/elements/eventHandlers/ContentTraverser.ts b/packages/app-dynamic-pages/src/admin/elements/eventHandlers/ContentTraverser.ts new file mode 100644 index 00000000000..eabae74d6f5 --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/elements/eventHandlers/ContentTraverser.ts @@ -0,0 +1,18 @@ +import { PbEditorElement } from "@webiny/app-page-builder/types"; + +type ElementNode = Omit & { + elements: ElementNode[]; +}; + +interface ElementNodeVisitor { + (node: ElementNode): void; +} + +export class ContentTraverser { + traverse(element: ElementNode, visitor: ElementNodeVisitor): void { + visitor(element); + for (const node of element.elements) { + this.traverse(node, visitor); + } + } +} diff --git a/packages/app-dynamic-pages/src/admin/elements/renderers/DynamicGrid.tsx b/packages/app-dynamic-pages/src/admin/elements/renderers/DynamicGrid.tsx new file mode 100644 index 00000000000..f065a0ee91d --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/elements/renderers/DynamicGrid.tsx @@ -0,0 +1,59 @@ +import React from "react"; +import { useRenderer, Elements, ElementInput } from "@webiny/app-page-builder-elements"; +import { PeGrid } from "@webiny/app-page-builder/editor/plugins/elements/grid/PeGrid"; +import { useElementWithChildren } from "@webiny/app-page-builder/editor"; +import { GenericRecord } from "@webiny/app/types"; +import { DataSourceDataProvider } from "@webiny/app-page-builder/dataInjection"; + +const elementInputs = { + dataSource: ElementInput.create({ + name: "dataSource", + type: "array", + translatable: false, + getDefaultValue() { + return []; + } + }) +}; + +export const DynamicGrid = PeGrid.Component.createDecorator(Original => { + return function DynamicGrid(props) { + const { getElement, getInputValues } = useRenderer(); + const element = getElement(); + const elementWithChildren = useElementWithChildren(element.id); + const inputs = getInputValues(); + + if (!elementWithChildren) { + return null; + } + + if (Array.isArray(inputs.dataSource)) { + const hasData = inputs.dataSource.length > 0; + + const baseCell = elementWithChildren.elements[0]; + const dynamicElement = { + ...element, + elements: hasData + ? Array(inputs.dataSource.length).fill(baseCell) + : elementWithChildren.elements + }; + + return ( + { + const dataSource = inputs.dataSource ? inputs.dataSource[index] : {}; + + return ( + + {element} + + ); + }} + /> + ); + } + + return ; + }; +}); diff --git a/packages/app-dynamic-pages/src/admin/elements/renderers/EntriesList.tsx b/packages/app-dynamic-pages/src/admin/elements/renderers/EntriesList.tsx new file mode 100644 index 00000000000..9e07f326094 --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/elements/renderers/EntriesList.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { PbElement } from "@webiny/app-page-builder/types"; +import { EmptyCell } from "@webiny/app-page-builder/editor/plugins/elements/cell/EmptyCell"; +import { useElementWithChildren } from "@webiny/app-page-builder/editor"; +import { EntriesListRenderer } from "~/dataInjection/renderers/EntriesList"; + +interface AdminEntriesListRendererProps { + element: PbElement; +} + +export const AdminEntriesListRenderer = ({ element, ...rest }: AdminEntriesListRendererProps) => { + const elementWithChildren = useElementWithChildren(element.id); + + if (!elementWithChildren) { + return null; + } + + return ( + } + /> + ); +}; + +AdminEntriesListRenderer.displayName = "AdminEntriesListRenderer"; diff --git a/packages/app-dynamic-pages/src/admin/elements/renderers/Repeater.tsx b/packages/app-dynamic-pages/src/admin/elements/renderers/Repeater.tsx new file mode 100644 index 00000000000..9174b7f1bf5 --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/elements/renderers/Repeater.tsx @@ -0,0 +1,25 @@ +import React from "react"; +import { PbElement } from "@webiny/app-page-builder/types"; +import { EmptyCell } from "@webiny/app-page-builder/editor/plugins/elements/cell/EmptyCell"; +import { useElementWithChildren } from "@webiny/app-page-builder/editor"; +import { RepeaterRenderer } from "~/dataInjection/renderers/Repeater"; + +interface AdminRepeaterRendererProps { + element: PbElement; +} + +export const AdminRepeaterRenderer = ({ element, ...rest }: AdminRepeaterRendererProps) => { + const elementWithChildren = useElementWithChildren(element.id); + + if (!elementWithChildren) { + return null; + } + + return ( + } + /> + ); +}; diff --git a/packages/app-dynamic-pages/src/admin/elements/repeater.tsx b/packages/app-dynamic-pages/src/admin/elements/repeater.tsx new file mode 100644 index 00000000000..a8b1be760dc --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/elements/repeater.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { ReactComponent as RepeatIcon } from "@material-design-icons/svg/round/repeat.svg"; +import { PbEditorPageElementPlugin, PbElement } from "@webiny/app-page-builder/types"; +import { AdminRepeaterRenderer } from "./renderers/Repeater"; + +export const createRepeaterElement = (): PbEditorPageElementPlugin => { + return { + name: `pb-editor-page-element-repeater`, + type: "pb-editor-page-element", + elementType: "repeater", + canReceiveChildren: true, + toolbar: { + title: "Repeater Element", + group: "pb-editor-element-group-basic", + preview() { + return ; + } + }, + settings: [ + "pb-editor-page-element-settings-clone", + "pb-editor-page-element-settings-delete" + ], + target: ["cell", "block"], + create() { + return { + type: this.elementType, + elements: [], + data: {} + }; + }, + + render(props) { + return ; + } + }; +}; diff --git a/packages/app-dynamic-pages/src/admin/index.ts b/packages/app-dynamic-pages/src/admin/index.ts new file mode 100644 index 00000000000..bc1bda46e60 --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/index.ts @@ -0,0 +1 @@ +export { Extensions as DynamicPages } from "./Extensions"; diff --git a/packages/app-dynamic-pages/src/admin/pageEditor/DynamicPageEditorConfig.tsx b/packages/app-dynamic-pages/src/admin/pageEditor/DynamicPageEditorConfig.tsx new file mode 100644 index 00000000000..bec50c1fcec --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/pageEditor/DynamicPageEditorConfig.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { PageEditorConfig } from "@webiny/app-page-builder/pageEditor"; +import { ElementEventHandlers } from "./ElementEventHandlers"; +import { SetupElementDataSettings } from "~/dataInjection/editor/SetupElementDataSettings"; +import { AddEntriesListDataSourceContext } from "~/dataInjection/AddEntriesListDataSourceContext"; + +export const DynamicPageEditorConfig = () => { + return ( + <> + + + + + + + ); +}; diff --git a/packages/app-dynamic-pages/src/admin/pageEditor/ElementEventHandlers.tsx b/packages/app-dynamic-pages/src/admin/pageEditor/ElementEventHandlers.tsx new file mode 100644 index 00000000000..f7c46233aaa --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/pageEditor/ElementEventHandlers.tsx @@ -0,0 +1,140 @@ +import { useEffect } from "react"; +import { useEventActionHandler } from "@webiny/app-page-builder/editor"; +import { + CreateElementActionEvent, + DeleteElementActionEvent +} from "@webiny/app-page-builder/editor/recoil/actions"; +import type { + DynamicDocument, + EventActionCallable, + PbEditorElementTree +} from "@webiny/app-page-builder/types"; +import type { CreateElementEventActionArgsType } from "@webiny/app-page-builder/editor/recoil/actions/createElement/types"; +import type { DeleteElementActionArgsType } from "@webiny/app-page-builder/editor/recoil/actions/deleteElement/types"; +import type { PageAtomType } from "@webiny/app-page-builder/pageEditor/state"; +import { ContentTraverser } from "@webiny/app-page-builder/dataInjection"; + +const doNothing = { + actions: [] +}; + +const addCmsListDataSource = ( + document: T, + element: PbEditorElementTree +): T => { + const dataSourceName = `element:${element.id}`; + + const gridElement = element.elements[0]; + + return { + ...document, + dataSources: [ + ...(document.dataSources || []), + { + name: dataSourceName, + type: "cms.entries", + config: { + modelId: undefined, + limit: 10 + } + } + ], + dataBindings: [ + ...(document.dataBindings || []), + { + dataSource: dataSourceName, + bindFrom: "*", + bindTo: `element:${gridElement.id}.dataSource` + } + ] + }; +}; + +export const ElementEventHandlers = () => { + const eventHandler = useEventActionHandler(); + + const onElementCreate: EventActionCallable = ( + state, + _, + args + ) => { + if (!args) { + return doNothing; + } + + const { element } = args; + + if (element.type !== "entries-list") { + return doNothing; + } + + // @ts-expect-error Event callable types need to be more generic. + const page = state.page as PageAtomType; + + const updatedPage = addCmsListDataSource(page, element as PbEditorElementTree); + + return { + state: { + ...state, + page: updatedPage + }, + actions: [] + }; + }; + + const onElementDelete: EventActionCallable = async ( + state, + _, + args + ) => { + if (!args) { + return doNothing; + } + + const { element } = args; + + // @ts-expect-error Event callable types need to be more generic. + const page = state.page as PageAtomType; + + const withDescendants = await state.getElementTree({ element }); + + const traverser = new ContentTraverser(); + const deletedElements: string[] = [element.id]; + + traverser.traverse(withDescendants, node => { + deletedElements.push(node.id); + }); + + const deleteDataSources = deletedElements.map(id => `element:${id}`); + const deleteDataBindings = deletedElements.map(id => `element:${id}.`); + + const updatedPage: PageAtomType = { + ...page, + dataSources: (page.dataSources || []).filter(ds => { + return !deleteDataSources.includes(ds.name); + }), + dataBindings: (page.dataBindings || []).filter(binding => { + return !deleteDataBindings.some(toDelete => binding.bindTo.startsWith(toDelete)); + }) + }; + + return { + state: { + ...state, + page: updatedPage + }, + actions: [] + }; + }; + + useEffect(() => { + const offCreateElement = eventHandler.on(CreateElementActionEvent, onElementCreate); + const offDeleteElement = eventHandler.on(DeleteElementActionEvent, onElementDelete); + + return () => { + offCreateElement(); + offDeleteElement(); + }; + }, []); + return null; +}; diff --git a/packages/app-dynamic-pages/src/admin/templateEditor/DynamicTemplateEditorConfig.tsx b/packages/app-dynamic-pages/src/admin/templateEditor/DynamicTemplateEditorConfig.tsx new file mode 100644 index 00000000000..6fd5afc7207 --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/templateEditor/DynamicTemplateEditorConfig.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { TemplateEditorConfig } from "@webiny/app-page-builder/templateEditor"; +import { EntrySelector } from "~/admin/templateEditor/EntrySelector"; +import { hasMainDataSource } from "~/features"; +import { ElementEventHandlers } from "./ElementEventHandlers"; +import { useDynamicDocument } from "@webiny/app-page-builder/dataInjection"; +import { SetupElementDataSettings } from "~/dataInjection/editor/SetupElementDataSettings"; +import { AddEntriesListDataSourceContext } from "~/dataInjection/AddEntriesListDataSourceContext"; + +const { Ui } = TemplateEditorConfig; + +const OnDynamicTemplate = ({ children }: { children: React.ReactNode }) => { + const { dataSources } = useDynamicDocument(); + + return hasMainDataSource(dataSources) ? <>{children} : null; +}; + +export const DynamicTemplateEditorConfig = () => { + return ( + <> + + + + + + + } + group={"center"} + /> + + + + + ); +}; diff --git a/packages/app-dynamic-pages/src/admin/templateEditor/ElementEventHandlers.tsx b/packages/app-dynamic-pages/src/admin/templateEditor/ElementEventHandlers.tsx new file mode 100644 index 00000000000..b603985aeb8 --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/templateEditor/ElementEventHandlers.tsx @@ -0,0 +1,140 @@ +import { useEffect } from "react"; +import { useEventActionHandler } from "@webiny/app-page-builder/editor"; +import { + CreateElementActionEvent, + DeleteElementActionEvent +} from "@webiny/app-page-builder/editor/recoil/actions"; +import type { + DynamicDocument, + EventActionCallable, + PbEditorElementTree, + PbPageTemplate +} from "@webiny/app-page-builder/types"; +import type { CreateElementEventActionArgsType } from "@webiny/app-page-builder/editor/recoil/actions/createElement/types"; +import type { DeleteElementActionArgsType } from "@webiny/app-page-builder/editor/recoil/actions/deleteElement/types"; +import { ContentTraverser } from "@webiny/app-page-builder/dataInjection"; + +const doNothing = { + actions: [] +}; + +const addCmsListDataSource = ( + document: T, + element: PbEditorElementTree +): T => { + const dataSourceName = `element:${element.id}`; + + const gridElement = element.elements[0]; + + return { + ...document, + dataSources: [ + ...document.dataSources, + { + name: dataSourceName, + type: "cms.entries", + config: { + modelId: undefined, + limit: 10 + } + } + ], + dataBindings: [ + ...document.dataBindings, + { + dataSource: dataSourceName, + bindFrom: "*", + bindTo: `element:${gridElement.id}.dataSource` + } + ] + }; +}; + +export const ElementEventHandlers = () => { + const eventHandler = useEventActionHandler(); + + const onElementCreate: EventActionCallable = ( + state, + _, + args + ) => { + if (!args) { + return doNothing; + } + + const { element } = args; + + if (element.type !== "entries-list") { + return doNothing; + } + + // @ts-expect-error Event callable types need to be more generic. + const template = state.template as PbPageTemplate; + + const updatedTemplate = addCmsListDataSource(template, element as PbEditorElementTree); + + return { + state: { + ...state, + template: updatedTemplate + }, + actions: [] + }; + }; + + const onElementDelete: EventActionCallable = async ( + state, + _, + args + ) => { + if (!args) { + return doNothing; + } + + const { element } = args; + + // @ts-expect-error Event callable types need to be more generic. + const template = state.template as PbPageTemplate; + + const withDescendants = await state.getElementTree({ element }); + + const traverser = new ContentTraverser(); + const deletedElements: string[] = [element.id]; + + traverser.traverse(withDescendants, node => { + deletedElements.push(node.id); + }); + + const deleteDataSources = deletedElements.map(id => `element:${id}`); + const deleteDataBindings = deletedElements.map(id => `element:${id}.`); + + const updatedTemplate: PbPageTemplate = { + ...template, + dataSources: template.dataSources.filter(ds => { + return !deleteDataSources.includes(ds.name); + }), + dataBindings: template.dataBindings.filter(binding => { + return !deleteDataBindings.some(toDelete => binding.bindTo.startsWith(toDelete)); + }) + }; + + return { + state: { + ...state, + template: updatedTemplate + }, + actions: [] + }; + }; + + useEffect(() => { + const offCreateElement = eventHandler.on(CreateElementActionEvent, onElementCreate); + const offDeleteElement = eventHandler.on(DeleteElementActionEvent, onElementDelete); + + return () => { + offCreateElement(); + offDeleteElement(); + }; + }, []); + return null; +}; diff --git a/packages/app-dynamic-pages/src/admin/templateEditor/EntrySelector/EntrySelector.tsx b/packages/app-dynamic-pages/src/admin/templateEditor/EntrySelector/EntrySelector.tsx new file mode 100644 index 00000000000..f7217ec4947 --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/templateEditor/EntrySelector/EntrySelector.tsx @@ -0,0 +1,27 @@ +import React, { useState } from "react"; +import { Input } from "@webiny/ui/Input"; +import { useDocumentDataSource } from "@webiny/app-page-builder/templateEditor"; + +export const EntrySelector = () => { + const { getDataSource, updateDataSource } = useDocumentDataSource(); + const mainDataSource = getDataSource("main"); + const [localId, setLocalId] = useState(mainDataSource ? mainDataSource.config.entryId : ""); + + const applyPreviewId = () => { + updateDataSource("main", config => { + return { + ...config, + entryId: localId + }; + }); + }; + + return ( + + ); +}; diff --git a/packages/app-dynamic-pages/src/admin/templateEditor/EntrySelector/index.ts b/packages/app-dynamic-pages/src/admin/templateEditor/EntrySelector/index.ts new file mode 100644 index 00000000000..e9ffdf7d3cc --- /dev/null +++ b/packages/app-dynamic-pages/src/admin/templateEditor/EntrySelector/index.ts @@ -0,0 +1 @@ +export * from "./EntrySelector"; diff --git a/packages/app-dynamic-pages/src/dataInjection/AddEntriesListDataSourceContext.tsx b/packages/app-dynamic-pages/src/dataInjection/AddEntriesListDataSourceContext.tsx new file mode 100644 index 00000000000..ffcb595eb00 --- /dev/null +++ b/packages/app-dynamic-pages/src/dataInjection/AddEntriesListDataSourceContext.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { Element } from "@webiny/app-page-builder-elements"; +import { DataSourceProvider, useDynamicDocument } from "@webiny/app-page-builder/dataInjection"; + +export const AddEntriesListDataSourceContext = Element.createDecorator(Original => { + return function WithDataSourceContext(props) { + const { dataSources } = useDynamicDocument(); + + const { element } = props; + + const renderOriginal = ; + + if (!element) { + return renderOriginal; + } + + const isEntriesList = element.type === "entries-list"; + + if (isEntriesList) { + const dataSource = dataSources.find(source => source.name === `element:${element.id}`); + + return ( + {renderOriginal} + ); + } + + return renderOriginal; + }; +}); diff --git a/packages/app-dynamic-pages/src/dataInjection/editor/DisableGridDelete.tsx b/packages/app-dynamic-pages/src/dataInjection/editor/DisableGridDelete.tsx new file mode 100644 index 00000000000..e70a2558e9e --- /dev/null +++ b/packages/app-dynamic-pages/src/dataInjection/editor/DisableGridDelete.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import { EditorConfig } from "@webiny/app-page-builder/editor"; +import { HideIfEntriesListGridWithDataSource } from "./HideIfEntriesListGridWithDataSource"; + +const { ElementAction } = EditorConfig; + +export const DisableGridDelete = ElementAction.createDecorator(Original => { + return function DisableActions(props) { + if (props.name === "delete") { + return ( + + {props.element} + + } + /> + ); + } + return ; + }; +}); diff --git a/packages/app-dynamic-pages/src/dataInjection/editor/ElementDataSettings.tsx b/packages/app-dynamic-pages/src/dataInjection/editor/ElementDataSettings.tsx new file mode 100644 index 00000000000..7bbd6bc6f0f --- /dev/null +++ b/packages/app-dynamic-pages/src/dataInjection/editor/ElementDataSettings.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { + useActiveElement, + useIsElementChildOfType, + EditorConfig +} from "@webiny/app-page-builder/editor"; + +const { Ui } = EditorConfig; + +export const ElementDataSettings = () => { + const [element] = useActiveElement(); + const { isChildOfType } = useIsElementChildOfType(element, "entries-list"); + const isDisabled = !element || (isChildOfType && element?.type === "grid"); + + return ( + + + + } + disabled={isDisabled} + /> + ); +}; diff --git a/packages/app-dynamic-pages/src/dataInjection/editor/HideIfChildOfEntriesList.tsx b/packages/app-dynamic-pages/src/dataInjection/editor/HideIfChildOfEntriesList.tsx new file mode 100644 index 00000000000..aced823a39a --- /dev/null +++ b/packages/app-dynamic-pages/src/dataInjection/editor/HideIfChildOfEntriesList.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { useActiveElement, useIsElementChildOfType } from "@webiny/app-page-builder/editor"; + +interface HideIfChildOfEntriesListProps { + children: React.ReactNode; +} + +export const HideIfChildOfEntriesList = ({ children }: HideIfChildOfEntriesListProps) => { + const [element] = useActiveElement(); + const { isChildOfType } = useIsElementChildOfType(element, "entries-list"); + + if (!element) { + return null; + } + + return isChildOfType ? null : <>{children}; +}; diff --git a/packages/app-dynamic-pages/src/dataInjection/editor/HideIfEntriesListGridWithDataSource.tsx b/packages/app-dynamic-pages/src/dataInjection/editor/HideIfEntriesListGridWithDataSource.tsx new file mode 100644 index 00000000000..7ef1f6b1472 --- /dev/null +++ b/packages/app-dynamic-pages/src/dataInjection/editor/HideIfEntriesListGridWithDataSource.tsx @@ -0,0 +1,24 @@ +import React from "react"; +import { useActiveElement, useIsElementChildOfType } from "@webiny/app-page-builder/editor"; +import { useElementBindings } from "@webiny/app-page-builder/dataInjection"; + +interface HideIfEntriesListGridWithDataSourceProps { + children: React.ReactNode; +} + +export const HideIfEntriesListGridWithDataSource = ({ + children +}: HideIfEntriesListGridWithDataSourceProps) => { + const [element] = useActiveElement(); + const { bindings } = useElementBindings(element!.id); + const { isChildOfType } = useIsElementChildOfType(element, "entries-list"); + + if (!element) { + return null; + } + + const hasDataSourceBinding = bindings.some(binding => binding.bindFrom === "*"); + const shouldHide = isChildOfType && element.type === "grid" && hasDataSourceBinding; + + return shouldHide ? null : <>{children}; +}; diff --git a/packages/app-dynamic-pages/src/dataInjection/editor/SetupElementDataSettings.tsx b/packages/app-dynamic-pages/src/dataInjection/editor/SetupElementDataSettings.tsx new file mode 100644 index 00000000000..c6b15d74e0e --- /dev/null +++ b/packages/app-dynamic-pages/src/dataInjection/editor/SetupElementDataSettings.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { ElementDataSettings } from "./ElementDataSettings"; +import { EditorConfig } from "@webiny/app-page-builder/editor"; + +const { Ui } = EditorConfig; + +export const SetupElementDataSettings = () => { + return ( + <> + } /> + + ); +}; diff --git a/packages/app-dynamic-pages/src/dataInjection/renderers/DynamicElementRenderers.tsx b/packages/app-dynamic-pages/src/dataInjection/renderers/DynamicElementRenderers.tsx new file mode 100644 index 00000000000..5891d129dec --- /dev/null +++ b/packages/app-dynamic-pages/src/dataInjection/renderers/DynamicElementRenderers.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { PbRenderElementPlugin } from "@webiny/app-page-builder"; +import { RepeaterRenderer } from "~/dataInjection/renderers/Repeater"; +import { EntriesListRenderer } from "~/dataInjection/renderers/EntriesList"; +import { EntriesSearchRenderer } from "~/dataInjection/renderers/EntriesSearch"; +import { DynamicGrid } from "~/dataInjection/renderers/DynamicGrid"; + +export const DynamicElementRenderers = () => { + return ( + <> + + + + + + ); +}; diff --git a/packages/app-dynamic-pages/src/dataInjection/renderers/DynamicGrid.tsx b/packages/app-dynamic-pages/src/dataInjection/renderers/DynamicGrid.tsx new file mode 100644 index 00000000000..48239cded4d --- /dev/null +++ b/packages/app-dynamic-pages/src/dataInjection/renderers/DynamicGrid.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { useRenderer, Elements, ElementInput } from "@webiny/app-page-builder-elements"; +import { GridRenderer } from "@webiny/app-page-builder-elements/renderers/grid"; +import { GenericRecord } from "@webiny/app/types"; +import { DataSourceDataProvider } from "@webiny/app-page-builder/dataInjection"; + +const elementInputs = { + dataSource: ElementInput.create({ + name: "dataSource", + type: "array", + translatable: false, + getDefaultValue() { + return []; + } + }) +}; + +export const DynamicGrid = GridRenderer.Component.createDecorator(Original => { + return function DynamicGrid(props) { + const { getElement, getInputValues } = useRenderer(); + const element = getElement(); + const inputs = getInputValues(); + + if (Array.isArray(inputs.dataSource)) { + const hasData = inputs.dataSource.length > 0; + + const baseCell = element.elements[0]; + const dynamicElement = { + ...element, + elements: hasData + ? Array(inputs.dataSource.length).fill(baseCell) + : element.elements + }; + + return ( + { + const dataSource = inputs.dataSource ? inputs.dataSource[index] : {}; + + return ( + + {element} + + ); + }} + /> + ); + } + + return ; + }; +}); diff --git a/packages/app-dynamic-pages/src/dataInjection/renderers/EntriesList.tsx b/packages/app-dynamic-pages/src/dataInjection/renderers/EntriesList.tsx new file mode 100644 index 00000000000..f5794437ff2 --- /dev/null +++ b/packages/app-dynamic-pages/src/dataInjection/renderers/EntriesList.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { + createRenderer, + ElementInput, + Elements, + useRenderer +} from "@webiny/app-page-builder-elements"; +import { GenericRecord } from "@webiny/app/types"; +import { DataSourceDataProvider } from "@webiny/app-page-builder/dataInjection"; + +export const elementInputs = { + dataSource: ElementInput.create({ + name: "dataSource", + type: "array", + translatable: false, + getDefaultValue() { + return []; + } + }) +}; + +interface EntriesListRendererProps { + ifEmpty?: JSX.Element; +} + +export const EntriesListRenderer = createRenderer( + ({ ifEmpty = null }) => { + const { getElement, getInputValues } = useRenderer(); + + const element = getElement(); + const inputs = getInputValues(); + const dataSources = inputs.dataSource || []; + + if (element.elements.length === 0) { + return ifEmpty; + } + + if (!dataSources.length) { + return ; + } + + return ( + <> + {dataSources.map((dataSource, index) => { + return ( + + + + ); + })} + + ); + }, + { + inputs: elementInputs + } +); diff --git a/packages/app-dynamic-pages/src/dataInjection/renderers/EntriesSearch.tsx b/packages/app-dynamic-pages/src/dataInjection/renderers/EntriesSearch.tsx new file mode 100644 index 00000000000..d8484beda3d --- /dev/null +++ b/packages/app-dynamic-pages/src/dataInjection/renderers/EntriesSearch.tsx @@ -0,0 +1,29 @@ +import React, { ChangeEvent, useCallback, useState } from "react"; +import { createRenderer } from "@webiny/app-page-builder-elements"; +import { useDataSource } from "@webiny/app-page-builder/dataInjection"; + +export const EntriesSearchRenderer = createRenderer(() => { + const data = useDataSource(); + const [value, setValue] = useState(""); + + const onChange = useCallback( + (e: ChangeEvent) => { + if (!data) { + return; + } + + e.preventDefault(); + + const value = e.target.value; + + setValue(value); + + data.loadData({ + search: value !== "" ? value : undefined + }); + }, + [data] + ); + + return ; +}); diff --git a/packages/app-dynamic-pages/src/dataInjection/renderers/Repeater.tsx b/packages/app-dynamic-pages/src/dataInjection/renderers/Repeater.tsx new file mode 100644 index 00000000000..82bf1352072 --- /dev/null +++ b/packages/app-dynamic-pages/src/dataInjection/renderers/Repeater.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { + createRenderer, + ElementInput, + Elements, + useRenderer +} from "@webiny/app-page-builder-elements"; +import { GenericRecord } from "@webiny/app/types"; +import { DataSourceDataProvider } from "@webiny/app-page-builder/dataInjection"; + +export const elementInputs = { + dataSource: ElementInput.create({ + name: "dataSource", + type: "array", + translatable: false, + getDefaultValue() { + return []; + } + }) +}; + +interface RepeaterRendererProps { + ifEmpty?: JSX.Element; +} + +export const RepeaterRenderer = createRenderer( + ({ ifEmpty = null }) => { + const { getElement, getInputValues } = useRenderer(); + + const element = getElement(); + const inputs = getInputValues(); + const dataSources = inputs.dataSource || []; + + if (element.elements.length === 0) { + return ifEmpty; + } + + if (!dataSources.length) { + return ; + } + + return ( + <> + {dataSources.map((dataSource, index) => { + return ( + + + + ); + })} + + ); + }, + { + inputs: elementInputs + } +); diff --git a/packages/app-dynamic-pages/src/features/index.ts b/packages/app-dynamic-pages/src/features/index.ts new file mode 100644 index 00000000000..abe31c58c00 --- /dev/null +++ b/packages/app-dynamic-pages/src/features/index.ts @@ -0,0 +1,3 @@ +export * from "./pageTemplate/hasMainDataSource"; +export * from "./pageTemplate/createDynamicTemplate/useCreateDynamicTemplate"; +export * from "./pageTemplate/listDynamicTemplates/useListDynamicTemplates"; diff --git a/packages/app-dynamic-pages/src/features/pageTemplate/createDynamicTemplate/useCreateDynamicTemplate.ts b/packages/app-dynamic-pages/src/features/pageTemplate/createDynamicTemplate/useCreateDynamicTemplate.ts new file mode 100644 index 00000000000..e81a0f16705 --- /dev/null +++ b/packages/app-dynamic-pages/src/features/pageTemplate/createDynamicTemplate/useCreateDynamicTemplate.ts @@ -0,0 +1,66 @@ +import { useCallback } from "react"; +import slugify from "slugify"; +import { CmsModel } from "@webiny/app-headless-cms/types"; +import { useCreatePageTemplate } from "@webiny/app-page-builder/features"; +import { useListDynamicTemplates } from "~/features/pageTemplate/listDynamicTemplates/useListDynamicTemplates"; + +export const useCreateDynamicPageTemplate = () => { + const { dynamicTemplates } = useListDynamicTemplates(); + const { createPageTemplate } = useCreatePageTemplate(); + + const createDynamicPageTemplate = useCallback( + async (model: CmsModel) => { + const existingDynamicTemplate = dynamicTemplates.find(template => { + const dataSource = template.dataSources.find(ds => ds.name === "main"); + if (!dataSource) { + return false; + } + + return dataSource.config.modelId === model.modelId; + }); + + if (existingDynamicTemplate) { + return existingDynamicTemplate; + } + + const templateSlug = slugify(model.name, { + replacement: "-", + lower: true, + remove: /[*#\?<>_\{\}\[\]+~.()'"!:;@]/g, + trim: false + }); + + return createPageTemplate({ + title: `${model.name} Page Template`, + slug: templateSlug, + description: "Dynamic page template", + tags: [`model:${model.modelId}`], + layout: "static", + dataSources: [ + { + name: "main", + type: "cms.entry", + config: { + modelId: model.modelId + } + } + ], + dataBindings: [ + { + dataSource: "main", + bindFrom: "title", + bindTo: "page:title" + }, + { + dataSource: "main", + bindFrom: "title", + bindTo: "page:settings.general.title" + } + ] + }); + }, + [dynamicTemplates] + ); + + return { createDynamicPageTemplate }; +}; diff --git a/packages/app-dynamic-pages/src/features/pageTemplate/hasMainDataSource.ts b/packages/app-dynamic-pages/src/features/pageTemplate/hasMainDataSource.ts new file mode 100644 index 00000000000..902448fcd11 --- /dev/null +++ b/packages/app-dynamic-pages/src/features/pageTemplate/hasMainDataSource.ts @@ -0,0 +1,5 @@ +import type { PbDataSource } from "@webiny/app-page-builder/types"; + +export const hasMainDataSource = (dataSources: PbDataSource[]): boolean => { + return dataSources.some(source => source.name === "main"); +}; diff --git a/packages/app-dynamic-pages/src/features/pageTemplate/listDynamicTemplates/useListDynamicTemplates.ts b/packages/app-dynamic-pages/src/features/pageTemplate/listDynamicTemplates/useListDynamicTemplates.ts new file mode 100644 index 00000000000..7dcc1ffe1f1 --- /dev/null +++ b/packages/app-dynamic-pages/src/features/pageTemplate/listDynamicTemplates/useListDynamicTemplates.ts @@ -0,0 +1,12 @@ +import { useListPageTemplates } from "@webiny/app-page-builder/features"; +import { hasMainDataSource } from "~/features"; + +export const useListDynamicTemplates = () => { + const { pageTemplates } = useListPageTemplates(); + + const dynamicTemplates = pageTemplates.filter(template => + hasMainDataSource(template.dataSources) + ); + + return { dynamicTemplates }; +}; diff --git a/packages/app-dynamic-pages/tsconfig.build.json b/packages/app-dynamic-pages/tsconfig.build.json new file mode 100644 index 00000000000..6878c9968f1 --- /dev/null +++ b/packages/app-dynamic-pages/tsconfig.build.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.build.json", + "include": ["src"], + "references": [ + { "path": "../app/tsconfig.build.json" }, + { "path": "../app-admin/tsconfig.build.json" }, + { "path": "../app-headless-cms/tsconfig.build.json" }, + { "path": "../app-page-builder/tsconfig.build.json" }, + { "path": "../app-page-builder-elements/tsconfig.build.json" }, + { "path": "../plugins/tsconfig.build.json" }, + { "path": "../react-router/tsconfig.build.json" }, + { "path": "../ui/tsconfig.build.json" } + ], + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { "~/*": ["./src/*"], "~tests/*": ["./__tests__/*"] }, + "baseUrl": "." + } +} diff --git a/packages/app-dynamic-pages/tsconfig.json b/packages/app-dynamic-pages/tsconfig.json new file mode 100644 index 00000000000..c8eb010a2e1 --- /dev/null +++ b/packages/app-dynamic-pages/tsconfig.json @@ -0,0 +1,40 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "__tests__"], + "references": [ + { "path": "../app" }, + { "path": "../app-admin" }, + { "path": "../app-headless-cms" }, + { "path": "../app-page-builder" }, + { "path": "../app-page-builder-elements" }, + { "path": "../plugins" }, + { "path": "../react-router" }, + { "path": "../ui" } + ], + "compilerOptions": { + "rootDirs": ["./src", "./__tests__"], + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~/*": ["./src/*"], + "~tests/*": ["./__tests__/*"], + "@webiny/app/*": ["../app/src/*"], + "@webiny/app": ["../app/src"], + "@webiny/app-admin/*": ["../app-admin/src/*"], + "@webiny/app-admin": ["../app-admin/src"], + "@webiny/app-headless-cms/*": ["../app-headless-cms/src/*"], + "@webiny/app-headless-cms": ["../app-headless-cms/src"], + "@webiny/app-page-builder/*": ["../app-page-builder/src/*"], + "@webiny/app-page-builder": ["../app-page-builder/src"], + "@webiny/app-page-builder-elements/*": ["../app-page-builder-elements/src/*"], + "@webiny/app-page-builder-elements": ["../app-page-builder-elements/src"], + "@webiny/plugins/*": ["../plugins/src/*"], + "@webiny/plugins": ["../plugins/src"], + "@webiny/react-router/*": ["../react-router/src/*"], + "@webiny/react-router": ["../react-router/src"], + "@webiny/ui/*": ["../ui/src/*"], + "@webiny/ui": ["../ui/src"] + }, + "baseUrl": "." + } +} diff --git a/packages/app-dynamic-pages/webiny.config.js b/packages/app-dynamic-pages/webiny.config.js new file mode 100644 index 00000000000..6dff86766c9 --- /dev/null +++ b/packages/app-dynamic-pages/webiny.config.js @@ -0,0 +1,8 @@ +const { createWatchPackage, createBuildPackage } = require("@webiny/project-utils"); + +module.exports = { + commands: { + build: createBuildPackage({ cwd: __dirname }), + watch: createWatchPackage({ cwd: __dirname }) + } +}; diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/index.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/index.tsx index fd24d174685..d8211374697 100644 --- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/index.tsx +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerView/index.tsx @@ -83,7 +83,10 @@ export const FileManagerRenderer = createDecorator(BaseFileManagerRenderer, () = return function FileManagerRenderer(props) { const { onChange, onUploadCompletion, ...forwardProps } = props; - const handleFileOnChange = (value?: FileItem[] | FileItem) => { + const handleFileOnChange: FileManagerViewProviderProps["onChange"] = ( + value: FileItem[] | FileItem, + context + ) => { if (!onChange || !value || (Array.isArray(value) && !value.length)) { return; } @@ -91,20 +94,24 @@ export const FileManagerRenderer = createDecorator(BaseFileManagerRenderer, () = if (Array.isArray(value)) { const finalValue = value.map(formatFileItem); (onChange as FileManagerOnChange)(finalValue); + context.onClose(); return; } (onChange as FileManagerOnChange)(formatFileItem(value)); + context.onClose(); }; const handleFileOnUploadCompletion: FileManagerViewProviderProps["onUploadCompletion"] = ( - files: FileItem[] + files: FileItem[], + context ) => { if (!onUploadCompletion) { return; } onUploadCompletion(files.map(formatFileItem)); + context.onClose(); }; const viewProps: Omit = { diff --git a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerViewProvider/FileManagerViewContext.tsx b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerViewProvider/FileManagerViewContext.tsx index dc034cefd22..24fd0893af9 100644 --- a/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerViewProvider/FileManagerViewContext.tsx +++ b/packages/app-file-manager/src/modules/FileManagerRenderer/FileManagerViewProvider/FileManagerViewContext.tsx @@ -83,14 +83,14 @@ const getCurrentFolderList = ( }; export interface FileManagerViewProviderProps { - onChange?: (value: FileItem[] | FileItem) => void; + onChange?: (value: FileItem[] | FileItem, context: FileManagerViewContext) => void; onClose?: () => void; multiple?: boolean; accept: string[]; maxSize?: number | string; multipleMaxCount?: number; multipleMaxSize?: number | string; - onUploadCompletion?: (files: FileItem[]) => void; + onUploadCompletion?: (files: FileItem[], context: FileManagerViewContext) => void; tags?: string[]; scope?: string; own?: boolean; @@ -381,10 +381,8 @@ export const FileManagerViewProvider = ({ children, ...props }: FileManagerViewP multiple: Boolean(props.multiple), onChange(value: FileItem[] | FileItem) { if (typeof props.onChange === "function") { - props.onChange(value); + props.onChange(value, context); } - - context.onClose(); }, onClose() { if (typeof props.onClose === "function") { @@ -398,8 +396,7 @@ export const FileManagerViewProvider = ({ children, ...props }: FileManagerViewP })); if (typeof props.onUploadCompletion === "function") { - props.onUploadCompletion(files); - context.onClose(); + props.onUploadCompletion(files, context); } }, own: Boolean(props.own), diff --git a/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryForm.tsx b/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryForm.tsx index c143ddaa4fe..4aa5d042c6c 100644 --- a/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryForm.tsx +++ b/packages/app-headless-cms/src/admin/components/ContentEntryForm/ContentEntryForm.tsx @@ -1,7 +1,8 @@ import React, { useEffect, useRef } from "react"; import styled from "@emotion/styled"; -import { CmsContentEntry } from "~/types"; +import type { FormOnSubmit } from "@webiny/form"; import { makeDecoratable } from "@webiny/app-admin"; +import { CmsContentEntry } from "~/types"; import { ModelProvider, useModel } from "~/admin/components/ModelProvider"; import { useFormRenderer } from "~/admin/components/ContentEntryForm/useFormRenderer"; import { @@ -18,7 +19,8 @@ const FormWrapper = styled("div")({ overflow: "auto" }); -export interface ContentEntryFormProps extends React.HTMLAttributes { +export interface ContentEntryFormProps + extends Omit, "onChange"> { entry: Partial; /** * This callback is executed when an entry, or a revision, are created. @@ -29,6 +31,7 @@ export interface ContentEntryFormProps extends React.HTMLAttributes>; header?: React.ReactNode; /** * This prop is used to get a reference to `saveEntry` callback, so it can be triggered by components @@ -43,6 +46,7 @@ export const ContentEntryForm = makeDecoratable( ({ entry, persistEntry, + onChange, onAfterCreate, setSaveEntry, header = true, @@ -76,6 +80,7 @@ export const ContentEntryForm = makeDecoratable( >; onAfterCreate?: (entry: CmsContentEntry) => void; setSaveEntry?: SetSaveEntry; children: React.ReactNode; @@ -62,6 +64,7 @@ export const ContentEntryFormProvider = ({ entry, children, persistEntry, + onChange, onAfterCreate, setSaveEntry, confirmNavigationIfDirty @@ -134,6 +137,7 @@ export const ContentEntryFormProvider = ({ return ( onSubmit={onFormSubmit} + onChange={onChange} data={entry} ref={ref} validateOnFirstSubmit diff --git a/packages/app-page-builder-elements/src/components/Element.tsx b/packages/app-page-builder-elements/src/components/Element.tsx index 2f0d62a6eb9..3897759527c 100644 --- a/packages/app-page-builder-elements/src/components/Element.tsx +++ b/packages/app-page-builder-elements/src/components/Element.tsx @@ -21,7 +21,7 @@ export const Element = makeDecoratable("Element", (props: ElementProps) => { const ElementRenderer = renderers ? renderers[element.type] : null; if (!ElementRenderer) { - return null; + return
Missing renderer for {element.type}
; } const meta = { diff --git a/packages/app-page-builder-elements/src/components/Elements.tsx b/packages/app-page-builder-elements/src/components/Elements.tsx index 87498f5caf9..80f2fdd249a 100644 --- a/packages/app-page-builder-elements/src/components/Elements.tsx +++ b/packages/app-page-builder-elements/src/components/Elements.tsx @@ -9,11 +9,12 @@ import { useRenderer } from "~/hooks/useRenderer"; // 1. the pre-made block's ID // 2. an index of the nested element const getElementKey = ( + elementKeyPrefix: string | undefined, element: ElementType, elementIndex: number, parentBlockElement?: ElementType ) => { - let parts: string[] = [element.id]; + let parts = [elementKeyPrefix, element.id, elementIndex.toString()]; if (parentBlockElement) { parts = [parentBlockElement.id, elementIndex.toString()]; @@ -21,17 +22,26 @@ const getElementKey = ( // Add element type for easier debugging and more clarity in the profiler. parts.push(element.type); - return parts.join("-"); + return parts.filter(Boolean).join("-"); }; +interface ElementWrapper { + (element: JSX.Element, index: number): JSX.Element; +} + +const passthroughWrapper: ElementWrapper = element => element; + export interface ElementsProps { element: ElementType; + wrapper?: ElementWrapper; + elementKeyPrefix?: string; } export const Elements = (props: ElementsProps) => { // `Elements` component is used within a renderer, meaning // we can always be sure `useRenderer` hook is available. const { meta: currentRendererMeta } = useRenderer(); + const wrapper = props.wrapper || passthroughWrapper; const elements = props.element.elements; @@ -59,23 +69,31 @@ export const Elements = (props: ElementsProps) => { return ( <> {elements.map((element, index) => { - const key = getElementKey(element, index, parentBlockElement); + const key = getElementKey( + props.elementKeyPrefix, + element, + index, + parentBlockElement + ); - return ( - + return React.cloneElement( + wrapper( + , + index + ), + { key } ); })} diff --git a/packages/app-page-builder-elements/src/index.ts b/packages/app-page-builder-elements/src/index.ts index 19ca8705fd2..52a22af89a3 100644 --- a/packages/app-page-builder-elements/src/index.ts +++ b/packages/app-page-builder-elements/src/index.ts @@ -14,6 +14,7 @@ export * from "./contexts/Renderer"; export * from "./contexts/ElementRendererInputs"; export * from "./components/Page"; +export * from "./components/Content"; export * from "./components/Element"; export * from "./components/Elements"; export * from "./components/ErrorBoundary"; diff --git a/packages/app-page-builder-elements/src/inputs/ElementInput.ts b/packages/app-page-builder-elements/src/inputs/ElementInput.ts index dd3378779c8..bad43d35394 100644 --- a/packages/app-page-builder-elements/src/inputs/ElementInput.ts +++ b/packages/app-page-builder-elements/src/inputs/ElementInput.ts @@ -15,7 +15,7 @@ export type ElementInputType = | "number" | "boolean" | "date" - | "richText" + | "lexical" | "link" | "svgIcon" | "color" @@ -41,6 +41,10 @@ export class ElementInput { return new ElementInput(params); } + getName() { + return this.params.name; + } + getType() { return this.params.type; } diff --git a/packages/app-page-builder-elements/src/renderers/block.tsx b/packages/app-page-builder-elements/src/renderers/block.tsx index 8bb210536bc..ab12943db4f 100644 --- a/packages/app-page-builder-elements/src/renderers/block.tsx +++ b/packages/app-page-builder-elements/src/renderers/block.tsx @@ -2,16 +2,23 @@ import React from "react"; import { Elements } from "~/components/Elements"; import { createRenderer } from "~/createRenderer"; import { useRenderer } from "~/hooks/useRenderer"; -import { makeDecoratable } from "@webiny/react-composition"; import { BlockProvider } from "./block/BlockProvider"; export * from "./block/BlockProvider"; -const BaseBlockRenderer = createRenderer( - () => { +interface BlockRendererProps { + ifEmpty?: JSX.Element; +} + +export const BlockRenderer = createRenderer( + ({ ifEmpty = null }) => { const { getElement } = useRenderer(); const element = getElement(); + if (element.elements.length === 0) { + return ifEmpty; + } + return ( @@ -31,5 +38,3 @@ const BaseBlockRenderer = createRenderer( } } ); - -export const BlockRenderer = makeDecoratable("BlockRenderer", BaseBlockRenderer); diff --git a/packages/app-page-builder-elements/src/renderers/grid.tsx b/packages/app-page-builder-elements/src/renderers/grid.tsx index 70e433a7bec..7b74aaa357d 100644 --- a/packages/app-page-builder-elements/src/renderers/grid.tsx +++ b/packages/app-page-builder-elements/src/renderers/grid.tsx @@ -3,25 +3,21 @@ import { Elements } from "~/components/Elements"; import { createRenderer } from "~/createRenderer"; import { useRenderer } from "~/hooks/useRenderer"; -export type GridRenderer = ReturnType; +export const GridRenderer = createRenderer( + () => { + const { getElement } = useRenderer(); -export const createGrid = () => { - return createRenderer( - () => { - const { getElement } = useRenderer(); - - const element = getElement(); - return ; - }, - { - baseStyles: { - boxSizing: "border-box", - display: "flex", - flexDirection: "row", - justifyContent: "flex-start", - alignItems: "flex-start", - width: "100%" - } + const element = getElement(); + return ; + }, + { + baseStyles: { + boxSizing: "border-box", + display: "flex", + flexDirection: "row", + justifyContent: "flex-start", + alignItems: "flex-start", + width: "100%" } - ); -}; + } +); diff --git a/packages/app-page-builder-elements/src/renderers/heading.tsx b/packages/app-page-builder-elements/src/renderers/heading.tsx index 16b2b18832a..441cf6b20df 100644 --- a/packages/app-page-builder-elements/src/renderers/heading.tsx +++ b/packages/app-page-builder-elements/src/renderers/heading.tsx @@ -2,11 +2,13 @@ import React from "react"; import { createRenderer } from "~/createRenderer"; import { useRenderer } from "~/hooks/useRenderer"; import { ElementInput } from "~/inputs/ElementInput"; +import { isJson } from "~/renderers/isJson"; +import { isHtml } from "~/renderers/isHtml"; export const elementInputs = { text: ElementInput.create({ name: "text", - type: "richText", + type: "lexical", translatable: true, getDefaultValue: ({ element }) => { return element.data.text.data.text; @@ -28,11 +30,15 @@ export const HeadingRenderer = createRenderer( () => { const { getInputValues } = useRenderer(); const inputs = getInputValues(); - const __html = inputs.text || ""; + const content = inputs.text || ""; const tag = inputs.tag || "h1"; + if (isJson(content) || !isHtml(content)) { + return null; + } + return React.createElement(tag, { - dangerouslySetInnerHTML: { __html } + dangerouslySetInnerHTML: { __html: content } }); }, { inputs: elementInputs } diff --git a/packages/app-page-builder-elements/src/renderers/image.tsx b/packages/app-page-builder-elements/src/renderers/image.tsx index 459e6f7057c..f74c96b6211 100644 --- a/packages/app-page-builder-elements/src/renderers/image.tsx +++ b/packages/app-page-builder-elements/src/renderers/image.tsx @@ -1,9 +1,10 @@ import React from "react"; import styled from "@emotion/styled"; -import { createRenderer, CreateRendererOptions } from "~/createRenderer"; +import { createRenderer } from "~/createRenderer"; import { useRenderer } from "~/hooks/useRenderer"; import { LinkComponent as LinkComponentType } from "~/types"; import { DefaultLinkComponent } from "~/renderers/components"; +import { ElementInput } from "~/inputs/ElementInput"; declare global { // eslint-disable-next-line @@ -31,8 +32,6 @@ export interface ImageElementData { }; } -export interface ImageRendererComponentProps extends Props, CreateImageParams {} - const SUPPORTED_IMAGE_RESIZE_WIDTHS = [100, 300, 500, 750, 1000, 1500, 2500]; const PbImg = styled.img` @@ -52,122 +51,122 @@ const PbImgObject = styled.object` pointer-events: none; `; -export const ImageRendererComponent = ({ - onClick, - renderEmpty, - value, - link, - linkComponent -}: ImageRendererComponentProps) => { - const LinkComponent = linkComponent || DefaultLinkComponent; - const { getElement } = useRenderer(); - const element = getElement(); - - let content; - if (element.data?.image?.file?.src) { - const { title, width, height, file } = element.data.image; - let { htmlTag } = element.data.image; - const { src } = value || file; - - if (htmlTag === "auto") { - htmlTag = src.endsWith(".svg") ? "object" : "img"; +const elementInputs = { + imageSrc: ElementInput.create({ + name: "imageSrc", + translatable: false, + type: "link", + getDefaultValue({ element }) { + return element.data.image?.file?.src; } + }) +}; + +export interface ImageRendererProps { + onClick?: () => void; + renderEmpty?: React.ReactNode; + value?: { id: string; src: string }; + link?: { href: string; newTab?: boolean }; + linkComponent?: LinkComponentType; +} + +export const ImageRenderer = createRenderer( + props => { + const { link, renderEmpty, onClick } = props; + const LinkComponent = props.linkComponent || DefaultLinkComponent; + const { getElement, getInputValues } = useRenderer(); + const element = getElement(); + const inputs = getInputValues(); + const imageSrc = inputs.imageSrc; + + let content; + if (imageSrc) { + const { + title = "", + width = "100%", + height = "auto", + htmlTag = "auto" + } = element.data.image || {}; + + const imageTag = htmlTag === "auto" && imageSrc.endsWith(".svg") ? "object" : "img"; + + if (imageTag === "object") { + content = ( + + + + ); + } else { + // If a fixed image width in pixels was set, let's filter out unneeded + // image resize widths. For example, if 155px was set as the fixed image + // width, then we want the `srcset` attribute to only contain 100w and 300w. + let srcSetWidths: number[] = []; + + if (width && width.endsWith("px")) { + const imageWidthInt = parseInt(width); + for (let i = 0; i < SUPPORTED_IMAGE_RESIZE_WIDTHS.length; i++) { + const supportedResizeWidth = SUPPORTED_IMAGE_RESIZE_WIDTHS[i]; + if (imageWidthInt > supportedResizeWidth) { + srcSetWidths.push(supportedResizeWidth); + } else { + srcSetWidths.push(supportedResizeWidth); + break; + } + } + } else { + // If a fixed image width was not provided, we + // rely on all the supported image resize widths. + srcSetWidths = SUPPORTED_IMAGE_RESIZE_WIDTHS; + } - if (htmlTag === "object") { - content = ( - - { + return `${imageSrc}?width=${item} ${item}w`; + }) + .join(", "); + + content = ( + - - ); - } else { - // If a fixed image width in pixels was set, let's filter out unneeded - // image resize widths. For example, if 155px was set as the fixed image - // width, then we want the `srcset` attribute to only contain 100w and 300w. - let srcSetWidths: number[] = []; - - if (width && width.endsWith("px")) { - const imageWidthInt = parseInt(width); - for (let i = 0; i < SUPPORTED_IMAGE_RESIZE_WIDTHS.length; i++) { - const supportedResizeWidth = SUPPORTED_IMAGE_RESIZE_WIDTHS[i]; - if (imageWidthInt > supportedResizeWidth) { - srcSetWidths.push(supportedResizeWidth); - } else { - srcSetWidths.push(supportedResizeWidth); - break; - } - } - } else { - // If a fixed image width was not provided, we - // rely on all the supported image resize widths. - srcSetWidths = SUPPORTED_IMAGE_RESIZE_WIDTHS; + ); } - - const srcSet = srcSetWidths - .map(item => { - return `${src}?width=${item} ${item}w`; - }) - .join(", "); - - content = ( - - ); + } else { + content = renderEmpty || null; } - } else { - content = renderEmpty || null; - } - const linkProps = link || element.data?.link; - if (linkProps) { - const { href, newTab } = linkProps; - if (href) { - content = ( - - {content} - - ); + const linkProps = link || element.data?.link; + if (linkProps) { + const { href, newTab } = linkProps; + if (href) { + content = ( + + {content} + + ); + } } - } - - return <>{content}; -}; -export const imageRendererOptions: CreateRendererOptions = { - baseStyles: { width: "100%" }, - propsAreEqual: (prevProps: Props, nextProps: Props) => { - return prevProps.value === nextProps.value; + return <>{content}; + }, + { + inputs: elementInputs, + baseStyles: { width: "100%" }, + propsAreEqual: (prevProps, nextProps) => { + return prevProps.value === nextProps.value; + } } -}; - -export type ImageRenderer = ReturnType; - -interface Props { - onClick?: () => void; - renderEmpty?: React.ReactNode; - value?: { id: string; src: string }; - link?: { href: string; newTab?: boolean }; -} - -export interface CreateImageParams { - linkComponent?: LinkComponentType; -} - -export const createImage = (params: CreateImageParams = {}) => { - return createRenderer(props => { - return ; - }, imageRendererOptions); -}; +); diff --git a/packages/app-page-builder-elements/src/renderers/isHtml.ts b/packages/app-page-builder-elements/src/renderers/isHtml.ts new file mode 100644 index 00000000000..eb22013a563 --- /dev/null +++ b/packages/app-page-builder-elements/src/renderers/isHtml.ts @@ -0,0 +1,7 @@ +export const isHtml = (value: unknown) => { + if (typeof value !== "string" || value.startsWith("<")) { + return false; + } + + return true; +}; diff --git a/packages/app-page-builder-elements/src/renderers/isJson.ts b/packages/app-page-builder-elements/src/renderers/isJson.ts new file mode 100644 index 00000000000..53b086b2efe --- /dev/null +++ b/packages/app-page-builder-elements/src/renderers/isJson.ts @@ -0,0 +1,12 @@ +export const isJson = (value: unknown) => { + if (typeof value !== "string") { + return true; + } + + try { + JSON.parse(value); + return true; + } catch { + return false; + } +}; diff --git a/packages/app-page-builder-elements/src/renderers/paragraph.tsx b/packages/app-page-builder-elements/src/renderers/paragraph.tsx index 4f4ddbd7676..703ddd298e9 100644 --- a/packages/app-page-builder-elements/src/renderers/paragraph.tsx +++ b/packages/app-page-builder-elements/src/renderers/paragraph.tsx @@ -2,11 +2,13 @@ import React from "react"; import { createRenderer } from "~/createRenderer"; import { useRenderer } from "~/hooks/useRenderer"; import { ElementInput } from "~/inputs/ElementInput"; +import { isJson } from "~/renderers/isJson"; +import { isHtml } from "~/renderers/isHtml"; export const elementInputs = { text: ElementInput.create({ name: "text", - type: "richText", + type: "lexical", translatable: true, getDefaultValue: ({ element }) => { return element.data.text.data.text; @@ -14,15 +16,6 @@ export const elementInputs = { }) }; -const isJson = (value: string) => { - try { - JSON.parse(value); - return true; - } catch { - return false; - } -}; - /** * This renderer works with plain HTML. In the past, we used to have the MediumEditor, and it produced plain HTML. * For the new Lexical Editor, we decorate this renderer from the `@webiny/app-page-builder` package. @@ -31,9 +24,9 @@ export const ParagraphRenderer = createRenderer( () => { const { getInputValues } = useRenderer(); const inputs = getInputValues(); - const __html = inputs.text || ""; + const content = inputs.text || ""; - if (isJson(__html)) { + if (isJson(content) || !isHtml(content)) { return null; } @@ -43,12 +36,12 @@ export const ParagraphRenderer = createRenderer( // removing the wrapper `p` tags from the received text. But that wasn't enough. There // were cases where the received text was not just one `p` tag, but an array of `p` tags. // In that case, we still need a separate wrapper element. So, we're leaving this solution. - if (__html.startsWith("; + return ; } - return

; + return

; }, { inputs: elementInputs } ); diff --git a/packages/app-page-builder-elements/src/types.ts b/packages/app-page-builder-elements/src/types.ts index 19ea0dac7e3..805c04ddc5d 100644 --- a/packages/app-page-builder-elements/src/types.ts +++ b/packages/app-page-builder-elements/src/types.ts @@ -134,7 +134,7 @@ export interface PageProviderProps { export type Renderer< T = Record, TElementData = Record -> = React.FunctionComponent & T>; +> = React.FunctionComponent & T> & { inputs?: ElementInputs }; // TODO: maybe call this `Renderer` but rename the base one to `BaseRenderer` ? export type DecoratableRenderer = ReturnType; diff --git a/packages/app-page-builder/package.json b/packages/app-page-builder/package.json index 829f5a4f6b6..87df61c27e0 100644 --- a/packages/app-page-builder/package.json +++ b/packages/app-page-builder/package.json @@ -34,6 +34,7 @@ "@webiny/app-tenancy": "0.0.0", "@webiny/app-theme": "0.0.0", "@webiny/error": "0.0.0", + "@webiny/feature-flags": "0.0.0", "@webiny/form": "0.0.0", "@webiny/lexical-editor": "0.0.0", "@webiny/plugins": "0.0.0", diff --git a/packages/app-page-builder/src/IfDynamicPagesEnabled.tsx b/packages/app-page-builder/src/IfDynamicPagesEnabled.tsx new file mode 100644 index 00000000000..afa43bd9d24 --- /dev/null +++ b/packages/app-page-builder/src/IfDynamicPagesEnabled.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import { featureFlags } from "@webiny/feature-flags"; + +export const IfDynamicPagesEnabled = ({ children }: { children: React.ReactNode }) => { + if (featureFlags.experimentalDynamicPages === true) { + return <>{children}; + } + + return null; +}; diff --git a/packages/app-page-builder/src/PageBuilder.tsx b/packages/app-page-builder/src/PageBuilder.tsx index 2ee9e4ab1b2..dbb46a2ec85 100644 --- a/packages/app-page-builder/src/PageBuilder.tsx +++ b/packages/app-page-builder/src/PageBuilder.tsx @@ -19,6 +19,10 @@ import { LexicalHeadingRenderer } from "~/render/plugins/elements/heading/Lexica import { NullLoaderCache } from "@webiny/app-page-builder-elements/hooks/useLoader/NullLoaderCache"; import { ConvertIconSettings as EditorConvertIconSettings } from "~/editor/prepareEditorContent/ConvertIconSettings"; import { ConvertIconSettings as RendererConvertIconSettings } from "~/render/plugins/elementSettings/icon"; +import { AddImageLinkComponent } from "~/elementDecorators/AddImageLinkComponent"; +import { PageTemplatesPreview } from "./dataInjection/preview/PageTemplatesPreview"; +import { PagesPreview } from "~/dataInjection/preview/PagesPreview"; +import { IfDynamicPagesEnabled } from "~/IfDynamicPagesEnabled"; export type { EditorProps }; export { EditorRenderer }; @@ -148,6 +152,7 @@ export const PageBuilder = () => { + {/* Ensure data is in the correct shape when editor is mounting. */} {/* This works only within the block/template/page editor. */} @@ -155,6 +160,12 @@ export const PageBuilder = () => { {/* Ensure each element renderer is receiving data in the correct shape. */} {/* This works for page previews, block previews, etc. */} + + {/* Decorate page template content preview. */} + + {/* Decorate page content preview. */} + + ); }; diff --git a/packages/app-page-builder/src/admin/graphql/pages.ts b/packages/app-page-builder/src/admin/graphql/pages.ts index 25b1111ea22..5830eea11ad 100644 --- a/packages/app-page-builder/src/admin/graphql/pages.ts +++ b/packages/app-page-builder/src/admin/graphql/pages.ts @@ -185,6 +185,16 @@ export const GET_PAGE = gql` name } content + dataBindings { + dataSource + bindFrom + bindTo + } + dataSources { + name + type + config + } } ${error} diff --git a/packages/app-page-builder/src/admin/plugins/pageDetails/header/pageOptionsMenu/PageOptionsMenu.tsx b/packages/app-page-builder/src/admin/plugins/pageDetails/header/pageOptionsMenu/PageOptionsMenu.tsx index 7fddcdaab67..406ac6a4d3e 100644 --- a/packages/app-page-builder/src/admin/plugins/pageDetails/header/pageOptionsMenu/PageOptionsMenu.tsx +++ b/packages/app-page-builder/src/admin/plugins/pageDetails/header/pageOptionsMenu/PageOptionsMenu.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useState } from "react"; -import { useApolloClient } from "@apollo/react-hooks"; import { IconButton } from "@webiny/ui/Button"; import { Icon } from "@webiny/ui/Icon"; import { ReactComponent as MoreVerticalIcon } from "~/admin/assets/more_vert.svg"; @@ -7,11 +6,7 @@ import { ReactComponent as HomeIcon } from "~/admin/assets/round-home-24px.svg"; import { ReactComponent as GridViewIcon } from "@material-design-icons/svg/outlined/grid_view.svg"; import { ListItemGraphic } from "@webiny/ui/List"; import { MenuItem, Menu } from "@webiny/ui/Menu"; -import { - CREATE_TEMPLATE_FROM_PAGE, - LIST_PAGE_TEMPLATES -} from "~/admin/views/PageTemplates/graphql"; -import CreatePageTemplateDialog from "~/admin/views/PageTemplates/CreatePageTemplateDialog"; +import { CreatePageTemplateDialog } from "~/admin/views/PageTemplates/CreatePageTemplateDialog"; import { usePageBuilderSettings } from "~/admin/hooks/usePageBuilderSettings"; import { css } from "emotion"; import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; @@ -25,6 +20,7 @@ import { useFolders } from "@webiny/app-aco"; import { useTemplatesPermissions } from "~/hooks/permissions"; import { PreviewPage } from "./PreviewPage"; import { DuplicatePage } from "./DuplicatePage"; +import { useCreatePageTemplateFromPage } from "~/features"; const menuStyles = css({ width: 250, @@ -44,7 +40,7 @@ const PageOptionsMenu = (props: PageOptionsMenuProps) => { const { page } = props; const [isCreateTemplateDialogOpen, setIsCreateTemplateDialogOpen] = useState(false); const { settings, isSpecialPage, updateSettingsMutation } = usePageBuilderSettings(); - const client = useApolloClient(); + const { createPageTemplateFromPage } = useCreatePageTemplateFromPage(); const pageBuilder = useAdminPageBuilder(); @@ -64,19 +60,10 @@ const PageOptionsMenu = (props: PageOptionsMenuProps) => { const handleCreateTemplateClick = useCallback( async (formData: Pick) => { try { - const { data: res } = await client.mutate({ - mutation: CREATE_TEMPLATE_FROM_PAGE, - variables: { pageId: page.id, data: formData }, - refetchQueries: [{ query: LIST_PAGE_TEMPLATES }] - }); - - const { error, data } = res.pageBuilder.pageTemplate; - if (error) { - showSnackbar(error.message); - } else { - setIsCreateTemplateDialogOpen(false); - showSnackbar(`Template "${data.title}" created successfully.`); - } + const pageTemplate = await createPageTemplateFromPage(page.id, formData); + + setIsCreateTemplateDialogOpen(false); + showSnackbar(`Template "${pageTemplate.title}" created successfully.`); } catch (error) { showSnackbar(error.message); } diff --git a/packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/PagePreview.tsx b/packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/PageContentPreview.tsx similarity index 87% rename from packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/PagePreview.tsx rename to packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/PageContentPreview.tsx index 555d6f5d378..22aa9638126 100644 --- a/packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/PagePreview.tsx +++ b/packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/PageContentPreview.tsx @@ -1,12 +1,12 @@ import React, { CSSProperties } from "react"; -import { QueryResult } from "@apollo/react-common"; import { css } from "emotion"; import styled from "@emotion/styled"; import { Typography } from "@webiny/ui/Typography"; import { Select } from "@webiny/ui/Select"; -import { Page } from "@webiny/app-page-builder-elements/components/Page"; import { Zoom } from "./Zoom"; -import { PbPageData, PbPageTemplate } from "~/types"; +import { PbPageData } from "~/types"; +import { Content } from "@webiny/app-page-builder-elements"; +import { makeDecoratable } from "@webiny/react-composition"; const webinyZoomStyles = css` &.mdc-select--no-label:not(.mdc-select--outlined) @@ -81,11 +81,10 @@ const SelectPageZoom: React.ComponentType = ({ zoom, setZ ); interface PagePreviewProps { - page: PbPageData | PbPageTemplate; - getPageQuery?: QueryResult; + page: PbPageData; } -export const PagePreview = ({ page }: PagePreviewProps) => { +export const PageContentPreview = makeDecoratable(({ page }: PagePreviewProps) => { return ( {({ zoom, setZoom }) => ( @@ -93,10 +92,10 @@ export const PagePreview = ({ page }: PagePreviewProps) => { className={pageInnerWrapper} style={{ "--webiny-pb-page-preview-scale": zoom } as CSSProperties} > - + )} ); -}; +}); diff --git a/packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/index.tsx b/packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/index.tsx index a6a595aece4..7e53c7c38fb 100644 --- a/packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/index.tsx +++ b/packages/app-page-builder/src/admin/plugins/pageDetails/previewContent/index.tsx @@ -7,8 +7,8 @@ import { import { Tab } from "@webiny/ui/Tabs"; import styled from "@emotion/styled"; import { Elevation } from "@webiny/ui/Elevation"; -import { PagePreview } from "./PagePreview"; import { CircularProgress } from "@webiny/ui/Progress"; +import { PageContentPreview } from "./PageContentPreview"; const RenderBlock = styled("div")({ position: "relative", @@ -44,7 +44,7 @@ const revisionContentPreviewPlugin: PbPageDetailsRevisionContentPreviewPlugin = name: "pb-page-details-revision-preview", type: "pb-page-details-revision-content-preview", render(props) { - return ; + return ; } }; diff --git a/packages/app-page-builder/src/admin/utils/createElementPlugin.tsx b/packages/app-page-builder/src/admin/utils/createElementPlugin.tsx index 8b694b6d8c3..757d3c15a74 100644 --- a/packages/app-page-builder/src/admin/utils/createElementPlugin.tsx +++ b/packages/app-page-builder/src/admin/utils/createElementPlugin.tsx @@ -39,7 +39,6 @@ export default (el: PbEditorElement): void => { onCreate: OnCreateActions.SKIP, settings: rootPlugin ? rootPlugin.settings : [], - // @ts-expect-error create() { return cloneDeep(el.content); }, diff --git a/packages/app-page-builder/src/admin/views/PageTemplates/CreatePageTemplateDialog.tsx b/packages/app-page-builder/src/admin/views/PageTemplates/CreatePageTemplateDialog.tsx index 8cf84bd0bc3..90eb1054f4a 100644 --- a/packages/app-page-builder/src/admin/views/PageTemplates/CreatePageTemplateDialog.tsx +++ b/packages/app-page-builder/src/admin/views/PageTemplates/CreatePageTemplateDialog.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useState } from "react"; import { css } from "emotion"; +import { makeDecoratable } from "@webiny/app"; import { Form } from "@webiny/form"; import { ButtonPrimary } from "@webiny/ui/Button"; import { Grid, Cell } from "@webiny/ui/Grid"; @@ -37,67 +38,69 @@ type CreatePageTemplateDialogProps = { onSubmit: (formData: Pick) => Promise; }; -const CreatePageTemplateDialog = ({ open, onClose, onSubmit }: CreatePageTemplateDialogProps) => { - const [loading, setLoading] = useState(false); - const submitForm = useCallback( - async (formData: PbPageTemplate) => { - setLoading(true); - await onSubmit(formData); - setLoading(false); - }, - [onSubmit] - ); - - return ( -

-
- {({ form, Bind }) => ( - <> - Create Page Template - - - - - - - - - - - - - - - - - - - - - - - Cancel - - Create - - - - )} -
-
- ); -}; +export const CreatePageTemplateDialog = makeDecoratable( + "CreatePageTemplateDialog", + ({ open, onClose, onSubmit }: CreatePageTemplateDialogProps) => { + const [loading, setLoading] = useState(false); + const submitForm = useCallback( + async (formData: PbPageTemplate) => { + setLoading(true); + await onSubmit(formData); + setLoading(false); + onClose(); + }, + [onSubmit] + ); -export default CreatePageTemplateDialog; + return ( + +
+ {({ form, Bind }) => ( + <> + Create Page Template + + + + + + + + + + + + + + + + + + + + + + + Cancel + + Create + + + + )} +
+
+ ); + } +); diff --git a/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplateContentPreview.tsx b/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplateContentPreview.tsx new file mode 100644 index 00000000000..ca4ce0acbe3 --- /dev/null +++ b/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplateContentPreview.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { css } from "emotion"; +import { makeDecoratable } from "@webiny/app"; +import { Content } from "@webiny/app-page-builder-elements"; +import type { PbPageTemplateWithContent } from "~/types"; + +const pageInnerWrapper = css` + overflow-y: scroll; + overflow-x: hidden; + height: calc(100vh - 165px); +`; + +interface PageTemplateContentPreviewProps { + template: PbPageTemplateWithContent; +} + +export const PageTemplateContentPreview = makeDecoratable( + "PageTemplateContentPreview", + ({ template }: PageTemplateContentPreviewProps) => { + return ( +
+ +
+ ); + } +); diff --git a/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplateDetails.tsx b/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplateDetails.tsx index 60bbde3c309..82d01526032 100644 --- a/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplateDetails.tsx +++ b/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplateDetails.tsx @@ -1,9 +1,6 @@ import React from "react"; import styled from "@emotion/styled"; -import { useQuery } from "@apollo/react-hooks"; - import { useRouter } from "@webiny/react-router"; -import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; import { ButtonDefault, ButtonIcon, IconButton } from "@webiny/ui/Button"; import { Elevation } from "@webiny/ui/Elevation"; import { CircularProgress } from "@webiny/ui/Progress"; @@ -14,10 +11,10 @@ import { ReactComponent as AddIcon } from "@material-design-icons/svg/filled/add import { ReactComponent as EditIcon } from "@material-design-icons/svg/round/edit.svg"; import { ReactComponent as DeleteIcon } from "@material-design-icons/svg/round/delete.svg"; -import { GET_PAGE_TEMPLATE } from "./graphql"; import { CreatableItem } from "./PageTemplates"; -import { PagePreview } from "~/admin/plugins/pageDetails/previewContent/PagePreview"; import { PbPageTemplate } from "~/types"; +import { useListPageTemplates } from "~/features"; +import { PageTemplateContentPreview } from "~/admin/views/PageTemplates/PageTemplateContentPreview"; const t = i18n.ns("app-page-builder/admin/views/page-templates/page-template-details"); @@ -25,10 +22,6 @@ const DetailsContainer = styled.div` height: calc(100% - 10px); overflow: hidden; position: relative; - - .mdc-tab-bar { - background-color: var(--mdc-theme-surface); - } `; const RenderBlock = styled.div` @@ -47,10 +40,7 @@ const HeaderTitle = styled.div` border-bottom: 1px solid var(--mdc-theme-on-background); color: var(--mdc-theme-text-primary-on-background); background: var(--mdc-theme-surface); - padding-top: 10px; - padding-bottom: 9px; - padding-left: 24px; - padding-right: 24px; + padding: 10px 24px 9px; `; const PageTemplateTitle = styled.div` @@ -102,7 +92,7 @@ type PageBuilderPageTemplateDetailsProps = { onDelete: (item: PbPageTemplate) => void; }; -const PageTemplatesDetails = ({ +export const PageTemplateDetails = ({ canCreate, canEdit, canDelete, @@ -110,35 +100,22 @@ const PageTemplatesDetails = ({ onDelete }: PageBuilderPageTemplateDetailsProps) => { const { history, location } = useRouter(); - const { showSnackbar } = useSnackbar(); + const { pageTemplates, loading } = useListPageTemplates(); const query = new URLSearchParams(location.search); const templateId = query.get("id"); + const template = pageTemplates.find(template => template.id === templateId); - const getPageTemplateQuery = useQuery(GET_PAGE_TEMPLATE, { - variables: { id: templateId }, - skip: !templateId, - onCompleted: data => { - const error = data?.pageBuilder?.getPageTemplate?.error; - if (error) { - history.push("/page-builder/page-templates"); - showSnackbar(error.message); - } - } - }); - - if (!templateId) { + if (!templateId || !template) { return ; } - const template = getPageTemplateQuery.data?.pageBuilder?.getPageTemplate?.data || {}; - return (
- {getPageTemplateQuery.loading && } + {loading && } {template.title} @@ -162,12 +139,10 @@ const PageTemplatesDetails = ({ )} - +
); }; - -export default PageTemplatesDetails; diff --git a/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplates.tsx b/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplates.tsx index 08e32737690..7e60f697727 100644 --- a/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplates.tsx +++ b/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplates.tsx @@ -1,18 +1,16 @@ import React, { useCallback } from "react"; -import get from "lodash/get"; import { i18n } from "@webiny/app/i18n"; import { useRouter } from "@webiny/react-router"; -import { useMutation, useApolloClient } from "@apollo/react-hooks"; import { SplitView, LeftPanel, RightPanel } from "@webiny/app-admin/components/SplitView"; import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; import { useConfirmationDialog } from "@webiny/app-admin/hooks/useConfirmationDialog"; import { useStateWithCallback } from "@webiny/app-admin/hooks"; import PageTemplatesDataList from "./PageTemplatesDataList"; -import PageTemplateDetails from "./PageTemplateDetails"; -import CreatePageTemplateDialog from "./CreatePageTemplateDialog"; +import { PageTemplateDetails } from "./PageTemplateDetails"; +import { CreatePageTemplateDialog } from "./CreatePageTemplateDialog"; import { PbPageTemplate } from "~/types"; -import { LIST_PAGE_TEMPLATES, CREATE_PAGE_TEMPLATE, DELETE_PAGE_TEMPLATE } from "./graphql"; import { useTemplatesPermissions } from "~/hooks/permissions"; +import { useCreatePageTemplate, useDeletePageTemplate } from "~/features"; const t = i18n.ns("app-page-builder/admin/views/page-templates"); @@ -24,9 +22,10 @@ export interface CreatableItem { const PageTemplates = () => { const { history } = useRouter(); - const client = useApolloClient(); const { showSnackbar } = useSnackbar(); const { showConfirmation } = useConfirmationDialog(); + const { createPageTemplate } = useCreatePageTemplate(); + const { deletePageTemplate } = useDeletePageTemplate(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useStateWithCallback(false); const { canCreate, canUpdate, canDelete } = useTemplatesPermissions(); @@ -34,49 +33,34 @@ const PageTemplates = () => { const onCreatePageTemplate = async ( formData: Pick ) => { - const { data: res } = await client.mutate({ - mutation: CREATE_PAGE_TEMPLATE, - variables: { - data: { - title: formData.title, - slug: formData.slug, - description: formData.description, - tags: [], - layout: "static", // Hardcoded until better UI is in place - pageCategory: "static" - } - }, - refetchQueries: [{ query: LIST_PAGE_TEMPLATES }] - }); + try { + const pageTemplate = await createPageTemplate({ + title: formData.title, + slug: formData.slug, + description: formData.description, + tags: [], + layout: "static" + }); - const { error, data } = get(res, `pageBuilder.pageTemplate`); - if (data) { setIsCreateDialogOpen(false, () => { - history.push(`/page-builder/template-editor/${data.id}`); + history.push(`/page-builder/template-editor/${pageTemplate.id}`); }); - } else { + } catch (error) { showSnackbar(error.message); } }; - const [deleteIt, deleteMutation] = useMutation(DELETE_PAGE_TEMPLATE, { - refetchQueries: [{ query: LIST_PAGE_TEMPLATES }] - }); - const handleDeleteTemplateClick = useCallback((item: PbPageTemplate) => { showConfirmation(async () => { - const response = await deleteIt({ - variables: { id: item.id } - }); - - const error = response?.data?.pageBuilder?.deletePageTemplate?.error; - if (error) { - return showSnackbar(error.message); + try { + await deletePageTemplate(item.id); + history.push("/page-builder/page-templates"); + showSnackbar( + t`Template "{title}" was deleted successfully!`({ title: item.title }) + ); + } catch (error) { + showSnackbar(error.message); } - - history.push("/page-builder/page-templates"); - - showSnackbar(t`Template "{title}" deleted.`({ title: item.title })); }); }, []); @@ -90,7 +74,6 @@ const PageTemplates = () => { canDelete={record => canDelete(record.createdBy?.id)} onCreate={() => setIsCreateDialogOpen(true)} onDelete={handleDeleteTemplateClick} - isLoading={deleteMutation?.loading} /> diff --git a/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplatesDataList.tsx b/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplatesDataList.tsx index 609f46cb12f..5087d82680b 100644 --- a/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplatesDataList.tsx +++ b/packages/app-page-builder/src/admin/views/PageTemplates/PageTemplatesDataList.tsx @@ -2,7 +2,6 @@ import React, { useCallback, useMemo, useState } from "react"; import styled from "@emotion/styled"; import { i18n } from "@webiny/app/i18n"; import { useRouter } from "@webiny/react-router"; -import { useQuery } from "@apollo/react-hooks"; import orderBy from "lodash/orderBy"; import { TimeAgo } from "@webiny/ui/TimeAgo"; @@ -24,7 +23,6 @@ import { Cell, Grid } from "@webiny/ui/Grid"; import { Select } from "@webiny/ui/Select"; import SearchUI from "@webiny/app-admin/components/SearchUI"; import { ButtonIcon, ButtonSecondary, IconButton } from "@webiny/ui/Button"; -import { CircularProgress } from "@webiny/ui/Progress"; import { ReactComponent as AddIcon } from "@material-design-icons/svg/filled/add.svg"; import { ReactComponent as FilterIcon } from "@material-design-icons/svg/round/filter_alt.svg"; import { ReactComponent as EditIcon } from "@material-design-icons/svg/round/edit.svg"; @@ -37,7 +35,7 @@ import useImportTemplate from "~/admin/views/PageTemplates/hooks/useImportTempla import { OptionsMenu } from "~/admin/components/OptionsMenu"; import { PbPageTemplate } from "~/types"; -import { LIST_PAGE_TEMPLATES } from "./graphql"; +import { useListPageTemplates } from "~/features"; const t = i18n.ns("app-page-builder/admin/views/page-templates/page-templates-details"); @@ -77,7 +75,6 @@ type PageTemplatesDataListProps = { canDelete: (item: CreatableItem) => boolean; onCreate: () => void; onDelete: (item: PbPageTemplate) => void; - isLoading: boolean; }; const PageTemplatesDataList = ({ @@ -85,21 +82,17 @@ const PageTemplatesDataList = ({ canEdit, canDelete, onCreate, - onDelete, - isLoading + onDelete }: PageTemplatesDataListProps) => { const [filter, setFilter] = useState(""); const [sort, setSort] = useState(SORTERS[0].sort); const { history } = useRouter(); - const listQuery = useQuery(LIST_PAGE_TEMPLATES) || {}; + const { loading, pageTemplates } = useListPageTemplates(); const query = new URLSearchParams(location.search); const search = { query: query.get("search") || undefined }; - const pageTemplatesData: PbPageTemplate[] = - listQuery?.data?.pageBuilder?.listPageTemplates?.data || []; - const filterData = useCallback( ({ title }: PbPageTemplate) => { return title.toLowerCase().includes(filter); @@ -119,7 +112,6 @@ const PageTemplatesDataList = ({ ); const selectedTemplate = new URLSearchParams(location.search).get("id"); - const loading = [listQuery].find(item => item.loading); const templatesDataListModalOverlay = useMemo( () => ( @@ -177,7 +169,7 @@ const PageTemplatesDataList = ({ }, [canCreate, showImportDialog]); const filteredTemplatesData: PbPageTemplate[] = - filter === "" ? pageTemplatesData : pageTemplatesData.filter(filterData); + filter === "" ? pageTemplates : pageTemplates.filter(filterData); const templatesList: PbPageTemplate[] = sortData(filteredTemplatesData); const multiSelectProps = useMultiSelect({ @@ -217,16 +209,9 @@ const PageTemplatesDataList = ({ inputPlaceholder={t`Search templates`} /> } - refresh={() => { - if (!listQuery.refetch) { - return; - } - listQuery.refetch(); - }} > {({ data }: { data: PbPageTemplate[] }) => ( <> - {isLoading && } {data.map(template => { return ( diff --git a/packages/app-page-builder/src/admin/views/PageTemplates/graphql.ts b/packages/app-page-builder/src/admin/views/PageTemplates/graphql.ts deleted file mode 100644 index 8b4033bfc34..00000000000 --- a/packages/app-page-builder/src/admin/views/PageTemplates/graphql.ts +++ /dev/null @@ -1,185 +0,0 @@ -import gql from "graphql-tag"; -import { PbPageTemplate, PbErrorResponse } from "~/types"; - -const PAGE_TEMPLATE_BASE_FIELDS = ` - id - title - slug - tags - description - layout - content - pageCategory - createdOn - savedOn - createdBy { - id - displayName - type - } -`; - -/** - * ############################## - * Get Page Template Query - */ -export interface GetPageTemplateQueryResponse { - pageBuilder: { - data?: PbPageTemplate; - error?: PbErrorResponse; - }; -} - -export interface GetPageTemplateQueryVariables { - id: string; -} - -export const GET_PAGE_TEMPLATE = gql` - query GetPageTemplate($id: ID!) { - pageBuilder { - getPageTemplate(id: $id) { - data { - ${PAGE_TEMPLATE_BASE_FIELDS} - content - } - error { - code - data - message - } - } - } - } -`; -/** - * ############################## - * List Page Templates Query - */ -export interface ListPageTemplatesQueryResponse { - pageBuilder: { - data?: PbPageTemplate[]; - error?: PbErrorResponse; - }; -} - -export interface ListPageTemplatesQueryVariables { - templateCategory: string; -} - -export const LIST_PAGE_TEMPLATES = gql` - query ListPageTemplates { - pageBuilder { - listPageTemplates { - data { - ${PAGE_TEMPLATE_BASE_FIELDS} - } - error { - code - data - message - } - } - } - } -`; -/** - * ########################### - * Create Page Template Mutation Response - */ -export interface CreatePageTemplateMutationResponse { - pageBuilder: { - pageTemplate: { - data: PbPageTemplate | null; - error: PbErrorResponse | null; - }; - }; -} -export interface CreatePageTemplateMutationVariables { - data: PbPageTemplate; -} -export const CREATE_PAGE_TEMPLATE = gql` - mutation CreatePageTemplate($data: PbCreatePageTemplateInput!){ - pageBuilder { - pageTemplate: createPageTemplate(data: $data) { - data { - ${PAGE_TEMPLATE_BASE_FIELDS} - } - error { - code - message - data - } - } - } - } -`; -/** - * ########################### - * Update Page Template Mutation Response - */ -export interface UpdatePageTemplateMutationResponse { - pageBuilder: { - pageTemplate: { - data: PbPageTemplate | null; - error: PbErrorResponse | null; - }; - }; -} -export interface UpdatePageTemplateMutationVariables { - id: string; - data: { - title?: string; - description?: string; - slug?: string; - tags?: string[]; - layout?: string; - content?: string; - }; -} -export const UPDATE_PAGE_TEMPLATE = gql` - mutation UpdatePageTemplate($id: ID!, $data: PbUpdatePageTemplateInput!){ - pageBuilder { - pageTemplate: updatePageTemplate(id: $id, data: $data) { - data { - ${PAGE_TEMPLATE_BASE_FIELDS} - content - } - error { - code - message - data - } - } - } - } -`; - -export const DELETE_PAGE_TEMPLATE = gql` - mutation DeletePageTemplate($id: ID!) { - pageBuilder { - deletePageTemplate(id: $id) { - error { - code - message - } - } - } - } -`; - -export const CREATE_TEMPLATE_FROM_PAGE = gql` - mutation PbCreateTemplateFromPage($pageId: ID!, $data: PbCreateTemplateFromPageInput) { - pageBuilder { - pageTemplate: createTemplateFromPage(pageId: $pageId, data: $data) { - data { - ${PAGE_TEMPLATE_BASE_FIELDS} - } - error { - code - message - data - } - } - } - } -`; diff --git a/packages/app-page-builder/src/admin/views/Pages/PageTemplatesDialog.tsx b/packages/app-page-builder/src/admin/views/Pages/PageTemplatesDialog.tsx index fd71a138bf9..90eb4b9283d 100644 --- a/packages/app-page-builder/src/admin/views/Pages/PageTemplatesDialog.tsx +++ b/packages/app-page-builder/src/admin/views/Pages/PageTemplatesDialog.tsx @@ -1,8 +1,6 @@ import React, { useCallback, useState, useEffect, useMemo } from "react"; import styled from "@emotion/styled"; import classNames from "classnames"; -import { useQuery } from "@apollo/react-hooks"; - import { OverlayLayout } from "@webiny/app-admin/components/OverlayLayout"; import { LeftPanel, RightPanel, SplitView } from "@webiny/app-admin/components/SplitView"; import { ScrollList, ListItem } from "@webiny/ui/List"; @@ -14,8 +12,6 @@ import { ButtonSecondary } from "@webiny/ui/Button"; import { ReactComponent as SearchIcon } from "~/editor/assets/icons/search.svg"; import { useKeyHandler } from "~/editor/hooks/useKeyHandler"; -import { LIST_PAGE_TEMPLATES } from "~/admin/views/PageTemplates/graphql"; -import { PagePreview } from "~/admin/plugins/pageDetails/previewContent/PagePreview"; import { listItem, activeListItem, @@ -24,7 +20,9 @@ import { TitleContent } from "./PageTemplatesDialogStyled"; import * as Styled from "~/templateEditor/config/Content/BlocksBrowser/StyledComponents"; -import { PbPageTemplate } from "~/types"; +import { PbPageTemplate, PbPageTemplateWithContent } from "~/types"; +import { useListPageTemplates } from "~/features"; +import { PageTemplateContentPreview } from "~/admin/views/PageTemplates/PageTemplateContentPreview"; const ListContainer = styled.div` width: 100%; @@ -119,11 +117,8 @@ type PageTemplatesDialogProps = { const PageTemplatesDialog = ({ onClose, onSelect, isLoading }: PageTemplatesDialogProps) => { const [search, setSearch] = useState(""); - const [activeTemplate, setActiveTemplate] = useState(); - const listQuery = useQuery(LIST_PAGE_TEMPLATES) || {}; - - const pageTemplatesData: PbPageTemplate[] = - listQuery?.data?.pageBuilder?.listPageTemplates?.data || []; + const [activeTemplate, setActiveTemplate] = useState(); + const { pageTemplates } = useListPageTemplates(); const handleCreatePageFromTemplate = useCallback((template: PbPageTemplate) => { onSelect(template); @@ -142,13 +137,13 @@ const PageTemplatesDialog = ({ onClose, onSelect, isLoading }: PageTemplatesDial const filteredPageTemplates = useMemo(() => { if (search) { - return pageTemplatesData.filter(item => { + return pageTemplates.filter(item => { return item.title.toLowerCase().includes(search.toLowerCase()); }); } - return pageTemplatesData; - }, [search, pageTemplatesData]); + return pageTemplates; + }, [search, pageTemplates]); return ( } onExited={onClose}> @@ -235,7 +230,7 @@ const PageTemplatesDialog = ({ onClose, onSelect, isLoading }: PageTemplatesDial - + diff --git a/packages/app-page-builder/src/blockEditor/Editor.tsx b/packages/app-page-builder/src/blockEditor/Editor.tsx index e5b92b6d449..0b978963dec 100644 --- a/packages/app-page-builder/src/blockEditor/Editor.tsx +++ b/packages/app-page-builder/src/blockEditor/Editor.tsx @@ -14,7 +14,7 @@ import createElementPlugin from "~/admin/utils/createElementPlugin"; import { createStateInitializer } from "./createStateInitializer"; import { BlockWithContent } from "~/blockEditor/state"; import { createElement } from "~/editor/helpers"; -import { PbEditorElement } from "~/types"; +import { PbEditorElementTree } from "~/types"; import elementVariablePlugins from "~/blockEditor/plugins/elementVariables"; import { usePageBlocks } from "~/admin/contexts/AdminPageBuilder/PageBlocks/usePageBlocks"; import { DefaultBlockEditorConfig } from "~/blockEditor/config/DefaultBlockEditorConfig"; @@ -47,7 +47,7 @@ export const BlockEditor = () => { const blockData = getBlockById(blockId).then(pageBlock => { // We need to wrap all elements into a "document" element, it's a requirement for the editor to work. - const content: PbEditorElement = { + const content: PbEditorElementTree = { ...createElement("document"), elements: [pageBlock.content] }; diff --git a/packages/app-page-builder/src/blockEditor/graphql.ts b/packages/app-page-builder/src/blockEditor/graphql.ts deleted file mode 100644 index e353bc711e3..00000000000 --- a/packages/app-page-builder/src/blockEditor/graphql.ts +++ /dev/null @@ -1,90 +0,0 @@ -import gql from "graphql-tag"; -import { PbErrorResponse, PbPageData } from "~/types"; - -const ERROR_FIELD = /* GraphQL */ ` - { - code - data - message - } -`; - -const DATA_FIELD = ` - { - id - pid - title - path - version - locked - status - category { - url - name - slug - } - revisions { - id - title - status - locked - version - savedOn - } - createdBy { - id - } - content - savedOn - } -`; -/** - * ##################### - * Create Page From Mutation - */ -export interface CreatePageFromMutationResponse { - pageBuilder: { - createPage: { - data: PbPageData; - error?: PbErrorResponse; - }; - }; -} -export interface CreatePageFromMutationVariables { - from: string; -} -export const CREATE_PAGE_FROM = gql` - mutation CreatePageFrom($from: ID) { - pageBuilder { - createPage(from: $from) { - data ${DATA_FIELD} - error ${ERROR_FIELD} - } - } - } -`; -/** - * ##################### - * Get Page Query Response - */ -export interface GetPageQueryResponse { - pageBuilder: { - getPage: { - data: PbPageData | null; - error: PbErrorResponse | null; - }; - }; -} -export interface GetPageQueryVariables { - id: string; -} -export const GET_PAGE = gql` - query PbGetPage($id: ID!) { - pageBuilder { - getPage(id: $id) { - data ${DATA_FIELD} - error ${ERROR_FIELD} - } - } - } -`; diff --git a/packages/app-page-builder/src/blockEditor/hooks/index.ts b/packages/app-page-builder/src/blockEditor/hooks/index.ts index 7b70e05de83..2868ae56f34 100644 --- a/packages/app-page-builder/src/blockEditor/hooks/index.ts +++ b/packages/app-page-builder/src/blockEditor/hooks/index.ts @@ -1,2 +1,3 @@ export { useBlock } from "./useBlock"; export { useBlockCategories } from "./useBlockCategories"; +export { useElementRendererInputs } from "./useElementRendererInputs"; diff --git a/packages/app-page-builder/src/blockEditor/hooks/useElementRendererInputs.ts b/packages/app-page-builder/src/blockEditor/hooks/useElementRendererInputs.ts new file mode 100644 index 00000000000..72474b79ca9 --- /dev/null +++ b/packages/app-page-builder/src/blockEditor/hooks/useElementRendererInputs.ts @@ -0,0 +1,23 @@ +import { plugins } from "@webiny/plugins"; +import type { Renderer } from "@webiny/app-page-builder-elements/types"; +import type { PbEditorElement, PbRenderElementPlugin } from "~/types"; + +/** + * Gets element renderer inputs. These are defined for each individual renderer, when using + * `createRenderer` factory function. + */ +export const useElementRendererInputs = (element: PbEditorElement | null) => { + const renderers = plugins + .byType("pb-render-page-element") + .reduce>((current, item) => { + return { ...current, [item.elementType]: item.render }; + }, {}); + + if (!element) { + return { inputs: [] }; + } + + const renderer = renderers[element.type]; + + return { inputs: renderer && renderer.inputs ? Object.values(renderer.inputs) : [] }; +}; diff --git a/packages/app-page-builder/src/blockEditor/state/blockAtom.ts b/packages/app-page-builder/src/blockEditor/state/blockAtom.ts index 2fcc4af8eee..c48fe0a3fd5 100644 --- a/packages/app-page-builder/src/blockEditor/state/blockAtom.ts +++ b/packages/app-page-builder/src/blockEditor/state/blockAtom.ts @@ -1,8 +1,8 @@ import { atom } from "recoil"; -import { PbEditorElement } from "~/types"; +import { PbEditorElementTree } from "~/types"; export interface BlockWithContent extends BlockAtomType { - content: PbEditorElement; + content: PbEditorElementTree; } export interface BlockAtomType { diff --git a/packages/app-page-builder/src/dataInjection/AddBindingContext.tsx b/packages/app-page-builder/src/dataInjection/AddBindingContext.tsx new file mode 100644 index 00000000000..61c8a85df6e --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/AddBindingContext.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { Element } from "@webiny/app-page-builder-elements"; +import { BindingProvider } from "./BindingProvider"; +import { useDynamicDocument } from "./useDynamicDocument"; + +// TODO: find a better way to manage this! +const addPrefixForTypes = ["grid", "repeater"]; + +export const AddBindingContext = Element.createDecorator(Original => { + return function WithBindPrefixContext(props) { + const { dataBindings } = useDynamicDocument(); + + const { element } = props; + + const renderOriginal = ; + + if (!element) { + return renderOriginal; + } + + if (!addPrefixForTypes.includes(element.type)) { + return renderOriginal; + } + + const myBinding = dataBindings.find(binding => { + return binding.bindTo === `element:${element.id}.dataSource`; + }); + + if (!myBinding) { + return renderOriginal; + } + + return ( + + + + ); + }; +}); diff --git a/packages/app-page-builder/src/dataInjection/BindingProvider.tsx b/packages/app-page-builder/src/dataInjection/BindingProvider.tsx new file mode 100644 index 00000000000..6b9c3d3c018 --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/BindingProvider.tsx @@ -0,0 +1,13 @@ +import React, { createContext } from "react"; +import { PbDataBinding } from "~/types"; + +export const BindingContext = createContext(undefined); + +export interface BindingProviderProps { + binding: PbDataBinding; + children: React.ReactNode; +} + +export const BindingProvider = ({ binding, children }: BindingProviderProps) => { + return {children}; +}; diff --git a/packages/app-page-builder/src/dataInjection/ContentTraverser.ts b/packages/app-page-builder/src/dataInjection/ContentTraverser.ts new file mode 100644 index 00000000000..40c1b2f8e7f --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/ContentTraverser.ts @@ -0,0 +1,18 @@ +import { PbEditorElement } from "~/types"; + +type ElementNode = Omit & { + elements: ElementNode[]; +}; + +interface ElementNodeVisitor { + (node: ElementNode): void; +} + +export class ContentTraverser { + traverse(element: ElementNode, visitor: ElementNodeVisitor): void { + visitor(element); + for (const node of element.elements) { + this.traverse(node, visitor); + } + } +} diff --git a/packages/app-page-builder/src/dataInjection/DataSourceDataProvider.tsx b/packages/app-page-builder/src/dataInjection/DataSourceDataProvider.tsx new file mode 100644 index 00000000000..3d71365837d --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/DataSourceDataProvider.tsx @@ -0,0 +1,19 @@ +import React, { createContext, useContext } from "react"; +import type { GenericRecord } from "@webiny/app/types"; + +const Context = createContext<{ dataSource: GenericRecord }>({ dataSource: {} }); + +interface Props { + children: React.ReactNode; + dataSource: GenericRecord; +} + +export const DataSourceDataProvider = ({ children, dataSource }: Props) => { + return {children}; +}; + +export const useDataSourceData = () => { + const { dataSource } = useContext(Context); + + return { data: dataSource }; +}; diff --git a/packages/app-page-builder/src/dataInjection/DataSourceProvider.tsx b/packages/app-page-builder/src/dataInjection/DataSourceProvider.tsx new file mode 100644 index 00000000000..4b17fa8c9ea --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/DataSourceProvider.tsx @@ -0,0 +1,45 @@ +import React, { createContext, useMemo } from "react"; +import { useLoadDataSource } from "~/features"; +import { PbDataSource } from "~/types"; +import type { GenericRecord } from "@webiny/app/types"; +import { useDynamicDocument } from "./useDynamicDocument"; +import { DataSourceDataProvider } from "./DataSourceDataProvider"; + +export interface PreviewDataProviderProps { + dataSource: PbDataSource; + children: React.ReactNode; +} + +export interface DataSourceContext { + data: GenericRecord; + dataSource: PbDataSource; + loadData: (params: GenericRecord) => void; +} + +export const DataSourceContext = createContext(undefined); + +export const DataSourceProvider = ({ dataSource, children }: PreviewDataProviderProps) => { + const { dataBindings } = useDynamicDocument(); + + const paths = useMemo(() => { + if (!dataSource) { + return []; + } + + const binds = dataBindings + .filter(b => b.dataSource === dataSource.name) + .map(binding => binding.bindFrom) + .filter(path => path !== "*"); + + return Array.from(new Set(binds)).sort(); + }, [dataSource, dataBindings]); + + const { data, loadData } = useLoadDataSource(dataSource, paths); + + return ( + + {/* TODO: Maybe this next provider is not necessary here? */} + {children} + + ); +}; diff --git a/packages/app-page-builder/src/dataInjection/DynamicDocumentProvider.tsx b/packages/app-page-builder/src/dataInjection/DynamicDocumentProvider.tsx new file mode 100644 index 00000000000..c377c00ef0d --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/DynamicDocumentProvider.tsx @@ -0,0 +1,56 @@ +import React, { createContext } from "react"; +import { PbDataBinding, PbDataSource } from "~/types"; + +const passthrough = (cb: Updater) => cb([]); + +export const DynamicDocumentContext = createContext<{ + dataSources: PbDataSource[]; + dataBindings: PbDataBinding[]; + updateDataSources: (cb: Updater) => void; + updateDataBindings: (cb: Updater) => void; +}>({ + dataSources: [], + dataBindings: [], + updateDataBindings: passthrough, + updateDataSources: passthrough +}); + +interface Props { + children: React.ReactNode; + dataSources: PbDataSource[]; + dataBindings: PbDataBinding[]; + onDataSources?: (dataSources: PbDataSource[]) => void; + onDataBindings?: (dataBindings: PbDataBinding[]) => void; +} + +export interface Updater { + (items: T[]): T[]; +} + +export const DynamicDocumentProvider = ({ + children, + dataSources, + dataBindings, + onDataSources, + onDataBindings +}: Props) => { + const updateDataBindings = (cb: Updater) => { + if (onDataBindings) { + onDataBindings(cb(dataBindings)); + } + }; + + const updateDataSources = (cb: Updater) => { + if (onDataSources) { + onDataSources(cb(dataSources)); + } + }; + + return ( + + {children} + + ); +}; diff --git a/packages/app-page-builder/src/dataInjection/ElementInputBinding.ts b/packages/app-page-builder/src/dataInjection/ElementInputBinding.ts new file mode 100644 index 00000000000..fe829cdeca8 --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/ElementInputBinding.ts @@ -0,0 +1,34 @@ +import { PbDataBinding } from "~/types"; + +export class ElementInputBinding { + private binding: PbDataBinding; + private readonly elementId: string; + private readonly inputName: string; + + private constructor(binding: PbDataBinding) { + this.binding = binding; + const [elementId, inputName] = binding.bindTo.replace("element:", "").split("."); + this.elementId = elementId; + this.inputName = inputName; + } + + static create(binding: PbDataBinding) { + return new ElementInputBinding(binding); + } + + getDataSource() { + return this.binding.dataSource; + } + + getElementId() { + return this.elementId; + } + + getInputName() { + return this.inputName; + } + + getSource() { + return this.binding.bindFrom; + } +} diff --git a/packages/app-page-builder/src/dataInjection/InjectDynamicValues.tsx b/packages/app-page-builder/src/dataInjection/InjectDynamicValues.tsx new file mode 100644 index 00000000000..e79c898f6a6 --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/InjectDynamicValues.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { ElementRendererInputs } from "@webiny/app-page-builder-elements"; +import { useBindElementInputs } from "./useBindElementInputs"; + +export const InjectDynamicValues = ElementRendererInputs.createDecorator(Original => { + return function ElementRendererInputs(props) { + const { element, inputs, values } = props; + const { elementInputs } = useBindElementInputs(element, inputs, values); + + return ; + }; +}); diff --git a/packages/app-page-builder/src/dataInjection/editor/DataSourceConfigAndBindings.tsx b/packages/app-page-builder/src/dataInjection/editor/DataSourceConfigAndBindings.tsx new file mode 100644 index 00000000000..74c9adab301 --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/editor/DataSourceConfigAndBindings.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { ElementBinding } from "./DataSourceProperties/ElementBinding"; +import { DataSourceConfigInput } from "./DataSourceProperties/DataSourceConfigInput"; + +export const DataSourceConfigAndBindings = () => { + return ( + <> + {/* Element input bindings. */} + + + + + + + + + {/* DataSource config inputs. */} + + + (value !== "" ? parseInt(value) : 10)} + /> + + ); +}; diff --git a/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/DataSourceConfigInput.tsx b/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/DataSourceConfigInput.tsx new file mode 100644 index 00000000000..8e516d3170a --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/DataSourceConfigInput.tsx @@ -0,0 +1,86 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { useActiveElement, EditorConfig } from "~/editor"; +import type { PbEditorElement, PbDataSource } from "~/types"; +import { DelayedOnChange } from "@webiny/ui/DelayedOnChange"; +import { Input } from "@webiny/ui/Input"; +import { OnElementType } from "./OnElementType"; +import { useGetElementDataSource } from "./useGetElementDataSource"; +import { useDocumentDataSource } from "~/dataInjection"; + +const { Ui } = EditorConfig; + +interface ValueFormatter { + (value: string): unknown; +} + +export interface DataSourceConfigInputProps { + elementType: string; + configName: string; + label: string; + format?: ValueFormatter; +} + +const defaultFormat: ValueFormatter = (value: string) => value; + +export const DataSourceConfigInput = ({ + elementType, + configName, + format = defaultFormat, + label +}: DataSourceConfigInputProps) => { + return ( + + + + } + /> + ); +}; + +interface ConfigInputUiProps { + configName: string; + label: string; + format: ValueFormatter; +} + +const ConfigInputUi = ({ configName, label, format }: ConfigInputUiProps) => { + const [element] = useActiveElement(); + const { updateDataSource } = useDocumentDataSource(); + const { getElementDataSource } = useGetElementDataSource(); + const [value, setValue] = useState(""); + const [dataSource, setDataSource] = useState(undefined); + + useEffect(() => { + getElementDataSource(element).then(dataSource => { + const value = dataSource ? dataSource.config[configName] : ""; + setValue(value); + setDataSource(dataSource); + }); + }, [element.id]); + + const onChange = useCallback( + async (value: string) => { + if (!dataSource) { + return; + } + + updateDataSource(dataSource.name, config => { + return { + ...config, + [configName]: format(value) + }; + }); + }, + [updateDataSource, configName, dataSource?.name] + ); + + return ( + + {({ value, onChange }) => } + + ); +}; diff --git a/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/ElementBinding.tsx b/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/ElementBinding.tsx new file mode 100644 index 00000000000..927fe952784 --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/ElementBinding.tsx @@ -0,0 +1,63 @@ +import React from "react"; +import { EditorConfig } from "~/editor"; +import { useActiveElement } from "~/editor"; +import { Input } from "@webiny/ui/Input"; +import { PbEditorElement } from "~/types"; +import { useInputBinding } from "./useInputBinding"; +import { DelayedOnChange } from "@webiny/ui/DelayedOnChange"; + +const { Ui } = EditorConfig; + +export interface OnElementTypeProps { + elementType: string; + children: React.ReactNode; +} + +const OnElementType = ({ elementType, children }: OnElementTypeProps) => { + const [element] = useActiveElement(); + + if (element?.type === elementType) { + return <>{children}; + } + + return null; +}; + +export interface ElementInputProps { + elementType: string; + inputName: string; + label: string; +} + +export const ElementBinding = ({ elementType, inputName, label }: ElementInputProps) => { + return ( + + + + } + /> + ); +}; + +interface ElementInputUiProps { + inputName: string; + label: string; +} + +const ElementInputUi = ({ inputName, label }: ElementInputUiProps) => { + const [element] = useActiveElement(); + const { binding, onChange } = useInputBinding(element, inputName); + const value = binding ? binding.getSource() : ""; + + return ( + <> + + {({ value, onChange }) => } + + + ); +}; diff --git a/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/ElementInputs.tsx b/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/ElementInputs.tsx new file mode 100644 index 00000000000..5bb18003651 --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/ElementInputs.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { ElementBinding } from "./ElementBinding"; + +export const ElementInputs = () => { + return ( + <> + + + + + + + + ); +}; diff --git a/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/OnElementType.tsx b/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/OnElementType.tsx new file mode 100644 index 00000000000..9d99ebbf525 --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/OnElementType.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { useActiveElement } from "~/editor"; + +export interface OnElementTypeProps { + elementType: string; + children: React.ReactNode; +} + +export const OnElementType = ({ elementType, children }: OnElementTypeProps) => { + const [element] = useActiveElement(); + + if (element?.type === elementType) { + return <>{children}; + } + + return null; +}; diff --git a/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/useGetElementDataSource.ts b/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/useGetElementDataSource.ts new file mode 100644 index 00000000000..b23c9a84674 --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/useGetElementDataSource.ts @@ -0,0 +1,58 @@ +import type { PbEditorElement } from "~/types"; +import { useGetElement } from "~/editor"; +import { ElementInputBinding, useDocumentDataSource, useDynamicDocument } from "~/dataInjection"; + +const getDataSourceFromBindings = async ( + getElement: (id: string) => Promise, + bindings: ElementInputBinding[], + element: PbEditorElement +): Promise => { + const maybeBinding = bindings.find(binding => { + return binding.getElementId() === element.id; + }); + + if (maybeBinding) { + return maybeBinding.getDataSource(); + } + + if (!element.parent) { + return undefined; + } + + const parent = await getElement(element.parent); + if (!parent) { + return undefined; + } + + return getDataSourceFromBindings(getElement, bindings, parent); +}; + +export const useGetElementDataSource = () => { + const getElement = useGetElement(); + const { dataBindings, dataSources } = useDynamicDocument(); + const { getDataSource } = useDocumentDataSource(); + + const getElementDataSource = async (element: PbEditorElement) => { + // First, we check if there's a dataSource dedicated to the requested element. + const dedicatedDataSource = dataSources.find(dataSource => { + return dataSource.name === `element:${element.id}`; + }); + + if (dedicatedDataSource) { + return dedicatedDataSource; + } + + // Then we proceed with the lookup through the element tree. + const bindings = dataBindings.map(binding => ElementInputBinding.create(binding)); + const dataSource = await getDataSourceFromBindings(getElement, bindings, element); + + if (dataSource) { + return getDataSource(dataSource); + } + + // Fall back to "main" dataSource. + return getDataSource("main"); + }; + + return { getElementDataSource }; +}; diff --git a/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/useInputBinding.ts b/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/useInputBinding.ts new file mode 100644 index 00000000000..5282f860ca2 --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/editor/DataSourceProperties/useInputBinding.ts @@ -0,0 +1,66 @@ +import { useCallback } from "react"; +import type { PbEditorElement, PbDataBinding } from "~/types"; +import { ElementInputBinding, useDynamicDocument } from "~/dataInjection"; +import { useGetElementDataSource } from "./useGetElementDataSource"; + +const byElementIdAndName = (id: string, inputName: string) => { + return (binding: ElementInputBinding) => { + return binding.getInputName() === inputName && binding.getElementId() === id; + }; +}; + +export const useInputBinding = (element: PbEditorElement, inputName: string) => { + const { dataBindings, updateDataBindings } = useDynamicDocument(); + const { getElementDataSource } = useGetElementDataSource(); + + // TODO: ideally, we want this mapping to happen in the dynamic document provider. + const bindings = dataBindings.map(binding => ElementInputBinding.create(binding)); + + const binding = bindings.find(byElementIdAndName(element.id, inputName)); + + const onChange = useCallback( + async (value: unknown) => { + const bindingIndex = bindings.findIndex(byElementIdAndName(element.id, inputName)); + + if (value === "") { + // Remove binding. + return updateDataBindings(bindings => { + return [ + ...bindings.slice(0, bindingIndex), + ...bindings.slice(bindingIndex + 1) + ]; + }); + } + + const elementDataSource = await getElementDataSource(element); + + if (!elementDataSource) { + console.warn(`DataSource not found for element`, element); + return; + } + + const binding: PbDataBinding = { + dataSource: elementDataSource.name, + bindFrom: String(value), + bindTo: `element:${element.id}.${inputName}` + }; + + updateDataBindings(bindings => { + if (bindingIndex > -1) { + // Update binding. + return [ + ...bindings.slice(0, bindingIndex), + binding, + ...bindings.slice(bindingIndex + 1) + ]; + } + + // Add binding. + return [...bindings, binding]; + }); + }, + [bindings] + ); + + return { binding, onChange }; +}; diff --git a/packages/app-page-builder/src/dataInjection/editor/DeveloperUtilities.tsx b/packages/app-page-builder/src/dataInjection/editor/DeveloperUtilities.tsx new file mode 100644 index 00000000000..896cf545fa8 --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/editor/DeveloperUtilities.tsx @@ -0,0 +1,45 @@ +import { useEffect } from "react"; +import { PbDataBinding } from "~/types"; +import { useDynamicDocument } from "~/dataInjection"; + +export const DeveloperUtilities = () => { + const { dataSources, dataBindings, updateDataBindings } = useDynamicDocument(); + + useEffect(() => { + // @ts-expect-error This is a developers-only utility. + window["debug_resetBindings"] = () => { + updateDataBindings(() => []); + }; + + // @ts-expect-error This is a developers-only utility. + window["debug_printBindings"] = () => { + console.log(dataBindings); + }; + + // @ts-expect-error This is a developers-only utility. + window["debug_printDataSources"] = () => { + console.log(dataSources); + }; + + // @ts-expect-error This is a developers-only utility. + window["debug_refreshBindings"] = () => { + updateDataBindings(dataBindings => { + const uniqueBindings: PbDataBinding[] = []; + + dataBindings.forEach(db => { + if ( + !uniqueBindings.some( + b => b.dataSource === db.dataSource && b.bindTo === db.bindTo + ) + ) { + uniqueBindings.push(db); + } + }); + + return uniqueBindings; + }); + }; + }, [dataSources, dataBindings]); + + return null; +}; diff --git a/packages/app-page-builder/src/dataInjection/editor/InjectDynamicValues.tsx b/packages/app-page-builder/src/dataInjection/editor/InjectDynamicValues.tsx new file mode 100644 index 00000000000..fb1b65f9173 --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/editor/InjectDynamicValues.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { ElementRendererInputs } from "@webiny/app-page-builder-elements"; +import { useActiveElement } from "~/editor"; +import { useBindElementInputs } from "~/dataInjection"; + +const skipInjection = ["heading", "paragraph"]; + +export const InjectDynamicValues = ElementRendererInputs.createDecorator(Original => { + return function ElementRendererInputs(props) { + const { element, inputs, values } = props; + const { elementInputs } = useBindElementInputs(element, inputs, values); + + const [activeElement] = useActiveElement(); + const isActive = activeElement?.id === element.id; + + // We don't want to inject preview values when certain elements are active. + const shouldInject = !isActive || !skipInjection.includes(element.type); + + return ; + }; +}); diff --git a/packages/app-page-builder/src/dataInjection/editor/SetupDynamicDataInEditor.tsx b/packages/app-page-builder/src/dataInjection/editor/SetupDynamicDataInEditor.tsx new file mode 100644 index 00000000000..96be17a3fa9 --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/editor/SetupDynamicDataInEditor.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { EditorConfig } from "~/editor"; +import { InjectDynamicValues } from "./InjectDynamicValues"; +import { AddBindingContext, DataSourceProvider, useDynamicDocument } from "~/dataInjection"; +import { DataSourceConfigAndBindings } from "./DataSourceConfigAndBindings"; +import { ElementInputs } from "~/dataInjection/editor/DataSourceProperties/ElementInputs"; +import { DeveloperUtilities } from "./DeveloperUtilities"; + +const { Ui } = EditorConfig; + +const ContentDecorator = Ui.Content.createDecorator(Original => { + return function ContentWithPreview() { + const { dataSources } = useDynamicDocument(); + + const dataSource = dataSources.find(ds => ds.name === "main"); + + if (!dataSource) { + return ; + } + + return ( + + + + ); + }; +}); + +export const SetupDynamicDataInEditor = () => { + return ( + <> + + + + + + + + ); +}; diff --git a/packages/app-page-builder/src/dataInjection/editor/useGetElementDataSource.ts b/packages/app-page-builder/src/dataInjection/editor/useGetElementDataSource.ts new file mode 100644 index 00000000000..1f1303bdbaf --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/editor/useGetElementDataSource.ts @@ -0,0 +1,41 @@ +import { useCallback } from "react"; +import type { PbEditorElement, PbDataBinding, PbDataSource } from "~/types"; +import { useGetElement } from "~/editor"; + +export function useGetElementDataSource() { + const getElementById = useGetElement(); + + const getElementDataSource = useCallback( + async ( + dataSources: PbDataSource[], + bindings: PbDataBinding[], + element: PbEditorElement + ): Promise => { + const elementBinding = bindings.find(binding => + binding.bindTo.startsWith(`element:${element.id}.`) + ); + + if (elementBinding) { + const dataSource = dataSources.find( + source => source.name === elementBinding.dataSource + ); + + if (!dataSource) { + return undefined; + } + } + + if (element.parent) { + const parentElement = await getElementById(element.parent); + if (parentElement) { + return getElementDataSource(dataSources, bindings, parentElement); + } + } + + return undefined; + }, + [] + ); + + return { getElementDataSource }; +} diff --git a/packages/app-page-builder/src/dataInjection/index.ts b/packages/app-page-builder/src/dataInjection/index.ts new file mode 100644 index 00000000000..0a93a05372e --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/index.ts @@ -0,0 +1,15 @@ +export * from "./AddBindingContext"; +export * from "./ContentTraverser"; +export * from "./BindingProvider"; +export * from "./DataSourceProvider"; +export * from "./DataSourceDataProvider"; +export * from "./DynamicDocumentProvider"; +export * from "./ElementInputBinding"; +export * from "./InjectDynamicValues"; +export * from "./useBindElementInputs"; +export * from "./useBindingContext"; +export * from "./useDataSource"; +export * from "./useDynamicDocument"; +export * from "./useDocumentDataSource"; +export * from "./useElementBindings"; +export * from "./editor/SetupDynamicDataInEditor"; diff --git a/packages/app-page-builder/src/dataInjection/presets/WebsiteDataInjection.tsx b/packages/app-page-builder/src/dataInjection/presets/WebsiteDataInjection.tsx new file mode 100644 index 00000000000..59b72896e1f --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/presets/WebsiteDataInjection.tsx @@ -0,0 +1,13 @@ +import React from "react"; +import { AddBindingContext, InjectDynamicValues } from "~/dataInjection"; + +export const WebsiteDataInjection = React.memo(() => { + return ( + <> + + + + ); +}); + +WebsiteDataInjection.displayName = "WebsiteDataInjection"; diff --git a/packages/app-page-builder/src/dataInjection/preview/PageTemplatesPreview.tsx b/packages/app-page-builder/src/dataInjection/preview/PageTemplatesPreview.tsx new file mode 100644 index 00000000000..08f9f623202 --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/preview/PageTemplatesPreview.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { PageTemplateContentPreview } from "~/admin/views/PageTemplates/PageTemplateContentPreview"; +import { DataSourceProvider, DynamicDocumentProvider } from "~/dataInjection"; +import { WebsiteDataInjection } from "~/dataInjection/presets/WebsiteDataInjection"; + +export const PageTemplatesPreview = PageTemplateContentPreview.createDecorator(Original => { + return function PreviewWithDynamicData(props) { + const { template } = props; + + // TODO: maybe this logic should be in `app-dynamic-page` + const mainDataSource = template.dataSources.find(ds => ds.name === "main"); + + return ( + <> + + + + + + + + ); + }; +}); diff --git a/packages/app-page-builder/src/dataInjection/preview/PagesPreview.tsx b/packages/app-page-builder/src/dataInjection/preview/PagesPreview.tsx new file mode 100644 index 00000000000..7494c1b18d4 --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/preview/PagesPreview.tsx @@ -0,0 +1,20 @@ +import React from "react"; +import { PageContentPreview } from "~/admin/plugins/pageDetails/previewContent/PageContentPreview"; +import { DynamicDocumentProvider } from "~/dataInjection"; +import { WebsiteDataInjection } from "~/dataInjection/presets/WebsiteDataInjection"; + +export const PagesPreview = PageContentPreview.createDecorator(Original => { + return function PreviewWithDynamicData(props) { + return ( + <> + + + + + + ); + }; +}); diff --git a/packages/app-page-builder/src/dataInjection/useBindElementInputs.ts b/packages/app-page-builder/src/dataInjection/useBindElementInputs.ts new file mode 100644 index 00000000000..af42742354a --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/useBindElementInputs.ts @@ -0,0 +1,73 @@ +import get from "lodash/get"; +import type { ElementInputs } from "@webiny/app-page-builder-elements"; +import type { GenericRecord } from "@webiny/app/types"; +import type { PbEditorElement } from "~/types"; +import { isValidLexicalData } from "@webiny/lexical-editor"; +import { + ElementInputBinding, + useBindingContext, + useDataSourceData, + useDynamicDocument +} from "~/dataInjection"; + +const BIND_ALL = "*"; + +export const useBindElementInputs = ( + element: PbEditorElement, + inputs: ElementInputs | undefined, + values: GenericRecord +) => { + const { dataBindings } = useDynamicDocument(); + const { data } = useDataSourceData(); + const { getRelativePath } = useBindingContext(); + + const elementInputBindings = dataBindings.filter(binding => + binding.bindTo.startsWith(`element:${element.id}.`) + ); + + const elementInputs = elementInputBindings.reduce((acc, binding) => { + const relativePath = getRelativePath(binding.bindFrom); + let inputValue = binding.bindFrom === BIND_ALL ? data : get(data, relativePath); + + if (inputValue) { + const inputBinding = ElementInputBinding.create(binding); + const inputName = inputBinding.getInputName(); + const input = inputs ? inputs[inputName] : undefined; + + if (!input) { + return { ...acc, [inputName]: inputValue }; + } + + // Experiment! If you want to target a specific location for value injection, you could use + // a special placeholder. This would also help with application of styles in Lexical. + const baseValue: any = values[inputName]; + + if (String(baseValue).includes("{=value}")) { + inputValue = baseValue.replace("{=value}", inputValue); + } + + if (input.getType() === "lexical") { + inputValue = injectTextIntoLexicalState(values[inputName], inputValue); + } + + return { ...acc, [inputName]: inputValue }; + } + return acc; + }, values); + + return { elementInputs }; +}; + +function injectTextIntoLexicalState(lexicalState: string, text: GenericRecord | string) { + if (typeof text !== "string") { + return JSON.stringify(text); + } + + if (isValidLexicalData(text)) { + return text; + } + + const value = JSON.parse(lexicalState); + value["root"].children[0].children[0].text = text; + return JSON.stringify(value); +} diff --git a/packages/app-page-builder/src/dataInjection/useBindingContext.ts b/packages/app-page-builder/src/dataInjection/useBindingContext.ts new file mode 100644 index 00000000000..227fb71f9e6 --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/useBindingContext.ts @@ -0,0 +1,19 @@ +import React, { useCallback } from "react"; +import { BindingContext } from "./BindingProvider"; + +export const useBindingContext = () => { + const binding = React.useContext(BindingContext); + + const getRelativePath = useCallback( + (path: string) => { + if (!binding || binding.bindFrom === "*") { + return path; + } + + return path.replace(new RegExp(`^${binding.bindFrom}\\.`), ""); + }, + [binding] + ); + + return { binding, getRelativePath }; +}; diff --git a/packages/app-page-builder/src/dataInjection/useDataSource.ts b/packages/app-page-builder/src/dataInjection/useDataSource.ts new file mode 100644 index 00000000000..84102b76782 --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/useDataSource.ts @@ -0,0 +1,14 @@ +import { useContext } from "react"; +import { DataSourceContext } from "./DataSourceProvider"; + +export const useDataSource = () => { + const context = useContext(DataSourceContext); + + if (!context) { + console.info( + `Missing DataSourceProvider in the component hierarchy! [TODO: Fix the "saveElement" action dialog!]` + ); + } + + return context; +}; diff --git a/packages/app-page-builder/src/dataInjection/useDocumentDataSource.ts b/packages/app-page-builder/src/dataInjection/useDocumentDataSource.ts new file mode 100644 index 00000000000..c38a29953ad --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/useDocumentDataSource.ts @@ -0,0 +1,65 @@ +import { useCallback, useEffect } from "react"; +import { PbDataSource } from "~/types"; +import { useDynamicDocument } from "~/dataInjection/useDynamicDocument"; + +export interface DataSourceUpdater { + (config: PbDataSource["config"]): PbDataSource["config"]; +} + +export const useDocumentDataSource = () => { + const { dataSources, dataBindings, updateDataSources } = useDynamicDocument(); + const key = JSON.stringify(dataSources); + + useEffect(() => { + console.log({ dataSources, dataBindings }); + }, [JSON.stringify(dataSources), JSON.stringify(dataBindings)]); + + const getDataSource = useCallback( + (name: string) => { + return dataSources.find(ds => ds.name === name); + }, + [key] + ); + + const createDataSource = useCallback( + (dataSource: PbDataSource) => { + updateDataSources(sources => { + return [...sources, dataSource]; + }); + }, + [key] + ); + + const updateDataSource = useCallback( + (name: string, updater: DataSourceUpdater) => { + const dataSource = dataSources.find(ds => ds.name === name); + if (!dataSource) { + return; + } + + const updatedConfig = updater(dataSource.config); + + updateDataSources(dataSources => { + const dsIndex = dataSources.findIndex(ds => ds.name === name); + + return [ + ...dataSources.slice(0, dsIndex), + { ...dataSources[dsIndex], config: updatedConfig }, + ...dataSources.slice(dsIndex + 1) + ]; + }); + }, + [key] + ); + + const deleteDataSource = useCallback( + (name: string) => { + updateDataSources(dataSources => { + return dataSources.filter(ds => ds.name !== name); + }); + }, + [key] + ); + + return { getDataSource, createDataSource, updateDataSource, deleteDataSource }; +}; diff --git a/packages/app-page-builder/src/dataInjection/useDynamicDocument.ts b/packages/app-page-builder/src/dataInjection/useDynamicDocument.ts new file mode 100644 index 00000000000..846a864a41d --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/useDynamicDocument.ts @@ -0,0 +1,11 @@ +import { useContext } from "react"; +import { DynamicDocumentContext } from "./DynamicDocumentProvider"; + +export const useDynamicDocument = () => { + const context = useContext(DynamicDocumentContext); + if (!context) { + throw Error(`Missing DynamicDocumentProvider in the component hierarchy!`); + } + + return context; +}; diff --git a/packages/app-page-builder/src/dataInjection/useElementBindings.ts b/packages/app-page-builder/src/dataInjection/useElementBindings.ts new file mode 100644 index 00000000000..ba38e3bfec9 --- /dev/null +++ b/packages/app-page-builder/src/dataInjection/useElementBindings.ts @@ -0,0 +1,9 @@ +import { useDynamicDocument } from "./useDynamicDocument"; + +export const useElementBindings = (elementId: string) => { + const { dataBindings } = useDynamicDocument(); + + return { + bindings: dataBindings.filter(binding => binding.bindTo.startsWith(`element:${elementId}`)) + }; +}; diff --git a/packages/app-page-builder/src/editor/Editor.tsx b/packages/app-page-builder/src/editor/Editor.tsx index f03eb53db19..8d85416fec6 100644 --- a/packages/app-page-builder/src/editor/Editor.tsx +++ b/packages/app-page-builder/src/editor/Editor.tsx @@ -7,15 +7,15 @@ import { HTML5Backend } from "react-dnd-html5-backend"; import { DndProvider } from "react-dnd"; import { elementsAtom, rootElementAtom } from "~/editor/recoil/modules"; import { flattenElements } from "~/editor/helpers"; -import { PbEditorElement } from "~/types"; import { EditorWithConfig } from "~/editor/config"; +import { PbEditorElementTree } from "~/types"; export interface EditorStateInitializerFactory { (): EditorStateInitializer; } export interface EditorStateInitializer { - content: PbEditorElement; + content: PbEditorElementTree; recoilInitializer: (mutableSnapshot: MutableSnapshot) => void; } diff --git a/packages/app-page-builder/src/editor/PrepareEditorContent.tsx b/packages/app-page-builder/src/editor/PrepareEditorContent.tsx index 1c52778bd28..58f4c3b979a 100644 --- a/packages/app-page-builder/src/editor/PrepareEditorContent.tsx +++ b/packages/app-page-builder/src/editor/PrepareEditorContent.tsx @@ -1,12 +1,12 @@ import React, { useMemo } from "react"; import type { Element } from "@webiny/app-page-builder-elements/types"; import { Editor, EditorProps } from "~/admin/components/Editor"; -import { PbEditorElement } from "~/types"; +import { PbEditorElementTree } from "~/types"; export type ElementVisitor = (element: Element) => Element; const traverseContent = ( - element: PbEditorElement, + element: PbEditorElementTree, visitor: (element: Element) => Element ): Element => { const newElement = visitor(element as Element) ?? element; @@ -14,7 +14,7 @@ const traverseContent = ( return { ...newElement, elements: newElement.elements.map(element => { - return traverseContent(element as PbEditorElement, visitor); + return traverseContent(element, visitor); }) }; }; diff --git a/packages/app-page-builder/src/editor/defaultConfig/Sidebar/ScrollableContainer.tsx b/packages/app-page-builder/src/editor/config/Sidebar/ScrollableContainer.tsx similarity index 100% rename from packages/app-page-builder/src/editor/defaultConfig/Sidebar/ScrollableContainer.tsx rename to packages/app-page-builder/src/editor/config/Sidebar/ScrollableContainer.tsx diff --git a/packages/app-page-builder/src/editor/config/Sidebar/Sidebar.tsx b/packages/app-page-builder/src/editor/config/Sidebar/Sidebar.tsx index 789ae7cc3d7..8ebe52c8ec7 100644 --- a/packages/app-page-builder/src/editor/config/Sidebar/Sidebar.tsx +++ b/packages/app-page-builder/src/editor/config/Sidebar/Sidebar.tsx @@ -10,6 +10,7 @@ import { Tab } from "./Tab"; import { useActiveGroup } from "~/editor/config/Sidebar/useActiveGroup"; import { createGetId } from "~/editor/config/createGetId"; import { CurrentBlockProvider } from "~/editor/contexts/CurrentBlockProvider"; +import { ScrollableContainer } from "~/editor/config/Sidebar/ScrollableContainer"; const SCOPE = "sidebar"; @@ -67,5 +68,6 @@ export const Sidebar = Object.assign(BaseSidebar, { Element: BaseElement, Elements, Group: Object.assign(BaseGroup, { Tab }), + ScrollableContainer, useActiveGroup }); 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 405cff5f941..48473ed9987 100644 --- a/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/ElementControlHorizontalDropZones.tsx +++ b/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/ElementControlHorizontalDropZones.tsx @@ -25,16 +25,30 @@ export const WrapperDroppable = styled.div(({ below, zInd interface InnerDivProps { zIndex: number; + label: string; } -const InnerDiv = styled.div(({ zIndex }) => ({ - height: 5, - width: "100%", //"calc(100% - 50px)", - zIndex: zIndex, - borderRadius: 5, - boxSizing: "border-box", - display: "none" -})); +const InnerDiv = styled.div` + height: 5px; + width: 100%; + z-index: ${props => props.zIndex}; + border-radius: 5px; + box-sizing: border-box; + display: none; + :before { + content: ${props => `"Drop into ${props.label}"`}; + background-color: var(--mdc-theme-primary); + color: #fff; + position: absolute; + padding: 2px 5px; + font-size: 10px; + text-align: center; + line-height: 14px; + left: 50%; + transform: translateX(-50%); + top: -7px; + } +`; interface OuterDivProps { isOver: boolean; @@ -73,30 +87,42 @@ export const ElementControlHorizontalDropZones = () => { const element = getElement(); const handler = useEventActionHandler(); const { showSnackbar } = useSnackbar(); + const parentType = meta.parentElement.type; const { type } = element; - const dropElementAction = (source: DragObjectWithTypeWithTarget, position: number) => { - const { target } = source; + const canDrop = (item: DragObjectWithTypeWithTarget) => { + if (!item) { + return false; + } + const { target } = item; // If the `target` property of the dragged element's plugin is an array, we want to // check if the dragged element can be dropped into the target element (the element // for which this drop zone is rendered). if (Array.isArray(target) && target.length > 0) { - if (!target.includes(meta.parentElement.type)) { - const sourceTitle = getElementTitle(source.type); - const targetTitle = getElementTitle(meta.parentElement.type); - showSnackbar(`${sourceTitle} cannot be dropped into ${targetTitle}.`); - return; + if (!target.includes(parentType)) { + return false; } } + return true; + }; + + const dropElementAction = (source: DragObjectWithTypeWithTarget, position: number) => { + if (!canDrop(source)) { + const sourceTitle = getElementTitle(source.type); + const targetTitle = getElementTitle(parentType); + showSnackbar(`${sourceTitle} cannot be dropped into ${targetTitle}.`); + return; + } + handler.trigger( new DropElementActionEvent({ source, target: { id: meta.parentElement.id, - type: meta.parentElement.type, + type: parentType, position } }) @@ -114,28 +140,28 @@ export const ElementControlHorizontalDropZones = () => { return ( <> true} + isVisible={({ item }) => canDrop(item)} onDrop={source => dropElementAction(source, meta.elementIndex)} type={type} > {({ drop, isOver }) => ( - + )} {meta.isLastElement && ( true} + isVisible={({ item }) => canDrop(item)} onDrop={source => dropElementAction(source, meta.elementIndex + 1)} type={type} > {({ drop, isOver }) => ( - + )} diff --git a/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/getElementTitle.ts b/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/getElementTitle.ts index 13f11566978..1d394c7e85d 100644 --- a/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/getElementTitle.ts +++ b/packages/app-page-builder/src/editor/contexts/EditorPageElementsProvider/getElementTitle.ts @@ -8,31 +8,31 @@ const titlesCache: Record = {}; * return the element type. A simple cache was added to avoid unnecessary lookups. */ export const getElementTitle = (elementType: string, suffix?: string): string => { - if (elementType in titlesCache) { - return titlesCache[elementType]; + const cacheKey = [elementType, suffix].filter(Boolean).join("."); + + if (cacheKey in titlesCache) { + return titlesCache[cacheKey]; } - titlesCache[elementType] = elementType; + titlesCache[cacheKey] = elementType; const elementEditorPlugin = plugins .byType("pb-editor-page-element") .find(item => item.elementType === elementType); if (!elementEditorPlugin) { - return titlesCache[elementType]; + return titlesCache[cacheKey]; } const toolbarTitle = elementEditorPlugin?.toolbar?.title; if (typeof toolbarTitle === "string") { - titlesCache[elementType] = toolbarTitle; + titlesCache[cacheKey] = toolbarTitle; } else { // Upper-case first the type. - titlesCache[elementType] = elementType.charAt(0).toUpperCase() + elementType.slice(1); + titlesCache[cacheKey] = elementType.charAt(0).toUpperCase() + elementType.slice(1); } - titlesCache[elementType] = suffix - ? `${titlesCache[elementType]} | ${suffix}` - : titlesCache[elementType]; + titlesCache[cacheKey] = suffix ? `${titlesCache[cacheKey]} | ${suffix}` : titlesCache[cacheKey]; - return titlesCache[elementType]; + return titlesCache[cacheKey]; }; diff --git a/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx b/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx index 2da1739be38..2c9ba64ce14 100644 --- a/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx +++ b/packages/app-page-builder/src/editor/contexts/EventActionHandlerProvider.tsx @@ -38,7 +38,8 @@ import { EventActionHandler, EventActionHandlerTarget, EventActionHandlerCallableState, - GetElementTreeProps + GetElementTreeProps, + PbEditorElementTree } from "~/types"; import { composeAsync, composeSync, AsyncProcessor, SyncProcessor } from "@webiny/utils/compose"; import { UpdateElementTreeActionEvent, UpdateDocumentActionEvent } from "~/editor/recoil/actions"; @@ -86,7 +87,7 @@ const isTrackedAtomChanged = (state: Partial): boolean => { return false; }; -export type GetElementTree = AsyncProcessor; +export type GetElementTree = AsyncProcessor; export type GetCallableState = SyncProcessor>; export type SaveCallableResults> = SyncProcessor<{ state: TState & Partial; @@ -125,12 +126,14 @@ export const EventActionHandlerProvider = makeDecoratable( isBatching: false, isDisabled: false }); + const goToSnapshot = useGotoRecoilSnapshot(); useEffect(() => { sidebarAtomValueRef.current = sidebarAtomValue; rootElementAtomValueRef.current = rootElementAtomValue; uiAtomValueRef.current = uiAtomValue; + snapshot.retain(); snapshotRef.current = snapshot; }, [sidebarAtomValue, rootElementAtomValue, uiAtomValue, snapshot]); @@ -176,16 +179,17 @@ export const EventActionHandlerProvider = makeDecoratable( } ); - const takeSnapshot = useRecoilCallback(({ snapshot }) => () => { + const takeSnapshot = () => { + const snapshot = snapshotRef.current!; snapshot.retain(); return snapshot; - }); + }; const defaultGetElementTree = useCallback( () => async function getChildElement( props: GetElementTreeProps - ): Promise { + ): Promise { let element = props?.element; if (!element) { element = (await getElementById(rootElementAtomValue)) as PbEditorElement; @@ -209,7 +213,7 @@ export const EventActionHandlerProvider = makeDecoratable( */ (element.elements as string[]).map(async child => { return await getChildElement({ - element: (await getElementById(child)) as PbEditorElement, + element: await getElementById(child), path: [...path] }); }) diff --git a/packages/app-page-builder/src/editor/defaultConfig/Content/Breadcrumbs/Breadcrumbs.tsx b/packages/app-page-builder/src/editor/defaultConfig/Content/Breadcrumbs/Breadcrumbs.tsx index 71b3726d14b..3ac75c3f559 100644 --- a/packages/app-page-builder/src/editor/defaultConfig/Content/Breadcrumbs/Breadcrumbs.tsx +++ b/packages/app-page-builder/src/editor/defaultConfig/Content/Breadcrumbs/Breadcrumbs.tsx @@ -111,7 +111,7 @@ export const Breadcrumbs = () => { className={"element"} style={{ "--element-count": index } as React.CSSProperties} > - {type} + {type.replace(/-/g, " ")} ))} diff --git a/packages/app-page-builder/src/editor/defaultConfig/Content/Breadcrumbs/styles.ts b/packages/app-page-builder/src/editor/defaultConfig/Content/Breadcrumbs/styles.ts index 6978e277754..97fbb048679 100644 --- a/packages/app-page-builder/src/editor/defaultConfig/Content/Breadcrumbs/styles.ts +++ b/packages/app-page-builder/src/editor/defaultConfig/Content/Breadcrumbs/styles.ts @@ -3,7 +3,7 @@ import { COLORS } from "~/editor/plugins/elementSettings/components/StyledCompon export const breadcrumbs = css({ display: "flex", - zIndex: 20, + zIndex: 100, flexDirection: "row", padding: 0, position: "fixed", diff --git a/packages/app-page-builder/src/editor/defaultConfig/DefaultEditorConfig.tsx b/packages/app-page-builder/src/editor/defaultConfig/DefaultEditorConfig.tsx index e262d614516..65f84772391 100644 --- a/packages/app-page-builder/src/editor/defaultConfig/DefaultEditorConfig.tsx +++ b/packages/app-page-builder/src/editor/defaultConfig/DefaultEditorConfig.tsx @@ -19,6 +19,7 @@ import { StyleProperties } from "./Sidebar/StyleSettings/StyleProperties"; import { ElementSettingsGroup } from "./Sidebar/ElementSettings/ElementSettingsGroup"; import { ElementActionsAdapter } from "./Sidebar/BackwardsCompatibility/ElementActionsAdapter"; import { PageOptionsDropdown } from "./TopBar/DropdownActions/PageOptionsDropdown"; +import { SetupDynamicDataInEditor } from "~/dataInjection"; const { Ui } = EditorConfig; @@ -104,6 +105,8 @@ export const DefaultEditorConfig = React.memo(() => { {/* This will register actions from plugins using the new API. */} + {/* Dynamic data */} + ); diff --git a/packages/app-page-builder/src/editor/defaultConfig/Sidebar/ElementSettings/ElementSettingsGroup.tsx b/packages/app-page-builder/src/editor/defaultConfig/Sidebar/ElementSettings/ElementSettingsGroup.tsx index a78b996d7bd..a8a10285ace 100644 --- a/packages/app-page-builder/src/editor/defaultConfig/Sidebar/ElementSettings/ElementSettingsGroup.tsx +++ b/packages/app-page-builder/src/editor/defaultConfig/Sidebar/ElementSettings/ElementSettingsGroup.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Sidebar } from "~/editor/config/Sidebar/Sidebar"; import { useActiveElement } from "~/editor/hooks/useActiveElement"; -import { ScrollableContainer } from "~/editor/defaultConfig/Sidebar/ScrollableContainer"; +import { ScrollableContainer } from "~/editor/config/Sidebar/ScrollableContainer"; export const ElementSettingsGroup = () => { const [element] = useActiveElement(); diff --git a/packages/app-page-builder/src/editor/defaultConfig/Sidebar/StyleSettings/StyleSettingsGroup.tsx b/packages/app-page-builder/src/editor/defaultConfig/Sidebar/StyleSettings/StyleSettingsGroup.tsx index 7712ab239c6..4c9c9de7228 100644 --- a/packages/app-page-builder/src/editor/defaultConfig/Sidebar/StyleSettings/StyleSettingsGroup.tsx +++ b/packages/app-page-builder/src/editor/defaultConfig/Sidebar/StyleSettings/StyleSettingsGroup.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Sidebar } from "~/editor/config/Sidebar/Sidebar"; -import { ScrollableContainer } from "~/editor/defaultConfig/Sidebar/ScrollableContainer"; +import { ScrollableContainer } from "~/editor/config/Sidebar/ScrollableContainer"; export const StyleSettingsGroup = () => { return ( diff --git a/packages/app-page-builder/src/editor/defaultConfig/Toolbar/Navigator/NavigatorDrawer.tsx b/packages/app-page-builder/src/editor/defaultConfig/Toolbar/Navigator/NavigatorDrawer.tsx index 93bfe0ff44c..625522cffef 100644 --- a/packages/app-page-builder/src/editor/defaultConfig/Toolbar/Navigator/NavigatorDrawer.tsx +++ b/packages/app-page-builder/src/editor/defaultConfig/Toolbar/Navigator/NavigatorDrawer.tsx @@ -8,7 +8,7 @@ import { TreeView } from "./TreeView"; import { ReactComponent as UnfoldMoreIcon } from "./assets/unfold_more_24px.svg"; import { ReactComponent as UnfoldLessIcon } from "./assets/unfold_less_24px.svg"; import { UpdateElementTreeActionEvent } from "~/editor/recoil/actions"; -import { PbEditorElement } from "~/types"; +import { PbEditorElementTree } from "~/types"; const t = i18n.ns("app-page-builder/editor/plugins/toolbar/navigator"); @@ -30,7 +30,7 @@ export const NavigatorContext = createContext({ }); export const NavigatorDrawer = () => { - const [elementTree, setElementTree] = useState(null); + const [elementTree, setElementTree] = useState(null); const [expandAll, setExpandAll] = useState(false); const [activeElementPath, setActiveElementPath] = useState([]); const eventHandler = useEventActionHandler(); diff --git a/packages/app-page-builder/src/editor/defaultConfig/Toolbar/Navigator/TreeView.tsx b/packages/app-page-builder/src/editor/defaultConfig/Toolbar/Navigator/TreeView.tsx index 5b55aadc91e..62c3fc3774e 100644 --- a/packages/app-page-builder/src/editor/defaultConfig/Toolbar/Navigator/TreeView.tsx +++ b/packages/app-page-builder/src/editor/defaultConfig/Toolbar/Navigator/TreeView.tsx @@ -12,7 +12,7 @@ import CollapsableList from "./CollapsableList"; import DragBlockIndicator from "./DragBlockIndicator"; import { BLOCK, useMoveBlock, useSortableList } from "./navigatorHooks"; import { NavigatorContext } from "./NavigatorDrawer"; -import { PbEditorElement } from "~/types"; +import { PbEditorElementTree } from "~/types"; const elementIdStyle = css` text-transform: none; @@ -59,7 +59,7 @@ const getHighlightItemProps = ({ }; interface TreeViewItemProps { - element: PbEditorElement; + element: PbEditorElementTree; level: number; index: number; children: React.ReactNode; @@ -161,7 +161,7 @@ const TreeViewItem = ({ element, level, children, index }: TreeViewItemProps) => data-handler-id={handlerId} > - {element.type} + {element.type.replace(/-/g, " ")} {elementIdAttribute && (

{`#${elementIdAttribute}`}

)} @@ -181,11 +181,7 @@ const TreeViewItem = ({ element, level, children, index }: TreeViewItemProps) => }; interface TreeViewProps { - element: { - id: string; - type: string; - elements: any[]; - }; + element: PbEditorElementTree; level: number; } diff --git a/packages/app-page-builder/src/editor/helpers.ts b/packages/app-page-builder/src/editor/helpers.ts index 10e5be5e897..d01ce5e2f56 100644 --- a/packages/app-page-builder/src/editor/helpers.ts +++ b/packages/app-page-builder/src/editor/helpers.ts @@ -7,6 +7,7 @@ import { PbBlockVariable, PbEditorBlockPlugin, PbEditorElement, + PbEditorElementTree, PbEditorPageElementPlugin, PbEditorPageElementSettingsPlugin, PbEditorPageElementStyleSettingsPlugin, @@ -15,6 +16,7 @@ import { import { CreateElementActionEvent, DeleteElementActionEvent, + UpdateDocumentActionEvent, updateElementAction, UpdateElementActionArgsType } from "~/editor/recoil/actions"; @@ -28,7 +30,10 @@ interface FlatElements { [id: string]: PbEditorElement; } -export const flattenElements = (el?: PbEditorElement, parent?: string): FlatElements => { +export const flattenElements = ( + el?: PbEditorElementTree | PbEditorElement, + parent?: string +): FlatElements => { if (!el || !el.id) { return {}; } @@ -348,10 +353,12 @@ export const onReceived: PbEditorPageElementPlugin["onReceived"] = props => { const element = createDroppedElement(source, target); const parent = addElementToParent(element, target, position); + const triggerDocumentUpdate = () => new UpdateDocumentActionEvent({ history: true }); + const result = executeAction(state, meta, updateElementAction, { element: parent, // Dropping of elements should always be stored to history, to trigger document save. - history: true + history: false }); result.actions.push(new AfterDropElementActionEvent({ element })); @@ -364,6 +371,8 @@ export const onReceived: PbEditorPageElementPlugin["onReceived"] = props => { }) ); + result.actions.push(triggerDocumentUpdate()); + return result; } @@ -374,5 +383,7 @@ export const onReceived: PbEditorPageElementPlugin["onReceived"] = props => { }) ); + result.actions.push(triggerDocumentUpdate()); + return result; }; diff --git a/packages/app-page-builder/src/editor/hooks/index.ts b/packages/app-page-builder/src/editor/hooks/index.ts index 72385053bf7..da00eca0fa1 100644 --- a/packages/app-page-builder/src/editor/hooks/index.ts +++ b/packages/app-page-builder/src/editor/hooks/index.ts @@ -4,6 +4,7 @@ export { useCurrentBlockElement } from "./useCurrentBlockElement"; export { useCurrentElement } from "./useCurrentElement"; export { useDisplayMode } from "./useDisplayMode"; export { useElementById } from "./useElementById"; +export { useElementWithChildren } from "./useElementWithChildren"; export { useGetElementById } from "./useGetElementById"; export { useMoveElement } from "./useMoveElement"; export { useElementSidebar } from "./useElementSidebar"; @@ -17,5 +18,7 @@ export { useRootElement } from "./useRootElement"; export { useUI } from "./useUI"; export { useUpdateElement } from "./useUpdateElement"; export { useUpdateHandlers } from "./useUpdateHandlers"; +export { useGetElement } from "./useGetElement"; +export { useIsElementChildOfType } from "./useIsElementChildOfType"; export { useDeleteElement } from "./useDeleteElement"; export { useFindElementBlock } from "./useFindElementBlock"; diff --git a/packages/app-page-builder/src/editor/hooks/useElementWithChildren.ts b/packages/app-page-builder/src/editor/hooks/useElementWithChildren.ts new file mode 100644 index 00000000000..0fa7708121a --- /dev/null +++ b/packages/app-page-builder/src/editor/hooks/useElementWithChildren.ts @@ -0,0 +1,12 @@ +import { useRecoilValue } from "recoil"; +import type { Element } from "@webiny/app-page-builder-elements/types"; +import { elementWithChildrenByIdSelector } from "~/editor/recoil/modules"; + +export const useElementWithChildren = (elementId: string) => { + const element = useRecoilValue(elementWithChildrenByIdSelector(elementId)); + if (!element) { + return null; + } + + return element as Element; +}; diff --git a/packages/app-page-builder/src/editor/hooks/useGetElement.ts b/packages/app-page-builder/src/editor/hooks/useGetElement.ts new file mode 100644 index 00000000000..1d16325522a --- /dev/null +++ b/packages/app-page-builder/src/editor/hooks/useGetElement.ts @@ -0,0 +1,8 @@ +import { useRecoilCallback } from "recoil"; +import { elementByIdSelector } from "~/editor/recoil/modules"; + +export const useGetElement = () => { + return useRecoilCallback(({ snapshot }) => async (id: string) => { + return await snapshot.getPromise(elementByIdSelector(id)); + }); +}; diff --git a/packages/app-page-builder/src/editor/hooks/useIsElementChildOfType.ts b/packages/app-page-builder/src/editor/hooks/useIsElementChildOfType.ts new file mode 100644 index 00000000000..44ba701bd24 --- /dev/null +++ b/packages/app-page-builder/src/editor/hooks/useIsElementChildOfType.ts @@ -0,0 +1,16 @@ +import { PbEditorElement } from "~/types"; +import { useElementById } from "~/editor"; + +export const useIsElementChildOfType = (element: PbEditorElement | null, elementType: string) => { + const [parent] = useElementById(element?.parent || "n/a"); + + if (!parent || !element) { + return { index: -1, isChildOfType: false }; + } + + if (parent.type === elementType) { + return { index: parent.elements.findIndex(el => element.id === el), isChildOfType: true }; + } + + return { index: -1, isChildOfType: false }; +}; diff --git a/packages/app-page-builder/src/editor/hooks/useUpdateElement.ts b/packages/app-page-builder/src/editor/hooks/useUpdateElement.ts index 2e56c9fa597..59aea77109a 100644 --- a/packages/app-page-builder/src/editor/hooks/useUpdateElement.ts +++ b/packages/app-page-builder/src/editor/hooks/useUpdateElement.ts @@ -1,5 +1,5 @@ import { useCallback } from "react"; -import { PbEditorElement } from "~/types"; +import { PbEditorElement, PbEditorElementTree } from "~/types"; import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; import { UpdateElementActionEvent } from "~/editor/recoil/actions"; @@ -13,7 +13,10 @@ export const useUpdateElement = () => { const handler = useEventActionHandler(); return useCallback( - (element: PbEditorElement, options: UpdateOptions = { history: true }) => { + ( + element: PbEditorElement | PbEditorElementTree, + options: UpdateOptions = { history: true } + ) => { handler.trigger( new UpdateElementActionEvent({ element, diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/advanced/elementSettingsAction.ts b/packages/app-page-builder/src/editor/plugins/elementSettings/advanced/elementSettingsAction.ts index c9a717d0634..800427afad8 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/advanced/elementSettingsAction.ts +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/advanced/elementSettingsAction.ts @@ -1,8 +1,12 @@ -import { CreateElementEventActionCallable } from "~/editor/recoil/actions/createElement/types"; -import { PbEditorPageElementPlugin } from "~/types"; +import { CreateElementEventActionArgsType } from "~/editor/recoil/actions/createElement/types"; +import { EventActionCallable, PbEditorPageElementPlugin } from "~/types"; import { plugins } from "@webiny/plugins"; -export const elementSettingsAction: CreateElementEventActionCallable = (state, _, args) => { +export const elementSettingsAction: EventActionCallable = ( + state, + _, + args +) => { if (!args) { return { actions: [] diff --git a/packages/app-page-builder/src/editor/plugins/elementSettings/grid/GridSize.tsx b/packages/app-page-builder/src/editor/plugins/elementSettings/grid/GridSize.tsx index dd15cfccffd..c4adbb4ec73 100644 --- a/packages/app-page-builder/src/editor/plugins/elementSettings/grid/GridSize.tsx +++ b/packages/app-page-builder/src/editor/plugins/elementSettings/grid/GridSize.tsx @@ -11,12 +11,12 @@ import { PbEditorElement, PbEditorGridPresetPluginType, PbEditorPageElementSettingsRenderComponentProps -} from "../../../../types"; -import { useEventActionHandler } from "../../../hooks/useEventActionHandler"; -import { createElement } from "../../../helpers"; -import { calculatePresetPluginCells, getPresetPlugins } from "../../../plugins/gridPresets"; -import { UpdateElementActionEvent } from "../../../recoil/actions"; -import { activeElementAtom, elementWithChildrenByIdSelector } from "../../../recoil/modules"; +} from "~/types"; +import { useEventActionHandler } from "~/editor"; +import { createElement } from "~/editor/helpers"; +import { calculatePresetPluginCells, getPresetPlugins } from "~/editor/plugins/gridPresets"; +import { UpdateElementActionEvent } from "~/editor/recoil/actions"; +import { activeElementAtom, elementWithChildrenByIdSelector } from "~/editor/recoil/modules"; // Components import CounterInput from "./CounterInput"; diff --git a/packages/app-page-builder/src/editor/plugins/elements/block/Block.tsx b/packages/app-page-builder/src/editor/plugins/elements/block/Block.tsx index 960c59a494a..a2e5dd72c5f 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/block/Block.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/block/Block.tsx @@ -1,10 +1,8 @@ import React from "react"; -import { useRecoilValue } from "recoil"; import { BlockRenderer } from "@webiny/app-page-builder-elements/renderers/block"; -import { Element } from "@webiny/app-page-builder-elements/types"; -import { elementWithChildrenByIdSelector } from "~/editor/recoil/modules"; import { EmptyCell } from "~/editor/plugins/elements/cell/EmptyCell"; import { PbEditorElement } from "~/types"; +import { useElementWithChildren } from "~/editor"; type Props = Omit, "element"> & { element: PbEditorElement; @@ -13,15 +11,16 @@ type Props = Omit, "element"> & { export const Block = (props: Props) => { const { element } = props; - const elementWithChildren = useRecoilValue( - elementWithChildrenByIdSelector(element.id) - ) as Element; - - const childrenElements = elementWithChildren?.elements; - - if (Array.isArray(childrenElements) && childrenElements.length > 0) { - return ; + const elementWithChildren = useElementWithChildren(element.id); + if (!elementWithChildren) { + return null; } - return ; + return ( + } + /> + ); }; diff --git a/packages/app-page-builder/src/editor/plugins/elements/block/index.tsx b/packages/app-page-builder/src/editor/plugins/elements/block/index.tsx index ef2e73a3cce..9221b087122 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/block/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/block/index.tsx @@ -78,6 +78,7 @@ export default (args: PbEditorElementPluginArgs = {}): PbEditorPageElementPlugin return typeof args.create === "function" ? args.create(defaultValue) : defaultValue; }, + target: ["document"], render(props) { return ; }, diff --git a/packages/app-page-builder/src/editor/plugins/elements/cell/EmptyCell.tsx b/packages/app-page-builder/src/editor/plugins/elements/cell/EmptyCell.tsx index 4d82ae12223..5ef4e0a7b44 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/cell/EmptyCell.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/cell/EmptyCell.tsx @@ -6,7 +6,8 @@ import { useElementById } from "~/editor/hooks/useElementById"; import { PbEditorElement } from "~/types"; import { AddElementButton } from "~/editor/plugins/elements/cell/AddElementButton"; -const EmptyCellStyled = styled.div<{ isActive: boolean }>` +const EmptyCellStyled = styled.div<{ isActive: boolean; zIndex: number }>` + z-index: ${props => props.zIndex}; box-sizing: border-box; display: flex; justify-content: center; @@ -30,10 +31,10 @@ const EmptyCellStyled = styled.div<{ isActive: boolean }>` interface EmptyCellProps { element: PbEditorElement; - onClick?: (element: PbEditorElement) => void; + depth?: number; } -export const EmptyCell = ({ element }: EmptyCellProps) => { +export const EmptyCell = ({ element, depth = 1 }: EmptyCellProps) => { const [activeElementId] = useActiveElementId(); const isActive = activeElementId === element.id; @@ -43,9 +44,10 @@ export const EmptyCell = ({ element }: EmptyCellProps) => { ]; const dragEntered = editorElement.dragEntered; + const zIndex = 10 + depth + 1; return ( - + ); diff --git a/packages/app-page-builder/src/editor/plugins/elements/grid/Grid.tsx b/packages/app-page-builder/src/editor/plugins/elements/grid/Grid.tsx index 620c1b1ec43..fd2f70c2951 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/grid/Grid.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/grid/Grid.tsx @@ -1,7 +1,7 @@ import React from "react"; import { PbEditorElement } from "~/types"; -import PeGrid from "./PeGrid"; +import { PeGrid } from "./PeGrid"; import { Element } from "@webiny/app-page-builder-elements/types"; interface GridProps { diff --git a/packages/app-page-builder/src/editor/plugins/elements/grid/PeGrid.tsx b/packages/app-page-builder/src/editor/plugins/elements/grid/PeGrid.tsx index 8ad66160d78..9160a934241 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/grid/PeGrid.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/grid/PeGrid.tsx @@ -4,7 +4,7 @@ import { Element } from "@webiny/app-page-builder-elements/types"; import { useRecoilValue } from "recoil"; import { elementWithChildrenByIdSelector } from "~/editor/recoil/modules"; -const PeGrid = createRenderer( +export const PeGrid = createRenderer( () => { const { getElement } = useRenderer(); const element = getElement(); @@ -30,5 +30,3 @@ const PeGrid = createRenderer( } } ); - -export default PeGrid; diff --git a/packages/app-page-builder/src/editor/plugins/elements/grid/index.tsx b/packages/app-page-builder/src/editor/plugins/elements/grid/index.tsx index 1ff891c4c70..9a51c7447e2 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/grid/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/grid/index.tsx @@ -67,15 +67,12 @@ export default (args: PbEditorElementPluginArgs = {}): PbEditorPageElementPlugin type: "pb-editor-page-element", name: `pb-editor-page-element-${elementType}`, elementType: elementType, - /** - * TODO @ts-refactor @ashutosh - */ // @ts-expect-error toolbar: typeof args.toolbar === "function" ? args.toolbar(defaultToolbar) : defaultToolbar, settings: typeof args.settings === "function" ? args.settings(defaultSettings) : defaultSettings, - target: ["cell", "block", "carousel-element", "tab"], + target: ["cell", "block", "carousel-element", "tab", "repeater", "entries-list"], canDelete: () => { return true; }, diff --git a/packages/app-page-builder/src/editor/plugins/elements/heading/index.tsx b/packages/app-page-builder/src/editor/plugins/elements/heading/index.tsx index 75c7a0f1d3e..3bef71dcd20 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/heading/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/heading/index.tsx @@ -4,7 +4,8 @@ import { DisplayMode, PbEditorElement, PbEditorPageElementPlugin, - PbEditorTextElementPluginsArgs + PbEditorTextElementPluginsArgs, + PbElement } from "~/types"; import { createInitialPerDeviceSettingValue } from "../../elementSettings/elementSettingsUtils"; import { createInitialTextValue } from "../utils/textUtils"; @@ -49,8 +50,8 @@ export default (args: PbEditorTextElementPluginsArgs = {}): PbEditorPageElementP toolbar: typeof args.toolbar === "function" ? args.toolbar(defaultToolbar) : defaultToolbar, settings: typeof args.settings === "function" ? args.settings(defaultSettings) : defaultSettings, - target: ["cell", "block"], - create({ content = {}, ...options }) { + target: ["cell", "block", "repeater"], + create({ content = {}, ...options }: Partial & { content?: any }) { const previewText = content.text || defaultText; const defaultValue: Partial = { diff --git a/packages/app-page-builder/src/editor/plugins/elements/image/Image.tsx b/packages/app-page-builder/src/editor/plugins/elements/image/Image.tsx deleted file mode 100644 index 82377ef7180..00000000000 --- a/packages/app-page-builder/src/editor/plugins/elements/image/Image.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from "react"; -import { PbEditorElement } from "~/types"; -import PeImage from "./PeImage"; -import { Element } from "@webiny/app-page-builder-elements/types"; - -interface ImageProps { - element: PbEditorElement; -} - -const Image = (props: ImageProps) => { - const { element, ...rest } = props; - return ; -}; - -export default React.memo(Image); diff --git a/packages/app-page-builder/src/editor/plugins/elements/image/PeImage.tsx b/packages/app-page-builder/src/editor/plugins/elements/image/PeImage.tsx index 5978de1fe89..d586ca3b93d 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/image/PeImage.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/image/PeImage.tsx @@ -1,13 +1,12 @@ import React, { useCallback } from "react"; import { FileManager, SingleImageUploadProps } from "@webiny/app-admin"; -import { UpdateElementActionEvent } from "~/editor/recoil/actions"; -import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; -import { ImageRendererComponent } from "@webiny/app-page-builder-elements/renderers/image"; +import { ImageRenderer } from "@webiny/app-page-builder-elements/renderers/image"; import { AddImageIconWrapper, AddImageWrapper } from "@webiny/ui/ImageUpload/styled"; import { ReactComponent as AddImageIcon } from "@webiny/ui/ImageUpload/icons/round-add_photo_alternate-24px.svg"; import { Typography } from "@webiny/ui/Typography"; -import { useElementVariableValue } from "~/editor/hooks/useElementVariableValue"; -import { createRenderer, useRenderer } from "@webiny/app-page-builder-elements"; +import { UpdateElementActionEvent } from "~/editor/recoil/actions"; +import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; +import { PbElement } from "~/types"; const RenderBlank = (props: { onClick?: () => void }) => { return ( @@ -22,13 +21,15 @@ const RenderBlank = (props: { onClick?: () => void }) => { const emptyLink = { href: "" }; -const PeImage = createRenderer(() => { - const { getElement } = useRenderer(); - const element = getElement(); - const variableValue = useElementVariableValue(element); +interface PeImageProps { + element: PbElement; + [key: string]: any; +} + +export const PeImage = ({ element, ...rest }: PeImageProps) => { const handler = useEventActionHandler(); - const id = element?.id; + const id = element.id; const onChange = useCallback>( file => { @@ -56,18 +57,17 @@ const PeImage = createRenderer(() => { ( - } - value={variableValue} // Even if the link might've been applied via the right sidebar, we still don't // want to have it rendered in the editor. Because, otherwise, user wouldn't be // able to click again on the component and bring back the file manager overlay. link={emptyLink} + {...rest} /> )} /> ); -}); - -export default PeImage; +}; diff --git a/packages/app-page-builder/src/editor/plugins/elements/image/imageCreatedEditorAction.ts b/packages/app-page-builder/src/editor/plugins/elements/image/imageCreatedEditorAction.ts index c0442e8398d..48ba58c2c52 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/image/imageCreatedEditorAction.ts +++ b/packages/app-page-builder/src/editor/plugins/elements/image/imageCreatedEditorAction.ts @@ -1,6 +1,6 @@ -import { CreateElementEventActionCallable } from "../../../recoil/actions/createElement/types"; -import { PbEditorPageElementPlugin, PbEditorElement } from "../../../../types"; import { plugins } from "@webiny/plugins"; +import { CreateElementEventActionArgsType } from "~/editor/recoil/actions/createElement/types"; +import { PbEditorPageElementPlugin, PbEditorElement, EventActionCallable } from "~/types"; const MAX_ELEMENT_FIND_RETRIES = 10; const ELEMENT_FIND_RETRY_TIMEOUT = 100; @@ -19,7 +19,11 @@ const clickOnImageWithRetries = (element: PbEditorElement, retryNumber: number) setTimeout(() => clickOnImageWithRetries(element, retryNumber + 1), ELEMENT_FIND_RETRY_TIMEOUT); }; -export const imageCreatedEditorAction: CreateElementEventActionCallable = (_, __, args) => { +export const imageCreatedEditorAction: EventActionCallable = ( + _, + __, + args +) => { if (!args) { return { actions: [] diff --git a/packages/app-page-builder/src/editor/plugins/elements/image/index.tsx b/packages/app-page-builder/src/editor/plugins/elements/image/index.tsx index db66cf861c5..b9fed7323be 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/image/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/image/index.tsx @@ -2,7 +2,6 @@ import React from "react"; import styled from "@emotion/styled"; import kebabCase from "lodash/kebabCase"; import ImageSettings from "./ImageSettings"; -import Image from "./Image"; import { imageCreatedEditorAction } from "./imageCreatedEditorAction"; import { CreateElementActionEvent } from "../../../recoil/actions"; import { ReactComponent as ImageIcon } from "./round-image-24px.svg"; @@ -16,6 +15,8 @@ import { } from "~/types"; import { Plugin } from "@webiny/plugins/types"; import { createInitialPerDeviceSettingValue } from "../../elementSettings/elementSettingsUtils"; +import { Element } from "@webiny/app-page-builder-elements/types"; +import { PeImage } from "~/editor/plugins/elements/image/PeImage"; const PreviewBox = styled("div")({ textAlign: "center", @@ -91,8 +92,8 @@ export default (args: PbEditorElementPluginArgs = {}): Plugin[] => { return typeof args.create === "function" ? args.create(defaultValue) : defaultValue; }, - render(props) { - return ; + render({ element, ...rest }) { + return ; } } as PbEditorPageElementPlugin, { diff --git a/packages/app-page-builder/src/editor/plugins/elements/list/index.tsx b/packages/app-page-builder/src/editor/plugins/elements/list/index.tsx index 73d4c517398..f53e00474dc 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/list/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/list/index.tsx @@ -5,7 +5,8 @@ import { PbEditorElement, PbEditorPageElementPlugin, PbEditorPageElementPluginSettings, - PbEditorTextElementPluginsArgs + PbEditorTextElementPluginsArgs, + PbElement } from "~/types"; import List from "./List"; import { createInitialTextValue } from "../utils/textUtils"; @@ -38,7 +39,7 @@ export default (args: PbEditorTextElementPluginsArgs = {}): PbEditorPageElementP settings: typeof args.settings === "function" ? args.settings(defaultSettings) : defaultSettings, target: ["cell", "block"], - create({ content = {}, ...options }) { + create({ content = {}, ...options }: Partial & { content?: any }) { const previewText = content.text || `
    diff --git a/packages/app-page-builder/src/editor/plugins/elements/paragraph/index.tsx b/packages/app-page-builder/src/editor/plugins/elements/paragraph/index.tsx index 9d3c7144de1..ffc52e6777b 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/paragraph/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/paragraph/index.tsx @@ -4,7 +4,8 @@ import { DisplayMode, PbEditorElement, PbEditorPageElementPlugin, - PbEditorTextElementPluginsArgs + PbEditorTextElementPluginsArgs, + PbElement } from "~/types"; import { Paragraph, textClassName } from "./Paragraph"; import { createInitialPerDeviceSettingValue } from "../../elementSettings/elementSettingsUtils"; @@ -42,8 +43,8 @@ export default (args: PbEditorTextElementPluginsArgs = {}): PbEditorPageElementP toolbar: typeof args.toolbar === "function" ? args.toolbar(defaultToolbar) : defaultToolbar, settings: typeof args.settings === "function" ? args.settings(defaultSettings) : defaultSettings, - target: ["cell", "block"], - create({ content = {}, ...options }) { + target: ["cell", "block", "repeater"], + create({ content = {}, ...options }: Partial & { content?: any }) { const previewText = content.text || defaultText; const defaultValue: Partial = { diff --git a/packages/app-page-builder/src/editor/plugins/elements/quote/index.tsx b/packages/app-page-builder/src/editor/plugins/elements/quote/index.tsx index 97e7f6c60a4..11ccff1a06f 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/quote/index.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/quote/index.tsx @@ -6,7 +6,8 @@ import { DisplayMode, PbEditorElement, PbEditorPageElementPlugin, - PbEditorTextElementPluginsArgs + PbEditorTextElementPluginsArgs, + PbElement } from "~/types"; import { createInitialTextValue } from "../utils/textUtils"; import { createInitialPerDeviceSettingValue } from "../../elementSettings/elementSettingsUtils"; @@ -38,7 +39,7 @@ export default (args: PbEditorTextElementPluginsArgs = {}): PbEditorPageElementP settings: typeof args.settings === "function" ? args.settings(defaultSettings) : defaultSettings, target: ["cell", "block"], - create({ content = {}, ...options }) { + create({ content = {}, ...options }: Partial & { content?: any }) { const previewText = content.text || `
    ${defaultText}
    `; const defaultValue: Partial = { diff --git a/packages/app-page-builder/src/editor/plugins/elements/utils/oembed/createEmbedPlugin.tsx b/packages/app-page-builder/src/editor/plugins/elements/utils/oembed/createEmbedPlugin.tsx index 5d779687742..377789e9f21 100644 --- a/packages/app-page-builder/src/editor/plugins/elements/utils/oembed/createEmbedPlugin.tsx +++ b/packages/app-page-builder/src/editor/plugins/elements/utils/oembed/createEmbedPlugin.tsx @@ -65,7 +65,7 @@ export const createEmbedPlugin = (config: EmbedPluginConfig): PbEditorPageElemen : defaultSettings, target: config.target || ["cell", "block", "list-item"], // eslint-disable-next-line - create({ content = {}, ...options }) { + create(options) { const defaultValue: Partial = { type: config.type, elements: [], diff --git a/packages/app-page-builder/src/editor/recoil/actions/afterDropElement/action.ts b/packages/app-page-builder/src/editor/recoil/actions/afterDropElement/action.ts index e98128716b9..0f7b04892d1 100644 --- a/packages/app-page-builder/src/editor/recoil/actions/afterDropElement/action.ts +++ b/packages/app-page-builder/src/editor/recoil/actions/afterDropElement/action.ts @@ -1,5 +1,5 @@ import { plugins } from "@webiny/plugins"; -import { EventActionCallable, PbEditorPageElementPlugin } from "~/types"; +import { EventActionCallable, OnCreateActions, PbEditorPageElementPlugin } from "~/types"; import { AfterDropElementActionArgsType } from "./types"; const elementPluginType = "pb-editor-page-element"; @@ -26,12 +26,12 @@ export const afterDropElementAction: EventActionCallable; diff --git a/packages/app-page-builder/src/editor/recoil/actions/dropElement/action.ts b/packages/app-page-builder/src/editor/recoil/actions/dropElement/action.ts index d0c76b195e8..e22ee64c25e 100644 --- a/packages/app-page-builder/src/editor/recoil/actions/dropElement/action.ts +++ b/packages/app-page-builder/src/editor/recoil/actions/dropElement/action.ts @@ -26,7 +26,7 @@ const getSourceElement = async ( ): Promise => { if (source.id) { const element = await state.getElementById(source.id); - return await state.getElementTree({ element }); + return (await state.getElementTree({ element })) as PbEditorElement; } return source; diff --git a/packages/app-page-builder/src/editor/recoil/actions/updateElement/types.ts b/packages/app-page-builder/src/editor/recoil/actions/updateElement/types.ts index a04ff87db7f..9b43f14a9c5 100644 --- a/packages/app-page-builder/src/editor/recoil/actions/updateElement/types.ts +++ b/packages/app-page-builder/src/editor/recoil/actions/updateElement/types.ts @@ -1,7 +1,7 @@ -import { PbEditorElement } from "~/types"; +import { PbEditorElement, PbEditorElementTree } from "~/types"; export interface UpdateElementActionArgsType { - element: PbEditorElement; + element: PbEditorElement | PbEditorElementTree; history: boolean; triggerUpdateElementTree?: boolean; debounce?: boolean; diff --git a/packages/app-page-builder/src/elementDecorators/AddImageLinkComponent.tsx b/packages/app-page-builder/src/elementDecorators/AddImageLinkComponent.tsx new file mode 100644 index 00000000000..b5ce908930e --- /dev/null +++ b/packages/app-page-builder/src/elementDecorators/AddImageLinkComponent.tsx @@ -0,0 +1,25 @@ +import React, { ComponentProps } from "react"; +import { Link } from "@webiny/react-router"; +import { ImageRenderer } from "@webiny/app-page-builder-elements/renderers/image"; + +const LinkComponent: ComponentProps["linkComponent"] = ({ + href, + children, + ...rest +}) => { + return ( + // While testing, we noticed that the `href` prop is sometimes `null` or `undefined`. + // Hence, the `href || ""` part. This fixes the issue. + + {children} + + ); +}; + +LinkComponent.displayName = "ImageLink"; + +export const AddImageLinkComponent = ImageRenderer.createDecorator(Original => { + return function ImageWithLink(props) { + return ; + }; +}); diff --git a/packages/app-page-builder/src/features/ListCache.ts b/packages/app-page-builder/src/features/ListCache.ts new file mode 100644 index 00000000000..b45475794cb --- /dev/null +++ b/packages/app-page-builder/src/features/ListCache.ts @@ -0,0 +1,74 @@ +import { makeAutoObservable, runInAction, toJS } from "mobx"; + +export type Constructor = new (...args: any[]) => T; + +export interface IListCachePredicate { + (item: T): boolean; +} + +export interface IListCacheItemUpdater { + (item: T): T; +} + +export interface IListCache { + count(): number; + clear(): void; + hasItems(): boolean; + getItems(): T[]; + getItem(predicate: IListCachePredicate): T | undefined; + addItems(items: T[]): void; + updateItems(updater: IListCacheItemUpdater): void; + removeItems(predicate: IListCachePredicate): void; +} + +export class ListCache implements IListCache { + private state: T[]; + + constructor() { + this.state = []; + + makeAutoObservable(this); + } + + count() { + return this.state.length; + } + + clear() { + runInAction(() => { + this.state = []; + }); + } + + hasItems() { + return this.state.length > 0; + } + + getItems() { + return [...this.state.map(item => toJS(item))]; + } + + getItem(predicate: IListCachePredicate): T | undefined { + const item = this.state.find(item => predicate(item)); + + return item ? toJS(item) : undefined; + } + + addItems(items: T[]) { + runInAction(() => { + this.state = [...this.state, ...items]; + }); + } + + updateItems(updater: IListCacheItemUpdater) { + runInAction(() => { + this.state = [...this.state.map(item => updater(item))]; + }); + } + + removeItems(predicate: IListCachePredicate) { + runInAction(() => { + this.state = this.state.filter(item => !predicate(item)); + }); + } +} diff --git a/packages/app-page-builder/src/features/dataSource/loadDataSource/Checksum.ts b/packages/app-page-builder/src/features/dataSource/loadDataSource/Checksum.ts new file mode 100644 index 00000000000..28fc3cae12c --- /dev/null +++ b/packages/app-page-builder/src/features/dataSource/loadDataSource/Checksum.ts @@ -0,0 +1,19 @@ +import { createHashing } from "@webiny/app/utils"; + +const sha1 = createHashing("SHA-1"); + +export class Checksum { + private readonly checksum: string; + + private constructor(checksum: string) { + this.checksum = checksum; + } + + static async createFrom(value: unknown) { + return new Checksum(await sha1(value)); + } + + getChecksum() { + return this.checksum; + } +} diff --git a/packages/app-page-builder/src/features/dataSource/loadDataSource/IResolveDataSourceGateway.ts b/packages/app-page-builder/src/features/dataSource/loadDataSource/IResolveDataSourceGateway.ts new file mode 100644 index 00000000000..a4753381f29 --- /dev/null +++ b/packages/app-page-builder/src/features/dataSource/loadDataSource/IResolveDataSourceGateway.ts @@ -0,0 +1,5 @@ +import { DataRequest, DataSourceData } from "./IResolveDataSourceRepository"; + +export interface IResolveDataSourceGateway { + execute(request: DataRequest): Promise; +} diff --git a/packages/app-page-builder/src/features/dataSource/loadDataSource/IResolveDataSourceRepository.ts b/packages/app-page-builder/src/features/dataSource/loadDataSource/IResolveDataSourceRepository.ts new file mode 100644 index 00000000000..872a3da151a --- /dev/null +++ b/packages/app-page-builder/src/features/dataSource/loadDataSource/IResolveDataSourceRepository.ts @@ -0,0 +1,56 @@ +import type { GenericRecord } from "@webiny/app/types"; +import { Checksum } from "./Checksum"; + +export type DataSourceData = GenericRecord; + +export interface IDataRequest { + name: string; + type: string; + config: GenericRecord; + paths?: string[]; +} + +export class DataRequest { + private readonly request: IDataRequest; + + protected constructor(request: IDataRequest) { + this.request = request; + } + + static create(request: IDataRequest) { + return new DataRequest(request); + } + + getKey() { + return `${this.getName()}:${this.getType()}`; + } + + getName() { + return this.request.name; + } + + getType() { + return this.request.type; + } + + getConfig() { + return this.request.config; + } + + getPaths() { + return this.request.paths ?? []; + } + + async getChecksum() { + const checksum = await Checksum.createFrom({ + config: this.request.config, + paths: this.request.paths + }); + return checksum.getChecksum(); + } +} + +export interface IResolveDataSourceRepository { + getData(key: string): DataSourceData | undefined; + resolveData(request: DataRequest): Promise; +} diff --git a/packages/app-page-builder/src/features/dataSource/loadDataSource/ResolveDataSourceGqlGateway.ts b/packages/app-page-builder/src/features/dataSource/loadDataSource/ResolveDataSourceGqlGateway.ts new file mode 100644 index 00000000000..c3c709ff1c6 --- /dev/null +++ b/packages/app-page-builder/src/features/dataSource/loadDataSource/ResolveDataSourceGqlGateway.ts @@ -0,0 +1,81 @@ +import type ApolloClient from "apollo-client"; +import gql from "graphql-tag"; +import { GenericRecord } from "@webiny/app/types"; +import { WebinyError } from "@webiny/error"; +import { IResolveDataSourceGateway } from "./IResolveDataSourceGateway"; +import { DataRequest, DataSourceData } from "./IResolveDataSourceRepository"; + +const LOAD_DATA_SOURCE = gql` + query LoadDataSource($type: String!, $config: JSON!, $paths: [String!]) { + dataSources { + loadDataSource(type: $type, config: $config, paths: $paths) { + data + error { + code + message + data + } + } + } + } +`; + +interface QueryType { + dataSources: { + loadDataSource: + | { + data: DataSourceData; + error: undefined; + } + | { + data: undefined; + error: { + code: string; + message: string; + data: GenericRecord; + }; + }; + }; +} + +interface QueryVariables { + type: string; + config: GenericRecord; + paths: string[]; +} + +export class ResolveDataSourceGqlGateway implements IResolveDataSourceGateway { + private client: ApolloClient; + + constructor(client: ApolloClient) { + this.client = client; + } + + async execute(request: DataRequest): Promise { + const query = await this.client.query({ + query: LOAD_DATA_SOURCE, + fetchPolicy: "no-cache", + variables: { + type: request.getType(), + config: request.getConfig(), + paths: request.getPaths() + } + }); + + if (query.errors) { + throw new WebinyError(query.errors[0].message); + } + + if (!query.data) { + throw new WebinyError(`No data was returned from "loadOne" query!`); + } + + const { data, error } = query.data.dataSources.loadDataSource; + + if (!data) { + throw new WebinyError(error); + } + + return data; + } +} diff --git a/packages/app-page-builder/src/features/dataSource/loadDataSource/ResolveDataSourceMockGateway.ts b/packages/app-page-builder/src/features/dataSource/loadDataSource/ResolveDataSourceMockGateway.ts new file mode 100644 index 00000000000..740cf0d6d6f --- /dev/null +++ b/packages/app-page-builder/src/features/dataSource/loadDataSource/ResolveDataSourceMockGateway.ts @@ -0,0 +1,229 @@ +import { IResolveDataSourceGateway } from "./IResolveDataSourceGateway"; +import { GenericRecord } from "@webiny/app/types"; +import { + DataRequest, + DataSourceData +} from "~/features/dataSource/loadDataSource/IResolveDataSourceRepository"; + +// const lexicalParagraph = (value: string) => { +// return { +// root: { +// children: [ +// { +// children: [ +// { +// detail: 0, +// format: 0, +// mode: "normal", +// style: "", +// text: value, +// type: "text", +// version: 1 +// } +// ], +// direction: null, +// format: "", +// indent: 0, +// styles: [], +// type: "paragraph-element", +// version: 1 +// } +// ], +// direction: null, +// format: "", +// indent: 0, +// type: "root", +// version: 1 +// } +// }; +// }; + +const lexicalHeading = (value: string) => { + return { + root: { + children: [ + { + children: [ + { + detail: 0, + format: 0, + mode: "normal", + style: "", + text: value, + type: "text", + version: 1 + } + ], + direction: "ltr", + format: "", + indent: 0, + type: "heading-element", + version: 1, + tag: "h1", + styles: [{ styleId: "heading1", type: "typography" }] + } + ], + direction: "ltr", + format: "", + indent: 0, + type: "root", + version: 1 + } + }; +}; + +const mocks: GenericRecord = { + "cms.entry": { + "1": { + title: lexicalHeading("Mocked title"), + content: "Some random mocked content.", + cta: { + label: "Mocked label", + link: "https://mock-website.com" + }, + testimonials: [ + { + name: "John Smith", + text: "Amazing quality and quick delivery!" + }, + { + name: "Emily Johnson", + text: "Excellent service, highly recommend!" + }, + { + name: "Michael Brown", + text: "The product exceeded my expectations, thank you!" + }, + { + name: "Sophia Davis", + text: "Fast shipping and great customer support!" + }, + { + name: "Liam Wilson", + text: "Wide variety and top-notch quality!" + }, + { + name: "Isabella Martinez", + text: "Easy to order, and it arrived sooner than expected." + }, + { + name: "James Anderson", + text: "Impressive service, will shop again!" + }, + { + name: "Olivia Thomas", + text: "The selection was perfect for my needs!" + }, + { + name: "Benjamin Garcia", + text: "Fantastic experience, highly satisfied!" + }, + { + name: "Mia Robinson", + text: "Great products and seamless process!" + } + ] + }, + "2": { + title: lexicalHeading("Another title"), + content: "More mocked content.", + cta: { + label: "Another label", + link: "https://mock-website2.com" + }, + testimonials: [ + { + name: "Charlotte Lee", + text: "Superb quality and fast turnaround time!" + }, + { + name: "Ethan Harris", + text: "Very pleased with my purchase, will order again." + }, + { + name: "Amelia Clark", + text: "Outstanding service and easy ordering process." + }, + { + name: "William Walker", + text: "Affordable prices and excellent delivery speed!" + }, + { + name: "Harper Young", + text: "Smooth transaction and amazing products!" + }, + { + name: "Lucas Hall", + text: "Everything arrived perfectly, thank you!" + }, + { + name: "Ava King", + text: "Wonderful experience, great selection to choose from." + }, + { + name: "Henry Allen", + text: "Very reliable service and high-quality items." + }, + { + name: "Ella Wright", + text: "Shipping was faster than expected, great value!" + }, + { + name: "Jackson Scott", + text: "Fantastic range of products and quick support!" + } + ] + } + }, + "cms.list": { + product: [ + { + title: "Product title #1", + description: "Product description #1" + }, + { + title: "Product title #2", + description: "Product description #2" + } + ], + author: [ + { + name: "Lucas Hall", + bio: "Everything arrived perfectly, thank you!" + }, + { + name: "William Walker", + bio: "Affordable prices and excellent delivery speed!" + } + ] + } +}; + +export class ResolveDataSourceMockGateway implements IResolveDataSourceGateway { + private decoratee: IResolveDataSourceGateway; + + constructor(decoratee: IResolveDataSourceGateway) { + this.decoratee = decoratee; + } + + async execute(request: DataRequest): Promise { + if (request.getType() === "cms.entry") { + const mockedData = mocks["cms.entry"][request.getConfig().entryId]; + return mockedData ? mockedData : this.decoratee.execute(request); + } + + const modelId = request.getConfig().modelId; + + const data = mocks["cms.list"][modelId]; + if (data === undefined) { + return this.decoratee.execute(request); + } + + const search = request.getConfig().search?.toLowerCase(); + if (search) { + return data.filter((item: any) => item.title.toLowerCase().includes(search)); + } + + return data; + } +} diff --git a/packages/app-page-builder/src/features/dataSource/loadDataSource/ResolveDataSourceRepository.ts b/packages/app-page-builder/src/features/dataSource/loadDataSource/ResolveDataSourceRepository.ts new file mode 100644 index 00000000000..a5adc496c4c --- /dev/null +++ b/packages/app-page-builder/src/features/dataSource/loadDataSource/ResolveDataSourceRepository.ts @@ -0,0 +1,70 @@ +import { makeAutoObservable } from "mobx"; +import { + DataSourceData, + DataRequest, + IResolveDataSourceRepository +} from "./IResolveDataSourceRepository"; +import { IResolveDataSourceGateway } from "~/features/dataSource/loadDataSource/IResolveDataSourceGateway"; +import { DataSourceCache } from "~/features/dataSource/loadDataSource/dataSourceCache"; +import { IListCache } from "~/features/ListCache"; + +export class ResolveDataSourceRepository implements IResolveDataSourceRepository { + private gateway: IResolveDataSourceGateway; + private cache: IListCache; + + constructor(gateway: IResolveDataSourceGateway, cache: IListCache) { + this.gateway = gateway; + this.cache = cache; + makeAutoObservable(this); + } + + getData(key: string): DataSourceData | undefined { + const cacheItem = this.cache.getItem(cache => { + return cache.key === key; + }); + + return cacheItem ? cacheItem.data : undefined; + } + + async resolveData(request: DataRequest): Promise { + const cacheItem = this.cache.getItem(cache => { + return cache.key === request.getKey(); + }); + + const requestChecksum = await request.getChecksum(); + + if (cacheItem) { + if (cacheItem.checksum === requestChecksum) { + console.log( + `Data Source [${request.getName()}] ==> Returning from cache; checksum hasn't changed!`, + request + ); + return; + } + } + + try { + const dataSourceData = await this.gateway.execute(request); + + const newCache: DataSourceCache = { + data: dataSourceData, + key: request.getKey(), + checksum: requestChecksum + }; + + if (cacheItem) { + this.cache.updateItems(item => { + if (item.key === request.getKey()) { + return newCache; + } + + return item; + }); + } else { + this.cache.addItems([newCache]); + } + } catch (e) { + console.log("Error loading data source", request, e.message); + } + } +} diff --git a/packages/app-page-builder/src/features/dataSource/loadDataSource/dataSourceCache.ts b/packages/app-page-builder/src/features/dataSource/loadDataSource/dataSourceCache.ts new file mode 100644 index 00000000000..860496cea6a --- /dev/null +++ b/packages/app-page-builder/src/features/dataSource/loadDataSource/dataSourceCache.ts @@ -0,0 +1,13 @@ +import { DataSourceData } from "./IResolveDataSourceRepository"; +import { ListCache } from "~/features/ListCache"; + +export type DataSourceCache = { + // Data source name. + key: string; + // A checksum of data source config. If this changes, new data is requested from the API. + checksum: string; + // Resolved data. + data: DataSourceData; +}; + +export const dataSourceCache = new ListCache(); diff --git a/packages/app-page-builder/src/features/dataSource/loadDataSource/useLoadDataSource.ts b/packages/app-page-builder/src/features/dataSource/loadDataSource/useLoadDataSource.ts new file mode 100644 index 00000000000..5d79dbd14c5 --- /dev/null +++ b/packages/app-page-builder/src/features/dataSource/loadDataSource/useLoadDataSource.ts @@ -0,0 +1,94 @@ +import { autorun, toJS } from "mobx"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { GenericRecord } from "@webiny/app/types"; +import { useApolloClient } from "@apollo/react-hooks"; +import { PbDataSource } from "~/types"; +import { dataSourceCache } from "~/features/dataSource/loadDataSource/dataSourceCache"; +import { + DataRequest, + IResolveDataSourceRepository +} from "~/features/dataSource/loadDataSource/IResolveDataSourceRepository"; +import { ResolveDataSourceRepository } from "~/features/dataSource/loadDataSource/ResolveDataSourceRepository"; +import { ResolveDataSourceGqlGateway } from "~/features/dataSource/loadDataSource/ResolveDataSourceGqlGateway"; +import { Decorator } from "@webiny/react-composition"; +import { ResolveDataSourceMockGateway } from "~/features/dataSource/loadDataSource/ResolveDataSourceMockGateway"; + +interface DataSourceLoaderVm { + data: GenericRecord; +} + +const decorators: { repository: Decorator[] } = { + repository: [] +}; + +const useLoadDataSourceHook = (dataSource: PbDataSource, paths: string[]) => { + const client = useApolloClient(); + const [vm, setVm] = useState({ data: {} }); + + const dataRequest = useMemo(() => { + return dataSource + ? DataRequest.create({ + ...dataSource, + paths + }) + : undefined; + }, [dataSource, paths]); + + const repository = useMemo(() => { + const gateway = new ResolveDataSourceMockGateway(new ResolveDataSourceGqlGateway(client)); + + return decorators.repository.reduce( + (repository, decorator) => { + return decorator(repository); + }, + new ResolveDataSourceRepository(gateway, dataSourceCache) + ); + }, [client, decorators.repository.length]); + + useEffect(() => { + if (!dataRequest) { + return; + } + + repository.resolveData(dataRequest); + + return autorun(() => { + const data = repository.getData(dataRequest.getKey()); + setVm({ data: data ? toJS(data) : {} }); + }); + }, [repository, dataRequest]); + + const loadData = useCallback( + (params: GenericRecord) => { + const request = DataRequest.create({ + ...dataSource, + config: { + ...dataSource?.config, + ...params + }, + paths + }); + + repository.resolveData(request); + }, + [repository] + ); + + return { data: vm.data, loadData }; +}; + +export const useLoadDataSource = Object.assign(useLoadDataSourceHook, { + /** + * ATTENTION!!! + * This is an EXTREMELY EXPERIMENTAL feature, and should not be used outside of core Webiny packages. + * + * @internal + */ + decorateRepository: (decorator: Decorator) => { + decorators.repository.push(decorator); + + return () => { + decorators.repository = decorators.repository.filter(dec => dec !== decorator); + }; + } +}); diff --git a/packages/app-page-builder/src/features/index.ts b/packages/app-page-builder/src/features/index.ts new file mode 100644 index 00000000000..1f261b4c052 --- /dev/null +++ b/packages/app-page-builder/src/features/index.ts @@ -0,0 +1,7 @@ +export * from "./pageTemplate/listPageTemplates/useListPageTemplates"; +export * from "./pageTemplate/createPageTemplate/useCreatePageTemplate"; +export * from "./pageTemplate/updatePageTemplate/useUpdatePageTemplate"; +export * from "./pageTemplate/deletePageTemplate/useDeletePageTemplate"; +export * from "./pageTemplate/createPageTemplateFromPage/useCreatePageTemplateFromPage"; +export * from "./pageTemplate/refreshPageTemplates/useRefreshPageTemplates"; +export * from "./dataSource/loadDataSource/useLoadDataSource"; diff --git a/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/CreatePageTemplateGqlGateway.ts b/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/CreatePageTemplateGqlGateway.ts new file mode 100644 index 00000000000..95b4de3be92 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/CreatePageTemplateGqlGateway.ts @@ -0,0 +1,98 @@ +import type ApolloClient from "apollo-client"; +import gql from "graphql-tag"; +import { WebinyError } from "@webiny/error"; +import { GenericRecord } from "@webiny/app/types"; +import { ICreatePageTemplateGateway } from "~/features/pageTemplate/createPageTemplate/ICreatePageTemplateGateway"; +import { PageTemplateInputDto } from "~/features/pageTemplate/createPageTemplate/PageTemplateInputDto"; +import { PbPageTemplateWithContent } from "~/types"; + +const CREATE_PAGE_TEMPLATE = gql` + mutation CreatePageTemplate($data: PbCreatePageTemplateInput!) { + pageBuilder { + createPageTemplate(data: $data) { + data { + id + title + slug + tags + description + layout + content + dataSources { + name + type + config + } + dataBindings { + dataSource + bindFrom + bindTo + } + createdOn + savedOn + createdBy { + id + displayName + type + } + } + error { + code + message + data + } + } + } + } +`; + +interface MutationType { + pageBuilder: { + createPageTemplate: + | { + data: PbPageTemplateWithContent; + error: undefined; + } + | { + data: undefined; + error: { + code: string; + message: string; + data: GenericRecord; + }; + }; + }; +} + +export class CreatePageTemplateGqlGateway implements ICreatePageTemplateGateway { + private client: ApolloClient; + + constructor(client: ApolloClient) { + this.client = client; + } + + async execute(pageTemplateInputDto: PageTemplateInputDto): Promise { + const mutation = await this.client.mutate({ + mutation: CREATE_PAGE_TEMPLATE, + variables: { + data: pageTemplateInputDto + } + }); + + if (mutation.errors) { + throw new WebinyError(mutation.errors[0].message); + } + + if (!mutation.data) { + throw new WebinyError(`No data was returned from "CreatePageTemplate" mutation!`); + } + + const { data, error } = mutation.data.pageBuilder.createPageTemplate; + + if (!data) { + throw new WebinyError(error); + } + + return data; + } +} diff --git a/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/CreatePageTemplateRepository.ts b/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/CreatePageTemplateRepository.ts new file mode 100644 index 00000000000..3ebf8a28119 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/CreatePageTemplateRepository.ts @@ -0,0 +1,34 @@ +import { ICreatePageTemplateRepository } from "./ICreatePageTemplateRepository"; +import { PageTemplateInputDto } from "~/features/pageTemplate/createPageTemplate/PageTemplateInputDto"; +import { ICreatePageTemplateGateway } from "~/features/pageTemplate/createPageTemplate/ICreatePageTemplateGateway"; +import { PbPageTemplateWithContent } from "~/types"; +import { ListCache } from "~/features/ListCache"; + +export class CreatePageTemplateRepository implements ICreatePageTemplateRepository { + private cache: ListCache; + private gateway: ICreatePageTemplateGateway; + + constructor( + gateway: ICreatePageTemplateGateway, + pageTemplateCache: ListCache + ) { + this.gateway = gateway; + this.cache = pageTemplateCache; + } + + async execute(pageTemplateInput: PageTemplateInputDto): Promise { + // A naive implementation for the time being. + const pageTemplate = await this.gateway.execute(pageTemplateInput); + + this.cache.addItems([ + { + ...pageTemplate, + tags: pageTemplate.tags || [], + dataSources: pageTemplate.dataSources || [], + dataBindings: pageTemplate.dataBindings || [] + } + ]); + + return pageTemplate; + } +} diff --git a/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/ICreatePageTemplateGateway.ts b/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/ICreatePageTemplateGateway.ts new file mode 100644 index 00000000000..80f14772f96 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/ICreatePageTemplateGateway.ts @@ -0,0 +1,6 @@ +import { PageTemplateInputDto } from "./PageTemplateInputDto"; +import { PbPageTemplateWithContent } from "~/types"; + +export interface ICreatePageTemplateGateway { + execute(pageTemplateDto: PageTemplateInputDto): Promise; +} diff --git a/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/ICreatePageTemplateRepository.ts b/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/ICreatePageTemplateRepository.ts new file mode 100644 index 00000000000..d94c1c7d75a --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/ICreatePageTemplateRepository.ts @@ -0,0 +1,6 @@ +import { PageTemplateInputDto } from "./PageTemplateInputDto"; +import { PbPageTemplateWithContent } from "~/types"; + +export interface ICreatePageTemplateRepository { + execute(pageTemplate: PageTemplateInputDto): Promise; +} diff --git a/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/PageTemplateInputDto.ts b/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/PageTemplateInputDto.ts new file mode 100644 index 00000000000..5574dfab608 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/PageTemplateInputDto.ts @@ -0,0 +1,13 @@ +import type { GenericRecord } from "@webiny/app/types"; +import type { PbDataBinding, PbDataSource } from "~/types"; + +export interface PageTemplateInputDto { + title: string; + slug: string; + description: string; + tags: string[]; + layout: string; + content?: GenericRecord; + dataSources?: PbDataSource[]; + dataBindings?: PbDataBinding[]; +} diff --git a/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/useCreatePageTemplate.ts b/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/useCreatePageTemplate.ts new file mode 100644 index 00000000000..f3a92edcd4e --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/createPageTemplate/useCreatePageTemplate.ts @@ -0,0 +1,25 @@ +import { useCallback, useMemo } from "react"; +import { useApolloClient } from "@apollo/react-hooks"; +import { CreatePageTemplateRepository } from "~/features/pageTemplate/createPageTemplate/CreatePageTemplateRepository"; +import { pageTemplateCache } from "~/features/pageTemplate/pageTemplateCache"; +import { PageTemplateInputDto } from "~/features/pageTemplate/createPageTemplate/PageTemplateInputDto"; +import { CreatePageTemplateGqlGateway } from "~/features/pageTemplate/createPageTemplate/CreatePageTemplateGqlGateway"; + +export const useCreatePageTemplate = () => { + const client = useApolloClient(); + + const repository = useMemo(() => { + const gateway = new CreatePageTemplateGqlGateway(client); + + return new CreatePageTemplateRepository(gateway, pageTemplateCache); + }, [client]); + + const createPageTemplate = useCallback( + (pageTemplate: PageTemplateInputDto) => { + return repository.execute(pageTemplate); + }, + [repository] + ); + + return { createPageTemplate }; +}; diff --git a/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/CreatePageTemplateFromPageGqlGateway.ts b/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/CreatePageTemplateFromPageGqlGateway.ts new file mode 100644 index 00000000000..1994860f147 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/CreatePageTemplateFromPageGqlGateway.ts @@ -0,0 +1,92 @@ +import type ApolloClient from "apollo-client"; +import gql from "graphql-tag"; +import { WebinyError } from "@webiny/error"; +import { GenericRecord } from "@webiny/app/types"; +import { PageTemplateInputDto } from "~/features/pageTemplate/createPageTemplate/PageTemplateInputDto"; +import { PbPageTemplateWithContent } from "~/types"; +import { ICreatePageTemplateFromPageGateway } from "~/features/pageTemplate/createPageTemplateFromPage/ICreatePageTemplateFromPageGateway"; + +const CREATE_TEMPLATE_FROM_PAGE = gql` + mutation CreateTemplateFromPage($pageId: ID!, $data: PbCreateTemplateFromPageInput) { + pageBuilder { + createTemplateFromPage(pageId: $pageId, data: $data) { + data { + id + title + slug + tags + description + layout + content + createdOn + savedOn + createdBy { + id + displayName + type + } + } + error { + code + message + data + } + } + } + } +`; + +interface MutationType { + pageBuilder: { + createTemplateFromPage: + | { + data: PbPageTemplateWithContent; + error: undefined; + } + | { + data: undefined; + error: { + code: string; + message: string; + data: GenericRecord; + }; + }; + }; +} + +export class CreatePageTemplateFromPageGqlGateway implements ICreatePageTemplateFromPageGateway { + private client: ApolloClient; + + constructor(client: ApolloClient) { + this.client = client; + } + + async execute( + pageId: string, + pageTemplateInputDto: PageTemplateInputDto + ): Promise { + const mutation = await this.client.mutate({ + mutation: CREATE_TEMPLATE_FROM_PAGE, + variables: { + pageId, + data: pageTemplateInputDto + } + }); + + if (mutation.errors) { + throw new WebinyError(mutation.errors[0].message); + } + + if (!mutation.data) { + throw new WebinyError(`No data was returned from "CreateTemplateFromPage" mutation!`); + } + + const { data, error } = mutation.data.pageBuilder.createTemplateFromPage; + + if (!data) { + throw new WebinyError(error); + } + + return data; + } +} diff --git a/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/CreatePageTemplateFromPageRepository.ts b/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/CreatePageTemplateFromPageRepository.ts new file mode 100644 index 00000000000..d77a586fa72 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/CreatePageTemplateFromPageRepository.ts @@ -0,0 +1,29 @@ +import { ICreatePageTemplateFromPageRepository } from "./ICreatePageTemplateFromPageRepository"; +import { PageTemplateInputDto } from "~/features/pageTemplate/createPageTemplateFromPage/PageTemplateInputDto"; +import { PbPageTemplateWithContent } from "~/types"; +import { ListCache } from "~/features/ListCache"; +import { ICreatePageTemplateFromPageGateway } from "~/features/pageTemplate/createPageTemplateFromPage/ICreatePageTemplateFromPageGateway"; + +export class CreatePageTemplateFromPageRepository implements ICreatePageTemplateFromPageRepository { + private cache: ListCache; + private gateway: ICreatePageTemplateFromPageGateway; + + constructor( + gateway: ICreatePageTemplateFromPageGateway, + pageTemplateCache: ListCache + ) { + this.gateway = gateway; + this.cache = pageTemplateCache; + } + + async execute( + pageId: string, + pageTemplateInput: PageTemplateInputDto + ): Promise { + const pageTemplate = await this.gateway.execute(pageId, pageTemplateInput); + + this.cache.addItems([pageTemplate]); + + return pageTemplate; + } +} diff --git a/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/ICreatePageTemplateFromPageGateway.ts b/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/ICreatePageTemplateFromPageGateway.ts new file mode 100644 index 00000000000..2200dc85576 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/ICreatePageTemplateFromPageGateway.ts @@ -0,0 +1,9 @@ +import { PageTemplateInputDto } from "./PageTemplateInputDto"; +import { PbPageTemplateWithContent } from "~/types"; + +export interface ICreatePageTemplateFromPageGateway { + execute( + pageId: string, + pageTemplateDto: PageTemplateInputDto + ): Promise; +} diff --git a/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/ICreatePageTemplateFromPageRepository.ts b/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/ICreatePageTemplateFromPageRepository.ts new file mode 100644 index 00000000000..b2ab2727985 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/ICreatePageTemplateFromPageRepository.ts @@ -0,0 +1,6 @@ +import { PageTemplateInputDto } from "./PageTemplateInputDto"; +import { PbPageTemplateWithContent } from "~/types"; + +export interface ICreatePageTemplateFromPageRepository { + execute(pageId: string, pageTemplate: PageTemplateInputDto): Promise; +} diff --git a/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/PageTemplateInputDto.ts b/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/PageTemplateInputDto.ts new file mode 100644 index 00000000000..f0220407c9a --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/PageTemplateInputDto.ts @@ -0,0 +1,5 @@ +export interface PageTemplateInputDto { + title: string; + slug: string; + description: string; +} diff --git a/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/useCreatePageTemplateFromPage.ts b/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/useCreatePageTemplateFromPage.ts new file mode 100644 index 00000000000..be502f26a4a --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/createPageTemplateFromPage/useCreatePageTemplateFromPage.ts @@ -0,0 +1,25 @@ +import { useCallback, useMemo } from "react"; +import { useApolloClient } from "@apollo/react-hooks"; +import { CreatePageTemplateFromPageRepository } from "~/features/pageTemplate/createPageTemplateFromPage/CreatePageTemplateFromPageRepository"; +import { pageTemplateCache } from "~/features/pageTemplate/pageTemplateCache"; +import { PageTemplateInputDto } from "./PageTemplateInputDto"; +import { CreatePageTemplateFromPageGqlGateway } from "~/features/pageTemplate/createPageTemplateFromPage/CreatePageTemplateFromPageGqlGateway"; + +export const useCreatePageTemplateFromPage = () => { + const client = useApolloClient(); + + const repository = useMemo(() => { + const gateway = new CreatePageTemplateFromPageGqlGateway(client); + + return new CreatePageTemplateFromPageRepository(gateway, pageTemplateCache); + }, [client]); + + const createPageTemplateFromPage = useCallback( + (pageId: string, pageTemplate: PageTemplateInputDto) => { + return repository.execute(pageId, pageTemplate); + }, + [repository] + ); + + return { createPageTemplateFromPage }; +}; diff --git a/packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/DeletePageTemplateGqlGateway.ts b/packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/DeletePageTemplateGqlGateway.ts new file mode 100644 index 00000000000..c22b53501f6 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/DeletePageTemplateGqlGateway.ts @@ -0,0 +1,67 @@ +import type ApolloClient from "apollo-client"; +import gql from "graphql-tag"; +import { WebinyError } from "@webiny/error"; +import { GenericRecord } from "@webiny/app/types"; +import { IDeletePageTemplateGateway } from "~/features/pageTemplate/deletePageTemplate/IDeletePageTemplateGateway"; + +const DELETE_PAGE_TEMPLATE = gql` + mutation DeletePageTemplate($id: ID!) { + pageBuilder { + deletePageTemplate(id: $id) { + error { + code + message + } + } + } + } +`; + +interface MutationType { + pageBuilder: { + deletePageTemplate: + | { + data: boolean; + error: undefined; + } + | { + data: undefined; + error: { + code: string; + message: string; + data: GenericRecord; + }; + }; + }; +} + +export class DeletePageTemplateGqlGateway implements IDeletePageTemplateGateway { + private client: ApolloClient; + + constructor(client: ApolloClient) { + this.client = client; + } + + async execute(pageTemplateId: string): Promise { + const mutation = await this.client.mutate({ + mutation: DELETE_PAGE_TEMPLATE, + variables: { + id: pageTemplateId + } + }); + + if (mutation.errors) { + throw new WebinyError(mutation.errors[0].message); + } + + if (!mutation.data) { + throw new WebinyError(`No data was returned from "CreatePageTemplate" mutation!`); + } + + const { error } = mutation.data.pageBuilder.deletePageTemplate; + + if (error) { + throw new WebinyError(error); + } + } +} diff --git a/packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/DeletePageTemplateRepository.ts b/packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/DeletePageTemplateRepository.ts new file mode 100644 index 00000000000..17572025e8f --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/DeletePageTemplateRepository.ts @@ -0,0 +1,20 @@ +import { IDeletePageTemplateRepository } from "./IDeletePageTemplateRepository"; +import { PbPageTemplate } from "~/types"; +import { ListCache } from "~/features/ListCache"; +import { IDeletePageTemplateGateway } from "~/features/pageTemplate/deletePageTemplate/IDeletePageTemplateGateway"; + +export class DeletePageTemplateRepository implements IDeletePageTemplateRepository { + private cache: ListCache; + private gateway: IDeletePageTemplateGateway; + + constructor(gateway: IDeletePageTemplateGateway, pageTemplateCache: ListCache) { + this.gateway = gateway; + this.cache = pageTemplateCache; + } + + async execute(pageTemplateId: string): Promise { + await this.gateway.execute(pageTemplateId); + + this.cache.removeItems(item => item.id === pageTemplateId); + } +} diff --git a/packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/IDeletePageTemplateGateway.ts b/packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/IDeletePageTemplateGateway.ts new file mode 100644 index 00000000000..4d8e2e6ef4d --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/IDeletePageTemplateGateway.ts @@ -0,0 +1,3 @@ +export interface IDeletePageTemplateGateway { + execute(pageTemplateId: string): Promise; +} diff --git a/packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/IDeletePageTemplateRepository.ts b/packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/IDeletePageTemplateRepository.ts new file mode 100644 index 00000000000..759c4330714 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/IDeletePageTemplateRepository.ts @@ -0,0 +1,3 @@ +export interface IDeletePageTemplateRepository { + execute(pageTemplateId: string): Promise; +} diff --git a/packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/useDeletePageTemplate.ts b/packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/useDeletePageTemplate.ts new file mode 100644 index 00000000000..36ddaf09daf --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/deletePageTemplate/useDeletePageTemplate.ts @@ -0,0 +1,24 @@ +import { useCallback, useMemo } from "react"; +import { useApolloClient } from "@apollo/react-hooks"; +import { pageTemplateCache } from "~/features/pageTemplate/pageTemplateCache"; +import { DeletePageTemplateRepository } from "~/features/pageTemplate/deletePageTemplate/DeletePageTemplateRepository"; +import { DeletePageTemplateGqlGateway } from "~/features/pageTemplate/deletePageTemplate/DeletePageTemplateGqlGateway"; + +export const useDeletePageTemplate = () => { + const client = useApolloClient(); + + const repository = useMemo(() => { + const gateway = new DeletePageTemplateGqlGateway(client); + + return new DeletePageTemplateRepository(gateway, pageTemplateCache); + }, [client]); + + const deletePageTemplate = useCallback( + (pageTemplateId: string) => { + return repository.execute(pageTemplateId); + }, + [repository] + ); + + return { deletePageTemplate }; +}; diff --git a/packages/app-page-builder/src/features/pageTemplate/listPageTemplates/IListPageTemplatesGateway.ts b/packages/app-page-builder/src/features/pageTemplate/listPageTemplates/IListPageTemplatesGateway.ts new file mode 100644 index 00000000000..5eee041aa78 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/listPageTemplates/IListPageTemplatesGateway.ts @@ -0,0 +1,5 @@ +import { PbPageTemplateWithContent } from "~/types"; + +export interface IListPageTemplatesGateway { + execute(): Promise; +} diff --git a/packages/app-page-builder/src/features/pageTemplate/listPageTemplates/IListPageTemplatesRepository.ts b/packages/app-page-builder/src/features/pageTemplate/listPageTemplates/IListPageTemplatesRepository.ts new file mode 100644 index 00000000000..6eb43e17c72 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/listPageTemplates/IListPageTemplatesRepository.ts @@ -0,0 +1,7 @@ +import { PbPageTemplateWithContent } from "~/types"; + +export interface IListPageTemplatesRepository { + getLoading(): boolean; + getPageTemplates(): PbPageTemplateWithContent[]; + execute(): Promise; +} diff --git a/packages/app-page-builder/src/features/pageTemplate/listPageTemplates/ListPageTemplatesGqlGateway.ts b/packages/app-page-builder/src/features/pageTemplate/listPageTemplates/ListPageTemplatesGqlGateway.ts new file mode 100644 index 00000000000..d46805c270c --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/listPageTemplates/ListPageTemplatesGqlGateway.ts @@ -0,0 +1,101 @@ +import ApolloClient from "apollo-client"; +import gql from "graphql-tag"; +import { IListPageTemplatesGateway } from "./IListPageTemplatesGateway"; +import { PbPageTemplateWithContent } from "~/types"; +import { GenericRecord } from "@webiny/app/types"; +import { WebinyError } from "@webiny/error"; + +const LIST_PAGE_TEMPLATES = gql` + query ListPageTemplates { + pageBuilder { + listPageTemplates { + data { + id + title + slug + tags + description + layout + content + dataBindings { + dataSource + bindFrom + bindTo + } + dataSources { + name + type + config + } + createdOn + savedOn + createdBy { + id + displayName + type + } + } + error { + code + data + message + } + } + } + } +`; + +interface QueryType { + pageBuilder: { + listPageTemplates: + | { + data: PbPageTemplateWithContent[]; + error: undefined; + } + | { + data: undefined; + error: { + code: string; + message: string; + data: GenericRecord; + }; + }; + }; +} + +export class ListPageTemplatesGqlGateway implements IListPageTemplatesGateway { + private client: ApolloClient; + + constructor(client: ApolloClient) { + this.client = client; + } + + async execute(): Promise { + const query = await this.client.query({ + query: LIST_PAGE_TEMPLATES, + fetchPolicy: "no-cache" + }); + + if (query.errors) { + throw new WebinyError(query.errors[0].message); + } + + if (!query.data) { + throw new WebinyError(`No data was returned from "listPageTemplates" query!`); + } + + const { data, error } = query.data.pageBuilder.listPageTemplates; + + if (!data) { + throw new WebinyError(error); + } + + return data.map(template => { + return { + ...template, + dataSources: template.dataSources || [], + dataBindings: template.dataBindings || [] + }; + }); + } +} diff --git a/packages/app-page-builder/src/features/pageTemplate/listPageTemplates/ListPageTemplatesRepository.ts b/packages/app-page-builder/src/features/pageTemplate/listPageTemplates/ListPageTemplatesRepository.ts new file mode 100644 index 00000000000..559b8e9b031 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/listPageTemplates/ListPageTemplatesRepository.ts @@ -0,0 +1,63 @@ +import { makeAutoObservable, runInAction } from "mobx"; +import { IListPageTemplatesGateway } from "~/features/pageTemplate/listPageTemplates/IListPageTemplatesGateway"; +import { IListPageTemplatesRepository } from "~/features/pageTemplate/listPageTemplates/IListPageTemplatesRepository"; +import { PbPageTemplateWithContent } from "~/types"; +import { ListCache } from "~/features/ListCache"; + +export class ListPageTemplatesRepository implements IListPageTemplatesRepository { + private loading: boolean; + private loader: Promise | undefined = undefined; + private gateway: IListPageTemplatesGateway; + private cache: ListCache; + + constructor(gateway: IListPageTemplatesGateway, cache: ListCache) { + this.gateway = gateway; + this.cache = cache; + this.loading = false; + makeAutoObservable(this); + } + + getLoading() { + return this.loading; + } + + getPageTemplates(): PbPageTemplateWithContent[] { + return this.cache.getItems(); + } + + async execute() { + if (this.cache.hasItems()) { + return this.cache.getItems(); + } + + if (this.loader) { + return this.loader; + } + + this.loader = (async () => { + this.loading = true; + + let pageTemplateDtos: PbPageTemplateWithContent[] = []; + + try { + pageTemplateDtos = await this.gateway.execute(); + } catch (err) { + console.error(err); + } finally { + runInAction(() => { + this.loading = false; + }); + } + + runInAction(() => { + this.cache.addItems(pageTemplateDtos); + }); + + this.loader = undefined; + + return pageTemplateDtos; + })(); + + return this.loader; + } +} diff --git a/packages/app-page-builder/src/features/pageTemplate/listPageTemplates/useListPageTemplates.ts b/packages/app-page-builder/src/features/pageTemplate/listPageTemplates/useListPageTemplates.ts new file mode 100644 index 00000000000..480370d55df --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/listPageTemplates/useListPageTemplates.ts @@ -0,0 +1,52 @@ +import { useEffect, useMemo, useState } from "react"; +import type ApolloClient from "apollo-client"; +import { autorun, toJS } from "mobx"; +import { useApolloClient } from "@apollo/react-hooks"; +import { PbPageTemplateWithContent } from "~/types"; +import { pageTemplateCache } from "~/features/pageTemplate/pageTemplateCache"; +import { ListPageTemplatesGqlGateway } from "~/features/pageTemplate/listPageTemplates/ListPageTemplatesGqlGateway"; +import { ListPageTemplatesRepository } from "~/features/pageTemplate/listPageTemplates/ListPageTemplatesRepository"; +import { IListPageTemplatesRepository } from "~/features/pageTemplate/listPageTemplates/IListPageTemplatesRepository"; + +class RepositoryFactory { + private cache: Map, IListPageTemplatesRepository> = new Map(); + + get(client: ApolloClient): IListPageTemplatesRepository { + if (!this.cache.has(client)) { + const gateway = new ListPageTemplatesGqlGateway(client); + const repository = new ListPageTemplatesRepository(gateway, pageTemplateCache); + this.cache.set(client, repository); + } + + return this.cache.get(client) as IListPageTemplatesRepository; + } +} + +const repositoryFactory = new RepositoryFactory(); + +export const useListPageTemplates = () => { + const client = useApolloClient(); + + const [vm, setVm] = useState<{ loading: boolean; pageTemplates: PbPageTemplateWithContent[] }>({ + loading: true, + pageTemplates: [] + }); + + const repository = useMemo(() => { + return repositoryFactory.get(client); + }, []); + + useEffect(() => { + repository.execute(); + }, []); + + useEffect(() => { + autorun(() => { + const loading = repository.getLoading(); + const templates = repository.getPageTemplates(); + setVm({ loading, pageTemplates: templates.map(template => toJS(template)) }); + }); + }, [repository]); + + return vm; +}; diff --git a/packages/app-page-builder/src/features/pageTemplate/pageTemplateCache.ts b/packages/app-page-builder/src/features/pageTemplate/pageTemplateCache.ts new file mode 100644 index 00000000000..bde4e144fcb --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/pageTemplateCache.ts @@ -0,0 +1,4 @@ +import { ListCache } from "../ListCache"; +import { PbPageTemplateWithContent } from "~/types"; + +export const pageTemplateCache = new ListCache(); diff --git a/packages/app-page-builder/src/features/pageTemplate/refreshPageTemplates/IRefreshPageTemplatesRepository.ts b/packages/app-page-builder/src/features/pageTemplate/refreshPageTemplates/IRefreshPageTemplatesRepository.ts new file mode 100644 index 00000000000..a2c4bdb18c9 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/refreshPageTemplates/IRefreshPageTemplatesRepository.ts @@ -0,0 +1,3 @@ +export interface IRefreshPageTemplatesRepository { + execute(): Promise; +} diff --git a/packages/app-page-builder/src/features/pageTemplate/refreshPageTemplates/RefreshPageTemplatesRepository.ts b/packages/app-page-builder/src/features/pageTemplate/refreshPageTemplates/RefreshPageTemplatesRepository.ts new file mode 100644 index 00000000000..21ea0fc9ca9 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/refreshPageTemplates/RefreshPageTemplatesRepository.ts @@ -0,0 +1,42 @@ +import { makeAutoObservable, runInAction } from "mobx"; +import { IListPageTemplatesGateway } from "~/features/pageTemplate/listPageTemplates/IListPageTemplatesGateway"; +import { PbPageTemplateWithContent } from "~/types"; +import { ListCache } from "~/features/ListCache"; +import { IRefreshPageTemplatesRepository } from "~/features/pageTemplate/refreshPageTemplates/IRefreshPageTemplatesRepository"; + +export class RefreshPageTemplatesRepository implements IRefreshPageTemplatesRepository { + private loader: Promise | undefined = undefined; + private gateway: IListPageTemplatesGateway; + private cache: ListCache; + + constructor(gateway: IListPageTemplatesGateway, cache: ListCache) { + this.gateway = gateway; + this.cache = cache; + makeAutoObservable(this); + } + + async execute() { + if (this.loader) { + return this.loader; + } + + this.loader = (async () => { + let pageTemplateDtos: PbPageTemplateWithContent[] = []; + + try { + pageTemplateDtos = await this.gateway.execute(); + } catch (err) { + console.error(err); + } + + runInAction(() => { + this.cache.clear(); + this.cache.addItems(pageTemplateDtos); + }); + + this.loader = undefined; + })(); + + return this.loader; + } +} diff --git a/packages/app-page-builder/src/features/pageTemplate/refreshPageTemplates/useRefreshPageTemplates.ts b/packages/app-page-builder/src/features/pageTemplate/refreshPageTemplates/useRefreshPageTemplates.ts new file mode 100644 index 00000000000..129721a5f7e --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/refreshPageTemplates/useRefreshPageTemplates.ts @@ -0,0 +1,37 @@ +import { useCallback, useMemo } from "react"; +import type ApolloClient from "apollo-client"; +import { useApolloClient } from "@apollo/react-hooks"; +import { pageTemplateCache } from "~/features/pageTemplate/pageTemplateCache"; +import { ListPageTemplatesGqlGateway } from "~/features/pageTemplate/listPageTemplates/ListPageTemplatesGqlGateway"; +import { RefreshPageTemplatesRepository } from "~/features/pageTemplate/refreshPageTemplates/RefreshPageTemplatesRepository"; +import { IRefreshPageTemplatesRepository } from "~/features/pageTemplate/refreshPageTemplates/IRefreshPageTemplatesRepository"; + +class RepositoryFactory { + private cache: Map, IRefreshPageTemplatesRepository> = new Map(); + + get(client: ApolloClient): IRefreshPageTemplatesRepository { + if (!this.cache.has(client)) { + const gateway = new ListPageTemplatesGqlGateway(client); + const repository = new RefreshPageTemplatesRepository(gateway, pageTemplateCache); + this.cache.set(client, repository); + } + + return this.cache.get(client) as IRefreshPageTemplatesRepository; + } +} + +const repositoryFactory = new RepositoryFactory(); + +export const useRefreshPageTemplates = () => { + const client = useApolloClient(); + + const repository = useMemo(() => { + return repositoryFactory.get(client); + }, []); + + const refreshPageTemplates = useCallback(() => { + repository.execute(); + }, []); + + return { refreshPageTemplates }; +}; diff --git a/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/IUpdatePageTemplateGateway.ts b/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/IUpdatePageTemplateGateway.ts new file mode 100644 index 00000000000..c76fac344c3 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/IUpdatePageTemplateGateway.ts @@ -0,0 +1,5 @@ +import { PageTemplateDto } from "~/features/pageTemplate/updatePageTemplate/PageTemplateDto"; + +export interface IUpdatePageTemplateGateway { + execute(pageTemplateDto: PageTemplateDto): Promise; +} diff --git a/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/IUpdatePageTemplateRepository.ts b/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/IUpdatePageTemplateRepository.ts new file mode 100644 index 00000000000..6bf998045a4 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/IUpdatePageTemplateRepository.ts @@ -0,0 +1,5 @@ +import { PageTemplateDto } from "./PageTemplateDto"; + +export interface IUpdatePageTemplateRepository { + execute(pageTemplate: PageTemplateDto): Promise; +} diff --git a/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/PageTemplateDto.ts b/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/PageTemplateDto.ts new file mode 100644 index 00000000000..f931a50f927 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/PageTemplateDto.ts @@ -0,0 +1,12 @@ +import { PbDataBinding, PbDataSource } from "~/types"; + +export interface PageTemplateDto { + id: string; + title: string; + slug: string; + description: string; + tags: string[]; + layout: string; + dataSources: PbDataSource[]; + dataBindings: PbDataBinding[]; +} diff --git a/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/UpdatePageTemplateDto.ts b/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/UpdatePageTemplateDto.ts new file mode 100644 index 00000000000..fb5f2bbb180 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/UpdatePageTemplateDto.ts @@ -0,0 +1,13 @@ +import { PbDataBinding, PbDataSource } from "~/types"; + +export interface UpdatePageTemplateDto { + id: string; + title: string; + slug: string; + description: string; + tags: string[]; + layout: string; + content: any; + dataSources: PbDataSource[]; + dataBindings: PbDataBinding[]; +} diff --git a/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/UpdatePageTemplateGqlGateway.ts b/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/UpdatePageTemplateGqlGateway.ts new file mode 100644 index 00000000000..fcb004335a2 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/UpdatePageTemplateGqlGateway.ts @@ -0,0 +1,100 @@ +import type ApolloClient from "apollo-client"; +import gql from "graphql-tag"; +import { WebinyError } from "@webiny/error"; +import { GenericRecord } from "@webiny/app/types"; +import { PageTemplateDto } from "~/features/pageTemplate/updatePageTemplate/PageTemplateDto"; +import { IUpdatePageTemplateGateway } from "~/features/pageTemplate/updatePageTemplate/IUpdatePageTemplateGateway"; + +const UPDATE_PAGE_TEMPLATE = gql` + mutation UpdatePageTemplate($id: ID!, $data: PbUpdatePageTemplateInput!) { + pageBuilder { + updatePageTemplate(id: $id, data: $data) { + data { + id + title + slug + tags + description + layout + content + createdOn + savedOn + dataSources { + name + type + config + } + dataBindings { + dataSource + bindFrom + bindTo + } + createdBy { + id + displayName + type + } + } + error { + code + message + data + } + } + } + } +`; + +interface MutationType { + pageBuilder: { + updatePageTemplate: + | { + data: PageTemplateDto; + error: undefined; + } + | { + data: undefined; + error: { + code: string; + message: string; + data: GenericRecord; + }; + }; + }; +} + +export class UpdatePageTemplateGqlGateway implements IUpdatePageTemplateGateway { + private client: ApolloClient; + + constructor(client: ApolloClient) { + this.client = client; + } + + async execute(pageTemplateDto: PageTemplateDto): Promise { + const { id, ...pageTemplateData } = pageTemplateDto; + + const mutation = await this.client.mutate({ + mutation: UPDATE_PAGE_TEMPLATE, + variables: { + id, + data: pageTemplateData + } + }); + + if (mutation.errors) { + throw new WebinyError(mutation.errors[0].message); + } + + if (!mutation.data) { + throw new WebinyError(`No data was returned from "UpdatePageTemplate" mutation!`); + } + + const { data, error } = mutation.data.pageBuilder.updatePageTemplate; + + if (!data) { + throw new WebinyError(error); + } + + return data; + } +} diff --git a/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/UpdatePageTemplateRepository.ts b/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/UpdatePageTemplateRepository.ts new file mode 100644 index 00000000000..b82cd4578c6 --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/UpdatePageTemplateRepository.ts @@ -0,0 +1,33 @@ +import { IUpdatePageTemplateRepository } from "./IUpdatePageTemplateRepository"; +import { PbPageTemplateWithContent } from "~/types"; +import { ListCache } from "~/features/ListCache"; +import { IUpdatePageTemplateGateway } from "~/features/pageTemplate/updatePageTemplate/IUpdatePageTemplateGateway"; +import { UpdatePageTemplateDto } from "~/features/pageTemplate/updatePageTemplate/UpdatePageTemplateDto"; + +export class UpdatePageTemplateRepository implements IUpdatePageTemplateRepository { + private cache: ListCache; + private gateway: IUpdatePageTemplateGateway; + + constructor( + gateway: IUpdatePageTemplateGateway, + pageTemplateCache: ListCache + ) { + this.gateway = gateway; + this.cache = pageTemplateCache; + } + + async execute(pageTemplate: UpdatePageTemplateDto): Promise { + // A naive implementation for the time being. + const updatedTemplate = await this.gateway.execute(pageTemplate); + + this.cache.updateItems(item => { + if (item.id === updatedTemplate.id) { + return { + ...item, + ...updatedTemplate + }; + } + return item; + }); + } +} diff --git a/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/useUpdatePageTemplate.ts b/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/useUpdatePageTemplate.ts new file mode 100644 index 00000000000..d684defc52a --- /dev/null +++ b/packages/app-page-builder/src/features/pageTemplate/updatePageTemplate/useUpdatePageTemplate.ts @@ -0,0 +1,25 @@ +import { useCallback, useMemo } from "react"; +import { useApolloClient } from "@apollo/react-hooks"; +import { pageTemplateCache } from "~/features/pageTemplate/pageTemplateCache"; +import { UpdatePageTemplateRepository } from "~/features/pageTemplate/updatePageTemplate/UpdatePageTemplateRepository"; +import { UpdatePageTemplateGqlGateway } from "~/features/pageTemplate/updatePageTemplate/UpdatePageTemplateGqlGateway"; +import { UpdatePageTemplateDto } from "~/features/pageTemplate/updatePageTemplate/UpdatePageTemplateDto"; + +export const useUpdatePageTemplate = () => { + const client = useApolloClient(); + + const repository = useMemo(() => { + const gateway = new UpdatePageTemplateGqlGateway(client); + + return new UpdatePageTemplateRepository(gateway, pageTemplateCache); + }, [client]); + + const updatePageTemplate = useCallback( + (pageTemplate: UpdatePageTemplateDto) => { + return repository.execute(pageTemplate); + }, + [repository] + ); + + return { updatePageTemplate }; +}; diff --git a/packages/app-page-builder/src/pageEditor/Editor.tsx b/packages/app-page-builder/src/pageEditor/Editor.tsx index eaea32055d9..0c66763ed28 100644 --- a/packages/app-page-builder/src/pageEditor/Editor.tsx +++ b/packages/app-page-builder/src/pageEditor/Editor.tsx @@ -169,7 +169,7 @@ export const PageEditor = () => { return ( }> - + diff --git a/packages/app-page-builder/src/pageEditor/config/DefaultPageEditorConfig.tsx b/packages/app-page-builder/src/pageEditor/config/DefaultPageEditorConfig.tsx index 63c45aa4d94..139e8cf2ed1 100644 --- a/packages/app-page-builder/src/pageEditor/config/DefaultPageEditorConfig.tsx +++ b/packages/app-page-builder/src/pageEditor/config/DefaultPageEditorConfig.tsx @@ -20,6 +20,7 @@ import { PreviewPageOption } from "./TopBar/PreviewPageOption/PreviewPageOption" import { SetAsHomepageOption } from "./TopBar/SetAsHomepageOption/SetAsHomepageOption"; import { EditorConfig } from "~/editor/config"; import { InjectElementVariables } from "~/render/variables/InjectElementVariables"; +import { SetupDynamicDocument } from "~/pageEditor/config/SetupDynamicDocument"; const { ElementAction, Ui } = EditorConfig; @@ -28,6 +29,7 @@ export const DefaultPageEditorConfig = React.memo(() => { <> + } /> } /> diff --git a/packages/app-page-builder/src/pageEditor/config/SetupDynamicDocument.tsx b/packages/app-page-builder/src/pageEditor/config/SetupDynamicDocument.tsx new file mode 100644 index 00000000000..222e4856017 --- /dev/null +++ b/packages/app-page-builder/src/pageEditor/config/SetupDynamicDocument.tsx @@ -0,0 +1,51 @@ +import React from "react"; +import { PageEditorConfig } from "~/pageEditor"; +import { DynamicDocumentProvider } from "~/dataInjection"; +import { usePage } from "~/pageEditor"; +import { PbDataBinding, PbDataSource } from "~/types"; +import { useEventActionHandler } from "~/editor"; +import { UpdateDocumentActionEvent } from "~/editor/recoil/actions"; + +const { Ui } = PageEditorConfig; + +export const SetupDynamicDocument = Ui.Layout.createDecorator(Original => { + return function PageToDynamicDocument() { + const eventActionHandler = useEventActionHandler(); + const [page, updatePage] = usePage(); + + const onDataSources = (dataSources: PbDataSource[]) => { + updatePage(page => ({ ...page, dataSources })); + eventActionHandler.trigger( + new UpdateDocumentActionEvent({ + history: false, + document: { + dataSources + } + }) + ); + }; + + const onDataBindings = (dataBindings: PbDataBinding[]) => { + updatePage(page => ({ ...page, dataBindings })); + eventActionHandler.trigger( + new UpdateDocumentActionEvent({ + history: false, + document: { + dataBindings + } + }) + ); + }; + + return ( + + + + ); + }; +}); diff --git a/packages/app-page-builder/src/pageEditor/config/Sidebar/TemplateMode.tsx b/packages/app-page-builder/src/pageEditor/config/Sidebar/TemplateMode.tsx index b8fb700f66d..a23cf4ed82b 100644 --- a/packages/app-page-builder/src/pageEditor/config/Sidebar/TemplateMode.tsx +++ b/packages/app-page-builder/src/pageEditor/config/Sidebar/TemplateMode.tsx @@ -2,7 +2,7 @@ import React from "react"; import { VariableSettings } from "~/editor/plugins/elementSettings/variable/VariableSettings"; import { useTemplateMode } from "~/pageEditor/hooks/useTemplateMode"; import { PageEditorConfig } from "~/pageEditor/editorConfig/PageEditorConfig"; -import { ScrollableContainer } from "~/editor/defaultConfig/Sidebar/ScrollableContainer"; +import { ScrollableContainer } from "~/editor/config/Sidebar/ScrollableContainer"; export const TemplateMode = PageEditorConfig.Ui.Sidebar.Elements.createDecorator(Original => { return function TemplateMode(props) { diff --git a/packages/app-page-builder/src/pageEditor/config/Toolbar/InjectVariableValuesIntoElement.ts b/packages/app-page-builder/src/pageEditor/config/Toolbar/InjectVariableValuesIntoElement.ts index 06e75b37d3a..d8ef0529c0c 100644 --- a/packages/app-page-builder/src/pageEditor/config/Toolbar/InjectVariableValuesIntoElement.ts +++ b/packages/app-page-builder/src/pageEditor/config/Toolbar/InjectVariableValuesIntoElement.ts @@ -1,5 +1,9 @@ import { plugins } from "@webiny/plugins"; -import { PbBlockVariable, PbEditorPageElementVariableRendererPlugin, PbElement } from "~/types"; +import { + PbBlockVariable, + PbEditorElementTree, + PbEditorPageElementVariableRendererPlugin +} from "~/types"; export class InjectVariableValuesIntoElement { private elementVariablePlugins: PbEditorPageElementVariableRendererPlugin[]; @@ -9,7 +13,7 @@ export class InjectVariableValuesIntoElement { "pb-editor-page-element-variable-renderer" ); } - execute(element: PbElement, variables: PbBlockVariable[]): PbElement { + execute(element: PbEditorElementTree, variables: PbBlockVariable[]): PbEditorElementTree { element.elements = element.elements.map(element => { const { variableId } = element.data; diff --git a/packages/app-page-builder/src/pageEditor/config/Toolbar/UnlinkPageFromTemplate.ts b/packages/app-page-builder/src/pageEditor/config/Toolbar/UnlinkPageFromTemplate.ts index 52215b93a1a..91cffa12056 100644 --- a/packages/app-page-builder/src/pageEditor/config/Toolbar/UnlinkPageFromTemplate.ts +++ b/packages/app-page-builder/src/pageEditor/config/Toolbar/UnlinkPageFromTemplate.ts @@ -1,12 +1,12 @@ -import { PbEditorElement, PbElement } from "~/types"; +import { PbEditorElementTree } from "~/types"; import { InjectVariableValuesIntoElement } from "~/pageEditor/config/Toolbar/InjectVariableValuesIntoElement"; export class UnlinkPageFromTemplate { - execute(content: PbEditorElement) { + execute(content: PbEditorElementTree) { const newContent = structuredClone(content); const injectVariableValues = new InjectVariableValuesIntoElement(); - const unlinkedBlocks = (newContent.elements as PbElement[]).map(block => { + const unlinkedBlocks = newContent.elements.map(block => { delete block.data["templateBlockId"]; if (block.data.blockId) { diff --git a/packages/app-page-builder/src/pageEditor/config/TopBar/Title/Title.tsx b/packages/app-page-builder/src/pageEditor/config/TopBar/Title/Title.tsx index c7b9fc3c1d4..f574e26d516 100644 --- a/packages/app-page-builder/src/pageEditor/config/TopBar/Title/Title.tsx +++ b/packages/app-page-builder/src/pageEditor/config/TopBar/Title/Title.tsx @@ -26,18 +26,14 @@ interface PageInfo { pageTitle: string; pageVersion: number; pageLocked: boolean; - pageCategory?: string; - pageCategoryUrl?: string; } const extractPageInfo = (page: PageAtomType): PageInfo => { - const { title, version, locked, category } = page; + const { title, version, locked } = page; return { pageTitle: title as string, pageVersion: version, - pageLocked: locked, - pageCategory: category?.name, - pageCategoryUrl: category?.url + pageLocked: locked }; }; @@ -45,7 +41,7 @@ export const Title = () => { const handler = useEventActionHandler(); const [page] = usePage(); const { showSnackbar } = useSnackbar(); - const { pageTitle, pageVersion, pageLocked, pageCategory } = extractPageInfo(page); + const { pageTitle, pageVersion, pageLocked } = extractPageInfo(page); const [editTitle, setEdit] = useState(false); const [stateTitle, setTitle] = useState(null); let title = stateTitle === null ? pageTitle : stateTitle; @@ -120,7 +116,7 @@ export const Title = () => { - {`${pageCategory} (status: ${pageLocked ? "published" : "draft"})`} + {`(status: ${pageLocked ? "published" : "draft"})`}
    diff --git a/packages/app-page-builder/src/pageEditor/config/eventActions/EventActionHandlerDecorator.tsx b/packages/app-page-builder/src/pageEditor/config/eventActions/EventActionHandlerDecorator.tsx index 155831e3394..9cfcf20ff3f 100644 --- a/packages/app-page-builder/src/pageEditor/config/eventActions/EventActionHandlerDecorator.tsx +++ b/packages/app-page-builder/src/pageEditor/config/eventActions/EventActionHandlerDecorator.tsx @@ -10,7 +10,7 @@ import { useRevisions } from "~/pageEditor/hooks/useRevisions"; import { TemplateModeAtomType, useTemplateMode } from "~/pageEditor/hooks/useTemplateMode"; import { PageAtomType, RevisionsAtomType } from "~/pageEditor/state"; import { PageEditorEventActionCallableState } from "~/pageEditor/types"; -import { PbElement, PbEditorElement } from "~/types"; +import { PbEditorElementTree } from "~/types"; type ProviderProps = EventActionHandlerProviderProps; @@ -37,11 +37,11 @@ export const EventActionHandlerDecorator = createDecorator( next => { return async props => { const element = props?.element; - const res = (await next({ element })) as PbElement; + const res = (await next({ element })) as PbEditorElementTree; const cleanUpReferenceBlocks = ( - element: PbElement - ): PbEditorElement => { + element: PbEditorElementTree + ): PbEditorElementTree => { if (element.data.blockId) { return { ...element, @@ -50,7 +50,7 @@ export const EventActionHandlerDecorator = createDecorator( } else { return { ...element, - elements: element.elements.map((child: PbElement) => + elements: element.elements.map(child => cleanUpReferenceBlocks(child) ) }; diff --git a/packages/app-page-builder/src/pageEditor/config/eventActions/saveRevision/saveRevisionAction.ts b/packages/app-page-builder/src/pageEditor/config/eventActions/saveRevision/saveRevisionAction.ts index e7852e627a2..8f334d119b9 100644 --- a/packages/app-page-builder/src/pageEditor/config/eventActions/saveRevision/saveRevisionAction.ts +++ b/packages/app-page-builder/src/pageEditor/config/eventActions/saveRevision/saveRevisionAction.ts @@ -8,7 +8,11 @@ import { PageAtomType } from "~/pageEditor/state"; import { PageEventActionCallable } from "~/pageEditor/types"; import { PbElement } from "~/types"; -interface PageRevisionType extends Pick { +interface PageRevisionType + extends Pick< + PageAtomType, + "title" | "snippet" | "path" | "settings" | "dataBindings" | "dataSources" + > { category: string; content: any; } @@ -73,6 +77,7 @@ export const saveRevisionAction: PageEventActionCallable { + console.log("saveRevisionAction", state); if (state.page.locked) { return { actions: [] @@ -93,7 +98,9 @@ export const saveRevisionAction: PageEventActionCallable > = (state, _, args) => { + console.log("updatePageAction", state); return { state: { page: { diff --git a/packages/app-page-builder/src/pageEditor/graphql.ts b/packages/app-page-builder/src/pageEditor/graphql.ts index e353bc711e3..94b17795ff8 100644 --- a/packages/app-page-builder/src/pageEditor/graphql.ts +++ b/packages/app-page-builder/src/pageEditor/graphql.ts @@ -35,6 +35,16 @@ const DATA_FIELD = ` id } content + dataSources { + name + type + config + } + dataBindings { + dataSource + bindFrom + bindTo + } savedOn } `; diff --git a/packages/app-page-builder/src/pageEditor/state/page/pageAtom.ts b/packages/app-page-builder/src/pageEditor/state/page/pageAtom.ts index cf7c5e3353c..4c937aee9bd 100644 --- a/packages/app-page-builder/src/pageEditor/state/page/pageAtom.ts +++ b/packages/app-page-builder/src/pageEditor/state/page/pageAtom.ts @@ -1,5 +1,5 @@ import { atom } from "recoil"; -import { PbEditorElement } from "~/types"; +import { DynamicDocument, PbEditorElementTree } from "~/types"; interface PageCategoryType { slug: string; @@ -8,10 +8,10 @@ interface PageCategoryType { } export interface PageWithContent extends PageAtomType { - content: PbEditorElement; + content: PbEditorElementTree; } -export interface PageAtomType { +export interface PageAtomType extends DynamicDocument { id: string; title?: string; pid?: string; @@ -43,6 +43,8 @@ export const pageAtom = atom({ version: 1, published: false, snippet: null, + dataSources: [], + dataBindings: [], createdBy: { id: null }, diff --git a/packages/app-page-builder/src/render/PageBuilder.tsx b/packages/app-page-builder/src/render/PageBuilder.tsx index 63b9158b822..cf48c626744 100644 --- a/packages/app-page-builder/src/render/PageBuilder.tsx +++ b/packages/app-page-builder/src/render/PageBuilder.tsx @@ -5,11 +5,13 @@ import { InjectElementVariables } from "~/render/variables/InjectElementVariable import { LexicalParagraphRenderer } from "~/render/plugins/elements/paragraph/LexicalParagraph"; import { LexicalHeadingRenderer } from "~/render/plugins/elements/heading/LexicalHeading"; import { ConvertIconSettings } from "~/render/plugins/elementSettings/icon"; +import { AddImageLinkComponent } from "~/elementDecorators/AddImageLinkComponent"; export const PageBuilder = React.memo(() => { return ( <> + diff --git a/packages/app-page-builder/src/render/plugins/elements/grid/index.tsx b/packages/app-page-builder/src/render/plugins/elements/grid/index.tsx index ee310550b75..f5229b28427 100644 --- a/packages/app-page-builder/src/render/plugins/elements/grid/index.tsx +++ b/packages/app-page-builder/src/render/plugins/elements/grid/index.tsx @@ -1,11 +1,11 @@ import { PbRenderElementPlugin } from "~/types"; -import { createGrid } from "@webiny/app-page-builder-elements/renderers/grid"; +import { GridRenderer } from "@webiny/app-page-builder-elements/renderers/grid"; export default (): PbRenderElementPlugin => { return { type: "pb-render-page-element", name: "pb-render-page-element-grid", elementType: "grid", - render: createGrid() + render: GridRenderer }; }; diff --git a/packages/app-page-builder/src/render/plugins/elements/image/index.tsx b/packages/app-page-builder/src/render/plugins/elements/image/index.tsx index 2905e94dff3..5f07234a4bd 100644 --- a/packages/app-page-builder/src/render/plugins/elements/image/index.tsx +++ b/packages/app-page-builder/src/render/plugins/elements/image/index.tsx @@ -1,9 +1,6 @@ -import React from "react"; import kebabCase from "lodash/kebabCase"; import { PbRenderElementPluginArgs, PbRenderElementPlugin } from "~/types"; -import { createImage } from "@webiny/app-page-builder-elements/renderers/image"; - -import { Link } from "@webiny/react-router"; +import { ImageRenderer } from "@webiny/app-page-builder-elements/renderers/image"; export default (args: PbRenderElementPluginArgs = {}): PbRenderElementPlugin => { const elementType = kebabCase(args.elementType || "image"); @@ -12,14 +9,6 @@ export default (args: PbRenderElementPluginArgs = {}): PbRenderElementPlugin => name: `pb-render-page-element-${elementType}`, type: "pb-render-page-element", elementType: elementType, - render: createImage({ - linkComponent: ({ href, children, ...rest }) => { - return ( - - {children} - - ); - } - }) + render: ImageRenderer }; }; diff --git a/packages/app-page-builder/src/render/variables/InjectElementVariables.tsx b/packages/app-page-builder/src/render/variables/InjectElementVariables.tsx index 5517e7fe5e6..91a18090eef 100644 --- a/packages/app-page-builder/src/render/variables/InjectElementVariables.tsx +++ b/packages/app-page-builder/src/render/variables/InjectElementVariables.tsx @@ -8,6 +8,7 @@ export const InjectElementVariables = () => { return ( <> + {/* TODO: */} diff --git a/packages/app-page-builder/src/templateEditor/Editor.tsx b/packages/app-page-builder/src/templateEditor/Editor.tsx index f109ceba84c..a8be415e3de 100644 --- a/packages/app-page-builder/src/templateEditor/Editor.tsx +++ b/packages/app-page-builder/src/templateEditor/Editor.tsx @@ -1,127 +1,31 @@ -import React, { useMemo, useState } from "react"; -import { useApolloClient } from "@apollo/react-hooks"; +import React from "react"; import { plugins } from "@webiny/plugins"; import { useRouter } from "@webiny/react-router"; -import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; -import get from "lodash/get"; import { Editor as PbEditor } from "~/admin/components/Editor"; -import { createElement } from "~/editor/helpers"; -import { - GET_PAGE_TEMPLATE, - GetPageTemplateQueryResponse, - GetPageTemplateQueryVariables -} from "./graphql"; + import { EditorLoadingScreen } from "~/admin/components/EditorLoadingScreen"; -import { - LIST_PAGE_ELEMENTS, - ListPageElementsQueryResponse, - ListPageElementsQueryResponseData -} from "~/admin/graphql/pages"; -import { ListPageBlocksQueryResponse } from "~/admin/views/PageBlocks/graphql"; -import { LIST_BLOCK_CATEGORIES } from "~/admin/views/BlockCategories/graphql"; -import createElementPlugin from "~/admin/utils/createElementPlugin"; -import { PbErrorResponse, PbBlockCategory, PbPageTemplate } from "~/types"; -import createBlockCategoryPlugin from "~/admin/utils/createBlockCategoryPlugin"; -import { PageTemplateWithContent } from "~/templateEditor/state"; import { createStateInitializer } from "./createStateInitializer"; import { DefaultEditorConfig } from "~/editor/defaultConfig/DefaultEditorConfig"; import { DefaultTemplateEditorConfig } from "./config/DefaultTemplateEditorConfig"; import elementVariableRendererPlugins from "~/blockEditor/plugins/elementVariables"; -import { usePageBlocks } from "~/admin/contexts/AdminPageBuilder/PageBlocks/usePageBlocks"; +import { usePrepareEditor } from "~/templateEditor/usePrepareEditor"; export const TemplateEditor = () => { plugins.register(elementVariableRendererPlugins()); - const client = useApolloClient(); - const { history, params } = useRouter(); - const { showSnackbar } = useSnackbar(); - const { listBlocks } = usePageBlocks(); - const [template, setTemplate] = useState(); + const { params } = useRouter(); const templateId = decodeURIComponent(params["id"]); - - const LoadData = useMemo(() => { - const savedElements = client - .query({ query: LIST_PAGE_ELEMENTS }) - .then(({ data }) => { - const elements: ListPageElementsQueryResponseData[] = - get(data, "pageBuilder.listPageElements.data") || []; - elements.forEach(element => { - if (element.type === "element") { - createElementPlugin({ - ...element, - data: {}, - elements: [] - }); - } - }); - }); - - const savedBLocks = listBlocks(); - - const blockCategories = client - .query({ query: LIST_BLOCK_CATEGORIES }) - .then(({ data }) => { - const blockCategoriesData: PbBlockCategory[] = - get(data, "pageBuilder.listBlockCategories.data") || []; - blockCategoriesData.forEach(element => { - createBlockCategoryPlugin({ - ...element - }); - }); - }); - - const templateData = client - .query({ - query: GET_PAGE_TEMPLATE, - variables: { - id: templateId - }, - fetchPolicy: "network-only" - }) - .then(({ data }) => { - const errorData = get( - data, - "pageBuilder.getPageTemplate.error" - ) as unknown as PbErrorResponse; - const error = errorData?.message; - - if (error) { - history.push(`/page-builder/page-templates`); - showSnackbar(error); - return; - } - - const pageTemplateData = get( - data, - "pageBuilder.getPageTemplate.data" - ) as unknown as PbPageTemplate; - - const { content, ...restOfTemplateData } = pageTemplateData; - - setTemplate({ - ...restOfTemplateData, - content: content || createElement("document") - }); - }); - - return React.lazy(() => - Promise.all([savedElements, savedBLocks, blockCategories, templateData]).then(() => { - return { default: ({ children }: { children: React.ReactElement }) => children }; - }) - ); - }, [templateId]); + const template = usePrepareEditor(templateId); return ( - }> + <> - - - - + {template ? ( + + ) : ( + + )} + ); }; diff --git a/packages/app-page-builder/src/templateEditor/config/Content/BlocksBrowser/AddBlock.tsx b/packages/app-page-builder/src/templateEditor/config/Content/BlocksBrowser/AddBlock.tsx index 97fd0b5c672..c9d88667515 100644 --- a/packages/app-page-builder/src/templateEditor/config/Content/BlocksBrowser/AddBlock.tsx +++ b/packages/app-page-builder/src/templateEditor/config/Content/BlocksBrowser/AddBlock.tsx @@ -7,7 +7,7 @@ import { useBlocksBrowser } from "./useBlocksBrowser"; const SIDEBAR_WIDTH = 300; const BottomRight = styled("div")({ position: "fixed", - zIndex: 25, + zIndex: 101, bottom: 20, right: 20 + SIDEBAR_WIDTH }); diff --git a/packages/app-page-builder/src/templateEditor/config/DefaultTemplateEditorConfig.tsx b/packages/app-page-builder/src/templateEditor/config/DefaultTemplateEditorConfig.tsx index ce34f0788d8..754877bbd28 100644 --- a/packages/app-page-builder/src/templateEditor/config/DefaultTemplateEditorConfig.tsx +++ b/packages/app-page-builder/src/templateEditor/config/DefaultTemplateEditorConfig.tsx @@ -13,6 +13,7 @@ import { RefreshBlockAction } from "./Sidebar/RefreshBlockAction"; import { EditBlockAction } from "./Sidebar/EditBlockAction"; import { HideSaveAction } from "./Sidebar/HideSaveAction"; import { EditorConfig } from "~/editor/config"; +import { SetupDynamicDocument } from "./SetupDynamicDocument"; const { Ui, ElementAction } = EditorConfig; @@ -21,6 +22,7 @@ export const DefaultTemplateEditorConfig = React.memo(() => { <> + } /> } /> diff --git a/packages/app-page-builder/src/templateEditor/config/SetupDynamicDocument.tsx b/packages/app-page-builder/src/templateEditor/config/SetupDynamicDocument.tsx new file mode 100644 index 00000000000..07bee0adfa6 --- /dev/null +++ b/packages/app-page-builder/src/templateEditor/config/SetupDynamicDocument.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { useTemplate, TemplateEditorConfig } from "~/templateEditor"; +import { DynamicDocumentProvider } from "~/dataInjection"; +import { PbDataBinding, PbDataSource } from "~/types"; + +const { Ui } = TemplateEditorConfig; + +export const SetupDynamicDocument = Ui.Layout.createDecorator(Original => { + return function TemplateToDynamicDocument() { + const [template, updateTemplate] = useTemplate(); + + const onDataSources = (dataSources: PbDataSource[]) => { + updateTemplate(template => ({ ...template, dataSources })); + }; + + const onDataBindings = (dataBindings: PbDataBinding[]) => { + updateTemplate(template => ({ ...template, dataBindings })); + }; + + return ( + + + + ); + }; +}); diff --git a/packages/app-page-builder/src/templateEditor/config/TopBar/SaveTemplateButton/SaveTemplateButton.tsx b/packages/app-page-builder/src/templateEditor/config/TopBar/SaveTemplateButton/SaveTemplateButton.tsx index 282e9d838d8..51795babfac 100644 --- a/packages/app-page-builder/src/templateEditor/config/TopBar/SaveTemplateButton/SaveTemplateButton.tsx +++ b/packages/app-page-builder/src/templateEditor/config/TopBar/SaveTemplateButton/SaveTemplateButton.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useState } from "react"; import styled from "@emotion/styled"; import { useSnackbar } from "@webiny/app-admin/hooks/useSnackbar"; -import { useRouter } from "@webiny/react-router"; import { ButtonIcon, ButtonPrimary } from "@webiny/ui/Button"; import { CircularProgress } from "@webiny/ui/Progress"; import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; @@ -17,7 +16,6 @@ const SpinnerWrapper = styled.div` export const SaveTemplateButton = () => { const [template] = useTemplate(); const eventActionHandler = useEventActionHandler(); - const { history } = useRouter(); const { showSnackbar } = useSnackbar(); const [loading, setLoading] = useState(false); @@ -28,7 +26,6 @@ export const SaveTemplateButton = () => { debounce: false, onFinish: () => { setLoading(false); - history.push(`/page-builder/page-templates`); showSnackbar(`Template "${template.title}" saved successfully!`); } }) diff --git a/packages/app-page-builder/src/templateEditor/config/TopBar/TemplateSettingsButton/TemplateSettingsModal.tsx b/packages/app-page-builder/src/templateEditor/config/TopBar/TemplateSettingsButton/TemplateSettingsModal.tsx index 0d4fcd43249..c9245e96451 100644 --- a/packages/app-page-builder/src/templateEditor/config/TopBar/TemplateSettingsButton/TemplateSettingsModal.tsx +++ b/packages/app-page-builder/src/templateEditor/config/TopBar/TemplateSettingsButton/TemplateSettingsModal.tsx @@ -1,15 +1,12 @@ import React, { useCallback } from "react"; import slugify from "slugify"; import { css } from "emotion"; -import get from "lodash/get"; import pick from "lodash/pick"; -import { useQuery } from "@apollo/react-hooks"; import { Form, FormAPI } from "@webiny/form"; import { plugins } from "@webiny/plugins"; import { ButtonPrimary } from "@webiny/ui/Button"; import { Grid, Cell } from "@webiny/ui/Grid"; import { Select } from "@webiny/ui/Select"; -import { CircularProgress } from "@webiny/ui/Progress"; import { SimpleFormContent } from "@webiny/app-admin/components/SimpleForm"; import { validation } from "@webiny/validation"; import { Dialog, DialogCancel, DialogTitle, DialogActions, DialogContent } from "@webiny/ui/Dialog"; @@ -17,11 +14,9 @@ import { Dialog, DialogCancel, DialogTitle, DialogActions, DialogContent } from import { useTemplate } from "~/templateEditor/hooks/useTemplate"; import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; import { UpdateDocumentActionEvent } from "~/editor/recoil/actions"; -import { PageTemplate } from "~/templateEditor/state"; import { Input } from "@webiny/ui/Input"; -import { PbCategory, PbPageLayoutPlugin } from "~/types"; +import { PbPageLayoutPlugin, PbPageTemplate } from "~/types"; import { Tags } from "@webiny/ui/Tags"; -import { LIST_CATEGORIES } from "~/admin/views/Categories/graphql"; const narrowDialog = css` & .mdc-dialog__surface { @@ -44,14 +39,7 @@ const TemplateSettingsModal = (props: TemplateSettingsModalProps) => { return (layoutPlugins || []).map(pl => pl.layout); }, []); - const pageCategoriesQuery = useQuery(LIST_CATEGORIES); - const pageCategories: PbCategory[] = get( - pageCategoriesQuery, - "data.pageBuilder.listCategories.data", - [] - ); - - const updateTemplate = (data: Partial) => { + const updateTemplate = (data: Partial) => { handler.trigger( new UpdateDocumentActionEvent({ history: false, @@ -77,21 +65,12 @@ const TemplateSettingsModal = (props: TemplateSettingsModalProps) => { ); }; - const onSubmit = useCallback((formData: Partial) => { + const onSubmit = useCallback((formData: Partial) => { updateTemplate(formData); props.onClose(); }, []); - const settings = pick(template, [ - "title", - "description", - "slug", - "layout", - "tags", - "pageCategory" - ]); - - const loading = [pageCategoriesQuery].some(item => item.loading); + const settings = pick(template, ["title", "description", "slug", "layout", "tags"]); return ( @@ -101,75 +80,53 @@ const TemplateSettingsModal = (props: TemplateSettingsModalProps) => { Template Settings - {loading ? ( - - ) : ( - - - - - - - - - - - - - - - - - - + + + + + + + + - - - - - - - - - - - - - - - )} + /> + + + + + + + + + + + + + + + + + + diff --git a/packages/app-page-builder/src/templateEditor/config/TopBar/Title/Title.tsx b/packages/app-page-builder/src/templateEditor/config/TopBar/Title/Title.tsx index 58e215e87e2..6f984bbb176 100644 --- a/packages/app-page-builder/src/templateEditor/config/TopBar/Title/Title.tsx +++ b/packages/app-page-builder/src/templateEditor/config/TopBar/Title/Title.tsx @@ -4,9 +4,9 @@ import { Input } from "@webiny/ui/Input"; import { Tooltip } from "@webiny/ui/Tooltip"; import { TemplateTitle, templateTitleWrapper, TitleInputWrapper, TitleWrapper } from "./Styled"; import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; -import { PageTemplate } from "~/templateEditor/state"; import { UpdateDocumentActionEvent } from "~/editor/recoil/actions"; import { useTemplate } from "~/templateEditor/hooks/useTemplate"; +import { PbPageTemplate } from "~/types"; declare global { interface Window { @@ -16,19 +16,19 @@ declare global { export const Title = () => { const handler = useEventActionHandler(); - const [template] = useTemplate(); + const [pageTemplate] = useTemplate(); const { showSnackbar } = useSnackbar(); const [editTitle, setEdit] = useState(false); const [stateTitle, setTitle] = useState(null); - let title = stateTitle === null ? template.title : stateTitle; + let title = stateTitle === null ? pageTemplate.title : stateTitle; useEffect(() => { - if (template.title && template.title !== stateTitle) { - setTitle(template.title); + if (pageTemplate.title && pageTemplate.title !== stateTitle) { + setTitle(pageTemplate.title); } - }, [template.title]); + }, [pageTemplate.title]); - const updateTemplate = (data: Partial) => { + const updateTemplate = (data: Partial) => { handler.trigger( new UpdateDocumentActionEvent({ history: false, @@ -58,7 +58,7 @@ export const Title = () => { case "Escape": e.preventDefault(); setEdit(false); - setTitle(template.title || ""); + setTitle(pageTemplate.title || ""); break; case "Enter": if (title === "") { @@ -75,7 +75,7 @@ export const Title = () => { return; } }, - [title, template.title] + [title, pageTemplate.title] ); // Disable autoFocus because for some reason, blur event would automatically be triggered when clicking diff --git a/packages/app-page-builder/src/templateEditor/config/eventActions/EventActionHandlerDecorator.tsx b/packages/app-page-builder/src/templateEditor/config/eventActions/EventActionHandlerDecorator.tsx index b650bc52f46..253e9b0bf2f 100644 --- a/packages/app-page-builder/src/templateEditor/config/eventActions/EventActionHandlerDecorator.tsx +++ b/packages/app-page-builder/src/templateEditor/config/eventActions/EventActionHandlerDecorator.tsx @@ -6,9 +6,8 @@ import { GetCallableState } from "~/editor/contexts/EventActionHandlerProvider"; import { TemplateEditorEventActionCallableState } from "~/templateEditor/types"; -import { PageTemplate } from "~/templateEditor/state"; import { useTemplate } from "~/templateEditor/hooks/useTemplate"; -import { PbElement, PbEditorElement } from "~/types"; +import { PbPageTemplate, PbEditorElementTree } from "~/types"; type ProviderProps = EventActionHandlerProviderProps; @@ -16,7 +15,7 @@ export const EventActionHandlerDecorator = createDecorator( EventActionHandlerProvider as unknown as DecoratableComponent>, Component => { return function PbEventActionHandlerProvider(props) { - const templateAtomValueRef = useRef(); + const templateAtomValueRef = useRef(); const [templateAtomValue, setTemplateAtomValue] = useTemplate(); useEffect(() => { @@ -29,11 +28,11 @@ export const EventActionHandlerDecorator = createDecorator( next => { return async props => { const element = props?.element; - const res = (await next({ element })) as PbElement; + const res = await next({ element }); const cleanUpReferenceBlocks = ( - element: PbElement - ): PbEditorElement => { + element: PbEditorElementTree + ): PbEditorElementTree => { if (element.data.blockId) { return { ...element, @@ -42,7 +41,7 @@ export const EventActionHandlerDecorator = createDecorator( } else { return { ...element, - elements: element.elements.map((child: PbElement) => + elements: element.elements.map(child => cleanUpReferenceBlocks(child) ) }; @@ -77,7 +76,7 @@ export const EventActionHandlerDecorator = createDecorator( const callableState = next(state); return { - template: templateAtomValueRef.current as PageTemplate, + template: templateAtomValueRef.current as PbPageTemplate, ...callableState }; }; diff --git a/packages/app-page-builder/src/templateEditor/config/eventActions/EventActionHandlers.tsx b/packages/app-page-builder/src/templateEditor/config/eventActions/EventActionHandlers.tsx index b8c744f632d..ab11d7573c2 100644 --- a/packages/app-page-builder/src/templateEditor/config/eventActions/EventActionHandlers.tsx +++ b/packages/app-page-builder/src/templateEditor/config/eventActions/EventActionHandlers.tsx @@ -1,21 +1,24 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { plugins } from "@webiny/plugins"; import { Prompt } from "@webiny/react-router"; import { useEventActionHandler } from "~/editor/hooks/useEventActionHandler"; -import { saveTemplateAction, SaveTemplateActionEvent } from "./saveTemplate"; +import { createSaveAction, SaveTemplateActionEvent } from "./saveTemplate"; import { UpdateDocumentActionEvent } from "~/editor/recoil/actions"; import { TemplateEditorEventActionCallableState } from "~/templateEditor/types"; import { createCloneElementPlugin } from "./cloneElement/plugin"; +import { useUpdatePageTemplate } from "~/features"; export const EventActionHandlers = () => { plugins.register(createCloneElementPlugin()); const eventActionHandler = useEventActionHandler(); const [isDirty, setDirty] = useState(false); + const { updatePageTemplate } = useUpdatePageTemplate(); + const saveTemplate = useMemo(() => createSaveAction(updatePageTemplate), []); useEffect(() => { const offSaveTemplateAction = eventActionHandler.on(SaveTemplateActionEvent, (...args) => { setDirty(false); - return saveTemplateAction(...args); + return saveTemplate(...args); }); const offUpdateTemplateAction = eventActionHandler.on( diff --git a/packages/app-page-builder/src/templateEditor/config/eventActions/saveTemplate/saveTemplateAction.ts b/packages/app-page-builder/src/templateEditor/config/eventActions/saveTemplate/saveTemplateAction.ts index de4479b5238..dba6af98127 100644 --- a/packages/app-page-builder/src/templateEditor/config/eventActions/saveTemplate/saveTemplateAction.ts +++ b/packages/app-page-builder/src/templateEditor/config/eventActions/saveTemplate/saveTemplateAction.ts @@ -2,9 +2,8 @@ import lodashDebounce from "lodash/debounce"; import { plugins } from "@webiny/plugins"; import { SaveTemplateActionArgsType } from "./types"; import { TemplateEventActionCallable } from "~/templateEditor/types"; -import { PageTemplateWithContent } from "~/templateEditor/state"; -import { UPDATE_PAGE_TEMPLATE } from "~/admin/views/PageTemplates/graphql"; import { PbElement, PbBlockVariable, PbBlockEditorCreateVariablePlugin } from "~/types"; +import { useUpdatePageTemplate } from "~/features"; export const findElementByVariableId = (elements: PbElement[], variableId: string): any => { for (const element of elements) { @@ -73,61 +72,57 @@ const triggerOnFinish = (args?: SaveTemplateActionArgsType): void => { // Setting to `any` as this is not at all important. let debouncedSave: any = null; -export const saveTemplateAction: TemplateEventActionCallable = async ( - state, - meta, - args = {} -) => { - const content = (await state.getElementTree()) as PbElement; +type UpdatePageTemplate = ReturnType["updatePageTemplate"]; - const elements = content.elements.map((element: PbElement) => { - if (element.type === "block") { - return syncTemplateBlockVariables(element); - } - return element; - }); - - const data: Omit = { - title: state.template.title, - slug: state.template.slug, - tags: state.template.tags || [], - description: state.template?.description || "", - layout: state.template?.layout || "", - pageCategory: state.template?.pageCategory || "", - content: syncTemplateVariables({ ...content, elements }) - }; - - if (debouncedSave) { - debouncedSave.cancel(); - } +export const createSaveAction = ( + updatePageTemplate: UpdatePageTemplate +): TemplateEventActionCallable => { + return async (state, meta, args = {}) => { + const content = (await state.getElementTree()) as PbElement; - const runSave = async () => { - await meta.client.mutate({ - mutation: UPDATE_PAGE_TEMPLATE, - variables: { - id: state.template.id, - data + const elements = content.elements.map((element: PbElement) => { + if (element.type === "block") { + return syncTemplateBlockVariables(element); } + return element; }); - await new Promise(resolve => { - setTimeout(resolve, 500); - }); + if (debouncedSave) { + debouncedSave.cancel(); + } - triggerOnFinish(args); - }; + const runSave = async () => { + await updatePageTemplate({ + id: state.template.id, + title: state.template.title || "", + slug: state.template.slug || "", + tags: state.template.tags || [], + description: state.template?.description || "", + layout: state.template?.layout || "", + content: syncTemplateVariables({ ...content, elements }), + dataSources: state.template.dataSources || [], + dataBindings: state.template.dataBindings || [] + }); - if (args && args.debounce === false) { - runSave(); - return { - actions: [] + await new Promise(resolve => { + setTimeout(resolve, 500); + }); + + triggerOnFinish(args); }; - } - debouncedSave = lodashDebounce(runSave, 2000); - debouncedSave(); + if (args && args.debounce === false) { + runSave(); + return { + actions: [] + }; + } - return { - actions: [] + debouncedSave = lodashDebounce(runSave, 2000); + debouncedSave(); + + return { + actions: [] + }; }; }; diff --git a/packages/app-page-builder/src/templateEditor/createStateInitializer.ts b/packages/app-page-builder/src/templateEditor/createStateInitializer.ts index 23b7e03e7a3..a45198808b3 100644 --- a/packages/app-page-builder/src/templateEditor/createStateInitializer.ts +++ b/packages/app-page-builder/src/templateEditor/createStateInitializer.ts @@ -1,9 +1,10 @@ import omit from "lodash/omit"; -import { templateAtom, PageTemplate, PageTemplateWithContent } from "~/templateEditor/state"; +import { templateAtom } from "~/templateEditor/state"; import { EditorStateInitializerFactory } from "~/editor/Editor"; +import { PbPageTemplate, PbPageTemplateWithContent } from "~/types"; export const createStateInitializer = ( - template: PageTemplateWithContent + template: PbPageTemplateWithContent ): EditorStateInitializerFactory => { return () => ({ content: template.content, @@ -11,7 +12,7 @@ export const createStateInitializer = ( /** * We always unset the content because we are not using it via the template atom. */ - const templateData: PageTemplate = omit(template, ["content"]); + const templateData: PbPageTemplate = omit(template, ["content"]); set(templateAtom, templateData); } diff --git a/packages/app-page-builder/src/templateEditor/graphql.ts b/packages/app-page-builder/src/templateEditor/graphql.ts deleted file mode 100644 index b771c54c856..00000000000 --- a/packages/app-page-builder/src/templateEditor/graphql.ts +++ /dev/null @@ -1,56 +0,0 @@ -import gql from "graphql-tag"; -import { PbErrorResponse, PbPageTemplate } from "~/types"; - -const ERROR_FIELD = /* GraphQL */ ` - { - code - data - message - } -`; - -const DATA_FIELD = ` - { - id - title - slug - tags - layout - pageCategory - description - content - createdOn - savedOn - createdBy { - id - displayName - type - } - } -`; - -/** - * ##################### - * Get Page Template Query Response - */ -export interface GetPageTemplateQueryResponse { - pageBuilder: { - getPageTemplate: { - data: PbPageTemplate | null; - error: PbErrorResponse | null; - }; - }; -} -export interface GetPageTemplateQueryVariables { - id: string; -} -export const GET_PAGE_TEMPLATE = gql` - query PbGetPageTemplate($id: ID!) { - pageBuilder { - getPageTemplate(id: $id) { - data ${DATA_FIELD} - error ${ERROR_FIELD} - } - } - } -`; diff --git a/packages/app-page-builder/src/templateEditor/hooks/index.ts b/packages/app-page-builder/src/templateEditor/hooks/index.ts index 4e1010ee078..da3ccdea848 100644 --- a/packages/app-page-builder/src/templateEditor/hooks/index.ts +++ b/packages/app-page-builder/src/templateEditor/hooks/index.ts @@ -1 +1,2 @@ export { useTemplate } from "./useTemplate"; +export { useDocumentDataSource } from "./useDocumentDataSource"; diff --git a/packages/app-page-builder/src/templateEditor/hooks/useDocumentDataSource.ts b/packages/app-page-builder/src/templateEditor/hooks/useDocumentDataSource.ts new file mode 100644 index 00000000000..7864aa895b2 --- /dev/null +++ b/packages/app-page-builder/src/templateEditor/hooks/useDocumentDataSource.ts @@ -0,0 +1,75 @@ +import { useCallback, useEffect } from "react"; +import { useTemplate } from "./useTemplate"; +import { PbDataSource } from "~/types"; + +export interface DataSourceUpdater { + (config: PbDataSource["config"]): PbDataSource["config"]; +} + +export const useDocumentDataSource = () => { + const [template, updateTemplate] = useTemplate(); + const dataSources = template.dataSources; + const key = JSON.stringify(dataSources); + + useEffect(() => { + console.log({ dataSources: template.dataSources, dataBindings: template.dataBindings }); + }, [JSON.stringify(template)]); + + const getDataSource = useCallback( + (name: string) => { + return dataSources.find(ds => ds.name === name); + }, + [key] + ); + + const createDataSource = useCallback( + (dataSource: PbDataSource) => { + updateTemplate(template => { + return { + ...template, + dataSources: [...template.dataSources, dataSource] + }; + }); + }, + [key] + ); + + const updateDataSource = useCallback( + (name: string, updater: DataSourceUpdater) => { + const dataSource = dataSources.find(ds => ds.name === name); + if (!dataSource) { + return; + } + + const updatedConfig = updater(dataSource.config); + + updateTemplate(template => { + const dsIndex = template.dataSources.findIndex(ds => ds.name === name); + + return { + ...template, + dataSources: [ + ...template.dataSources.slice(0, dsIndex), + { ...template.dataSources[dsIndex], config: updatedConfig }, + ...template.dataSources.slice(dsIndex + 1) + ] + }; + }); + }, + [key] + ); + + const deleteDataSource = useCallback( + (name: string) => { + updateTemplate(template => { + return { + ...template, + dataSources: template.dataSources.filter(ds => ds.name !== name) + }; + }); + }, + [key] + ); + + return { getDataSource, createDataSource, updateDataSource, deleteDataSource }; +}; diff --git a/packages/app-page-builder/src/templateEditor/prepareEditor/useBlockCategories.ts b/packages/app-page-builder/src/templateEditor/prepareEditor/useBlockCategories.ts new file mode 100644 index 00000000000..f2791ec1a83 --- /dev/null +++ b/packages/app-page-builder/src/templateEditor/prepareEditor/useBlockCategories.ts @@ -0,0 +1,24 @@ +import { + LIST_BLOCK_CATEGORIES, + ListPageBlocksQueryResponse +} from "~/admin/views/PageBlocks/graphql"; +import { PbBlockCategory } from "~/types"; +import createBlockCategoryPlugin from "~/admin/utils/createBlockCategoryPlugin"; +import { useQuery } from "@apollo/react-hooks"; +import get from "lodash/get"; + +export const useBlockCategories = () => { + const blockCategories = useQuery(LIST_BLOCK_CATEGORIES, { + onCompleted(data) { + const blockCategoriesData: PbBlockCategory[] = + get(data, "pageBuilder.listBlockCategories.data") || []; + blockCategoriesData.forEach(element => { + createBlockCategoryPlugin({ + ...element + }); + }); + } + }); + + return blockCategories.loading; +}; diff --git a/packages/app-page-builder/src/templateEditor/prepareEditor/useSavedElements.ts b/packages/app-page-builder/src/templateEditor/prepareEditor/useSavedElements.ts new file mode 100644 index 00000000000..ecc7c541100 --- /dev/null +++ b/packages/app-page-builder/src/templateEditor/prepareEditor/useSavedElements.ts @@ -0,0 +1,29 @@ +import { useQuery } from "@apollo/react-hooks"; +import get from "lodash/get"; +import { + LIST_PAGE_ELEMENTS, + ListPageElementsQueryResponse, + ListPageElementsQueryResponseData +} from "~/admin/graphql/pages"; +import createElementPlugin from "~/admin/utils/createElementPlugin"; + +export const useSavedElements = () => { + const savedElements = useQuery(LIST_PAGE_ELEMENTS, { + onCompleted: data => { + const elements: ListPageElementsQueryResponseData[] = + get(data, "pageBuilder.listPageElements.data") || []; + + elements.forEach(element => { + if (element.type === "element") { + createElementPlugin({ + ...element, + data: {}, + elements: [] + }); + } + }); + } + }); + + return savedElements.loading; +}; diff --git a/packages/app-page-builder/src/templateEditor/state/templateAtom.ts b/packages/app-page-builder/src/templateEditor/state/templateAtom.ts index fde6869a192..fdd0b811446 100644 --- a/packages/app-page-builder/src/templateEditor/state/templateAtom.ts +++ b/packages/app-page-builder/src/templateEditor/state/templateAtom.ts @@ -1,30 +1,6 @@ import { atom } from "recoil"; -import { PbEditorElement } from "~/types"; +import { PbPageTemplate } from "~/types"; -export interface PageTemplateWithContent extends PageTemplate { - content: PbEditorElement; -} - -export interface PageTemplate { - id: string; - title?: string; - slug?: string; - tags?: string[]; - description?: string; - layout?: string; - pageCategory?: string; - savedOn?: string; - createdBy: { - id: string | null; - }; -} - -export const templateAtom = atom({ - key: "templateAtom", - default: { - id: "", - createdBy: { - id: null - } - } +export const templateAtom = atom({ + key: "templateAtom" }); diff --git a/packages/app-page-builder/src/templateEditor/types.ts b/packages/app-page-builder/src/templateEditor/types.ts index d093cc547ee..be6c94099be 100644 --- a/packages/app-page-builder/src/templateEditor/types.ts +++ b/packages/app-page-builder/src/templateEditor/types.ts @@ -1,8 +1,7 @@ -import { EventActionCallable, EventActionHandlerCallableArgs } from "~/types"; -import { PageTemplate } from "~/templateEditor/state"; +import { EventActionCallable, EventActionHandlerCallableArgs, PbPageTemplate } from "~/types"; export interface TemplateEditorEventActionCallableState { - template: PageTemplate; + template: PbPageTemplate; } export type TemplateEventActionCallable = diff --git a/packages/app-page-builder/src/templateEditor/usePrepareEditor.ts b/packages/app-page-builder/src/templateEditor/usePrepareEditor.ts new file mode 100644 index 00000000000..58b8233ce4a --- /dev/null +++ b/packages/app-page-builder/src/templateEditor/usePrepareEditor.ts @@ -0,0 +1,47 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "@webiny/react-router"; +import { useSnackbar } from "@webiny/app-admin"; +import { PbPageTemplateWithContent } from "~/types"; +import { createElement } from "~/editor/helpers"; +import { usePageBlocks } from "~/admin/contexts/AdminPageBuilder/PageBlocks/usePageBlocks"; +import { useListPageTemplates } from "~/features"; +import { useSavedElements } from "~/templateEditor/prepareEditor/useSavedElements"; +import { useBlockCategories } from "~/templateEditor/prepareEditor/useBlockCategories"; + +export const usePrepareEditor = (templateId: string) => { + const { history } = useRouter(); + const { showSnackbar } = useSnackbar(); + const pageBlocks = usePageBlocks(); + const templates = useListPageTemplates(); + const [template, setTemplate] = useState(undefined); + + const savedElementsLoading = useSavedElements(); + const blockCategoriesLoading = useBlockCategories(); + + useEffect(() => { + pageBlocks.listBlocks(); + }, []); + + useEffect(() => { + if (!templates.loading && templates.pageTemplates) { + const template = templates.pageTemplates.find(tpl => tpl.id === templateId); + if (!template) { + history.push(`/page-builder/page-templates`); + // TODO: replace with error snackbar after update from `next` + showSnackbar("Template not found!"); + return; + } + + const { content, ...restOfTemplateData } = template; + + setTemplate({ + ...restOfTemplateData, + content: content || createElement("document") + }); + } + }, [templates.loading]); + + const loaders = [pageBlocks.loading, savedElementsLoading, blockCategoriesLoading, !template]; + + return loaders.includes(true) ? undefined : template; +}; diff --git a/packages/app-page-builder/src/translations/ExtractTranslatableValues/SaveTranslatableValues.ts b/packages/app-page-builder/src/translations/ExtractTranslatableValues/SaveTranslatableValues.ts index 99be9a25b77..66d9b340a43 100644 --- a/packages/app-page-builder/src/translations/ExtractTranslatableValues/SaveTranslatableValues.ts +++ b/packages/app-page-builder/src/translations/ExtractTranslatableValues/SaveTranslatableValues.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; import debounce from "lodash/debounce"; -import { PbEditorElement } from "~/types"; +import { PbEditorElementTree } from "~/types"; import { useEventActionHandler } from "~/editor"; import { PageEditorEventActionCallableState } from "~/pageEditor/types"; import { useSaveTranslatableCollection } from "~/translations"; @@ -10,14 +10,9 @@ import { useTranslations } from "~/translations/ExtractTranslatableValues/TranslationContext"; -const extractElementIds = (elements: PbEditorElement[]): string[] => { +const extractElementIds = (elements: PbEditorElementTree[]): string[] => { return [ - ...elements - .map(element => [ - element.id, - ...extractElementIds(element.elements as PbEditorElement[]) - ]) - .flat() + ...elements.map(element => [element.id, ...extractElementIds(element.elements)]).flat() ]; }; diff --git a/packages/app-page-builder/src/types.ts b/packages/app-page-builder/src/types.ts index 983a94fd731..8d25240008b 100644 --- a/packages/app-page-builder/src/types.ts +++ b/packages/app-page-builder/src/types.ts @@ -2,7 +2,7 @@ import React, { ComponentType, ReactElement, ReactNode } from "react"; import { DragObjectWithTypeWithTarget } from "./editor/components/Droppable"; import { BaseEventAction, EventAction } from "./editor/recoil/eventActions"; import { PbState } from "./editor/recoil/modules/types"; -import { Plugin } from "@webiny/app/types"; +import { GenericRecord, Plugin } from "@webiny/app/types"; import { BindComponent } from "@webiny/form"; import { IconName, IconPrefix } from "@fortawesome/fontawesome-svg-core"; import { Icon } from "@webiny/app-admin/components/IconPicker/types"; @@ -245,20 +245,17 @@ export interface PbEditorElement { data: PbElementDataType; parent?: string; elements: (string | PbEditorElement)[]; - content?: PbEditorElement; path?: string[]; source?: string; [key: string]: any; } -export interface PbElement { +export interface PbElement { id: string; type: string; - data: PbElementDataType; - elements: PbElement[]; - content?: PbElement; - text?: string; + data: PbElementDataType & TData; + elements: PbElement[]; } export interface PbBlockVariable { @@ -280,7 +277,7 @@ export type PbEditorPageElementVariableRendererPlugin = Plugin & { elementType: string; getVariableValue: (element: PbEditorElement | null) => any; renderVariableInput: (variableId: string) => ReactNode; - setElementValue: (element: PbElement, variables: PbBlockVariable[]) => PbElement; + setElementValue: (element: PbEditorElementTree, variables: PbBlockVariable[]) => PbElement; }; /** @@ -367,7 +364,7 @@ export interface PbPageDataSettings { export type PbPageDataStatus = string | "draft" | "published" | "unpublished"; -export interface PbPageData { +export interface PbPageData extends DynamicDocument { id: string; pid: string; path: string; @@ -596,7 +593,7 @@ export type PbEditorPageElementPlugin = Plugin & { export enum OnCreateActions { OPEN_SETTINGS = "open-settings", SKIP = "skip", - SKIP_ELEMENT_HEIGHT = "skipElementHighlight" + SKIP_ELEMENT_HIGHLIGHT = "skipElementHighlight" } export type PbPageDetailsPlugin = Plugin & { @@ -796,11 +793,15 @@ export type GetElementTreeProps = { path?: string[]; } | void; +export type PbEditorElementTree = Omit & { + elements: PbEditorElementTree[]; +}; + // ============== EVENT ACTION HANDLER ================= // // TODO: at some point, convert this into an interface, and use module augmentation to add new properties. export type EventActionHandlerCallableState = PbState & { getElementById(id: string): Promise; - getElementTree(props: GetElementTreeProps): Promise; + getElementTree(props: GetElementTreeProps): Promise; }; export interface EventActionHandler { @@ -822,11 +823,11 @@ export interface EventActionHandler { /** * Get element tree (includes processing with decorators). */ - getElementTree: (props: GetElementTreeProps) => Promise; + getElementTree: (props: GetElementTreeProps) => Promise; /** * Get raw element tree (DOES NOT include processing with decorators). */ - getRawElementTree: (props: GetElementTreeProps) => Promise; + getRawElementTree: (props: GetElementTreeProps) => Promise; } export interface EventActionHandlerTarget { @@ -906,7 +907,7 @@ export interface PbBlockCategory { createdBy: PbIdentity; } -export interface PbPageBlock { +export interface PbPageBlock extends DynamicDocument { id: string; name: string; blockCategory: string; @@ -915,18 +916,43 @@ export interface PbPageBlock { createdBy: PbIdentity; } -export interface PbPageTemplate { +export interface DynamicDocument { + dataSources: PbDataSource[]; + dataBindings: PbDataBinding[]; +} + +export interface PbDataSource { + name: string; + type: string; + config: GenericRecord; +} + +export type PbDataBinding = + | { + dataSource: string; + bindFrom: string; + bindTo: string; + } + | { + dataSource: "static"; + bindFrom: any; + bindTo: string; + }; + +export interface PbPageTemplate extends DynamicDocument { id: string; slug: string; title: string; description: string; layout: string; - content: any; createdOn: string; savedOn: string; createdBy: PbIdentity; + tags: string[]; } +export type PbPageTemplateWithContent = PbPageTemplate & { content: PbElement }; + /** * TODO: have types for both API and app in the same package? * GraphQL response types diff --git a/packages/app-page-builder/tsconfig.build.json b/packages/app-page-builder/tsconfig.build.json index a3d551b08a9..66948f8b460 100644 --- a/packages/app-page-builder/tsconfig.build.json +++ b/packages/app-page-builder/tsconfig.build.json @@ -12,6 +12,7 @@ { "path": "../app-tenancy/tsconfig.build.json" }, { "path": "../app-theme/tsconfig.build.json" }, { "path": "../error/tsconfig.build.json" }, + { "path": "../feature-flags/tsconfig.build.json" }, { "path": "../form/tsconfig.build.json" }, { "path": "../lexical-editor/tsconfig.build.json" }, { "path": "../plugins/tsconfig.build.json" }, diff --git a/packages/app-page-builder/tsconfig.json b/packages/app-page-builder/tsconfig.json index 79e9fd61603..074ce6e2c4e 100644 --- a/packages/app-page-builder/tsconfig.json +++ b/packages/app-page-builder/tsconfig.json @@ -12,6 +12,7 @@ { "path": "../app-tenancy" }, { "path": "../app-theme" }, { "path": "../error" }, + { "path": "../feature-flags" }, { "path": "../form" }, { "path": "../lexical-editor" }, { "path": "../plugins" }, @@ -49,6 +50,8 @@ "@webiny/app-theme": ["../app-theme/src"], "@webiny/error/*": ["../error/src/*"], "@webiny/error": ["../error/src"], + "@webiny/feature-flags/*": ["../feature-flags/src/*"], + "@webiny/feature-flags": ["../feature-flags/src"], "@webiny/form/*": ["../form/src/*"], "@webiny/form": ["../form/src"], "@webiny/lexical-editor/*": ["../lexical-editor/src/*"], diff --git a/packages/app-serverless-cms/package.json b/packages/app-serverless-cms/package.json index 52833f745cc..570d83d3c75 100644 --- a/packages/app-serverless-cms/package.json +++ b/packages/app-serverless-cms/package.json @@ -16,6 +16,7 @@ "@webiny/app-admin-rmwc": "0.0.0", "@webiny/app-apw": "0.0.0", "@webiny/app-audit-logs": "0.0.0", + "@webiny/app-dynamic-pages": "0.0.0", "@webiny/app-file-manager": "0.0.0", "@webiny/app-file-manager-s3": "0.0.0", "@webiny/app-form-builder": "0.0.0", diff --git a/packages/app-serverless-cms/src/Admin.tsx b/packages/app-serverless-cms/src/Admin.tsx index 103c0b66434..c092173b7ca 100644 --- a/packages/app-serverless-cms/src/Admin.tsx +++ b/packages/app-serverless-cms/src/Admin.tsx @@ -32,6 +32,7 @@ import { Folders } from "@webiny/app-aco"; import { Websockets } from "@webiny/app-websockets"; import { RecordLocking } from "@webiny/app-record-locking"; import { TrashBinConfigs } from "@webiny/app-trash-bin"; +import { DynamicPages } from "@webiny/app-dynamic-pages/admin"; export interface AdminProps extends Omit { createApolloClient?: BaseAdminProps["createApolloClient"]; @@ -69,6 +70,7 @@ const App = (props: AdminProps) => { + {props.children} ); diff --git a/packages/app-serverless-cms/tsconfig.build.json b/packages/app-serverless-cms/tsconfig.build.json index 804755f3d06..421e090f48e 100644 --- a/packages/app-serverless-cms/tsconfig.build.json +++ b/packages/app-serverless-cms/tsconfig.build.json @@ -8,6 +8,7 @@ { "path": "../app-admin-rmwc/tsconfig.build.json" }, { "path": "../app-apw/tsconfig.build.json" }, { "path": "../app-audit-logs/tsconfig.build.json" }, + { "path": "../app-dynamic-pages/tsconfig.build.json" }, { "path": "../app-file-manager/tsconfig.build.json" }, { "path": "../app-file-manager-s3/tsconfig.build.json" }, { "path": "../app-form-builder/tsconfig.build.json" }, diff --git a/packages/app-serverless-cms/tsconfig.json b/packages/app-serverless-cms/tsconfig.json index ac59a189557..3838a1c5e85 100644 --- a/packages/app-serverless-cms/tsconfig.json +++ b/packages/app-serverless-cms/tsconfig.json @@ -8,6 +8,7 @@ { "path": "../app-admin-rmwc" }, { "path": "../app-apw" }, { "path": "../app-audit-logs" }, + { "path": "../app-dynamic-pages" }, { "path": "../app-file-manager" }, { "path": "../app-file-manager-s3" }, { "path": "../app-form-builder" }, @@ -47,6 +48,8 @@ "@webiny/app-apw": ["../app-apw/src"], "@webiny/app-audit-logs/*": ["../app-audit-logs/src/*"], "@webiny/app-audit-logs": ["../app-audit-logs/src"], + "@webiny/app-dynamic-pages/*": ["../app-dynamic-pages/src/*"], + "@webiny/app-dynamic-pages": ["../app-dynamic-pages/src"], "@webiny/app-file-manager/*": ["../app-file-manager/src/*"], "@webiny/app-file-manager": ["../app-file-manager/src"], "@webiny/app-file-manager-s3/*": ["../app-file-manager-s3/src/*"], diff --git a/packages/cwp-template-aws/template/ddb-es/dependencies.json b/packages/cwp-template-aws/template/ddb-es/dependencies.json index 3212f72e424..c88c4f39967 100644 --- a/packages/cwp-template-aws/template/ddb-es/dependencies.json +++ b/packages/cwp-template-aws/template/ddb-es/dependencies.json @@ -73,6 +73,6 @@ "react-dom": "18.2.0" }, "engines": { - "node": "^22.0.0" + "node": "^20.0.0" } } diff --git a/packages/cwp-template-aws/template/ddb-os/dependencies.json b/packages/cwp-template-aws/template/ddb-os/dependencies.json index 3212f72e424..c88c4f39967 100644 --- a/packages/cwp-template-aws/template/ddb-os/dependencies.json +++ b/packages/cwp-template-aws/template/ddb-os/dependencies.json @@ -73,6 +73,6 @@ "react-dom": "18.2.0" }, "engines": { - "node": "^22.0.0" + "node": "^20.0.0" } } diff --git a/packages/cwp-template-aws/template/ddb/dependencies.json b/packages/cwp-template-aws/template/ddb/dependencies.json index d49eed073f3..ee0e76a5aac 100644 --- a/packages/cwp-template-aws/template/ddb/dependencies.json +++ b/packages/cwp-template-aws/template/ddb/dependencies.json @@ -74,6 +74,6 @@ }, "engines": { - "node": "^22.0.0" + "node": "^20.0.0" } } diff --git a/packages/feature-flags/src/index.ts b/packages/feature-flags/src/index.ts index ec9609c9076..0139bbfcc7f 100644 --- a/packages/feature-flags/src/index.ts +++ b/packages/feature-flags/src/index.ts @@ -2,6 +2,7 @@ export type FeatureFlags> = { experimentalAdminOmniSearch?: boolean; allowCmsLegacyRichTextInput?: boolean; allowCmsFullScreenEditor?: boolean; + experimentalDynamicPages?: boolean; newWatchCommand?: boolean; } & TFeatureFlags; diff --git a/packages/handler-graphql/src/createGraphQLSchema.ts b/packages/handler-graphql/src/createGraphQLSchema.ts index 5bb8045bfef..f3f54f400ac 100644 --- a/packages/handler-graphql/src/createGraphQLSchema.ts +++ b/packages/handler-graphql/src/createGraphQLSchema.ts @@ -2,7 +2,7 @@ import gql from "graphql-tag"; import { makeExecutableSchema } from "@graphql-tools/schema"; import { mergeResolvers } from "@graphql-tools/merge"; import { GraphQLScalarType } from "graphql/type/definition"; -import { GraphQLScalarPlugin, GraphQLSchemaPlugin, Resolvers, TypeDefs } from "./types"; +import { GraphQLScalarPlugin, Resolvers, TypeDefs } from "./types"; import { Context } from "@webiny/api/types"; import { RefInputScalar, @@ -15,9 +15,15 @@ import { LongScalar } from "./builtInTypes"; import { ResolverDecoration } from "./ResolverDecoration"; +import { GraphQLSchemaPlugin } from "~/plugins"; export const getSchemaPlugins = (context: Context) => { - return context.plugins.byType("graphql-schema"); + return context.plugins.byType("graphql-schema").filter(pl => { + if (typeof pl.isApplicable === "function") { + return pl.isApplicable(context); + } + return true; + }); }; export const createGraphQLSchema = (context: Context) => { diff --git a/packages/project-utils/package.json b/packages/project-utils/package.json index f4ffab146c1..9006528a7ea 100644 --- a/packages/project-utils/package.json +++ b/packages/project-utils/package.json @@ -81,7 +81,7 @@ "url-loader": "4.1.1", "vm-browserify": "^1.1.2", "webpack": "^5.97.0", - "webpack-dev-server": "^5.1.0", + "webpack-dev-server": "^4.15.2", "webpack-manifest-plugin": "^5.0.0", "webpackbar": "^7.0.0" }, diff --git a/packages/react-router/src/Link.tsx b/packages/react-router/src/Link.tsx index 4a81aedb1d6..8c293a0cc4d 100644 --- a/packages/react-router/src/Link.tsx +++ b/packages/react-router/src/Link.tsx @@ -4,7 +4,7 @@ import { makeDecoratable } from "@webiny/react-composition"; export type LinkProps = RouterLinkProps; -const Link = makeDecoratable("Link", ({ children, ...props }: LinkProps) => { +export const Link = makeDecoratable("Link", ({ children, ...props }: LinkProps) => { let { to } = props; if (typeof to === "string" && to.startsWith(window.location.origin)) { @@ -20,5 +20,3 @@ const Link = makeDecoratable("Link", ({ children, ...props }: LinkProps) => { return {children}; }); - -export { Link }; diff --git a/webiny.project.ts b/webiny.project.ts index aa97cb7913c..4863494874e 100644 --- a/webiny.project.ts +++ b/webiny.project.ts @@ -57,6 +57,7 @@ export default { featureFlags: { experimentalAdminOmniSearch: true, newWatchCommand: true, - allowCmsFullScreenEditor: true + allowCmsFullScreenEditor: false, + experimentalDynamicPages: false } }; diff --git a/yarn.lock b/yarn.lock index 8d475f07fb3..0271a18dab0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5981,38 +5981,6 @@ __metadata: languageName: node linkType: hard -"@jsonjoy.com/base64@npm:^1.1.1": - version: 1.1.2 - resolution: "@jsonjoy.com/base64@npm:1.1.2" - peerDependencies: - tslib: 2 - checksum: 10/d76bb58eff841c090d9bf69a073611ffa73c40a664ccbcea689f65961f57d7b24051269d06b437e4f6204285d6ba92f50f587c5e95c5f9e4f10b36a2ed4cd0c8 - languageName: node - linkType: hard - -"@jsonjoy.com/json-pack@npm:^1.0.3": - version: 1.1.0 - resolution: "@jsonjoy.com/json-pack@npm:1.1.0" - dependencies: - "@jsonjoy.com/base64": "npm:^1.1.1" - "@jsonjoy.com/util": "npm:^1.1.2" - hyperdyperid: "npm:^1.2.0" - thingies: "npm:^1.20.0" - peerDependencies: - tslib: 2 - checksum: 10/cd2776085ad56b470cd53137880b87c2503b07781756c50f1e9f40dd909abeba130a6144d203fcf605ec03dee4cd19bb3424169c8cb588f90a3f06939994c64e - languageName: node - linkType: hard - -"@jsonjoy.com/util@npm:^1.1.2, @jsonjoy.com/util@npm:^1.3.0": - version: 1.5.0 - resolution: "@jsonjoy.com/util@npm:1.5.0" - peerDependencies: - tslib: 2 - checksum: 10/5b370183700cb40af52841294ba99c3dfb3dcb7fe2a122e15c737eb908d11392d314b75518874c7d631092bb29658ebe298d174b05baeb1adeb33884b9aa33cf - languageName: node - linkType: hard - "@ladjs/country-language@npm:^0.2.1": version: 0.2.1 resolution: "@ladjs/country-language@npm:0.2.1" @@ -10285,7 +10253,7 @@ __metadata: languageName: node linkType: hard -"@types/bonjour@npm:^3.5.13": +"@types/bonjour@npm:^3.5.9": version: 3.5.13 resolution: "@types/bonjour@npm:3.5.13" dependencies: @@ -10327,7 +10295,7 @@ __metadata: languageName: node linkType: hard -"@types/connect-history-api-fallback@npm:^1.5.4": +"@types/connect-history-api-fallback@npm:^1.3.5": version: 1.5.4 resolution: "@types/connect-history-api-fallback@npm:1.5.4" dependencies: @@ -10406,7 +10374,7 @@ __metadata: languageName: node linkType: hard -"@types/express@npm:*, @types/express@npm:^4.17.21": +"@types/express@npm:*, @types/express@npm:^4.17.13": version: 4.17.21 resolution: "@types/express@npm:4.17.21" dependencies: @@ -11027,13 +10995,6 @@ __metadata: languageName: node linkType: hard -"@types/retry@npm:0.12.2": - version: 0.12.2 - resolution: "@types/retry@npm:0.12.2" - checksum: 10/e5675035717b39ce4f42f339657cae9637cf0c0051cf54314a6a2c44d38d91f6544be9ddc0280587789b6afd056be5d99dbe3e9f4df68c286c36321579b1bf4a - languageName: node - linkType: hard - "@types/rimraf@npm:^3.0.2": version: 3.0.2 resolution: "@types/rimraf@npm:3.0.2" @@ -11077,7 +11038,7 @@ __metadata: languageName: node linkType: hard -"@types/serve-index@npm:^1.9.4": +"@types/serve-index@npm:^1.9.1": version: 1.9.4 resolution: "@types/serve-index@npm:1.9.4" dependencies: @@ -11097,7 +11058,7 @@ __metadata: languageName: node linkType: hard -"@types/serve-static@npm:^1.15.5": +"@types/serve-static@npm:^1.13.10": version: 1.15.7 resolution: "@types/serve-static@npm:1.15.7" dependencies: @@ -11154,7 +11115,7 @@ __metadata: languageName: node linkType: hard -"@types/sockjs@npm:^0.3.36": +"@types/sockjs@npm:^0.3.33": version: 0.3.36 resolution: "@types/sockjs@npm:0.3.36" dependencies: @@ -11237,7 +11198,7 @@ __metadata: languageName: node linkType: hard -"@types/ws@npm:^8.5.10": +"@types/ws@npm:^8.5.5": version: 8.5.13 resolution: "@types/ws@npm:8.5.13" dependencies: @@ -12928,6 +12889,7 @@ __metadata: "@webiny/aws-sdk": "npm:0.0.0" "@webiny/cli": "npm:0.0.0" "@webiny/error": "npm:0.0.0" + "@webiny/feature-flags": "npm:0.0.0" "@webiny/handler": "npm:0.0.0" "@webiny/handler-aws": "npm:0.0.0" "@webiny/handler-db": "npm:0.0.0" @@ -12946,6 +12908,7 @@ __metadata: load-json-file: "npm:^6.2.0" lodash: "npm:^4.17.21" node-fetch: "npm:2.6.7" + prettier: "npm:^2.8.8" rimraf: "npm:^6.0.1" stream: "npm:^0.0.3" ttypescript: "npm:^1.5.15" @@ -13745,6 +13708,40 @@ __metadata: languageName: unknown linkType: soft +"@webiny/app-dynamic-pages@npm:0.0.0, @webiny/app-dynamic-pages@workspace:packages/app-dynamic-pages": + version: 0.0.0-use.local + resolution: "@webiny/app-dynamic-pages@workspace:packages/app-dynamic-pages" + dependencies: + "@emotion/babel-plugin": "npm:^11.11.0" + "@emotion/react": "npm:^11.10.6" + "@emotion/styled": "npm:^11.10.6" + "@fortawesome/fontawesome-svg-core": "npm:^1.3.0" + "@fortawesome/react-fontawesome": "npm:^0.1.17" + "@material-design-icons/svg": "npm:^0.14.3" + "@types/react": "npm:18.2.79" + "@webiny/app": "npm:0.0.0" + "@webiny/app-admin": "npm:0.0.0" + "@webiny/app-headless-cms": "npm:0.0.0" + "@webiny/app-page-builder": "npm:0.0.0" + "@webiny/app-page-builder-elements": "npm:0.0.0" + "@webiny/cli": "npm:0.0.0" + "@webiny/plugins": "npm:0.0.0" + "@webiny/project-utils": "npm:0.0.0" + "@webiny/react-router": "npm:0.0.0" + "@webiny/ui": "npm:0.0.0" + apollo-client: "npm:^2.6.10" + emotion: "npm:10.0.27" + graphql: "npm:^15.7.2" + mobx: "npm:^6.9.0" + react: "npm:18.2.0" + react-dom: "npm:18.2.0" + rimraf: "npm:^5.0.5" + slugify: "npm:^1.6.6" + ttypescript: "npm:^1.5.12" + typescript: "npm:4.9.5" + languageName: unknown + linkType: soft + "@webiny/app-file-manager-s3@npm:0.0.0, @webiny/app-file-manager-s3@workspace:packages/app-file-manager-s3": version: 0.0.0-use.local resolution: "@webiny/app-file-manager-s3@workspace:packages/app-file-manager-s3" @@ -14181,6 +14178,7 @@ __metadata: "@webiny/app-theme": "npm:0.0.0" "@webiny/cli": "npm:0.0.0" "@webiny/error": "npm:0.0.0" + "@webiny/feature-flags": "npm:0.0.0" "@webiny/form": "npm:0.0.0" "@webiny/lexical-editor": "npm:0.0.0" "@webiny/plugins": "npm:0.0.0" @@ -14344,6 +14342,7 @@ __metadata: "@webiny/app-admin-rmwc": "npm:0.0.0" "@webiny/app-apw": "npm:0.0.0" "@webiny/app-audit-logs": "npm:0.0.0" + "@webiny/app-dynamic-pages": "npm:0.0.0" "@webiny/app-file-manager": "npm:0.0.0" "@webiny/app-file-manager-s3": "npm:0.0.0" "@webiny/app-form-builder": "npm:0.0.0" @@ -15647,7 +15646,7 @@ __metadata: url-loader: "npm:4.1.1" vm-browserify: "npm:^1.1.2" webpack: "npm:^5.97.0" - webpack-dev-server: "npm:^5.1.0" + webpack-dev-server: "npm:^4.15.2" webpack-manifest-plugin: "npm:^5.0.0" webpackbar: "npm:^7.0.0" yargs: "npm:^17.7.2" @@ -18079,7 +18078,7 @@ __metadata: languageName: node linkType: hard -"bonjour-service@npm:^1.2.1": +"bonjour-service@npm:^1.0.11": version: 1.3.0 resolution: "bonjour-service@npm:1.3.0" dependencies: @@ -18444,15 +18443,6 @@ __metadata: languageName: node linkType: hard -"bundle-name@npm:^4.1.0": - version: 4.1.0 - resolution: "bundle-name@npm:4.1.0" - dependencies: - run-applescript: "npm:^7.0.0" - checksum: 10/1d966c8d2dbf4d9d394e53b724ac756c2414c45c01340b37743621f59cc565a435024b394ddcb62b9b335d1c9a31f4640eb648c3fec7f97ee74dc0694c9beb6c - languageName: node - linkType: hard - "byte-size@npm:8.1.1": version: 8.1.1 resolution: "byte-size@npm:8.1.1" @@ -21021,20 +21011,12 @@ __metadata: languageName: node linkType: hard -"default-browser-id@npm:^5.0.0": - version: 5.0.0 - resolution: "default-browser-id@npm:5.0.0" - checksum: 10/185bfaecec2c75fa423544af722a3469b20704c8d1942794a86e4364fe7d9e8e9f63241a5b769d61c8151993bc65833a5b959026fa1ccea343b3db0a33aa6deb - languageName: node - linkType: hard - -"default-browser@npm:^5.2.1": - version: 5.2.1 - resolution: "default-browser@npm:5.2.1" +"default-gateway@npm:^6.0.3": + version: 6.0.3 + resolution: "default-gateway@npm:6.0.3" dependencies: - bundle-name: "npm:^4.1.0" - default-browser-id: "npm:^5.0.0" - checksum: 10/afab7eff7b7f5f7a94d9114d1ec67273d3fbc539edf8c0f80019879d53aa71e867303c6f6d7cffeb10a6f3cfb59d4f963dba3f9c96830b4540cc7339a1bf9840 + execa: "npm:^5.0.0" + checksum: 10/126f8273ecac8ee9ff91ea778e8784f6cd732d77c3157e8c5bdd6ed03651b5291f71446d05bc02d04073b1e67583604db5394ea3cf992ede0088c70ea15b7378 languageName: node linkType: hard @@ -21093,13 +21075,6 @@ __metadata: languageName: node linkType: hard -"define-lazy-prop@npm:^3.0.0": - version: 3.0.0 - resolution: "define-lazy-prop@npm:3.0.0" - checksum: 10/f28421cf9ee86eecaf5f3b8fe875f13d7009c2625e97645bfff7a2a49aca678270b86c39f9c32939e5ca7ab96b551377ed4139558c795e076774287ad3af1aa4 - languageName: node - linkType: hard - "define-properties@npm:^1.1.3, define-properties@npm:^1.1.4": version: 1.1.4 resolution: "define-properties@npm:1.1.4" @@ -24285,6 +24260,22 @@ __metadata: languageName: node linkType: hard +"glob@npm:^10.3.7": + version: 10.4.5 + resolution: "glob@npm:10.4.5" + dependencies: + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^3.1.2" + minimatch: "npm:^9.0.4" + minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" + path-scurry: "npm:^1.11.1" + bin: + glob: dist/esm/bin.mjs + checksum: 10/698dfe11828b7efd0514cd11e573eaed26b2dff611f0400907281ce3eab0c1e56143ef9b35adc7c77ecc71fba74717b510c7c223d34ca8a98ec81777b293d4ac + languageName: node + linkType: hard + "glob@npm:^11.0.0": version: 11.0.0 resolution: "glob@npm:11.0.0" @@ -24544,7 +24535,7 @@ __metadata: languageName: node linkType: hard -"graphql@npm:^15.9.0": +"graphql@npm:^15.7.2, graphql@npm:^15.9.0": version: 15.9.0 resolution: "graphql@npm:15.9.0" checksum: 10/ce1f50672bcb369395d07a47048bcbb429ed1ce06dbcafb7a0999df791cb7aa7206be21497907973dbc8a01df3cd7f632f43c583f248538f186f5adfa1a0d1c5 @@ -24925,7 +24916,7 @@ __metadata: languageName: node linkType: hard -"html-entities@npm:^2.4.0": +"html-entities@npm:^2.3.2": version: 2.5.2 resolution: "html-entities@npm:2.5.2" checksum: 10/4ec12ebdf2d5ba8192c68e1aef3c1e4a4f36b29246a0a88464fe278a54517d0196d3489af46a3145c7ecacb4fc5fd50497be19eb713b810acab3f0efcf36fdc2 @@ -25277,13 +25268,6 @@ __metadata: languageName: node linkType: hard -"hyperdyperid@npm:^1.2.0": - version: 1.2.0 - resolution: "hyperdyperid@npm:1.2.0" - checksum: 10/64abb5568ff17aa08ac0175ae55e46e22831c5552be98acdd1692081db0209f36fff58b31432017b4e1772c178962676a2cc3c54e4d5d7f020d7710cec7ad7a6 - languageName: node - linkType: hard - "hyperform@npm:^0.11.0": version: 0.11.0 resolution: "hyperform@npm:0.11.0" @@ -25712,7 +25696,7 @@ __metadata: languageName: node linkType: hard -"ipaddr.js@npm:^2.1.0": +"ipaddr.js@npm:^2.0.1": version: 2.2.0 resolution: "ipaddr.js@npm:2.2.0" checksum: 10/9e1cdd9110b3bca5d910ab70d7fb1933e9c485d9b92cb14ef39f30c412ba3fe02a553921bf696efc7149cc653453c48ccf173adb996ec27d925f1f340f872986 @@ -25870,15 +25854,6 @@ __metadata: languageName: node linkType: hard -"is-docker@npm:^3.0.0": - version: 3.0.0 - resolution: "is-docker@npm:3.0.0" - bin: - is-docker: cli.js - checksum: 10/b698118f04feb7eaf3338922bd79cba064ea54a1c3db6ec8c0c8d8ee7613e7e5854d802d3ef646812a8a3ace81182a085dfa0a71cc68b06f3fa794b9783b3c90 - languageName: node - linkType: hard - "is-extglob@npm:^2.1.1": version: 2.1.1 resolution: "is-extglob@npm:2.1.1" @@ -25964,17 +25939,6 @@ __metadata: languageName: node linkType: hard -"is-inside-container@npm:^1.0.0": - version: 1.0.0 - resolution: "is-inside-container@npm:1.0.0" - dependencies: - is-docker: "npm:^3.0.0" - bin: - is-inside-container: cli.js - checksum: 10/c50b75a2ab66ab3e8b92b3bc534e1ea72ca25766832c0623ac22d134116a98bcf012197d1caabe1d1c4bd5f84363d4aa5c36bb4b585fbcaf57be172cd10a1a03 - languageName: node - linkType: hard - "is-installed-globally@npm:~0.4.0": version: 0.4.0 resolution: "is-installed-globally@npm:0.4.0" @@ -26030,13 +25994,6 @@ __metadata: languageName: node linkType: hard -"is-network-error@npm:^1.0.0": - version: 1.1.0 - resolution: "is-network-error@npm:1.1.0" - checksum: 10/b2fe6aac07f814a9de275efd05934c832c129e7ba292d27614e9e8eec9e043b7a0bbeaeca5d0916b0f462edbec2aa2eaee974ee0a12ac095040e9515c222c251 - languageName: node - linkType: hard - "is-number-object@npm:^1.0.4": version: 1.0.7 resolution: "is-number-object@npm:1.0.7" @@ -26300,15 +26257,6 @@ __metadata: languageName: node linkType: hard -"is-wsl@npm:^3.1.0": - version: 3.1.0 - resolution: "is-wsl@npm:3.1.0" - dependencies: - is-inside-container: "npm:^1.0.0" - checksum: 10/f9734c81f2f9cf9877c5db8356bfe1ff61680f1f4c1011e91278a9c0564b395ae796addb4bf33956871041476ec82c3e5260ed57b22ac91794d4ae70a1d2f0a9 - languageName: node - linkType: hard - "isarray@npm:0.0.1": version: 0.0.1 resolution: "isarray@npm:0.0.1" @@ -26463,6 +26411,19 @@ __metadata: languageName: node linkType: hard +"jackspeak@npm:^3.1.2": + version: 3.4.3 + resolution: "jackspeak@npm:3.4.3" + dependencies: + "@isaacs/cliui": "npm:^8.0.2" + "@pkgjs/parseargs": "npm:^0.11.0" + dependenciesMeta: + "@pkgjs/parseargs": + optional: true + checksum: 10/96f8786eaab98e4bf5b2a5d6d9588ea46c4d06bbc4f2eb861fdd7b6b182b16f71d8a70e79820f335d52653b16d4843b29dd9cdcf38ae80406756db9199497cf3 + languageName: node + linkType: hard + "jackspeak@npm:^4.0.1": version: 4.0.2 resolution: "jackspeak@npm:4.0.2" @@ -27607,7 +27568,7 @@ __metadata: languageName: node linkType: hard -"launch-editor@npm:^2.6.1": +"launch-editor@npm:^2.6.0": version: 2.9.1 resolution: "launch-editor@npm:2.9.1" dependencies: @@ -28882,7 +28843,7 @@ __metadata: languageName: node linkType: hard -"memfs@npm:^3.4.1": +"memfs@npm:^3.4.1, memfs@npm:^3.4.3": version: 3.5.3 resolution: "memfs@npm:3.5.3" dependencies: @@ -28891,18 +28852,6 @@ __metadata: languageName: node linkType: hard -"memfs@npm:^4.6.0": - version: 4.14.1 - resolution: "memfs@npm:4.14.1" - dependencies: - "@jsonjoy.com/json-pack": "npm:^1.0.3" - "@jsonjoy.com/util": "npm:^1.3.0" - tree-dump: "npm:^1.0.1" - tslib: "npm:^2.0.0" - checksum: 10/a1e392608aacb359f170cad9e15ae5eabcd6228bed99f4d3cf1331ecd07a8dc9c72039632c164255ebf36251e01c1878173303eee94e2020f8c0b9731a37a564 - languageName: node - linkType: hard - "meow@npm:^8.0.0, meow@npm:^8.1.2": version: 8.1.2 resolution: "meow@npm:8.1.2" @@ -30473,7 +30422,7 @@ __metadata: languageName: node linkType: hard -"on-finished@npm:2.4.1, on-finished@npm:^2.4.1": +"on-finished@npm:2.4.1": version: 2.4.1 resolution: "on-finished@npm:2.4.1" dependencies: @@ -30525,18 +30474,6 @@ __metadata: languageName: node linkType: hard -"open@npm:^10.0.3": - version: 10.1.0 - resolution: "open@npm:10.1.0" - dependencies: - default-browser: "npm:^5.2.1" - define-lazy-prop: "npm:^3.0.0" - is-inside-container: "npm:^1.0.0" - is-wsl: "npm:^3.1.0" - checksum: 10/a9c4105243a1b3c5312bf2aeb678f78d31f00618b5100088ee01eed2769963ea1f2dd464ac8d93cef51bba2d911e1a9c0c34a753ec7b91d6b22795903ea6647a - languageName: node - linkType: hard - "open@npm:^7.3.1": version: 7.4.2 resolution: "open@npm:7.4.2" @@ -30547,25 +30484,25 @@ __metadata: languageName: node linkType: hard -"open@npm:^8.4.0": - version: 8.4.0 - resolution: "open@npm:8.4.0" +"open@npm:^8.0.9, open@npm:^8.4.2": + version: 8.4.2 + resolution: "open@npm:8.4.2" dependencies: define-lazy-prop: "npm:^2.0.0" is-docker: "npm:^2.1.1" is-wsl: "npm:^2.2.0" - checksum: 10/ccb8760068b48e277868423cdf21f4f4e5682ec86dbc3a5cf1c34ef0e8b49721ad98b3f001b4eb2cbd7df7921f84551ec5b9fecace3b3eced3e46dca1c785f03 + checksum: 10/acd81a1d19879c818acb3af2d2e8e9d81d17b5367561e623248133deb7dd3aefaed527531df2677d3e6aaf0199f84df57b6b2262babff8bf46ea0029aac536c9 languageName: node linkType: hard -"open@npm:^8.4.2": - version: 8.4.2 - resolution: "open@npm:8.4.2" +"open@npm:^8.4.0": + version: 8.4.0 + resolution: "open@npm:8.4.0" dependencies: define-lazy-prop: "npm:^2.0.0" is-docker: "npm:^2.1.1" is-wsl: "npm:^2.2.0" - checksum: 10/acd81a1d19879c818acb3af2d2e8e9d81d17b5367561e623248133deb7dd3aefaed527531df2677d3e6aaf0199f84df57b6b2262babff8bf46ea0029aac536c9 + checksum: 10/ccb8760068b48e277868423cdf21f4f4e5682ec86dbc3a5cf1c34ef0e8b49721ad98b3f001b4eb2cbd7df7921f84551ec5b9fecace3b3eced3e46dca1c785f03 languageName: node linkType: hard @@ -30834,7 +30771,7 @@ __metadata: languageName: node linkType: hard -"p-retry@npm:^4.6.2": +"p-retry@npm:^4.5.0, p-retry@npm:^4.6.2": version: 4.6.2 resolution: "p-retry@npm:4.6.2" dependencies: @@ -30844,17 +30781,6 @@ __metadata: languageName: node linkType: hard -"p-retry@npm:^6.2.0": - version: 6.2.1 - resolution: "p-retry@npm:6.2.1" - dependencies: - "@types/retry": "npm:0.12.2" - is-network-error: "npm:^1.0.0" - retry: "npm:^0.13.1" - checksum: 10/7104ef13703b155d70883b0d3654ecc03148407d2711a4516739cf93139e8bec383451e14925e25e3c1ae04dbace3ed53c26dc3853c1e9b9867fcbdde25f4cdc - languageName: node - linkType: hard - "p-timeout@npm:^3.2.0": version: 3.2.0 resolution: "p-timeout@npm:3.2.0" @@ -31253,6 +31179,16 @@ __metadata: languageName: node linkType: hard +"path-scurry@npm:^1.11.1": + version: 1.11.1 + resolution: "path-scurry@npm:1.11.1" + dependencies: + lru-cache: "npm:^10.2.0" + minipass: "npm:^5.0.0 || ^6.0.2 || ^7.0.0" + checksum: 10/5e8845c159261adda6f09814d7725683257fcc85a18f329880ab4d7cc1d12830967eae5d5894e453f341710d5484b8fdbbd4d75181b4d6e1eb2f4dc7aeadc434 + languageName: node + linkType: hard + "path-scurry@npm:^2.0.0": version: 2.0.0 resolution: "path-scurry@npm:2.0.0" @@ -34718,6 +34654,17 @@ __metadata: languageName: node linkType: hard +"rimraf@npm:^5.0.5": + version: 5.0.10 + resolution: "rimraf@npm:5.0.10" + dependencies: + glob: "npm:^10.3.7" + bin: + rimraf: dist/esm/bin.mjs + checksum: 10/f3b8ce81eecbde4628b07bdf9e2fa8b684e0caea4999acb1e3b0402c695cd41f28cd075609a808e61ce2672f528ca079f675ab1d8e8d5f86d56643a03e0b8d2e + languageName: node + linkType: hard + "rimraf@npm:^6.0.1": version: 6.0.1 resolution: "rimraf@npm:6.0.1" @@ -34874,13 +34821,6 @@ __metadata: languageName: node linkType: hard -"run-applescript@npm:^7.0.0": - version: 7.0.0 - resolution: "run-applescript@npm:7.0.0" - checksum: 10/b02462454d8b182ad4117e5d4626e9e6782eb2072925c9fac582170b0627ae3c1ea92ee9b2df7daf84b5e9ffe14eb1cf5fb70bc44b15c8a0bfcdb47987e2410c - languageName: node - linkType: hard - "run-async@npm:^2.2.0, run-async@npm:^2.4.0": version: 2.4.1 resolution: "run-async@npm:2.4.1" @@ -35184,7 +35124,7 @@ __metadata: languageName: node linkType: hard -"selfsigned@npm:^2.4.1": +"selfsigned@npm:^2.1.1": version: 2.4.1 resolution: "selfsigned@npm:2.4.1" dependencies: @@ -36939,15 +36879,6 @@ __metadata: languageName: unknown linkType: soft -"thingies@npm:^1.20.0": - version: 1.21.0 - resolution: "thingies@npm:1.21.0" - peerDependencies: - tslib: ^2 - checksum: 10/5c3954b67391d1432c252cb7089f29480e2164f06987a63d83c9747aa6999bfc313d6edfce71ed967316a3378dfcaf38f35ea77aaa5d423edaf776b8ff854f83 - languageName: node - linkType: hard - "thread-stream@npm:^0.15.1": version: 0.15.2 resolution: "thread-stream@npm:0.15.2" @@ -37217,15 +37148,6 @@ __metadata: languageName: node linkType: hard -"tree-dump@npm:^1.0.1": - version: 1.0.2 - resolution: "tree-dump@npm:1.0.2" - peerDependencies: - tslib: 2 - checksum: 10/ddcde4da9ded8edc2fa77fc9153ef8d7fba9cd5f813db27c30c7039191b50e1512b7106f0f4fe7ccaa3aa69f85b4671eda7ed0b9f9d34781eb26ebe4593ad4eb - languageName: node - linkType: hard - "tree-kill@npm:1.2.2": version: 1.2.2 resolution: "tree-kill@npm:1.2.2" @@ -37492,7 +37414,7 @@ __metadata: languageName: node linkType: hard -"ttypescript@npm:^1.5.15": +"ttypescript@npm:^1.5.12, ttypescript@npm:^1.5.15": version: 1.5.15 resolution: "ttypescript@npm:1.5.15" dependencies: @@ -38484,59 +38406,57 @@ __metadata: languageName: node linkType: hard -"webpack-dev-middleware@npm:^7.4.2": - version: 7.4.2 - resolution: "webpack-dev-middleware@npm:7.4.2" +"webpack-dev-middleware@npm:^5.3.4": + version: 5.3.4 + resolution: "webpack-dev-middleware@npm:5.3.4" dependencies: colorette: "npm:^2.0.10" - memfs: "npm:^4.6.0" + memfs: "npm:^3.4.3" mime-types: "npm:^2.1.31" - on-finished: "npm:^2.4.1" range-parser: "npm:^1.2.1" schema-utils: "npm:^4.0.0" peerDependencies: - webpack: ^5.0.0 - peerDependenciesMeta: - webpack: - optional: true - checksum: 10/608d101b82081a5bc6c0237f9945e14a8eefce1664c10877f3feb0042710f6c8b4288b07986505f791302d81b3c51180f679b97c91c3cdabd3fd0687a464ca1c + webpack: ^4.0.0 || ^5.0.0 + checksum: 10/3004374130f31c2910da39b80e24296009653bb11caa0b8449d962b67e003d7e73d01fbcfda9be1f1f04179f66a9c39f4caf7963df54303b430e39ba5a94f7c2 languageName: node linkType: hard -"webpack-dev-server@npm:^5.1.0": - version: 5.1.0 - resolution: "webpack-dev-server@npm:5.1.0" - dependencies: - "@types/bonjour": "npm:^3.5.13" - "@types/connect-history-api-fallback": "npm:^1.5.4" - "@types/express": "npm:^4.17.21" - "@types/serve-index": "npm:^1.9.4" - "@types/serve-static": "npm:^1.15.5" - "@types/sockjs": "npm:^0.3.36" - "@types/ws": "npm:^8.5.10" +"webpack-dev-server@npm:^4.15.2": + version: 4.15.2 + resolution: "webpack-dev-server@npm:4.15.2" + dependencies: + "@types/bonjour": "npm:^3.5.9" + "@types/connect-history-api-fallback": "npm:^1.3.5" + "@types/express": "npm:^4.17.13" + "@types/serve-index": "npm:^1.9.1" + "@types/serve-static": "npm:^1.13.10" + "@types/sockjs": "npm:^0.3.33" + "@types/ws": "npm:^8.5.5" ansi-html-community: "npm:^0.0.8" - bonjour-service: "npm:^1.2.1" - chokidar: "npm:^3.6.0" + bonjour-service: "npm:^1.0.11" + chokidar: "npm:^3.5.3" colorette: "npm:^2.0.10" compression: "npm:^1.7.4" connect-history-api-fallback: "npm:^2.0.0" - express: "npm:^4.19.2" + default-gateway: "npm:^6.0.3" + express: "npm:^4.17.3" graceful-fs: "npm:^4.2.6" - html-entities: "npm:^2.4.0" + html-entities: "npm:^2.3.2" http-proxy-middleware: "npm:^2.0.3" - ipaddr.js: "npm:^2.1.0" - launch-editor: "npm:^2.6.1" - open: "npm:^10.0.3" - p-retry: "npm:^6.2.0" - schema-utils: "npm:^4.2.0" - selfsigned: "npm:^2.4.1" + ipaddr.js: "npm:^2.0.1" + launch-editor: "npm:^2.6.0" + open: "npm:^8.0.9" + p-retry: "npm:^4.5.0" + rimraf: "npm:^3.0.2" + schema-utils: "npm:^4.0.0" + selfsigned: "npm:^2.1.1" serve-index: "npm:^1.9.1" sockjs: "npm:^0.3.24" spdy: "npm:^4.0.2" - webpack-dev-middleware: "npm:^7.4.2" - ws: "npm:^8.18.0" + webpack-dev-middleware: "npm:^5.3.4" + ws: "npm:^8.13.0" peerDependencies: - webpack: ^5.0.0 + webpack: ^4.37.0 || ^5.0.0 peerDependenciesMeta: webpack: optional: true @@ -38544,7 +38464,7 @@ __metadata: optional: true bin: webpack-dev-server: bin/webpack-dev-server.js - checksum: 10/f23255681cc5e2c2709b23ca7b2185aeed83b1c9912657d4512eda8685625a46d7a103a92446494a55fe2afdfab936f9bd4f037d20b52f7fdfff303e7e7199c7 + checksum: 10/86ca4fb49d2a264243b2284c6027a9a91fd7d47737bbb4096e873be8a3f8493a9577b1535d7cc84de1ee991da7da97686c85788ccac547b0f5cf5c7686aacee9 languageName: node linkType: hard @@ -39113,7 +39033,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.11.0, ws@npm:^8.17.1, ws@npm:^8.18.0": +"ws@npm:^8.11.0, ws@npm:^8.13.0, ws@npm:^8.17.1, ws@npm:^8.18.0": version: 8.18.0 resolution: "ws@npm:8.18.0" peerDependencies: