From e60e1fa755bfbff188b5edafb80a76d00a37d703 Mon Sep 17 00:00:00 2001 From: Daniel Lautzenheiser Date: Thu, 20 Oct 2022 11:57:30 -0400 Subject: [PATCH] Feature/typescript conversion (#44) --- .eslintrc.js | 19 +- babel.config.js | 2 +- dev-test/backends/azure/config.yml | 471 ++ dev-test/backends/azure/index.html | 12 + dev-test/backends/bitbucket/config.yml | 469 ++ dev-test/backends/bitbucket/index.html | 12 + dev-test/backends/git-gateway/config.yml | 468 ++ dev-test/backends/git-gateway/index.html | 12 + dev-test/backends/github/config.yml | 469 ++ dev-test/backends/github/index.html | 12 + dev-test/backends/gitlab/config.yml | 469 ++ dev-test/backends/gitlab/index.html | 12 + dev-test/backends/proxy/config.yml | 472 ++ dev-test/backends/proxy/index.html | 12 + dev-test/config.yml | 441 +- dev-test/index.html | 281 +- dev-test/index.js | 91 +- index.d.ts | 924 ---- package.json | 89 +- src/actions/auth.ts | 31 +- src/actions/collections.ts | 2 +- src/actions/config.ts | 158 +- src/actions/entries.ts | 937 ++-- src/actions/media.ts | 56 +- src/actions/mediaLibrary.ts | 252 +- src/actions/scroll.ts | 8 +- src/actions/search.ts | 87 +- src/actions/status.ts | 22 +- src/actions/waitUntil.ts | 12 +- src/backend.ts | 403 +- src/backends/azure/API.ts | 82 +- src/backends/azure/AuthenticationPage.js | 84 - src/backends/azure/AuthenticationPage.tsx | 80 + src/backends/azure/implementation.ts | 55 +- src/backends/azure/index.ts | 13 +- src/backends/bitbucket/API.ts | 73 +- src/backends/bitbucket/AuthenticationPage.js | 95 - src/backends/bitbucket/AuthenticationPage.tsx | 96 + src/backends/bitbucket/implementation.ts | 56 +- src/backends/bitbucket/index.ts | 13 +- .../git-gateway/AuthenticationPage.js | 230 - .../git-gateway/AuthenticationPage.tsx | 225 + .../{implementation.ts => implementation.tsx} | 105 +- src/backends/git-gateway/index.ts | 10 +- .../git-gateway/netlify-lfs-client.ts | 6 +- src/backends/github/API.ts | 143 +- src/backends/github/AuthenticationPage.js | 85 - src/backends/github/AuthenticationPage.tsx | 64 + src/backends/github/GraphQLAPI.ts | 13 +- .../{fragmentTypes.js => fragmentTypes.ts} | 0 src/backends/github/implementation.tsx | 38 +- src/backends/github/index.ts | 13 +- ...ragmentTypes.js => createFragmentTypes.ts} | 10 +- src/backends/github/types/semaphore.d.ts | 5 - src/backends/gitlab/API.ts | 113 +- src/backends/gitlab/AuthenticationPage.js | 104 - src/backends/gitlab/AuthenticationPage.tsx | 100 + src/backends/gitlab/implementation.ts | 30 +- src/backends/gitlab/index.ts | 13 +- src/backends/index.tsx | 24 +- src/backends/proxy/AuthenticationPage.js | 63 - src/backends/proxy/AuthenticationPage.tsx | 48 + src/backends/proxy/implementation.ts | 28 +- src/backends/proxy/index.ts | 10 +- src/backends/test/AuthenticationPage.js | 73 - src/backends/test/AuthenticationPage.tsx | 63 + src/backends/test/implementation.ts | 45 +- src/backends/test/index.ts | 10 +- src/{bootstrap.js => bootstrap.tsx} | 86 +- src/components/App/App.js | 344 -- src/components/App/App.tsx | 264 + src/components/App/Header.js | 226 - src/components/App/Header.tsx | 212 + src/components/App/MainView.tsx | 49 + src/components/App/NotFoundPage.js | 24 - src/components/App/NotFoundPage.tsx | 22 + src/components/Collection/Collection.tsx | 298 +- ...tionControls.js => CollectionControls.tsx} | 42 +- src/components/Collection/CollectionRoute.tsx | 60 + src/components/Collection/CollectionSearch.js | 239 - .../Collection/CollectionSearch.tsx | 229 + src/components/Collection/CollectionTop.js | 82 - src/components/Collection/CollectionTop.tsx | 78 + src/components/Collection/ControlButton.js | 28 - src/components/Collection/Entries/Entries.js | 73 - src/components/Collection/Entries/Entries.tsx | 93 + .../Collection/Entries/EntriesCollection.js | 166 - .../Collection/Entries/EntriesCollection.tsx | 191 + .../Collection/Entries/EntriesSearch.js | 91 - .../Collection/Entries/EntriesSearch.tsx | 101 + .../Collection/Entries/EntryCard.js | 167 - .../Collection/Entries/EntryCard.tsx | 102 + .../Collection/Entries/EntryListing.js | 87 - .../Collection/Entries/EntryListing.tsx | 147 + src/components/Collection/FilterControl.js | 39 - src/components/Collection/FilterControl.tsx | 85 + src/components/Collection/GroupControl.js | 39 - src/components/Collection/GroupControl.tsx | 79 + src/components/Collection/NestedCollection.js | 309 -- .../Collection/NestedCollection.tsx | 354 ++ src/components/Collection/Sidebar.js | 237 - src/components/Collection/Sidebar.tsx | 177 + src/components/Collection/SortControl.js | 68 - src/components/Collection/SortControl.tsx | 112 + src/components/Collection/ViewStyleControl.js | 50 - .../Collection/ViewStyleControl.tsx | 44 + src/components/Editor/Editor.js | 408 -- src/components/Editor/Editor.tsx | 400 ++ .../Editor/EditorControlPane/EditorControl.js | 431 -- .../EditorControlPane/EditorControl.tsx | 336 ++ .../EditorControlPane/EditorControlPane.js | 251 - .../EditorControlPane/EditorControlPane.tsx | 248 + .../Editor/EditorControlPane/Widget.js | 339 -- src/components/Editor/EditorInterface.js | 395 -- src/components/Editor/EditorInterface.tsx | 363 ++ .../Editor/EditorPreviewPane/EditorPreview.js | 44 - .../EditorPreviewPane/EditorPreview.tsx | 31 + .../EditorPreviewContent.tsx | 24 +- .../EditorPreviewPane/EditorPreviewPane.js | 272 -- .../EditorPreviewPane/EditorPreviewPane.tsx | 357 ++ .../Editor/EditorPreviewPane/PreviewHOC.js | 33 - .../Editor/EditorPreviewPane/PreviewHOC.tsx | 21 + src/components/Editor/EditorRoute.tsx | 40 + src/components/Editor/EditorToolbar.js | 372 -- src/components/Editor/EditorToolbar.tsx | 245 + .../EditorWidgets/Unknown/UnknownControl.js | 17 - .../EditorWidgets/Unknown/UnknownControl.tsx | 10 + .../EditorWidgets/Unknown/UnknownPreview.js | 19 - .../EditorWidgets/Unknown/UnknownPreview.tsx | 14 + .../EditorWidgets/{index.js => index.ts} | 0 src/components/MediaLibrary/EmptyMessage.js | 29 - src/components/MediaLibrary/EmptyMessage.tsx | 38 + src/components/MediaLibrary/MediaLibrary.js | 404 -- src/components/MediaLibrary/MediaLibrary.tsx | 407 ++ ...raryButtons.js => MediaLibraryButtons.tsx} | 102 +- .../MediaLibrary/MediaLibraryCard.js | 129 - .../MediaLibrary/MediaLibraryCard.tsx | 142 + .../MediaLibrary/MediaLibraryCardGrid.js | 198 - .../MediaLibrary/MediaLibraryCardGrid.tsx | 239 + .../MediaLibrary/MediaLibraryHeader.js | 49 - .../MediaLibrary/MediaLibraryModal.js | 200 - .../MediaLibrary/MediaLibraryModal.tsx | 227 + .../MediaLibrary/MediaLibrarySearch.js | 62 - .../MediaLibrary/MediaLibrarySearch.tsx | 43 + .../MediaLibrary/MediaLibraryTop.js | 143 - .../MediaLibrary/MediaLibraryTop.tsx | 130 + src/components/UI/Alert.tsx | 14 +- src/components/UI/AuthenticationPage.tsx | 86 + src/components/UI/Confirm.tsx | 16 +- src/components/UI/DragDrop.js | 66 - .../{ErrorBoundary.js => ErrorBoundary.tsx} | 89 +- src/components/UI/FieldLabel.tsx | 55 + src/components/UI/FileUploadButton.js | 24 - src/components/UI/FileUploadButton.tsx | 27 + src/components/UI/GoBackButton.tsx | 19 + src/components/UI/Icon.tsx | 97 + .../icons.js => components/UI/Icon/icons.tsx} | 40 +- src/components/UI/Icon/images/_index.tsx | 15 + .../UI}/Icon/images/azure.svg | 2 +- .../UI}/Icon/images/bitbucket.svg | 2 +- .../UI}/Icon/images/github.svg | 0 .../UI}/Icon/images/gitlab.svg | 0 .../UI}/Icon/images/static-cms-logo.svg | 0 src/components/UI/ListItemTopBar.tsx | 131 + src/components/UI/Loader.tsx | 55 + src/components/UI/Modal.js | 110 - src/components/UI/NavLink.tsx | 74 + src/components/UI/ObjectWidgetTopBar.tsx | 168 + src/components/UI/Outline.tsx | 60 + src/components/UI/ScrollTop.tsx | 43 + src/components/UI/SettingsDropdown.js | 103 - src/components/UI/SettingsDropdown.tsx | 74 + src/components/UI/WidgetPreviewContainer.tsx | 7 + src/components/UI/index.js | 5 - src/components/UI/index.ts | 3 + .../styles.js => components/UI/styles.tsx} | 170 +- src/components/page/Page.tsx | 61 +- src/components/snackbar/Snackbars.tsx | 17 +- ...{collectionViews.js => collectionViews.ts} | 2 + src/constants/commitProps.ts | 2 + .../{configSchema.js => configSchema.tsx} | 61 +- src/constants/fieldInference.tsx | 25 +- ...nErrorTypes.js => validationErrorTypes.ts} | 6 +- src/editor-components/editorPlugin.ts | 20 + .../image/{index.js => index.tsx} | 14 +- src/editor-components/index.tsx | 4 +- src/extensions.ts | 70 + src/formats/formats.ts | 27 +- src/formats/yaml.ts | 20 +- src/{index.js => index.ts} | 15 - src/integrations/index.js | 35 - src/integrations/index.ts | 74 + .../{implementation.js => implementation.ts} | 119 +- .../{implementation.js => implementation.ts} | 63 +- src/interface.ts | 1036 ++-- .../{implicit-oauth.js => implicit-oauth.ts} | 41 +- src/lib/auth/index.d.ts | 9 - src/lib/auth/index.js | 5 - src/lib/auth/index.ts | 3 + .../auth/{netlify-auth.js => netlify-auth.ts} | 92 +- src/lib/auth/{pkce-oauth.js => pkce-oauth.ts} | 47 +- src/lib/auth/{utils.js => utils.ts} | 4 +- src/lib/{consoleError.js => consoleError.ts} | 2 +- src/lib/formatters.ts | 168 +- src/lib/i18n.ts | 158 +- src/lib/{phrases.js => phrases.ts} | 4 +- src/lib/registry.js | 315 -- src/lib/registry.ts | 385 ++ src/lib/serializeEntryValues.js | 75 - src/lib/serializeEntryValues.ts | 88 + src/lib/{textHelper.js => textHelper.ts} | 2 +- src/lib/urlHelper.ts | 13 +- src/lib/util/API.ts | 70 +- src/lib/util/Cursor.ts | 215 +- src/lib/util/__tests__/object.util.ts | 159 + src/lib/util/asyncLock.ts | 4 +- src/lib/util/backendUtil.ts | 80 +- src/lib/util/collection.util.ts | 417 ++ src/lib/util/field.util.ts | 29 + src/lib/util/git-lfs.ts | 2 +- src/lib/util/implementation.ts | 99 +- src/lib/util/index.ts | 180 +- src/lib/util/loadScript.js | 24 - src/lib/util/loadScript.ts | 17 + src/lib/util/media.util.ts | 271 ++ src/lib/util/null.util.ts | 11 + src/lib/util/object.util.ts | 45 + src/lib/util/sort.util.ts | 14 + src/lib/util/string.util.ts | 24 + src/lib/util/types/semaphore.d.ts | 5 - src/lib/util/unsentRequest.js | 133 - src/lib/util/unsentRequest.ts | 142 + src/lib/util/validation.util.ts | 105 + src/lib/util/window.util.ts | 2 +- src/lib/widgets/index.ts | 10 +- src/lib/widgets/stringTemplate.ts | 42 +- src/lib/widgets/validations.ts | 12 +- src/locales/bg/{index.js => index.ts} | 4 +- src/locales/ca/{index.js => index.ts} | 4 +- src/locales/cs/{index.js => index.ts} | 4 +- src/locales/da/{index.js => index.ts} | 4 +- src/locales/de/{index.js => index.ts} | 4 +- src/locales/en/{index.js => index.ts} | 11 +- src/locales/es/{index.js => index.ts} | 4 +- src/locales/fr/{index.js => index.ts} | 4 +- src/locales/gr/{index.js => index.ts} | 4 +- src/locales/he/{index.js => index.ts} | 4 +- src/locales/hr/{index.js => index.ts} | 4 +- src/locales/hu/{index.js => index.ts} | 4 +- src/locales/index.ts | 4 +- src/locales/it/{index.js => index.ts} | 4 +- src/locales/ja/{index.js => index.ts} | 4 +- src/locales/ko/{index.js => index.ts} | 4 +- src/locales/lt/{index.js => index.ts} | 4 +- src/locales/nb_no/{index.js => index.ts} | 4 +- src/locales/nl/{index.js => index.ts} | 4 +- src/locales/nn_no/{index.js => index.ts} | 4 +- src/locales/pl/{index.js => index.ts} | 4 +- src/locales/pt/{index.js => index.ts} | 4 +- src/locales/ro/{index.js => index.ts} | 4 +- src/locales/ru/{index.js => index.ts} | 4 +- src/locales/sv/{index.js => index.ts} | 4 +- src/locales/th/{index.js => index.ts} | 4 +- src/locales/tr/{index.js => index.ts} | 4 +- src/locales/uk/{index.js => index.ts} | 4 +- src/locales/vi/{index.js => index.ts} | 4 +- src/locales/zh_Hans/{index.js => index.ts} | 4 +- src/locales/zh_Hant/{index.js => index.ts} | 4 +- .../cloudinary/{index.js => index.ts} | 56 +- src/media-libraries/index.tsx | 6 +- .../uploadcare/{index.js => index.ts} | 91 +- src/mediaLibrary.ts | 45 +- src/reducers/auth.ts | 8 +- src/reducers/collections.ts | 475 +- src/reducers/config.ts | 11 +- src/reducers/cursors.js | 36 - src/reducers/cursors.ts | 60 + src/reducers/entries.ts | 990 ++-- src/reducers/entryDraft.js | 193 - src/reducers/entryDraft.ts | 292 ++ src/reducers/globalUI.ts | 22 +- src/reducers/index.ts | 40 +- src/reducers/integrations.ts | 82 +- src/reducers/mediaLibrary.ts | 385 +- src/reducers/medias.ts | 10 +- src/reducers/scroll.ts | 4 +- src/reducers/search.ts | 71 +- src/reducers/status.ts | 8 +- src/store/slices/snackbars.ts | 14 +- src/types/constants.d.ts | 1 + src/types/css.d.ts | 4 + src/types/global.d.ts | 12 +- src/types/immutable.ts | 40 - src/{backends/git-gateway => }/types/ini.d.ts | 0 src/types/markdown.d.ts | 5 + src/types/redux.ts | 674 --- .../bitbucket => }/types/semaphore.d.ts | 0 src/types/svg.d.ts | 4 + src/types/uploadcare.d.ts | 33 + .../bitbucket => }/types/what-the-diff.d.ts | 0 src/ui/AuthenticationPage.js | 115 - src/ui/Dropdown.js | 176 - src/ui/FieldLabel.js | 59 - src/ui/GoBackButton.js | 39 - src/ui/Icon.js | 76 - src/ui/Icon/images/_index.js | 99 - src/ui/Icon/images/add-with.svg | 1 - src/ui/Icon/images/add.svg | 1 - src/ui/Icon/images/arrow.svg | 1 - src/ui/Icon/images/bold.svg | 1 - src/ui/Icon/images/check.svg | 1 - src/ui/Icon/images/chevron-double.svg | 8 - src/ui/Icon/images/chevron.svg | 1 - src/ui/Icon/images/circle.svg | 1 - src/ui/Icon/images/close.svg | 1 - src/ui/Icon/images/code-block.svg | 1 - src/ui/Icon/images/code.svg | 1 - src/ui/Icon/images/drag-handle.svg | 1 - src/ui/Icon/images/eye.svg | 1 - src/ui/Icon/images/folder.svg | 1 - src/ui/Icon/images/grid.svg | 1 - src/ui/Icon/images/h-options.svg | 1 - src/ui/Icon/images/h1.svg | 1 - src/ui/Icon/images/h2.svg | 1 - src/ui/Icon/images/home.svg | 1 - src/ui/Icon/images/image.svg | 1 - src/ui/Icon/images/info-circle.svg | 4 - src/ui/Icon/images/italic.svg | 1 - src/ui/Icon/images/link.svg | 1 - src/ui/Icon/images/list-bulleted.svg | 1 - src/ui/Icon/images/list-numbered.svg | 1 - src/ui/Icon/images/list.svg | 1 - src/ui/Icon/images/markdown.svg | 1 - src/ui/Icon/images/media-alt.svg | 1 - src/ui/Icon/images/media.svg | 1 - src/ui/Icon/images/netlify.svg | 21 - src/ui/Icon/images/new-tab.svg | 1 - src/ui/Icon/images/page.svg | 1 - src/ui/Icon/images/pages-alt.svg | 3 - src/ui/Icon/images/pages.svg | 3 - src/ui/Icon/images/quote.svg | 1 - src/ui/Icon/images/refresh.svg | 1 - src/ui/Icon/images/scroll.svg | 1 - src/ui/Icon/images/search.svg | 1 - src/ui/Icon/images/settings.svg | 1 - src/ui/Icon/images/user.svg | 1 - src/ui/Icon/images/write.svg | 1 - src/ui/IconButton.js | 40 - src/ui/ListItemTopBar.js | 112 - src/ui/Loader.js | 157 - src/ui/ObjectWidgetTopBar.js | 123 - src/ui/Toggle.js | 95 - src/ui/WidgetPreviewContainer.js | 7 - src/ui/index.js | 103 - src/valueObjects/AssetProxy.ts | 6 +- src/valueObjects/EditorComponent.js | 38 - src/valueObjects/EditorComponent.ts | 55 + src/valueObjects/Entry.ts | 28 +- src/widgets/boolean/BooleanControl.js | 50 - src/widgets/boolean/BooleanControl.tsx | 47 + src/widgets/boolean/index.js | 12 - src/widgets/boolean/index.ts | 12 + src/widgets/code/CodeControl.js | 318 -- src/widgets/code/CodeControl.tsx | 382 ++ src/widgets/code/CodePreview.js | 32 - src/widgets/code/CodePreview.tsx | 33 + src/widgets/code/SettingsButton.js | 34 - src/widgets/code/SettingsButton.tsx | 46 + src/widgets/code/SettingsPane.js | 116 - src/widgets/code/SettingsPane.tsx | 171 + src/widgets/code/data/languages-raw.yml | 4256 ++++++++--------- src/widgets/code/data/languages.json | 1 - src/widgets/code/data/languages.ts | 1604 +++++++ src/widgets/code/index.js | 18 - src/widgets/code/index.ts | 19 + ...electStyles.js => languageSelectStyles.ts} | 17 +- src/widgets/code/{schema.js => schema.ts} | 0 ...cess-languages.js => process-languages.ts} | 39 +- src/widgets/colorstring/ColorControl.js | 172 - src/widgets/colorstring/ColorControl.tsx | 227 + src/widgets/colorstring/ColorPreview.js | 14 - src/widgets/colorstring/ColorPreview.tsx | 11 + src/widgets/colorstring/index.js | 14 - src/widgets/colorstring/index.ts | 14 + src/widgets/datetime/DateTimeControl.js | 172 - src/widgets/datetime/DateTimeControl.tsx | 268 ++ src/widgets/datetime/DateTimePreview.js | 14 - src/widgets/datetime/DateTimePreview.tsx | 11 + src/widgets/datetime/{index.js => index.tsx} | 14 +- src/widgets/datetime/{schema.js => schema.ts} | 0 src/widgets/file/FilePreview.js | 47 - src/widgets/file/FilePreview.tsx | 61 + src/widgets/file/index.js | 18 - src/widgets/file/index.ts | 21 + src/widgets/file/{schema.js => schema.ts} | 0 src/widgets/file/withFileControl.js | 430 -- src/widgets/file/withFileControl.tsx | 500 ++ src/widgets/image/ImagePreview.js | 41 - src/widgets/image/ImagePreview.tsx | 60 + src/widgets/image/index.js | 18 - src/widgets/image/index.ts | 21 + src/widgets/image/{schema.js => schema.ts} | 0 src/widgets/index.tsx | 48 +- src/widgets/list/ListControl.js | 680 --- src/widgets/list/ListControl.tsx | 300 ++ src/widgets/list/ListItem.tsx | 225 + src/widgets/list/index.js | 18 - src/widgets/list/index.ts | 18 + src/widgets/list/{schema.js => schema.ts} | 0 src/widgets/list/typedListHelpers.js | 35 - src/widgets/list/typedListHelpers.ts | 52 + src/widgets/map/MapPreview.js | 14 - src/widgets/map/MapPreview.tsx | 11 + src/widgets/map/{index.js => index.ts} | 16 +- src/widgets/map/{schema.js => schema.ts} | 0 src/widgets/map/withMapControl.js | 97 - src/widgets/map/withMapControl.tsx | 152 + src/widgets/markdown/MarkdownControl.tsx | 149 + .../markdown/MarkdownControl/RawEditor.js | 157 - .../markdown/MarkdownControl/Toolbar.js | 278 -- .../markdown/MarkdownControl/ToolbarButton.js | 49 - .../markdown/MarkdownControl/VisualEditor.js | 279 -- .../MarkdownControl/components/Shortcode.js | 60 - .../MarkdownControl/components/VoidBlock.js | 40 - src/widgets/markdown/MarkdownControl/index.js | 125 - .../plugins/BreakToDefaultBlock.js | 23 - .../MarkdownControl/plugins/CloseBlock.js | 25 - .../plugins/CommandsAndQueries.js | 156 - .../plugins/CopyPasteVisual.js | 47 - .../MarkdownControl/plugins/ForceInsert.js | 38 - .../MarkdownControl/plugins/Hotkey.js | 29 - .../MarkdownControl/plugins/LineBreak.js | 15 - .../markdown/MarkdownControl/plugins/Link.js | 40 - .../markdown/MarkdownControl/plugins/List.js | 371 -- .../MarkdownControl/plugins/QuoteBlock.js | 103 - .../MarkdownControl/plugins/SelectAll.js | 16 - .../MarkdownControl/plugins/Shortcode.js | 49 - .../markdown/MarkdownControl/plugins/util.js | 11 - .../MarkdownControl/plugins/visual.js | 59 - .../markdown/MarkdownControl/renderers.js | 353 -- .../markdown/MarkdownControl/schema.js | 274 -- src/widgets/markdown/MarkdownPreview.js | 27 - src/widgets/markdown/MarkdownPreview.tsx | 28 + src/widgets/markdown/{index.js => index.ts} | 14 +- .../{regexHelper.js => regexHelper.ts} | 33 +- src/widgets/markdown/{schema.js => schema.ts} | 8 - src/widgets/markdown/serializers.ts | 40 + src/widgets/markdown/serializers/index.js | 226 - .../markdown/serializers/rehypePaperEmoji.js | 16 - .../serializers/remarkAllowHtmlEntities.js | 58 - .../serializers/remarkAssertParents.js | 83 - .../remarkEscapeMarkdownEntities.js | 269 -- .../serializers/remarkImagesToText.js | 26 - .../markdown/serializers/remarkPaddedLinks.js | 120 - .../serializers/remarkRehypeShortcodes.js | 67 - .../markdown/serializers/remarkShortcodes.js | 106 - .../markdown/serializers/remarkSlate.js | 446 -- .../serializers/remarkSquashReferences.js | 73 - .../serializers/remarkStripTrailingBreaks.js | 56 - .../markdown/serializers/remarkWrapHtml.js | 20 - .../markdown/serializers/slateRemark.js | 401 -- src/widgets/markdown/{styles.js => styles.ts} | 2 +- src/widgets/markdown/types.js | 3 - src/widgets/markdown/types.ts | 4 + src/widgets/number/NumberControl.js | 103 - src/widgets/number/NumberControl.tsx | 110 + src/widgets/number/NumberPreview.js | 14 - src/widgets/number/NumberPreview.tsx | 11 + src/widgets/number/index.js | 29 - src/widgets/number/index.ts | 32 + src/widgets/number/{schema.js => schema.ts} | 0 src/widgets/object/ObjectControl.js | 201 - src/widgets/object/ObjectControl.tsx | 149 + src/widgets/object/ObjectPreview.js | 18 - src/widgets/object/ObjectPreview.tsx | 13 + src/widgets/object/index.js | 16 - src/widgets/object/index.ts | 18 + src/widgets/object/{schema.js => schema.ts} | 0 src/widgets/relation/RelationControl.js | 319 -- src/widgets/relation/RelationControl.tsx | 255 + src/widgets/relation/RelationPreview.js | 14 - src/widgets/relation/RelationPreview.tsx | 11 + src/widgets/relation/index.js | 39 - src/widgets/relation/index.ts | 35 + src/widgets/relation/{schema.js => schema.ts} | 0 src/widgets/select/SelectControl.js | 117 - src/widgets/select/SelectControl.tsx | 109 + src/widgets/select/SelectPreview.js | 31 - src/widgets/select/SelectPreview.tsx | 33 + src/widgets/select/index.js | 34 - src/widgets/select/index.ts | 31 + src/widgets/select/{schema.js => schema.ts} | 0 src/widgets/string/StringControl.tsx | 74 +- src/widgets/string/StringPreview.tsx | 9 +- src/widgets/string/index.ts | 14 + src/widgets/string/index.tsx | 15 - src/widgets/text/TextControl.js | 47 - src/widgets/text/TextControl.tsx | 33 + src/widgets/text/TextPreview.js | 14 - src/widgets/text/TextPreview.tsx | 11 + src/widgets/text/{index.js => index.ts} | 10 +- things-to-remove.txt | 0 tsconfig.json | 8 +- webpack.config.js | 44 +- website/content/blog/welcome-to-simple-cms.md | 2 +- website/content/docs/architecture.md | 4 +- website/content/docs/beta-features.md | 23 +- website/content/docs/custom-widgets.md | 2 +- website/content/docs/customization.md | 169 +- website/content/docs/jekyll.md | 2 +- website/content/docs/widgets/color.md | 6 +- website/content/docs/widgets/markdown.md | 1 - website/data/updates.yml | 2 +- website/src/cms/cms.js | 32 +- website/src/templates/blog-post.js | 3 +- website/static/admin/config.yml | 6 - yarn.lock | 2775 ++++++----- 517 files changed, 27034 insertions(+), 27191 deletions(-) create mode 100644 dev-test/backends/azure/config.yml create mode 100644 dev-test/backends/azure/index.html create mode 100644 dev-test/backends/bitbucket/config.yml create mode 100644 dev-test/backends/bitbucket/index.html create mode 100644 dev-test/backends/git-gateway/config.yml create mode 100644 dev-test/backends/git-gateway/index.html create mode 100644 dev-test/backends/github/config.yml create mode 100644 dev-test/backends/github/index.html create mode 100644 dev-test/backends/gitlab/config.yml create mode 100644 dev-test/backends/gitlab/index.html create mode 100644 dev-test/backends/proxy/config.yml create mode 100644 dev-test/backends/proxy/index.html delete mode 100644 index.d.ts delete mode 100644 src/backends/azure/AuthenticationPage.js create mode 100644 src/backends/azure/AuthenticationPage.tsx delete mode 100644 src/backends/bitbucket/AuthenticationPage.js create mode 100644 src/backends/bitbucket/AuthenticationPage.tsx delete mode 100644 src/backends/git-gateway/AuthenticationPage.js create mode 100644 src/backends/git-gateway/AuthenticationPage.tsx rename src/backends/git-gateway/{implementation.ts => implementation.tsx} (85%) delete mode 100644 src/backends/github/AuthenticationPage.js create mode 100644 src/backends/github/AuthenticationPage.tsx rename src/backends/github/{fragmentTypes.js => fragmentTypes.ts} (100%) rename src/backends/github/scripts/{createFragmentTypes.js => createFragmentTypes.ts} (83%) delete mode 100644 src/backends/github/types/semaphore.d.ts delete mode 100644 src/backends/gitlab/AuthenticationPage.js create mode 100644 src/backends/gitlab/AuthenticationPage.tsx delete mode 100644 src/backends/proxy/AuthenticationPage.js create mode 100644 src/backends/proxy/AuthenticationPage.tsx delete mode 100644 src/backends/test/AuthenticationPage.js create mode 100644 src/backends/test/AuthenticationPage.tsx rename src/{bootstrap.js => bootstrap.tsx} (52%) delete mode 100644 src/components/App/App.js create mode 100644 src/components/App/App.tsx delete mode 100644 src/components/App/Header.js create mode 100644 src/components/App/Header.tsx create mode 100644 src/components/App/MainView.tsx delete mode 100644 src/components/App/NotFoundPage.js create mode 100644 src/components/App/NotFoundPage.tsx rename src/components/Collection/{CollectionControls.js => CollectionControls.tsx} (57%) create mode 100644 src/components/Collection/CollectionRoute.tsx delete mode 100644 src/components/Collection/CollectionSearch.js create mode 100644 src/components/Collection/CollectionSearch.tsx delete mode 100644 src/components/Collection/CollectionTop.js create mode 100644 src/components/Collection/CollectionTop.tsx delete mode 100644 src/components/Collection/ControlButton.js delete mode 100644 src/components/Collection/Entries/Entries.js create mode 100644 src/components/Collection/Entries/Entries.tsx delete mode 100644 src/components/Collection/Entries/EntriesCollection.js create mode 100644 src/components/Collection/Entries/EntriesCollection.tsx delete mode 100644 src/components/Collection/Entries/EntriesSearch.js create mode 100644 src/components/Collection/Entries/EntriesSearch.tsx delete mode 100644 src/components/Collection/Entries/EntryCard.js create mode 100644 src/components/Collection/Entries/EntryCard.tsx delete mode 100644 src/components/Collection/Entries/EntryListing.js create mode 100644 src/components/Collection/Entries/EntryListing.tsx delete mode 100644 src/components/Collection/FilterControl.js create mode 100644 src/components/Collection/FilterControl.tsx delete mode 100644 src/components/Collection/GroupControl.js create mode 100644 src/components/Collection/GroupControl.tsx delete mode 100644 src/components/Collection/NestedCollection.js create mode 100644 src/components/Collection/NestedCollection.tsx delete mode 100644 src/components/Collection/Sidebar.js create mode 100644 src/components/Collection/Sidebar.tsx delete mode 100644 src/components/Collection/SortControl.js create mode 100644 src/components/Collection/SortControl.tsx delete mode 100644 src/components/Collection/ViewStyleControl.js create mode 100644 src/components/Collection/ViewStyleControl.tsx delete mode 100644 src/components/Editor/Editor.js create mode 100644 src/components/Editor/Editor.tsx delete mode 100644 src/components/Editor/EditorControlPane/EditorControl.js create mode 100644 src/components/Editor/EditorControlPane/EditorControl.tsx delete mode 100644 src/components/Editor/EditorControlPane/EditorControlPane.js create mode 100644 src/components/Editor/EditorControlPane/EditorControlPane.tsx delete mode 100644 src/components/Editor/EditorControlPane/Widget.js delete mode 100644 src/components/Editor/EditorInterface.js create mode 100644 src/components/Editor/EditorInterface.tsx delete mode 100644 src/components/Editor/EditorPreviewPane/EditorPreview.js create mode 100644 src/components/Editor/EditorPreviewPane/EditorPreview.tsx delete mode 100644 src/components/Editor/EditorPreviewPane/EditorPreviewPane.js create mode 100644 src/components/Editor/EditorPreviewPane/EditorPreviewPane.tsx delete mode 100644 src/components/Editor/EditorPreviewPane/PreviewHOC.js create mode 100644 src/components/Editor/EditorPreviewPane/PreviewHOC.tsx create mode 100644 src/components/Editor/EditorRoute.tsx delete mode 100644 src/components/Editor/EditorToolbar.js create mode 100644 src/components/Editor/EditorToolbar.tsx delete mode 100644 src/components/EditorWidgets/Unknown/UnknownControl.js create mode 100644 src/components/EditorWidgets/Unknown/UnknownControl.tsx delete mode 100644 src/components/EditorWidgets/Unknown/UnknownPreview.js create mode 100644 src/components/EditorWidgets/Unknown/UnknownPreview.tsx rename src/components/EditorWidgets/{index.js => index.ts} (100%) delete mode 100644 src/components/MediaLibrary/EmptyMessage.js create mode 100644 src/components/MediaLibrary/EmptyMessage.tsx delete mode 100644 src/components/MediaLibrary/MediaLibrary.js create mode 100644 src/components/MediaLibrary/MediaLibrary.tsx rename src/components/MediaLibrary/{MediaLibraryButtons.js => MediaLibraryButtons.tsx} (51%) delete mode 100644 src/components/MediaLibrary/MediaLibraryCard.js create mode 100644 src/components/MediaLibrary/MediaLibraryCard.tsx delete mode 100644 src/components/MediaLibrary/MediaLibraryCardGrid.js create mode 100644 src/components/MediaLibrary/MediaLibraryCardGrid.tsx delete mode 100644 src/components/MediaLibrary/MediaLibraryHeader.js delete mode 100644 src/components/MediaLibrary/MediaLibraryModal.js create mode 100644 src/components/MediaLibrary/MediaLibraryModal.tsx delete mode 100644 src/components/MediaLibrary/MediaLibrarySearch.js create mode 100644 src/components/MediaLibrary/MediaLibrarySearch.tsx delete mode 100644 src/components/MediaLibrary/MediaLibraryTop.js create mode 100644 src/components/MediaLibrary/MediaLibraryTop.tsx create mode 100644 src/components/UI/AuthenticationPage.tsx delete mode 100644 src/components/UI/DragDrop.js rename src/components/UI/{ErrorBoundary.js => ErrorBoundary.tsx} (69%) create mode 100644 src/components/UI/FieldLabel.tsx delete mode 100644 src/components/UI/FileUploadButton.js create mode 100644 src/components/UI/FileUploadButton.tsx create mode 100644 src/components/UI/GoBackButton.tsx create mode 100644 src/components/UI/Icon.tsx rename src/{ui/Icon/icons.js => components/UI/Icon/icons.tsx} (54%) create mode 100644 src/components/UI/Icon/images/_index.tsx rename src/{ui => components/UI}/Icon/images/azure.svg (98%) rename src/{ui => components/UI}/Icon/images/bitbucket.svg (94%) rename src/{ui => components/UI}/Icon/images/github.svg (100%) rename src/{ui => components/UI}/Icon/images/gitlab.svg (100%) rename src/{ui => components/UI}/Icon/images/static-cms-logo.svg (100%) create mode 100644 src/components/UI/ListItemTopBar.tsx create mode 100644 src/components/UI/Loader.tsx delete mode 100644 src/components/UI/Modal.js create mode 100644 src/components/UI/NavLink.tsx create mode 100644 src/components/UI/ObjectWidgetTopBar.tsx create mode 100644 src/components/UI/Outline.tsx create mode 100644 src/components/UI/ScrollTop.tsx delete mode 100644 src/components/UI/SettingsDropdown.js create mode 100644 src/components/UI/SettingsDropdown.tsx create mode 100644 src/components/UI/WidgetPreviewContainer.tsx delete mode 100644 src/components/UI/index.js create mode 100644 src/components/UI/index.ts rename src/{ui/styles.js => components/UI/styles.tsx} (85%) rename src/constants/{collectionViews.js => collectionViews.ts} (54%) rename src/constants/{configSchema.js => configSchema.tsx} (89%) rename src/constants/{validationErrorTypes.js => validationErrorTypes.ts} (50%) create mode 100644 src/editor-components/editorPlugin.ts rename src/editor-components/image/{index.js => index.tsx} (73%) create mode 100644 src/extensions.ts rename src/{index.js => index.ts} (58%) delete mode 100644 src/integrations/index.js create mode 100644 src/integrations/index.ts rename src/integrations/providers/algolia/{implementation.js => implementation.ts} (56%) rename src/integrations/providers/assetStore/{implementation.js => implementation.ts} (68%) rename src/lib/auth/{implicit-oauth.js => implicit-oauth.ts} (58%) delete mode 100644 src/lib/auth/index.d.ts delete mode 100644 src/lib/auth/index.js create mode 100644 src/lib/auth/index.ts rename src/lib/auth/{netlify-auth.js => netlify-auth.ts} (67%) rename src/lib/auth/{pkce-oauth.js => pkce-oauth.ts} (73%) rename src/lib/auth/{utils.js => utils.ts} (87%) rename src/lib/{consoleError.js => consoleError.ts} (69%) rename src/lib/{phrases.js => phrases.ts} (61%) delete mode 100644 src/lib/registry.js create mode 100644 src/lib/registry.ts delete mode 100644 src/lib/serializeEntryValues.js create mode 100644 src/lib/serializeEntryValues.ts rename src/lib/{textHelper.js => textHelper.ts} (84%) create mode 100644 src/lib/util/__tests__/object.util.ts create mode 100644 src/lib/util/collection.util.ts create mode 100644 src/lib/util/field.util.ts delete mode 100644 src/lib/util/loadScript.js create mode 100644 src/lib/util/loadScript.ts create mode 100644 src/lib/util/media.util.ts create mode 100644 src/lib/util/null.util.ts create mode 100644 src/lib/util/object.util.ts create mode 100644 src/lib/util/sort.util.ts create mode 100644 src/lib/util/string.util.ts delete mode 100644 src/lib/util/types/semaphore.d.ts delete mode 100644 src/lib/util/unsentRequest.js create mode 100644 src/lib/util/unsentRequest.ts create mode 100644 src/lib/util/validation.util.ts rename src/locales/bg/{index.js => index.ts} (99%) rename src/locales/ca/{index.js => index.ts} (99%) rename src/locales/cs/{index.js => index.ts} (99%) rename src/locales/da/{index.js => index.ts} (99%) rename src/locales/de/{index.js => index.ts} (99%) rename src/locales/en/{index.js => index.ts} (97%) rename src/locales/es/{index.js => index.ts} (99%) rename src/locales/fr/{index.js => index.ts} (99%) rename src/locales/gr/{index.js => index.ts} (99%) rename src/locales/he/{index.js => index.ts} (99%) rename src/locales/hr/{index.js => index.ts} (99%) rename src/locales/hu/{index.js => index.ts} (98%) rename src/locales/it/{index.js => index.ts} (98%) rename src/locales/ja/{index.js => index.ts} (99%) rename src/locales/ko/{index.js => index.ts} (99%) rename src/locales/lt/{index.js => index.ts} (99%) rename src/locales/nb_no/{index.js => index.ts} (98%) rename src/locales/nl/{index.js => index.ts} (99%) rename src/locales/nn_no/{index.js => index.ts} (98%) rename src/locales/pl/{index.js => index.ts} (99%) rename src/locales/pt/{index.js => index.ts} (99%) rename src/locales/ro/{index.js => index.ts} (99%) rename src/locales/ru/{index.js => index.ts} (99%) rename src/locales/sv/{index.js => index.ts} (99%) rename src/locales/th/{index.js => index.ts} (99%) rename src/locales/tr/{index.js => index.ts} (99%) rename src/locales/uk/{index.js => index.ts} (98%) rename src/locales/vi/{index.js => index.ts} (99%) rename src/locales/zh_Hans/{index.js => index.ts} (99%) rename src/locales/zh_Hant/{index.js => index.ts} (99%) rename src/media-libraries/cloudinary/{index.js => index.ts} (65%) rename src/media-libraries/uploadcare/{index.js => index.ts} (70%) delete mode 100644 src/reducers/cursors.js create mode 100644 src/reducers/cursors.ts delete mode 100644 src/reducers/entryDraft.js create mode 100644 src/reducers/entryDraft.ts create mode 100644 src/types/constants.d.ts create mode 100644 src/types/css.d.ts delete mode 100644 src/types/immutable.ts rename src/{backends/git-gateway => }/types/ini.d.ts (100%) create mode 100644 src/types/markdown.d.ts delete mode 100644 src/types/redux.ts rename src/{backends/bitbucket => }/types/semaphore.d.ts (100%) create mode 100644 src/types/svg.d.ts create mode 100644 src/types/uploadcare.d.ts rename src/{backends/bitbucket => }/types/what-the-diff.d.ts (100%) delete mode 100644 src/ui/AuthenticationPage.js delete mode 100644 src/ui/Dropdown.js delete mode 100644 src/ui/FieldLabel.js delete mode 100644 src/ui/GoBackButton.js delete mode 100644 src/ui/Icon.js delete mode 100644 src/ui/Icon/images/_index.js delete mode 100644 src/ui/Icon/images/add-with.svg delete mode 100644 src/ui/Icon/images/add.svg delete mode 100644 src/ui/Icon/images/arrow.svg delete mode 100644 src/ui/Icon/images/bold.svg delete mode 100644 src/ui/Icon/images/check.svg delete mode 100644 src/ui/Icon/images/chevron-double.svg delete mode 100644 src/ui/Icon/images/chevron.svg delete mode 100644 src/ui/Icon/images/circle.svg delete mode 100644 src/ui/Icon/images/close.svg delete mode 100644 src/ui/Icon/images/code-block.svg delete mode 100644 src/ui/Icon/images/code.svg delete mode 100644 src/ui/Icon/images/drag-handle.svg delete mode 100644 src/ui/Icon/images/eye.svg delete mode 100644 src/ui/Icon/images/folder.svg delete mode 100644 src/ui/Icon/images/grid.svg delete mode 100644 src/ui/Icon/images/h-options.svg delete mode 100644 src/ui/Icon/images/h1.svg delete mode 100644 src/ui/Icon/images/h2.svg delete mode 100644 src/ui/Icon/images/home.svg delete mode 100644 src/ui/Icon/images/image.svg delete mode 100644 src/ui/Icon/images/info-circle.svg delete mode 100644 src/ui/Icon/images/italic.svg delete mode 100644 src/ui/Icon/images/link.svg delete mode 100644 src/ui/Icon/images/list-bulleted.svg delete mode 100644 src/ui/Icon/images/list-numbered.svg delete mode 100644 src/ui/Icon/images/list.svg delete mode 100644 src/ui/Icon/images/markdown.svg delete mode 100644 src/ui/Icon/images/media-alt.svg delete mode 100644 src/ui/Icon/images/media.svg delete mode 100644 src/ui/Icon/images/netlify.svg delete mode 100644 src/ui/Icon/images/new-tab.svg delete mode 100644 src/ui/Icon/images/page.svg delete mode 100644 src/ui/Icon/images/pages-alt.svg delete mode 100644 src/ui/Icon/images/pages.svg delete mode 100644 src/ui/Icon/images/quote.svg delete mode 100644 src/ui/Icon/images/refresh.svg delete mode 100644 src/ui/Icon/images/scroll.svg delete mode 100644 src/ui/Icon/images/search.svg delete mode 100644 src/ui/Icon/images/settings.svg delete mode 100644 src/ui/Icon/images/user.svg delete mode 100644 src/ui/Icon/images/write.svg delete mode 100644 src/ui/IconButton.js delete mode 100644 src/ui/ListItemTopBar.js delete mode 100644 src/ui/Loader.js delete mode 100644 src/ui/ObjectWidgetTopBar.js delete mode 100644 src/ui/Toggle.js delete mode 100644 src/ui/WidgetPreviewContainer.js delete mode 100644 src/ui/index.js delete mode 100644 src/valueObjects/EditorComponent.js create mode 100644 src/valueObjects/EditorComponent.ts delete mode 100644 src/widgets/boolean/BooleanControl.js create mode 100644 src/widgets/boolean/BooleanControl.tsx delete mode 100644 src/widgets/boolean/index.js create mode 100644 src/widgets/boolean/index.ts delete mode 100644 src/widgets/code/CodeControl.js create mode 100644 src/widgets/code/CodeControl.tsx delete mode 100644 src/widgets/code/CodePreview.js create mode 100644 src/widgets/code/CodePreview.tsx delete mode 100644 src/widgets/code/SettingsButton.js create mode 100644 src/widgets/code/SettingsButton.tsx delete mode 100644 src/widgets/code/SettingsPane.js create mode 100644 src/widgets/code/SettingsPane.tsx delete mode 100644 src/widgets/code/data/languages.json create mode 100644 src/widgets/code/data/languages.ts delete mode 100644 src/widgets/code/index.js create mode 100644 src/widgets/code/index.ts rename src/widgets/code/{languageSelectStyles.js => languageSelectStyles.ts} (56%) rename src/widgets/code/{schema.js => schema.ts} (100%) rename src/widgets/code/scripts/{process-languages.js => process-languages.ts} (52%) delete mode 100644 src/widgets/colorstring/ColorControl.js create mode 100644 src/widgets/colorstring/ColorControl.tsx delete mode 100644 src/widgets/colorstring/ColorPreview.js create mode 100644 src/widgets/colorstring/ColorPreview.tsx delete mode 100644 src/widgets/colorstring/index.js create mode 100644 src/widgets/colorstring/index.ts delete mode 100644 src/widgets/datetime/DateTimeControl.js create mode 100644 src/widgets/datetime/DateTimeControl.tsx delete mode 100644 src/widgets/datetime/DateTimePreview.js create mode 100644 src/widgets/datetime/DateTimePreview.tsx rename src/widgets/datetime/{index.js => index.tsx} (51%) rename src/widgets/datetime/{schema.js => schema.ts} (100%) delete mode 100644 src/widgets/file/FilePreview.js create mode 100644 src/widgets/file/FilePreview.tsx delete mode 100644 src/widgets/file/index.js create mode 100644 src/widgets/file/index.ts rename src/widgets/file/{schema.js => schema.ts} (100%) delete mode 100644 src/widgets/file/withFileControl.js create mode 100644 src/widgets/file/withFileControl.tsx delete mode 100644 src/widgets/image/ImagePreview.js create mode 100644 src/widgets/image/ImagePreview.tsx delete mode 100644 src/widgets/image/index.js create mode 100644 src/widgets/image/index.ts rename src/widgets/image/{schema.js => schema.ts} (100%) delete mode 100644 src/widgets/list/ListControl.js create mode 100644 src/widgets/list/ListControl.tsx create mode 100644 src/widgets/list/ListItem.tsx delete mode 100644 src/widgets/list/index.js create mode 100644 src/widgets/list/index.ts rename src/widgets/list/{schema.js => schema.ts} (100%) delete mode 100644 src/widgets/list/typedListHelpers.js create mode 100644 src/widgets/list/typedListHelpers.ts delete mode 100644 src/widgets/map/MapPreview.js create mode 100644 src/widgets/map/MapPreview.tsx rename src/widgets/map/{index.js => index.ts} (57%) rename src/widgets/map/{schema.js => schema.ts} (100%) delete mode 100644 src/widgets/map/withMapControl.js create mode 100644 src/widgets/map/withMapControl.tsx create mode 100644 src/widgets/markdown/MarkdownControl.tsx delete mode 100644 src/widgets/markdown/MarkdownControl/RawEditor.js delete mode 100644 src/widgets/markdown/MarkdownControl/Toolbar.js delete mode 100644 src/widgets/markdown/MarkdownControl/ToolbarButton.js delete mode 100644 src/widgets/markdown/MarkdownControl/VisualEditor.js delete mode 100644 src/widgets/markdown/MarkdownControl/components/Shortcode.js delete mode 100644 src/widgets/markdown/MarkdownControl/components/VoidBlock.js delete mode 100644 src/widgets/markdown/MarkdownControl/index.js delete mode 100644 src/widgets/markdown/MarkdownControl/plugins/BreakToDefaultBlock.js delete mode 100644 src/widgets/markdown/MarkdownControl/plugins/CloseBlock.js delete mode 100644 src/widgets/markdown/MarkdownControl/plugins/CommandsAndQueries.js delete mode 100644 src/widgets/markdown/MarkdownControl/plugins/CopyPasteVisual.js delete mode 100644 src/widgets/markdown/MarkdownControl/plugins/ForceInsert.js delete mode 100644 src/widgets/markdown/MarkdownControl/plugins/Hotkey.js delete mode 100644 src/widgets/markdown/MarkdownControl/plugins/LineBreak.js delete mode 100644 src/widgets/markdown/MarkdownControl/plugins/Link.js delete mode 100644 src/widgets/markdown/MarkdownControl/plugins/List.js delete mode 100644 src/widgets/markdown/MarkdownControl/plugins/QuoteBlock.js delete mode 100644 src/widgets/markdown/MarkdownControl/plugins/SelectAll.js delete mode 100644 src/widgets/markdown/MarkdownControl/plugins/Shortcode.js delete mode 100644 src/widgets/markdown/MarkdownControl/plugins/util.js delete mode 100644 src/widgets/markdown/MarkdownControl/plugins/visual.js delete mode 100644 src/widgets/markdown/MarkdownControl/renderers.js delete mode 100644 src/widgets/markdown/MarkdownControl/schema.js delete mode 100644 src/widgets/markdown/MarkdownPreview.js create mode 100644 src/widgets/markdown/MarkdownPreview.tsx rename src/widgets/markdown/{index.js => index.ts} (51%) rename src/widgets/markdown/{regexHelper.js => regexHelper.ts} (85%) rename src/widgets/markdown/{schema.js => schema.ts} (79%) create mode 100644 src/widgets/markdown/serializers.ts delete mode 100644 src/widgets/markdown/serializers/index.js delete mode 100644 src/widgets/markdown/serializers/rehypePaperEmoji.js delete mode 100644 src/widgets/markdown/serializers/remarkAllowHtmlEntities.js delete mode 100644 src/widgets/markdown/serializers/remarkAssertParents.js delete mode 100644 src/widgets/markdown/serializers/remarkEscapeMarkdownEntities.js delete mode 100644 src/widgets/markdown/serializers/remarkImagesToText.js delete mode 100644 src/widgets/markdown/serializers/remarkPaddedLinks.js delete mode 100644 src/widgets/markdown/serializers/remarkRehypeShortcodes.js delete mode 100644 src/widgets/markdown/serializers/remarkShortcodes.js delete mode 100644 src/widgets/markdown/serializers/remarkSlate.js delete mode 100644 src/widgets/markdown/serializers/remarkSquashReferences.js delete mode 100644 src/widgets/markdown/serializers/remarkStripTrailingBreaks.js delete mode 100644 src/widgets/markdown/serializers/remarkWrapHtml.js delete mode 100644 src/widgets/markdown/serializers/slateRemark.js rename src/widgets/markdown/{styles.js => styles.ts} (83%) delete mode 100644 src/widgets/markdown/types.js create mode 100644 src/widgets/markdown/types.ts delete mode 100644 src/widgets/number/NumberControl.js create mode 100644 src/widgets/number/NumberControl.tsx delete mode 100644 src/widgets/number/NumberPreview.js create mode 100644 src/widgets/number/NumberPreview.tsx delete mode 100644 src/widgets/number/index.js create mode 100644 src/widgets/number/index.ts rename src/widgets/number/{schema.js => schema.ts} (100%) delete mode 100644 src/widgets/object/ObjectControl.js create mode 100644 src/widgets/object/ObjectControl.tsx delete mode 100644 src/widgets/object/ObjectPreview.js create mode 100644 src/widgets/object/ObjectPreview.tsx delete mode 100644 src/widgets/object/index.js create mode 100644 src/widgets/object/index.ts rename src/widgets/object/{schema.js => schema.ts} (100%) delete mode 100644 src/widgets/relation/RelationControl.js create mode 100644 src/widgets/relation/RelationControl.tsx delete mode 100644 src/widgets/relation/RelationPreview.js create mode 100644 src/widgets/relation/RelationPreview.tsx delete mode 100644 src/widgets/relation/index.js create mode 100644 src/widgets/relation/index.ts rename src/widgets/relation/{schema.js => schema.ts} (100%) delete mode 100644 src/widgets/select/SelectControl.js create mode 100644 src/widgets/select/SelectControl.tsx delete mode 100644 src/widgets/select/SelectPreview.js create mode 100644 src/widgets/select/SelectPreview.tsx delete mode 100644 src/widgets/select/index.js create mode 100644 src/widgets/select/index.ts rename src/widgets/select/{schema.js => schema.ts} (100%) create mode 100644 src/widgets/string/index.ts delete mode 100644 src/widgets/string/index.tsx delete mode 100644 src/widgets/text/TextControl.js create mode 100644 src/widgets/text/TextControl.tsx delete mode 100644 src/widgets/text/TextPreview.js create mode 100644 src/widgets/text/TextPreview.tsx rename src/widgets/text/{index.js => index.ts} (50%) delete mode 100644 things-to-remove.txt diff --git a/.eslintrc.js b/.eslintrc.js index 753acb04..37bacab7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,5 +1,3 @@ -const fs = require('fs'); - module.exports = { parser: 'babel-eslint', extends: [ @@ -21,9 +19,13 @@ module.exports = { CMS_ENV: false, }, rules: { + 'react-hooks/rules-of-hooks': 'error', // Checks rules of Hooks + 'react-hooks/exhaustive-deps': 'warn', // Checks effect dependencies 'no-console': [0], 'react/prop-types': [0], + 'react/require-default-props': 0, 'import/no-named-as-default': 0, + "react/react-in-jsx-scope": "off", 'import/order': [ 'error', { @@ -45,8 +47,16 @@ module.exports = { ], 'unicorn/prefer-string-slice': 'error', 'react/no-unknown-property': ['error', { ignore: ['css'] }], + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + argsIgnorePattern: '^_', + varsIgnorePattern: '^_', + caughtErrorsIgnorePattern: '^_', + }, + ], }, - plugins: ['babel', '@emotion', 'cypress', 'unicorn'], + plugins: ['babel', '@emotion', 'cypress', 'unicorn', 'react-hooks'], settings: { react: { version: 'detect', @@ -79,6 +89,9 @@ module.exports = { }, }, rules: { + "react/react-in-jsx-scope": "off", + 'react/prop-types': [0], + 'react/require-default-props': 0, 'no-duplicate-imports': [0], // handled by @typescript-eslint '@typescript-eslint/ban-types': [0], // TODO enable in future '@typescript-eslint/no-non-null-assertion': [0], diff --git a/babel.config.js b/babel.config.js index e5dd9da8..40a4d8b8 100644 --- a/babel.config.js +++ b/babel.config.js @@ -84,7 +84,7 @@ function plugins() { } if (!isProduction) { - return [...defaultPlugins, 'react-hot-loader/babel']; + return [...defaultPlugins]; } return defaultPlugins; diff --git a/dev-test/backends/azure/config.yml b/dev-test/backends/azure/config.yml new file mode 100644 index 00000000..926817bd --- /dev/null +++ b/dev-test/backends/azure/config.yml @@ -0,0 +1,471 @@ +backend: + name: azure + branch: master + repo: organization/project/repo # replace with actual path + tenant_id: tenantId # replace with your tenantId + app_id: appId # replace with your appId + +media_folder: static/media +public_folder: /media +collections: + - name: posts + label: Posts + label_singular: Post + description: > + The description is a great place for tone setting, high level information, + and editing guidelines that are specific to a collection. + folder: _posts + slug: '{{year}}-{{month}}-{{day}}-{{slug}}' + summary: '{{title}} -- {{year}}/{{month}}/{{day}}' + sortable_fields: + fields: + - title + - date + default: + field: title + create: true + view_filters: + - label: Posts With Index + field: title + pattern: 'This is post #' + - label: Posts Without Index + field: title + pattern: front matter post + - label: Drafts + field: draft + pattern: true + view_groups: + - label: Year + field: date + pattern: '\d{4}' + - label: Drafts + field: draft + fields: + - label: Title + name: title + widget: string + - label: Draft + name: draft + widget: boolean + default: false + - label: Publish Date + name: date + widget: datetime + date_format: yyyy-MM-dd + time_format: 'HH:mm' + format: 'yyyy-MM-dd HH:mm' + - label: Cover Image + name: image + widget: image + required: false + - label: Body + name: body + widget: markdown + hint: Main content goes here. + - name: faq + label: FAQ + folder: _faqs + create: true + fields: + - label: Question + name: title + widget: string + - label: Answer + name: body + widget: markdown + - name: posts + label: Posts + label_singular: Post + widget: list + summary: '{{fields.post | split(''|'', ''$1'')}}' + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + display_fields: + - title + - date + search_fields: + - title + - body + value_field: '{{title}}|{{date}}' + - name: settings + label: Settings + delete: false + editor: + preview: false + files: + - name: general + label: Site Settings + file: _data/settings.json + description: General Site Settings + fields: + - label: Number of posts on frontpage + name: front_limit + widget: number + min: 1 + max: 10 + - label: Global title + name: site_title + widget: string + - label: Post Settings + name: posts + widget: object + fields: + - label: Number of posts on frontpage + name: front_limit + widget: number + min: 1 + max: 10 + - label: Default Author + name: author + widget: string + - label: Default Thumbnail + name: thumb + widget: image + required: false + - name: authors + label: Authors + file: _data/authors.yml + description: Author descriptions + fields: + - name: authors + label: Authors + label_singular: Author + widget: list + fields: + - label: Name + name: name + widget: string + hint: First and Last + - label: Description + name: description + widget: text + - name: kitchenSink + label: Kitchen Sink + folder: _sink + create: true + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + display_fields: + - title + - date + search_fields: + - title + - body + value_field: title + - label: Title + name: title + widget: string + - label: Boolean + name: boolean + widget: boolean + default: true + - label: Map + name: map + widget: map + - label: Text + name: text + widget: text + hint: 'Plain text, not markdown' + - label: Number + name: number + widget: number + hint: To infinity and beyond! + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Color + name: color + widget: color + - label: Color string editable and alpha enabled + name: colorEditable + widget: color + enable_alpha: true + allow_input: true + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Select multiple + name: select_multiple + widget: select + options: + - a + - b + - c + multiple: true + - label: Select numeric + name: select_numeric + widget: select + options: + - label: One + value: 1 + - label: Two + value: 2 + - label: Three + value: 3 + - label: Hidden + name: hidden + widget: hidden + default: hidden + - label: Object + name: object + widget: object + collapsed: true + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + search_fields: + - title + - body + value_field: title + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + default: false + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: List + name: list + widget: list + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Object + name: object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: List + name: list + widget: list + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + search_fields: + - title + - body + value_field: title + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: text + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Hidden + name: hidden + widget: hidden + default: hidden + - label: Object + name: object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: text + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Typed List + name: typed_list + widget: list + types: + - label: Type 1 Object + name: type_1_object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Type 2 Object + name: type_2_object + widget: object + fields: + - label: Number + name: number + widget: number + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Datetime + name: datetime + widget: datetime + - label: Markdown + name: markdown + widget: text + - label: Type 3 Object + name: type_3_object + widget: object + fields: + - label: Image + name: image + widget: image + - label: File + name: file + widget: file diff --git a/dev-test/backends/azure/index.html b/dev-test/backends/azure/index.html new file mode 100644 index 00000000..4c589562 --- /dev/null +++ b/dev-test/backends/azure/index.html @@ -0,0 +1,12 @@ + + + + + + Static CMS - Azure Development Test + + + + + + diff --git a/dev-test/backends/bitbucket/config.yml b/dev-test/backends/bitbucket/config.yml new file mode 100644 index 00000000..3237b24f --- /dev/null +++ b/dev-test/backends/bitbucket/config.yml @@ -0,0 +1,469 @@ +backend: + name: bitbucket + branch: master + repo: owner/repo + +media_folder: static/media +public_folder: /media +collections: + - name: posts + label: Posts + label_singular: Post + description: > + The description is a great place for tone setting, high level information, + and editing guidelines that are specific to a collection. + folder: _posts + slug: '{{year}}-{{month}}-{{day}}-{{slug}}' + summary: '{{title}} -- {{year}}/{{month}}/{{day}}' + sortable_fields: + fields: + - title + - date + default: + field: title + create: true + view_filters: + - label: Posts With Index + field: title + pattern: 'This is post #' + - label: Posts Without Index + field: title + pattern: front matter post + - label: Drafts + field: draft + pattern: true + view_groups: + - label: Year + field: date + pattern: '\d{4}' + - label: Drafts + field: draft + fields: + - label: Title + name: title + widget: string + - label: Draft + name: draft + widget: boolean + default: false + - label: Publish Date + name: date + widget: datetime + date_format: yyyy-MM-dd + time_format: 'HH:mm' + format: 'yyyy-MM-dd HH:mm' + - label: Cover Image + name: image + widget: image + required: false + - label: Body + name: body + widget: markdown + hint: Main content goes here. + - name: faq + label: FAQ + folder: _faqs + create: true + fields: + - label: Question + name: title + widget: string + - label: Answer + name: body + widget: markdown + - name: posts + label: Posts + label_singular: Post + widget: list + summary: '{{fields.post | split(''|'', ''$1'')}}' + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + display_fields: + - title + - date + search_fields: + - title + - body + value_field: '{{title}}|{{date}}' + - name: settings + label: Settings + delete: false + editor: + preview: false + files: + - name: general + label: Site Settings + file: _data/settings.json + description: General Site Settings + fields: + - label: Number of posts on frontpage + name: front_limit + widget: number + min: 1 + max: 10 + - label: Global title + name: site_title + widget: string + - label: Post Settings + name: posts + widget: object + fields: + - label: Number of posts on frontpage + name: front_limit + widget: number + min: 1 + max: 10 + - label: Default Author + name: author + widget: string + - label: Default Thumbnail + name: thumb + widget: image + required: false + - name: authors + label: Authors + file: _data/authors.yml + description: Author descriptions + fields: + - name: authors + label: Authors + label_singular: Author + widget: list + fields: + - label: Name + name: name + widget: string + hint: First and Last + - label: Description + name: description + widget: text + - name: kitchenSink + label: Kitchen Sink + folder: _sink + create: true + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + display_fields: + - title + - date + search_fields: + - title + - body + value_field: title + - label: Title + name: title + widget: string + - label: Boolean + name: boolean + widget: boolean + default: true + - label: Map + name: map + widget: map + - label: Text + name: text + widget: text + hint: 'Plain text, not markdown' + - label: Number + name: number + widget: number + hint: To infinity and beyond! + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Color + name: color + widget: color + - label: Color string editable and alpha enabled + name: colorEditable + widget: color + enable_alpha: true + allow_input: true + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Select multiple + name: select_multiple + widget: select + options: + - a + - b + - c + multiple: true + - label: Select numeric + name: select_numeric + widget: select + options: + - label: One + value: 1 + - label: Two + value: 2 + - label: Three + value: 3 + - label: Hidden + name: hidden + widget: hidden + default: hidden + - label: Object + name: object + widget: object + collapsed: true + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + search_fields: + - title + - body + value_field: title + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + default: false + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: List + name: list + widget: list + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Object + name: object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: List + name: list + widget: list + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + search_fields: + - title + - body + value_field: title + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: text + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Hidden + name: hidden + widget: hidden + default: hidden + - label: Object + name: object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: text + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Typed List + name: typed_list + widget: list + types: + - label: Type 1 Object + name: type_1_object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Type 2 Object + name: type_2_object + widget: object + fields: + - label: Number + name: number + widget: number + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Datetime + name: datetime + widget: datetime + - label: Markdown + name: markdown + widget: text + - label: Type 3 Object + name: type_3_object + widget: object + fields: + - label: Image + name: image + widget: image + - label: File + name: file + widget: file diff --git a/dev-test/backends/bitbucket/index.html b/dev-test/backends/bitbucket/index.html new file mode 100644 index 00000000..aaf1ec46 --- /dev/null +++ b/dev-test/backends/bitbucket/index.html @@ -0,0 +1,12 @@ + + + + + + Static CMS - Bitbucket Development Test + + + + + + diff --git a/dev-test/backends/git-gateway/config.yml b/dev-test/backends/git-gateway/config.yml new file mode 100644 index 00000000..e6410057 --- /dev/null +++ b/dev-test/backends/git-gateway/config.yml @@ -0,0 +1,468 @@ +backend: + name: git-gateway + branch: master + +media_folder: static/media +public_folder: /media +collections: + - name: posts + label: Posts + label_singular: Post + description: > + The description is a great place for tone setting, high level information, + and editing guidelines that are specific to a collection. + folder: _posts + slug: '{{year}}-{{month}}-{{day}}-{{slug}}' + summary: '{{title}} -- {{year}}/{{month}}/{{day}}' + sortable_fields: + fields: + - title + - date + default: + field: title + create: true + view_filters: + - label: Posts With Index + field: title + pattern: 'This is post #' + - label: Posts Without Index + field: title + pattern: front matter post + - label: Drafts + field: draft + pattern: true + view_groups: + - label: Year + field: date + pattern: '\d{4}' + - label: Drafts + field: draft + fields: + - label: Title + name: title + widget: string + - label: Draft + name: draft + widget: boolean + default: false + - label: Publish Date + name: date + widget: datetime + date_format: yyyy-MM-dd + time_format: 'HH:mm' + format: 'yyyy-MM-dd HH:mm' + - label: Cover Image + name: image + widget: image + required: false + - label: Body + name: body + widget: markdown + hint: Main content goes here. + - name: faq + label: FAQ + folder: _faqs + create: true + fields: + - label: Question + name: title + widget: string + - label: Answer + name: body + widget: markdown + - name: posts + label: Posts + label_singular: Post + widget: list + summary: '{{fields.post | split(''|'', ''$1'')}}' + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + display_fields: + - title + - date + search_fields: + - title + - body + value_field: '{{title}}|{{date}}' + - name: settings + label: Settings + delete: false + editor: + preview: false + files: + - name: general + label: Site Settings + file: _data/settings.json + description: General Site Settings + fields: + - label: Number of posts on frontpage + name: front_limit + widget: number + min: 1 + max: 10 + - label: Global title + name: site_title + widget: string + - label: Post Settings + name: posts + widget: object + fields: + - label: Number of posts on frontpage + name: front_limit + widget: number + min: 1 + max: 10 + - label: Default Author + name: author + widget: string + - label: Default Thumbnail + name: thumb + widget: image + required: false + - name: authors + label: Authors + file: _data/authors.yml + description: Author descriptions + fields: + - name: authors + label: Authors + label_singular: Author + widget: list + fields: + - label: Name + name: name + widget: string + hint: First and Last + - label: Description + name: description + widget: text + - name: kitchenSink + label: Kitchen Sink + folder: _sink + create: true + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + display_fields: + - title + - date + search_fields: + - title + - body + value_field: title + - label: Title + name: title + widget: string + - label: Boolean + name: boolean + widget: boolean + default: true + - label: Map + name: map + widget: map + - label: Text + name: text + widget: text + hint: 'Plain text, not markdown' + - label: Number + name: number + widget: number + hint: To infinity and beyond! + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Color + name: color + widget: color + - label: Color string editable and alpha enabled + name: colorEditable + widget: color + enable_alpha: true + allow_input: true + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Select multiple + name: select_multiple + widget: select + options: + - a + - b + - c + multiple: true + - label: Select numeric + name: select_numeric + widget: select + options: + - label: One + value: 1 + - label: Two + value: 2 + - label: Three + value: 3 + - label: Hidden + name: hidden + widget: hidden + default: hidden + - label: Object + name: object + widget: object + collapsed: true + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + search_fields: + - title + - body + value_field: title + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + default: false + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: List + name: list + widget: list + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Object + name: object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: List + name: list + widget: list + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + search_fields: + - title + - body + value_field: title + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: text + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Hidden + name: hidden + widget: hidden + default: hidden + - label: Object + name: object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: text + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Typed List + name: typed_list + widget: list + types: + - label: Type 1 Object + name: type_1_object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Type 2 Object + name: type_2_object + widget: object + fields: + - label: Number + name: number + widget: number + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Datetime + name: datetime + widget: datetime + - label: Markdown + name: markdown + widget: text + - label: Type 3 Object + name: type_3_object + widget: object + fields: + - label: Image + name: image + widget: image + - label: File + name: file + widget: file diff --git a/dev-test/backends/git-gateway/index.html b/dev-test/backends/git-gateway/index.html new file mode 100644 index 00000000..03a2b384 --- /dev/null +++ b/dev-test/backends/git-gateway/index.html @@ -0,0 +1,12 @@ + + + + + + Static CMS - Git Gateway Development Test + + + + + + diff --git a/dev-test/backends/github/config.yml b/dev-test/backends/github/config.yml new file mode 100644 index 00000000..9a489331 --- /dev/null +++ b/dev-test/backends/github/config.yml @@ -0,0 +1,469 @@ +backend: + name: github + branch: master + repo: owner/repo + +media_folder: static/media +public_folder: /media +collections: + - name: posts + label: Posts + label_singular: Post + description: > + The description is a great place for tone setting, high level information, + and editing guidelines that are specific to a collection. + folder: _posts + slug: '{{year}}-{{month}}-{{day}}-{{slug}}' + summary: '{{title}} -- {{year}}/{{month}}/{{day}}' + sortable_fields: + fields: + - title + - date + default: + field: title + create: true + view_filters: + - label: Posts With Index + field: title + pattern: 'This is post #' + - label: Posts Without Index + field: title + pattern: front matter post + - label: Drafts + field: draft + pattern: true + view_groups: + - label: Year + field: date + pattern: '\d{4}' + - label: Drafts + field: draft + fields: + - label: Title + name: title + widget: string + - label: Draft + name: draft + widget: boolean + default: false + - label: Publish Date + name: date + widget: datetime + date_format: yyyy-MM-dd + time_format: 'HH:mm' + format: 'yyyy-MM-dd HH:mm' + - label: Cover Image + name: image + widget: image + required: false + - label: Body + name: body + widget: markdown + hint: Main content goes here. + - name: faq + label: FAQ + folder: _faqs + create: true + fields: + - label: Question + name: title + widget: string + - label: Answer + name: body + widget: markdown + - name: posts + label: Posts + label_singular: Post + widget: list + summary: '{{fields.post | split(''|'', ''$1'')}}' + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + display_fields: + - title + - date + search_fields: + - title + - body + value_field: '{{title}}|{{date}}' + - name: settings + label: Settings + delete: false + editor: + preview: false + files: + - name: general + label: Site Settings + file: _data/settings.json + description: General Site Settings + fields: + - label: Number of posts on frontpage + name: front_limit + widget: number + min: 1 + max: 10 + - label: Global title + name: site_title + widget: string + - label: Post Settings + name: posts + widget: object + fields: + - label: Number of posts on frontpage + name: front_limit + widget: number + min: 1 + max: 10 + - label: Default Author + name: author + widget: string + - label: Default Thumbnail + name: thumb + widget: image + required: false + - name: authors + label: Authors + file: _data/authors.yml + description: Author descriptions + fields: + - name: authors + label: Authors + label_singular: Author + widget: list + fields: + - label: Name + name: name + widget: string + hint: First and Last + - label: Description + name: description + widget: text + - name: kitchenSink + label: Kitchen Sink + folder: _sink + create: true + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + display_fields: + - title + - date + search_fields: + - title + - body + value_field: title + - label: Title + name: title + widget: string + - label: Boolean + name: boolean + widget: boolean + default: true + - label: Map + name: map + widget: map + - label: Text + name: text + widget: text + hint: 'Plain text, not markdown' + - label: Number + name: number + widget: number + hint: To infinity and beyond! + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Color + name: color + widget: color + - label: Color string editable and alpha enabled + name: colorEditable + widget: color + enable_alpha: true + allow_input: true + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Select multiple + name: select_multiple + widget: select + options: + - a + - b + - c + multiple: true + - label: Select numeric + name: select_numeric + widget: select + options: + - label: One + value: 1 + - label: Two + value: 2 + - label: Three + value: 3 + - label: Hidden + name: hidden + widget: hidden + default: hidden + - label: Object + name: object + widget: object + collapsed: true + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + search_fields: + - title + - body + value_field: title + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + default: false + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: List + name: list + widget: list + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Object + name: object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: List + name: list + widget: list + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + search_fields: + - title + - body + value_field: title + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: text + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Hidden + name: hidden + widget: hidden + default: hidden + - label: Object + name: object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: text + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Typed List + name: typed_list + widget: list + types: + - label: Type 1 Object + name: type_1_object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Type 2 Object + name: type_2_object + widget: object + fields: + - label: Number + name: number + widget: number + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Datetime + name: datetime + widget: datetime + - label: Markdown + name: markdown + widget: text + - label: Type 3 Object + name: type_3_object + widget: object + fields: + - label: Image + name: image + widget: image + - label: File + name: file + widget: file diff --git a/dev-test/backends/github/index.html b/dev-test/backends/github/index.html new file mode 100644 index 00000000..5eb1e792 --- /dev/null +++ b/dev-test/backends/github/index.html @@ -0,0 +1,12 @@ + + + + + + Static CMS - GitHub Development Test + + + + + + diff --git a/dev-test/backends/gitlab/config.yml b/dev-test/backends/gitlab/config.yml new file mode 100644 index 00000000..6d41d3fa --- /dev/null +++ b/dev-test/backends/gitlab/config.yml @@ -0,0 +1,469 @@ +backend: + name: gitlab + branch: master + repo: owner/repo + +media_folder: static/media +public_folder: /media +collections: + - name: posts + label: Posts + label_singular: Post + description: > + The description is a great place for tone setting, high level information, + and editing guidelines that are specific to a collection. + folder: _posts + slug: '{{year}}-{{month}}-{{day}}-{{slug}}' + summary: '{{title}} -- {{year}}/{{month}}/{{day}}' + sortable_fields: + fields: + - title + - date + default: + field: title + create: true + view_filters: + - label: Posts With Index + field: title + pattern: 'This is post #' + - label: Posts Without Index + field: title + pattern: front matter post + - label: Drafts + field: draft + pattern: true + view_groups: + - label: Year + field: date + pattern: '\d{4}' + - label: Drafts + field: draft + fields: + - label: Title + name: title + widget: string + - label: Draft + name: draft + widget: boolean + default: false + - label: Publish Date + name: date + widget: datetime + date_format: yyyy-MM-dd + time_format: 'HH:mm' + format: 'yyyy-MM-dd HH:mm' + - label: Cover Image + name: image + widget: image + required: false + - label: Body + name: body + widget: markdown + hint: Main content goes here. + - name: faq + label: FAQ + folder: _faqs + create: true + fields: + - label: Question + name: title + widget: string + - label: Answer + name: body + widget: markdown + - name: posts + label: Posts + label_singular: Post + widget: list + summary: '{{fields.post | split(''|'', ''$1'')}}' + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + display_fields: + - title + - date + search_fields: + - title + - body + value_field: '{{title}}|{{date}}' + - name: settings + label: Settings + delete: false + editor: + preview: false + files: + - name: general + label: Site Settings + file: _data/settings.json + description: General Site Settings + fields: + - label: Number of posts on frontpage + name: front_limit + widget: number + min: 1 + max: 10 + - label: Global title + name: site_title + widget: string + - label: Post Settings + name: posts + widget: object + fields: + - label: Number of posts on frontpage + name: front_limit + widget: number + min: 1 + max: 10 + - label: Default Author + name: author + widget: string + - label: Default Thumbnail + name: thumb + widget: image + required: false + - name: authors + label: Authors + file: _data/authors.yml + description: Author descriptions + fields: + - name: authors + label: Authors + label_singular: Author + widget: list + fields: + - label: Name + name: name + widget: string + hint: First and Last + - label: Description + name: description + widget: text + - name: kitchenSink + label: Kitchen Sink + folder: _sink + create: true + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + display_fields: + - title + - date + search_fields: + - title + - body + value_field: title + - label: Title + name: title + widget: string + - label: Boolean + name: boolean + widget: boolean + default: true + - label: Map + name: map + widget: map + - label: Text + name: text + widget: text + hint: 'Plain text, not markdown' + - label: Number + name: number + widget: number + hint: To infinity and beyond! + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Color + name: color + widget: color + - label: Color string editable and alpha enabled + name: colorEditable + widget: color + enable_alpha: true + allow_input: true + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Select multiple + name: select_multiple + widget: select + options: + - a + - b + - c + multiple: true + - label: Select numeric + name: select_numeric + widget: select + options: + - label: One + value: 1 + - label: Two + value: 2 + - label: Three + value: 3 + - label: Hidden + name: hidden + widget: hidden + default: hidden + - label: Object + name: object + widget: object + collapsed: true + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + search_fields: + - title + - body + value_field: title + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + default: false + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: List + name: list + widget: list + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Object + name: object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: List + name: list + widget: list + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + search_fields: + - title + - body + value_field: title + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: text + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Hidden + name: hidden + widget: hidden + default: hidden + - label: Object + name: object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: text + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Typed List + name: typed_list + widget: list + types: + - label: Type 1 Object + name: type_1_object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Type 2 Object + name: type_2_object + widget: object + fields: + - label: Number + name: number + widget: number + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Datetime + name: datetime + widget: datetime + - label: Markdown + name: markdown + widget: text + - label: Type 3 Object + name: type_3_object + widget: object + fields: + - label: Image + name: image + widget: image + - label: File + name: file + widget: file diff --git a/dev-test/backends/gitlab/index.html b/dev-test/backends/gitlab/index.html new file mode 100644 index 00000000..a63fb7a5 --- /dev/null +++ b/dev-test/backends/gitlab/index.html @@ -0,0 +1,12 @@ + + + + + + Static CMS - GitLab Development Test + + + + + + diff --git a/dev-test/backends/proxy/config.yml b/dev-test/backends/proxy/config.yml new file mode 100644 index 00000000..5d54289b --- /dev/null +++ b/dev-test/backends/proxy/config.yml @@ -0,0 +1,472 @@ +backend: + name: github + branch: main + repo: owner/repo + +media_folder: static/media +public_folder: /media + +local_backend: true + +collections: + - name: posts + label: Posts + label_singular: Post + description: > + The description is a great place for tone setting, high level information, + and editing guidelines that are specific to a collection. + folder: _posts + slug: '{{year}}-{{month}}-{{day}}-{{slug}}' + summary: '{{title}} -- {{year}}/{{month}}/{{day}}' + sortable_fields: + fields: + - title + - date + default: + field: title + create: true + view_filters: + - label: Posts With Index + field: title + pattern: 'This is post #' + - label: Posts Without Index + field: title + pattern: front matter post + - label: Drafts + field: draft + pattern: true + view_groups: + - label: Year + field: date + pattern: '\d{4}' + - label: Drafts + field: draft + fields: + - label: Title + name: title + widget: string + - label: Draft + name: draft + widget: boolean + default: false + - label: Publish Date + name: date + widget: datetime + date_format: yyyy-MM-dd + time_format: 'HH:mm' + format: 'yyyy-MM-dd HH:mm' + - label: Cover Image + name: image + widget: image + required: false + - label: Body + name: body + widget: markdown + hint: Main content goes here. + - name: faq + label: FAQ + folder: _faqs + create: true + fields: + - label: Question + name: title + widget: string + - label: Answer + name: body + widget: markdown + - name: posts + label: Posts + label_singular: Post + widget: list + summary: '{{fields.post | split(''|'', ''$1'')}}' + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + display_fields: + - title + - date + search_fields: + - title + - body + value_field: '{{title}}|{{date}}' + - name: settings + label: Settings + delete: false + editor: + preview: false + files: + - name: general + label: Site Settings + file: _data/settings.json + description: General Site Settings + fields: + - label: Number of posts on frontpage + name: front_limit + widget: number + min: 1 + max: 10 + - label: Global title + name: site_title + widget: string + - label: Post Settings + name: posts + widget: object + fields: + - label: Number of posts on frontpage + name: front_limit + widget: number + min: 1 + max: 10 + - label: Default Author + name: author + widget: string + - label: Default Thumbnail + name: thumb + widget: image + required: false + - name: authors + label: Authors + file: _data/authors.yml + description: Author descriptions + fields: + - name: authors + label: Authors + label_singular: Author + widget: list + fields: + - label: Name + name: name + widget: string + hint: First and Last + - label: Description + name: description + widget: text + - name: kitchenSink + label: Kitchen Sink + folder: _sink + create: true + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + display_fields: + - title + - date + search_fields: + - title + - body + value_field: title + - label: Title + name: title + widget: string + - label: Boolean + name: boolean + widget: boolean + default: true + - label: Map + name: map + widget: map + - label: Text + name: text + widget: text + hint: 'Plain text, not markdown' + - label: Number + name: number + widget: number + hint: To infinity and beyond! + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Color + name: color + widget: color + - label: Color string editable and alpha enabled + name: colorEditable + widget: color + enable_alpha: true + allow_input: true + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Select multiple + name: select_multiple + widget: select + options: + - a + - b + - c + multiple: true + - label: Select numeric + name: select_numeric + widget: select + options: + - label: One + value: 1 + - label: Two + value: 2 + - label: Three + value: 3 + - label: Hidden + name: hidden + widget: hidden + default: hidden + - label: Object + name: object + widget: object + collapsed: true + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + search_fields: + - title + - body + value_field: title + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + default: false + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: List + name: list + widget: list + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Object + name: object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: markdown + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: List + name: list + widget: list + fields: + - label: Related Post + name: post + widget: relationKitchenSinkPost + collection: posts + search_fields: + - title + - body + value_field: title + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: text + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Hidden + name: hidden + widget: hidden + default: hidden + - label: Object + name: object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Number + name: number + widget: number + - label: Markdown + name: markdown + widget: text + - label: Datetime + name: datetime + widget: datetime + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Typed List + name: typed_list + widget: list + types: + - label: Type 1 Object + name: type_1_object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Type 2 Object + name: type_2_object + widget: object + fields: + - label: Number + name: number + widget: number + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Datetime + name: datetime + widget: datetime + - label: Markdown + name: markdown + widget: text + - label: Type 3 Object + name: type_3_object + widget: object + fields: + - label: Image + name: image + widget: image + - label: File + name: file + widget: file diff --git a/dev-test/backends/proxy/index.html b/dev-test/backends/proxy/index.html new file mode 100644 index 00000000..38c207b6 --- /dev/null +++ b/dev-test/backends/proxy/index.html @@ -0,0 +1,12 @@ + + + + + + Static CMS - Proxy Development Test + + + + + + diff --git a/dev-test/config.yml b/dev-test/config.yml index 350ccccf..94f33736 100644 --- a/dev-test/config.yml +++ b/dev-test/config.yml @@ -55,7 +55,7 @@ collections: required: false - label: Body name: body - widget: text + widget: markdown hint: Main content goes here. - name: faq label: FAQ @@ -67,12 +67,12 @@ collections: widget: string - label: Answer name: body - widget: text + widget: markdown - name: posts label: Posts label_singular: Post widget: list - summary: '{{fields.post | split(''|'', ''$1'')}}' + summary: "{{fields.post | split('|', '$1')}}" fields: - label: Related Post name: post @@ -85,6 +85,408 @@ collections: - title - body value_field: '{{title}}|{{date}}' + - name: widgets + label: Widgets + delete: false + editor: + preview: false + files: + - name: boolean + label: Boolean + file: _widgets/boolean.json + description: Boolean widget + fields: + - name: required + label: 'Required Validation' + widget: boolean + - name: pattern + label: 'Pattern Validation' + widget: boolean + pattern: ['true', 'Must be true'] + required: false + - name: code + label: Code + file: _widgets/code.json + description: Code widget + fields: + - name: required + label: 'Required Validation' + widget: code + - name: pattern + label: 'Pattern Validation' + widget: code + pattern: ['.{12,}', 'Must have at least 12 characters'] + allow_input: true + required: false + - name: language + label: 'Language Selection' + widget: code + allow_language_selection: true + required: false + - name: color + label: Color + file: _widgets/color.json + description: Color widget + fields: + - name: required + label: 'Required Validation' + widget: color + - name: pattern + label: 'Pattern Validation' + widget: color + pattern: ['^#([0-9a-fA-F]{3})(?:[0-9a-fA-F]{3})?$', 'Must be a valid hex code'] + allow_input: true + required: false + - name: alpha + label: Alpha + widget: color + enable_alpha: true + required: false + - name: datetime + label: DateTime + file: _widgets/datetime.json + description: DateTime widget + fields: + - name: required + label: 'Required Validation' + widget: datetime + - name: pattern + label: 'Pattern Validation' + widget: datetime + format: 'MMM d, yyyy h:mm aaa' + date_format: 'MMM d, yyyy' + time_format: 'h:mm aaa' + pattern: ['pm', 'Must be in the afternoon'] + required: false + - name: date_and_time + label: Date and Time + widget: datetime + format: 'MMM d, yyyy h:mm aaa' + date_format: 'MMM d, yyyy' + time_format: 'h:mm aaa' + required: false + - name: date + label: Date + widget: datetime + format: 'MMM d, yyyy' + date_format: 'MMM d, yyyy' + required: false + - name: time + label: Time + widget: datetime + format: 'h:mm aaa' + time_format: 'h:mm aaa' + required: false + - name: file + label: File + file: _widgets/file.json + description: File widget + fields: + - name: required + label: 'Required Validation' + widget: file + - name: pattern + label: 'Pattern Validation' + widget: file + pattern: ['\.pdf', 'Must be a pdf'] + required: false + - name: choose_url + label: 'Choose URL' + widget: file + required: false + media_library: + choose_url: true + - name: image + label: Image + file: _widgets/image.json + description: Image widget + fields: + - name: required + label: 'Required Validation' + widget: image + - name: pattern + label: 'Pattern Validation' + widget: image + pattern: ['\.png', 'Must be a png'] + required: false + - name: choose_url + label: 'Choose URL' + widget: image + required: false + media_library: + choose_url: true + - name: list + label: List + file: _widgets/list.yml + description: List widget + fields: + - name: list + label: List + widget: list + fields: + - label: Name + name: name + widget: string + hint: First and Last + - label: Description + name: description + widget: text + - name: typed_list + label: Typed List + widget: list + types: + - label: Type 1 Object + name: type_1_object + widget: object + fields: + - label: String + name: string + widget: string + - label: Boolean + name: boolean + widget: boolean + - label: Text + name: text + widget: text + - label: Type 2 Object + name: type_2_object + widget: object + fields: + - label: Number + name: number + widget: number + - label: Select + name: select + widget: select + options: + - a + - b + - c + - label: Datetime + name: datetime + widget: datetime + - label: Markdown + name: markdown + widget: text + - label: Type 3 Object + name: type_3_object + widget: object + fields: + - label: Image + name: image + widget: image + - label: File + name: file + widget: file + - name: map + label: Map + file: _widgets/map.json + description: Map widget + fields: + - name: required + label: 'Required Validation' + widget: map + - name: pattern + label: 'Pattern Validation' + widget: map + pattern: ['\[-([7-9][0-9]|1[0-2][0-9])\.', 'Must be between latitude -70 and -129'] + required: false + - name: markdown + label: Markdown + file: _widgets/markdown.json + description: Markdown widget + fields: + - name: required + label: 'Required Validation' + widget: markdown + - name: pattern + label: 'Pattern Validation' + widget: markdown + pattern: ['# [a-zA-Z0-9]+', 'Must have a header'] + required: false + - name: number + label: Number + file: _widgets/number.json + description: Number widget + fields: + - name: required + label: 'Required Validation' + widget: number + - name: min + label: 'Min Validation' + widget: number + min: 5 + required: false + - name: max + label: 'Max Validation' + widget: number + max: 10 + required: false + - name: min_and_max + label: 'Min and Max Validation' + widget: number + min: 5 + max: 10 + required: false + - name: pattern + label: 'Pattern Validation' + widget: number + pattern: ['[0-9]{3,}', 'Must be at least 3 digits'] + required: false + - name: object + label: Object + file: _widgets/object.json + description: Object widget + fields: + - label: Required Validation + name: required + widget: object + fields: + - label: Number of posts on frontpage + name: front_limit + widget: number + - label: Default Author + name: author + widget: string + - label: Default Thumbnail + name: thumb + widget: image + - label: Optional Validation + name: optional + widget: object + required: false + fields: + - label: Number of posts on frontpage + name: front_limit + widget: number + required: false + - label: Default Author + name: author + widget: string + required: false + - label: Default Thumbnail + name: thumb + widget: image + required: false + - name: relation + label: Relation + file: _widgets/relation.json + description: Relation widget + fields: + - label: Required Validation + name: required + widget: relation + collection: posts + display_fields: + - title + - date + search_fields: + - title + - body + value_field: title + - label: Optional Validation + name: optional + widget: relation + required: false + collection: posts + display_fields: + - title + - date + search_fields: + - title + - body + value_field: title + - label: Multiple + name: multiple + widget: relation + multiple: true + required: false + collection: posts + display_fields: + - title + - date + search_fields: + - title + - body + value_field: title + - name: select + label: Select + file: _widgets/select.json + description: Select widget + fields: + - label: Required Validation + name: required + widget: select + options: + - a + - b + - c + - label: Pattern Validation + name: pattern + widget: select + options: + - a + - b + - c + pattern: ['[a-b]', 'Must be a or b'] + required: false + - label: Value and Label + name: value_and_label + widget: select + options: + - value: a + label: A fancy label + - value: b + label: Another fancy label + - value: c + label: And one more fancy label + - label: Multiple + name: multiple + widget: select + options: + - a + - b + - c + pattern: ['[a-b]', 'Must be a or b'] + multiple: true + required: false + - label: Value and Label Multiple + name: value_and_label_multiple + widget: select + multiple: true + options: + - value: a + label: A fancy label + - value: b + label: Another fancy label + - value: c + label: And one more fancy label + - name: string + label: String + file: _widgets/string.json + description: String widget + fields: + - name: required + label: 'Required Validation' + widget: string + - name: pattern + label: 'Pattern Validation' + widget: string + pattern: ['.{12,}', 'Must have at least 12 characters'] + required: false + - name: text + label: Text + file: _widgets/text.json + description: Text widget + fields: + - name: required + label: 'Required Validation' + widget: text + - name: pattern + label: 'Pattern Validation' + widget: text + pattern: ['.{12,}', 'Must have at least 12 characters'] + required: false - name: settings label: Settings delete: false @@ -173,21 +575,18 @@ collections: hint: To infinity and beyond! - label: Markdown name: markdown - widget: string + widget: markdown - label: Datetime name: datetime widget: datetime - - label: Date - name: date - widget: datetime - label: Color name: color widget: color - label: Color string editable and alpha enabled name: colorEditable widget: color - enableAlpha: true - allowInput: true + enable_alpha: true + allow_input: true - label: Image name: image widget: image @@ -251,13 +650,10 @@ collections: widget: number - label: Markdown name: markdown - widget: text + widget: markdown - label: Datetime name: datetime widget: datetime - - label: Date - name: date - widget: datetime - label: Image name: image widget: image @@ -289,13 +685,10 @@ collections: widget: number - label: Markdown name: markdown - widget: text + widget: markdown - label: Datetime name: datetime widget: datetime - - label: Date - name: date - widget: datetime - label: Image name: image widget: image @@ -327,13 +720,10 @@ collections: widget: number - label: Markdown name: markdown - widget: text + widget: markdown - label: Datetime name: datetime widget: datetime - - label: Date - name: date - widget: datetime - label: Image name: image widget: image @@ -377,9 +767,6 @@ collections: - label: Datetime name: datetime widget: datetime - - label: Date - name: date - widget: datetime - label: Image name: image widget: image @@ -419,9 +806,6 @@ collections: - label: Datetime name: datetime widget: datetime - - label: Date - name: date - widget: datetime - label: Image name: image widget: image @@ -476,9 +860,6 @@ collections: name: type_3_object widget: object fields: - - label: Date - name: date - widget: datetime - label: Image name: image widget: image diff --git a/dev-test/index.html b/dev-test/index.html index 883ec515..c2e6ecfa 100644 --- a/dev-test/index.html +++ b/dev-test/index.html @@ -1,117 +1,184 @@ - + - - + + - Static CMS Development Test - - - - - - + }; + + + + + + diff --git a/dev-test/index.js b/dev-test/index.js index 037e3ca3..11cdcce8 100644 --- a/dev-test/index.js +++ b/dev-test/index.js @@ -1,35 +1,4 @@ // Register all the things -window.CMS.registerBackend('git-gateway', window.CMS.GitGatewayBackend); -window.CMS.registerBackend('proxy', window.CMS.ProxyBackend); -window.CMS.registerBackend('test-repo', window.CMS.TestBackend); -window.CMS.registerWidget([ - window.CMS.StringWidget.Widget(), - window.CMS.NumberWidget.Widget(), - window.CMS.TextWidget.Widget(), - window.CMS.ImageWidget.Widget(), - window.CMS.FileWidget.Widget(), - window.CMS.SelectWidget.Widget(), - window.CMS.MarkdownWidget.Widget(), - window.CMS.ListWidget.Widget(), - window.CMS.ObjectWidget.Widget(), - window.CMS.RelationWidget.Widget(), - window.CMS.BooleanWidget.Widget(), - window.CMS.DateTimeWidget.Widget(), - window.CMS.ColorStringWidget.Widget(), -]); -window.CMS.registerEditorComponent(window.CMS.imageEditorComponent); -window.CMS.registerEditorComponent({ - id: 'code-block', - label: 'Code Block', - widget: 'code', - type: 'code-block', -}); -window.CMS.registerLocale('en', window.CMS.locales.en); - -Object.keys(window.CMS.images).forEach(iconName => { - window.CMS.registerIcon(iconName, window.h(window.CMS.Icon, { type: iconName })); -}); - window.CMS.init(); const PostPreview = window.createClass({ @@ -41,10 +10,10 @@ const PostPreview = window.createClass({ window.h( 'div', { className: 'cover' }, - window.h('h1', {}, entry.getIn(['data', 'title'])), + window.h('h1', {}, entry.data.title), this.props.widgetFor('image'), ), - window.h('p', {}, window.h('small', {}, 'Written ' + entry.getIn(['data', 'date']))), + window.h('p', {}, window.h('small', {}, 'Written ' + entry.data.date)), window.h('div', { className: 'text' }, this.props.widgetFor('body')), ); }, @@ -53,9 +22,9 @@ const PostPreview = window.createClass({ const GeneralPreview = window.createClass({ render: function () { const entry = this.props.entry; - const title = entry.getIn(['data', 'site_title']); - const posts = entry.getIn(['data', 'posts']); - const thumb = posts && posts.get('thumb'); + const title = entry.data.site_title; + const posts = entry.data.posts; + const thumb = posts && posts.thumb; return window.h( 'div', @@ -65,10 +34,10 @@ const GeneralPreview = window.createClass({ 'dl', {}, window.h('dt', {}, 'Posts on Frontpage'), - window.h('dd', {}, this.props.widgetsFor('posts').getIn(['widgets', 'front_limit']) || 0), + window.h('dd', {}, this.props.widgetsFor('posts').widgets.front_limit || 0), window.h('dt', {}, 'Default Author'), - window.h('dd', {}, this.props.widgetsFor('posts').getIn(['data', 'author']) || 'None'), + window.h('dd', {}, this.props.widgetsFor('posts').data.author || 'None'), window.h('dt', {}, 'Default Thumbnail'), window.h( @@ -91,8 +60,8 @@ const AuthorsPreview = window.createClass({ 'div', { key: index }, window.h('hr', {}), - window.h('strong', {}, author.getIn(['data', 'name'])), - author.getIn(['widgets', 'description']), + window.h('strong', {}, author.data.name), + author.widgets.description, ); }), ); @@ -108,49 +77,31 @@ const RelationKitchenSinkPostPreview = window.createClass({ // the title of the selected post, since our `value_field` in the config // is "title". const { value, fieldsMetaData } = this.props; - const post = fieldsMetaData && fieldsMetaData.getIn(['posts', value]); + const post = fieldsMetaData && fieldsMetaData.posts.value; const style = { border: '2px solid #ccc', borderRadius: '8px', padding: '20px' }; return post ? window.h( 'div', { style: style }, window.h('h2', {}, 'Related Post'), - window.h('h3', {}, post.get('title')), - window.h('img', { src: post.get('image') }), - window.h('p', {}, post.get('body', '').slice(0, 100) + '...'), + window.h('h3', {}, post.title), + window.h('img', { src: post.image }), + window.h('p', {}, (post.body ?? '').slice(0, 100) + '...'), ) : null; }, }); -const previewStyles = ` - html, - body { - color: #444; - font-size: 14px; - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; - } - - body { - padding: 20px; - } - - h1 { - margin-top: 20px; - color: #666; - font-weight: bold; - font-size: 32px; - } - - img { - max-width: 100%; - } -`; - window.CMS.registerPreviewTemplate('posts', PostPreview); window.CMS.registerPreviewTemplate('general', GeneralPreview); window.CMS.registerPreviewTemplate('authors', AuthorsPreview); -window.CMS.registerPreviewStyle(previewStyles, { raw: true }); // Pass the name of a registered control to reuse with a new widget preview. window.CMS.registerWidget('relationKitchenSinkPost', 'relation', RelationKitchenSinkPostPreview); -window.CMS.registerAdditionalLink('example', 'Example.com', 'https://example.com', 'page'); +window.CMS.registerAdditionalLink({ + id: 'example', + title: 'Example.com', + data: 'https://example.com', + options: { + icon: 'page', + }, +}); diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index 807f0f80..00000000 --- a/index.d.ts +++ /dev/null @@ -1,924 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -declare module '@staticcms/core' { - import type { Iterable as ImmutableIterable, List, Map } from 'immutable'; - import type { ComponentType, FocusEventHandler, ReactNode } from 'react'; - import type { t } from 'react-polyglot'; - import type { Pluggable } from 'unified'; - - export type CmsBackendType = - | 'azure' - | 'git-gateway' - | 'github' - | 'gitlab' - | 'bitbucket' - | 'test-repo' - | 'proxy'; - - export type CmsMapWidgetType = 'Point' | 'LineString' | 'Polygon'; - - export type CmsMarkdownWidgetButton = - | 'bold' - | 'italic' - | 'code' - | 'link' - | 'heading-one' - | 'heading-two' - | 'heading-three' - | 'heading-four' - | 'heading-five' - | 'heading-six' - | 'quote' - | 'code-block' - | 'bulleted-list' - | 'numbered-list'; - - export interface CmsSelectWidgetOptionObject { - label: string; - value: any; - } - - export type CmsCollectionFormatType = - | 'yml' - | 'yaml' - | 'toml' - | 'json' - | 'frontmatter' - | 'yaml-frontmatter' - | 'toml-frontmatter' - | 'json-frontmatter'; - - export type CmsAuthScope = 'repo' | 'public_repo'; - - export type CmsSlugEncoding = 'unicode' | 'ascii'; - - export interface CmsI18nConfig { - structure: 'multiple_folders' | 'multiple_files' | 'single_file'; - locales: string[]; - default_locale?: string; - } - - export interface CmsFieldBase { - name: string; - label?: string; - required?: boolean; - hint?: string; - pattern?: [string, string]; - i18n?: boolean | 'translate' | 'duplicate' | 'none'; - media_folder?: string; - public_folder?: string; - comment?: string; - } - - export interface CmsFieldBoolean { - widget: 'boolean'; - default?: boolean; - } - - export interface CmsFieldCode { - widget: 'code'; - default?: any; - - default_language?: string; - allow_language_selection?: boolean; - keys?: { code: string; lang: string }; - output_code_only?: boolean; - } - - export interface CmsFieldColor { - widget: 'color'; - default?: string; - - allowInput?: boolean; - enableAlpha?: boolean; - } - - export interface CmsFieldDateTime { - widget: 'datetime'; - default?: string; - - format?: string; - date_format?: boolean | string; - time_format?: boolean | string; - picker_utc?: boolean; - - /** - * @deprecated Use date_format instead - */ - dateFormat?: boolean | string; - /** - * @deprecated Use time_format instead - */ - timeFormat?: boolean | string; - /** - * @deprecated Use picker_utc instead - */ - pickerUtc?: boolean; - } - - export interface CmsFieldFileOrImage { - widget: 'file' | 'image'; - default?: string; - - media_library?: CmsMediaLibrary; - allow_multiple?: boolean; - config?: any; - } - - export interface CmsFieldObject { - widget: 'object'; - default?: any; - - collapsed?: boolean; - summary?: string; - fields: CmsField[]; - } - - export interface CmsFieldList { - widget: 'list'; - default?: any; - - allow_add?: boolean; - collapsed?: boolean; - summary?: string; - minimize_collapsed?: boolean; - label_singular?: string; - field?: CmsField; - fields?: CmsField[]; - max?: number; - min?: number; - add_to_top?: boolean; - types?: (CmsFieldBase & CmsFieldObject)[]; - } - - export interface CmsFieldMap { - widget: 'map'; - default?: string; - - decimals?: number; - type?: CmsMapWidgetType; - } - - export interface CmsFieldMarkdown { - widget: 'markdown'; - default?: string; - - minimal?: boolean; - buttons?: CmsMarkdownWidgetButton[]; - editor_components?: string[]; - modes?: ('raw' | 'rich_text')[]; - - /** - * @deprecated Use editor_components instead - */ - editorComponents?: string[]; - } - - export interface CmsFieldNumber { - widget: 'number'; - default?: string | number; - - value_type?: 'int' | 'float' | string; - min?: number; - max?: number; - - step?: number; - - /** - * @deprecated Use valueType instead - */ - valueType?: 'int' | 'float' | string; - } - - export interface CmsFieldSelect { - widget: 'select'; - default?: string | string[]; - - options: string[] | CmsSelectWidgetOptionObject[]; - multiple?: boolean; - min?: number; - max?: number; - } - - export interface CmsFieldRelation { - widget: 'relation'; - default?: string | string[]; - - collection: string; - value_field: string; - search_fields: string[]; - file?: string; - display_fields?: string[]; - multiple?: boolean; - options_length?: number; - - /** - * @deprecated Use value_field instead - */ - valueField?: string; - /** - * @deprecated Use search_fields instead - */ - searchFields?: string[]; - /** - * @deprecated Use display_fields instead - */ - displayFields?: string[]; - /** - * @deprecated Use options_length instead - */ - optionsLength?: number; - } - - export interface CmsFieldHidden { - widget: 'hidden'; - default?: any; - } - - export interface CmsFieldStringOrText { - // This is the default widget, so declaring its type is optional. - widget?: 'string' | 'text'; - default?: string; - } - - export interface CmsFieldMeta { - name: string; - label: string; - widget: string; - required: boolean; - index_file: string; - meta: boolean; - } - - export type CmsField = CmsFieldBase & - ( - | CmsFieldBoolean - | CmsFieldCode - | CmsFieldColor - | CmsFieldDateTime - | CmsFieldFileOrImage - | CmsFieldList - | CmsFieldMap - | CmsFieldMarkdown - | CmsFieldNumber - | CmsFieldObject - | CmsFieldRelation - | CmsFieldSelect - | CmsFieldHidden - | CmsFieldStringOrText - | CmsFieldMeta - ); - - export interface CmsCollectionFile { - name: string; - label: string; - file: string; - fields: CmsField[]; - label_singular?: string; - description?: string; - i18n?: boolean | CmsI18nConfig; - media_folder?: string; - public_folder?: string; - editor?: { - preview?: boolean; - }; - } - - export interface ViewFilter { - label: string; - field: string; - pattern: string; - } - - export interface ViewGroup { - label: string; - field: string; - pattern?: string; - } - - export type SortDirection = 'Ascending' | 'Descending' | 'None'; - - export interface CmsSortableFieldsDefault { - field: string; - direction?: SortDirection; - } - - export interface CmsSortableFields { - default?: CmsSortableFieldsDefault; - fields: string[]; - } - - export interface CmsCollection { - name: string; - icon?: string; - label: string; - label_singular?: string; - description?: string; - folder?: string; - files?: CmsCollectionFile[]; - identifier_field?: string; - summary?: string; - slug?: string; - create?: boolean; - delete?: boolean; - hide?: boolean; - editor?: { - preview?: boolean; - }; - publish?: boolean; - nested?: { - depth: number; - }; - meta?: { path?: { label: string; widget: string; index_file: string } }; - - /** - * It accepts the following values: yml, yaml, toml, json, md, markdown, html - * - * You may also specify a custom extension not included in the list above, by specifying the format value. - */ - extension?: string; - format?: CmsCollectionFormatType; - - frontmatter_delimiter?: string[] | string; - fields?: CmsField[]; - filter?: { field: string; value: any }; - path?: string; - media_folder?: string; - public_folder?: string; - sortable_fields?: CmsSortableFields; - view_filters?: ViewFilter[]; - view_groups?: ViewGroup[]; - i18n?: boolean | CmsI18nConfig; - } - - export interface CmsBackend { - name: CmsBackendType; - auth_scope?: CmsAuthScope; - repo?: string; - branch?: string; - api_root?: string; - site_domain?: string; - base_url?: string; - auth_endpoint?: string; - app_id?: string; - auth_type?: 'implicit' | 'pkce'; - proxy_url?: string; - commit_messages?: { - create?: string; - update?: string; - delete?: string; - uploadMedia?: string; - deleteMedia?: string; - }; - } - - export interface CmsSlug { - encoding?: CmsSlugEncoding; - clean_accents?: boolean; - sanitize_replacement?: string; - } - - export interface CmsLocalBackend { - url?: string; - allowed_hosts?: string[]; - } - - export interface CmsConfig { - backend: CmsBackend; - collections: CmsCollection[]; - locale?: string; - site_url?: string; - display_url?: string; - logo_url?: string; - media_folder?: string; - public_folder?: string; - media_folder_relative?: boolean; - media_library?: CmsMediaLibrary; - load_config_file?: boolean; - integrations?: { - hooks: string[]; - provider: string; - collections?: '*' | string[]; - applicationID?: string; - apiKey?: string; - getSignedFormURL?: string; - }[]; - slug?: CmsSlug; - i18n?: CmsI18nConfig; - local_backend?: boolean | CmsLocalBackend; - editor?: { - preview?: boolean; - }; - } - - export interface InitOptions { - config: CmsConfig; - } - - export interface EditorComponentField { - name: string; - label: string; - widget: string; - } - - export interface EditorComponentWidgetOptions { - id: string; - label: string; - widget: string; - type: string; - } - - export interface EditorComponentManualOptions { - id: string; - label: string; - fields: EditorComponentField[]; - pattern: RegExp; - allow_add?: boolean; - fromBlock: (match: RegExpMatchArray) => any; - toBlock: (data: any) => string; - toPreview: (data: any) => string; - } - - export type EditorComponentOptions = EditorComponentManualOptions | EditorComponentWidgetOptions; - - export interface PreviewStyleOptions { - raw: boolean; - } - - export interface PreviewStyle extends PreviewStyleOptions { - value: string; - } - - export type CmsBackendClass = Implementation; - - export interface CmsRegistryBackend { - init: (args: any) => CmsBackendClass; - } - - export interface CmsWidgetControlProps { - value: T; - field: Map; - onChange: (value: T) => void; - forID: string; - classNameWrapper: string; - setActiveStyle: FocusEventHandler; - setInactiveStyle: FocusEventHandler; - t: t; - } - - export interface CmsWidgetPreviewProps { - value: T; - field: Map; - metadata: Map; - getAsset: GetAssetFunction; - entry: Map; - fieldsMetaData: Map; - } - - export interface CmsWidgetParam { - name: string; - controlComponent: CmsWidgetControlProps; - previewComponent?: CmsWidgetPreviewProps; - validator?: (props: { - field: Map; - value: T | undefined | null; - t: t; - }) => boolean | { error: any } | Promise; - globalStyles?: any; - } - - export interface CmsWidget { - control: ComponentType>; - preview?: ComponentType>; - globalStyles?: any; - } - - export type CmsWidgetValueSerializer = any; // TODO: type properly - - export type CmsMediaLibraryOptions = any; // TODO: type properly - - export interface CmsMediaLibrary { - name: string; - config?: CmsMediaLibraryOptions; - } - - export interface CmsEventListener { - name: 'prePublish' | 'postPublish' | 'preSave' | 'postSave'; - handler: ({ - entry, - author, - }: { - entry: Map; - author: { login: string; name: string }; - }) => any; - } - - export type CmsEventListenerOptions = any; // TODO: type properly - - export type CmsLocalePhrases = any; // TODO: type properly - - export interface CmsRegistry { - backends: { - [name: string]: CmsRegistryBackend; - }; - templates: { - [name: string]: ComponentType; - }; - previewStyles: PreviewStyle[]; - widgets: { - [name: string]: CmsWidget; - }; - editorComponents: Map>; - widgetValueSerializers: { - [name: string]: CmsWidgetValueSerializer; - }; - mediaLibraries: CmsMediaLibrary[]; - locales: { - [name: string]: CmsLocalePhrases; - }; - } - - type GetAssetFunction = (asset: string) => { - url: string; - path: string; - field?: any; - fileObj: File; - }; - - export type PreviewTemplateComponentProps = { - entry: Map; - collection: Map; - widgetFor: (name: any, fields?: any, values?: any, fieldsMetaData?: any) => JSX.Element | null; - widgetsFor: (name: any) => any; - getAsset: GetAssetFunction; - boundGetAsset: (collection: any, path: any) => GetAssetFunction; - fieldsMetaData: Map; - config: Map; - fields: List>; - isLoadingAsset: boolean; - window: Window; - document: Document; - }; - - export interface CMSApi { - getBackend: (name: string) => CmsRegistryBackend | undefined; - getEditorComponents: () => Map>; - getRemarkPlugins: () => Array; - getLocale: (locale: string) => CmsLocalePhrases | undefined; - getMediaLibrary: (name: string) => CmsMediaLibrary | undefined; - resolveWidget: (name: string) => CmsWidget | undefined; - getPreviewStyles: () => PreviewStyle[]; - getPreviewTemplate: (name: string) => ComponentType | undefined; - getWidget: (name: string) => CmsWidget | undefined; - getWidgetValueSerializer: (widgetName: string) => CmsWidgetValueSerializer | undefined; - init: (options?: InitOptions) => void; - registerBackend: (name: string, backendClass: CmsBackendClass) => void; - registerEditorComponent: (options: EditorComponentOptions) => void; - registerRemarkPlugin: (plugin: Pluggable) => void; - registerEventListener: ( - eventListener: CmsEventListener, - options?: CmsEventListenerOptions, - ) => void; - registerLocale: (locale: string, phrases: CmsLocalePhrases) => void; - registerMediaLibrary: (mediaLibrary: CmsMediaLibrary, options?: CmsMediaLibraryOptions) => void; - registerPreviewStyle: (filePath: string, options?: PreviewStyleOptions) => void; - registerPreviewTemplate: ( - name: string, - component: ComponentType, - ) => void; - registerWidget: ( - widget: string | CmsWidgetParam | CmsWidgetParam[], - control?: ComponentType | string, - preview?: ComponentType, - ) => void; - registerWidgetValueSerializer: ( - widgetName: string, - serializer: CmsWidgetValueSerializer, - ) => void; - registerIcon: (iconName: string, icon: ReactNode) => void; - getIcon: (iconName: string) => ReactNode; - registerAdditionalLink: ( - id: string, - title: string, - data: string | ComponentType, - iconName?: string, - ) => void; - getAdditionalLinks: () => { title: string; data: string | ComponentType; iconName?: string }[]; - getAdditionalLink: ( - id: string, - ) => { title: string; data: string | ComponentType; iconName?: string } | undefined; - } - - export const CMS: CMSApi; - - export default CMS; - - // Backends - export type DisplayURLObject = { id: string; path: string }; - - export type DisplayURL = DisplayURLObject | string; - - export type DataFile = { - path: string; - slug: string; - raw: string; - newPath?: string; - }; - - export type AssetProxy = { - path: string; - fileObj?: File; - toBase64?: () => Promise; - }; - - export type Entry = { - dataFiles: DataFile[]; - assets: AssetProxy[]; - }; - - export type PersistOptions = { - newEntry?: boolean; - commitMessage: string; - collectionName?: string; - status?: string; - }; - - export type DeleteOptions = {}; - - export type Credentials = { token: string | {}; refresh_token?: string }; - - export type User = Credentials & { - backendName?: string; - login?: string; - name: string; - }; - - export interface ImplementationEntry { - data: string; - file: { path: string; label?: string; id?: string | null; author?: string; updatedOn?: string }; - } - - export type ImplementationFile = { - id?: string | null | undefined; - label?: string; - path: string; - }; - export interface ImplementationMediaFile { - name: string; - id: string; - size?: number; - displayURL?: DisplayURL; - path: string; - draft?: boolean; - url?: string; - file?: File; - } - - export type CursorStoreObject = { - actions: Set; - data: Map; - meta: Map; - }; - - export type CursorStore = { - get( - key: K, - defaultValue?: CursorStoreObject[K], - ): CursorStoreObject[K]; - getIn(path: string[]): V; - set( - key: K, - value: V, - ): CursorStoreObject[K]; - setIn(path: string[], value: unknown): CursorStore; - hasIn(path: string[]): boolean; - mergeIn(path: string[], value: unknown): CursorStore; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - update: (...args: any[]) => CursorStore; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - updateIn: (...args: any[]) => CursorStore; - }; - - export type ActionHandler = (action: string) => unknown; - - export class Cursor { - static create(...args: {}[]): Cursor; - updateStore(...args: any[]): Cursor; - updateInStore(...args: any[]): Cursor; - hasAction(action: string): boolean; - addAction(action: string): Cursor; - removeAction(action: string): Cursor; - setActions(actions: Iterable): Cursor; - mergeActions(actions: Set): Cursor; - getActionHandlers(handler: ActionHandler): ImmutableIterable; - setData(data: {}): Cursor; - mergeData(data: {}): Cursor; - wrapData(data: {}): Cursor; - unwrapData(): [Map, Cursor]; - clearData(): Cursor; - setMeta(meta: {}): Cursor; - mergeMeta(meta: {}): Cursor; - } - - class Implementation { - authComponent: () => void; - restoreUser: (user: User) => Promise; - - authenticate: (credentials: Credentials) => Promise; - logout: () => Promise | void | null; - getToken: () => Promise; - - getEntry: (path: string) => Promise; - entriesByFolder: ( - folder: string, - extension: string, - depth: number, - ) => Promise; - entriesByFiles: (files: ImplementationFile[]) => Promise; - - getMediaDisplayURL?: (displayURL: DisplayURL) => Promise; - getMedia: (folder?: string) => Promise; - getMediaFile: (path: string) => Promise; - - persistEntry: (entry: Entry, opts: PersistOptions) => Promise; - persistMedia: (file: AssetProxy, opts: PersistOptions) => Promise; - deleteFiles: (paths: string[], commitMessage: string) => Promise; - - allEntriesByFolder?: ( - folder: string, - extension: string, - depth: number, - ) => Promise; - traverseCursor?: ( - cursor: Cursor, - action: string, - ) => Promise<{ entries: ImplementationEntry[]; cursor: Cursor }>; - - isGitBackend?: () => boolean; - status: () => Promise<{ - auth: { status: boolean }; - api: { status: boolean; statusPage: string }; - }>; - } - - export const AzureBackend: Implementation; - export const BitbucketBackend: Implementation; - export const GitGatewayBackend: Implementation; - export const GitHubBackend: Implementation; - export const GitLabBackend: Implementation; - export const ProxyBackend: Implementation; - export const TestBackend: Implementation; - - // Widgets - export const BooleanWidget: { - Widget: () => CmsWidgetParam; - }; - export const CodeWidget: { - Widget: () => CmsWidgetParam; - }; - export const ColorStringWidget: { - Widget: () => CmsWidgetParam; - }; - export const DateTimeWidget: { - Widget: () => CmsWidgetParam; - }; - export const FileWidget: { - Widget: () => CmsWidgetParam>; - }; - export const ImageWidget: { - Widget: () => CmsWidgetParam>; - }; - export const ListWidget: { - Widget: () => CmsWidgetParam>; - }; - export const MapWidget: { - Widget: () => CmsWidgetParam; - }; - export const MarkdownWidget: { - Widget: () => CmsWidgetParam; - }; - export const NumberWidget: { - Widget: () => CmsWidgetParam; - }; - export const ObjectWidget: { - Widget: () => CmsWidgetParam | Record>; - }; - export const RelationWidget: { - Widget: () => CmsWidgetParam; - }; - export const SelectWidget: { - Widget: () => CmsWidgetParam; - }; - export const StringWidget: { - Widget: () => CmsWidgetParam; - }; - export const TextWidget: { - Widget: () => CmsWidgetParam; - }; - - export const MediaLibraryCloudinary: { - name: string; - init: ({ - options, - handleInsert, - }?: { - options?: Record | undefined; - handleInsert: any; - }) => Promise<{ - show: ({ - config, - allowMultiple, - }?: { - config?: Record | undefined; - allowMultiple: boolean; - }) => any; - hide: () => any; - enableStandalone: () => boolean; - }>; - }; - - export const MediaLibraryUploadcare: { - name: string; - init: ({ - options, - handleInsert, - }?: { - options?: - | { - config: Record; - settings: Record; - } - | undefined; - handleInsert: any; - }) => Promise<{ - show: ({ - value, - config, - allowMultiple, - imagesOnly, - }?: { - value: any; - config?: Record | undefined; - allowMultiple: boolean; - imagesOnly?: boolean | undefined; - }) => any; - enableStandalone: () => boolean; - }>; - }; - - export const imageEditorComponent: EditorComponentManualOptions; - - export const locales: { - cs: Record; - da: Record; - de: Record; - en: Record; - es: Record; - ca: Record; - fr: Record; - gr: Record; - hu: Record; - it: Record; - lt: Record; - ja: Record; - nl: Record; - nb_no: Record; - nn_no: Record; - pl: Record; - pt: Record; - ro: Record; - ru: Record; - sv: Record; - th: Record; - tr: Record; - uk: Record; - vi: Record; - zh_Hant: Record; - ko: Record; - hr: Record; - bg: Record; - zh_Hans: Record; - he: Record; - }; - - class NetlifyAuthenticator { - constructor(config: Record); - - refresh: (args: { - provider: string; - refresh_token: string; - }) => Promise<{ token: string; refresh_token: string }>; - } - export { NetlifyAuthenticator }; - - // Images - export interface IconProps { - type: string; - direction?: 'right' | 'down' | 'left' | 'up'; - size?: string; - className?: string; - } - - export const Icon: React.ComponentType; - - export const images: Record; -} diff --git a/package.json b/package.json index c2be8d5a..47f7d688 100644 --- a/package.json +++ b/package.json @@ -13,18 +13,20 @@ "scripts": { "build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore **/__tests__ --extensions \".js,.jsx,.ts,.tsx\"", "build:webpack": "webpack", - "build": "cross-env NODE_ENV=production run-s build:esm build:webpack", + "build:types": "tsc", + "build": "cross-env NODE_ENV=production run-s build:esm build:webpack build:types", "clean": "rimraf dist dev-test/dist", - "develop": "webpack serve --hot", + "develop": "webpack serve", "format:prettier": "prettier \"{{src,scripts,website}/**/,}*.{js,jsx,ts,tsx,css}\"", "format": "run-s \"lint:js --fix --quiet\" \"format:prettier --write\"", "lint-quiet": "run-p -c --aggregate-output \"lint:* --quiet\"", "lint:css": "stylelint --ignore-path .gitignore \"{src/**/*.{css,js,jsx,ts,tsx},website/**/*.css}\"", - "lint:format": "prettier \"{{src,scripts,website}/**/,}*.{js,jsx,ts,tsx,css}\" --list-different", - "lint:js": "eslint --color --ignore-path .gitignore \"{{src,scripts,website}/**/,}*.{js,jsx,ts,tsx}\"", + "lint:format": "prettier \"src/**/*.{js,jsx,ts,tsx,css}\" --list-different", + "lint:js": "eslint --color --ignore-path .gitignore \"src/**/*.{js,jsx,ts,tsx}\"", "lint": "run-p -c --aggregate-output \"lint:*\"", "prepublishOnly": "yarn build", - "start": "run-s clean develop" + "start": "run-s clean develop", + "type-check": "tsc --watch" }, "module": "dist/esm/index.js", "main": "dist/static-cms-core.js", @@ -48,25 +50,27 @@ "@emotion/css": "11.10.0", "@emotion/react": "11.10.4", "@emotion/styled": "11.10.4", - "@hot-loader/react-dom": "17.0.2", "@iarna/toml": "2.2.5", "@mui/icons-material": "5.10.6", "@mui/material": "5.10.6", + "@mui/x-date-pickers": "5.0.4", "@reduxjs/toolkit": "1.8.5", - "ajv": "6.12.6", - "ajv-errors": "1.0.1", - "ajv-keywords": "3.5.2", + "@toast-ui/react-editor": "3.2.2", + "ajv": "8.11.0", + "ajv-errors": "3.0.0", + "ajv-keywords": "5.1.0", "apollo-cache-inmemory": "1.6.6", "apollo-client": "2.6.10", "apollo-link-context": "1.0.20", "apollo-link-http": "1.5.17", "array-move": "4.0.0", - "buffer": "^6.0.3", + "buffer": "6.0.3", "clean-stack": "4.2.0", "codemirror": "5.65.9", "common-tags": "1.8.1", "copy-text-to-clipboard": "3.0.1", "create-react-class": "15.7.0", + "date-fns": "2.29.3", "deepmerge": "4.2.2", "diacritics": "1.3.0", "dompurify": "2.4.0", @@ -80,7 +84,6 @@ "gray-matter": "4.0.3", "history": "4.10.1", "immer": "9.0.15", - "immutable": "3.8.2", "ini": "2.0.0", "is-hotkey": "0.2.0", "js-base64": "3.7.2", @@ -90,60 +93,44 @@ "lodash": "4.17.21", "mdast-util-definitions": "1.2.5", "mdast-util-to-string": "1.1.0", + "mime-types": "^2.1.35", "minimatch": "3.0.4", "moment": "2.29.4", "node-polyglot": "2.4.2", "ol": "6.15.1", "path-browserify": "1.0.1", - "prop-types": "15.8.1", - "react": "17.0.2", + "react": "18.2.0", "react-aria-menubutton": "7.0.3", "react-codemirror2": "7.2.1", "react-color": "2.19.3", "react-datetime": "3.1.1", "react-dnd": "14.0.5", "react-dnd-html5-backend": "14.1.0", - "react-dom": "17.0.2", + "react-dom": "18.2.0", "react-frame-component": "5.2.3", - "react-hot-loader": "4.13.0", - "react-immutable-proptypes": "2.2.0", "react-is": "18.2.0", - "react-markdown": "6.0.3", - "react-modal": "3.15.1", "react-polyglot": "0.7.2", "react-redux": "8.0.4", - "react-router-dom": "5.3.3", + "react-router-dom": "6.4.1", "react-scroll-sync": "0.9.0", - "react-select": "4.3.1", "react-sortable-hoc": "2.0.0", "react-split-pane": "0.1.92", "react-textarea-autosize": "8.3.4", "react-toggled": "1.2.7", "react-topbar-progress-indicator": "4.1.1", - "react-transition-group": "4.4.5", "react-virtualized-auto-sizer": "1.0.7", "react-waypoint": "10.3.0", "react-window": "1.8.7", - "rehype-parse": "6.0.2", - "rehype-remark": "8.1.1", - "rehype-stringify": "7.0.0", + "rehype-stringify": "9.0.3", "remark-gfm": "3.0.1", - "remark-parse": "6.0.3", - "remark-rehype": "4.0.1", - "remark-stringify": "6.0.4", + "remark-parse": "10.0.1", + "remark-rehype": "10.1.0", "sanitize-filename": "1.6.3", "semaphore": "1.1.0", - "slate": "0.47.9", - "slate-base64-serializer": "0.2.115", - "slate-plain-serializer": "0.7.13", - "slate-react": "0.22.10", - "slate-soft-break": "0.9.0", "stream-browserify": "3.0.0", "tomlify-j0.4": "3.0.0", "ts-loader": "9.4.1", - "unified": "7.1.0", - "unist-builder": "1.0.4", - "unist-util-visit-parents": "2.1.2", + "unified": "10.1.2", "uploadcare-widget": "3.19.0", "uploadcare-widget-tab-effects": "1.5.0", "url": "0.11.0", @@ -169,17 +156,29 @@ "@babel/preset-typescript": "7.18.6", "@emotion/eslint-plugin": "11.10.0", "@octokit/rest": "16.43.2", + "@pmmmwh/react-refresh-webpack-plugin": "0.5.8", "@stylelint/postcss-css-in-js": "0.37.3", + "@types/codemirror": "5.60.5", "@types/common-tags": "1.8.0", + "@types/create-react-class": "15.6.3", + "@types/dompurify": "2.3.4", + "@types/fs-extra": "9.0.13", "@types/history": "4.7.11", + "@types/is-hotkey": "0.1.7", + "@types/jest": "29.1.2", "@types/js-base64": "3.3.1", + "@types/js-yaml": "4.0.5", "@types/jwt-decode": "2.2.1", "@types/lodash": "4.14.185", - "@types/minimatch": "^5.1.2", - "@types/react": "17.0.50", - "@types/react-dom": "17.0.17", - "@types/react-router-dom": "5.3.3", + "@types/mime-types": "^2.1.1", + "@types/minimatch": "5.1.2", + "@types/node-fetch": "2.6.2", + "@types/react": "18.0.21", + "@types/react-color": "3.0.6", + "@types/react-dom": "18.0.6", "@types/react-scroll-sync": "0.8.4", + "@types/react-virtualized-auto-sizer": "1.0.1", + "@types/react-window": "1.8.5", "@types/url-join": "4.0.1", "@types/uuid": "3.4.10", "@typescript-eslint/eslint-plugin": "5.38.0", @@ -207,11 +206,13 @@ "eslint-plugin-import": "2.26.0", "eslint-plugin-prettier": "4.2.1", "eslint-plugin-react": "7.31.8", + "eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-unicorn": "41.0.1", "execa": "5.1.1", "fs-extra": "10.1.0", "gitlab": "14.2.2", "http-server": "14.1.1", + "jest": "29.1.2", "js-yaml": "4.1.0", "mockserver-client": "5.14.0", "mockserver-node": "5.14.0", @@ -222,19 +223,19 @@ "postcss": "8.4.16", "postcss-scss": "4.0.5", "prettier": "2.7.1", + "process": "0.11.10", + "react-refresh": "0.14.0", "react-svg-loader": "3.0.3", - "rehype": "7.0.0", "rimraf": "3.0.2", "simple-git": "3.14.1", - "slate-hyperscript": "0.13.9", - "source-map-loader": "^4.0.0", + "source-map-loader": "4.0.0", + "style-loader": "3.3.1", "stylelint": "14.12.1", "stylelint-config-standard-scss": "3.0.0", "stylelint-config-styled-components": "0.1.1", "stylelint-processor-styled-components": "1.10.0", "to-string-loader": "1.2.0", - "typescript": "3.9.10", - "unist-util-visit": "1.4.1", + "typescript": "4.8.4", "webpack": "5.74.0", "webpack-cli": "4.10.0", "webpack-dev-server": "4.11.1" diff --git a/src/actions/auth.ts b/src/actions/auth.ts index e749c0a6..49fab04c 100644 --- a/src/actions/auth.ts +++ b/src/actions/auth.ts @@ -1,10 +1,10 @@ import { currentBackend } from '../backend'; import { addSnackbar } from '../store/slices/snackbars'; -import type { Credentials, User } from '../lib/util'; -import type { ThunkDispatch } from 'redux-thunk'; import type { AnyAction } from 'redux'; -import type { State } from '../types/redux'; +import type { ThunkDispatch } from 'redux-thunk'; +import type { Credentials, User } from '../interface'; +import type { RootState } from '../store'; export const AUTH_REQUEST = 'AUTH_REQUEST'; export const AUTH_SUCCESS = 'AUTH_SUCCESS'; @@ -47,9 +47,14 @@ export function logout() { // Check if user data token is cached and is valid export function authenticateUser() { - return (dispatch: ThunkDispatch, getState: () => State) => { + return (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const backend = currentBackend(state.config); + if (!state.config.config) { + return; + } + + const backend = currentBackend(state.config.config); + dispatch(authenticating()); return Promise.resolve(backend.currentUser()) .then(user => { @@ -67,9 +72,13 @@ export function authenticateUser() { } export function loginUser(credentials: Credentials) { - return (dispatch: ThunkDispatch, getState: () => State) => { + return (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const backend = currentBackend(state.config); + if (!state.config.config) { + return; + } + + const backend = currentBackend(state.config.config); dispatch(authenticating()); return backend @@ -94,9 +103,13 @@ export function loginUser(credentials: Credentials) { } export function logoutUser() { - return (dispatch: ThunkDispatch, getState: () => State) => { + return (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const backend = currentBackend(state.config); + if (!state.config.config) { + return; + } + + const backend = currentBackend(state.config.config); Promise.resolve(backend.logout()).then(() => { dispatch(logout()); }); diff --git a/src/actions/collections.ts b/src/actions/collections.ts index 9c87de4d..0171b3f1 100644 --- a/src/actions/collections.ts +++ b/src/actions/collections.ts @@ -1,7 +1,7 @@ import { history } from '../routing/history'; import { getCollectionUrl, getNewEntryUrl } from '../lib/urlHelper'; -export function searchCollections(query: string, collection: string) { +export function searchCollections(query: string, collection?: string) { if (collection) { history.push(`/collections/${collection}/search/${query}`); } else { diff --git a/src/actions/config.ts b/src/actions/config.ts index 8649c608..a41b61eb 100644 --- a/src/actions/config.ts +++ b/src/actions/config.ts @@ -1,52 +1,50 @@ -import yaml from 'yaml'; -import { fromJS } from 'immutable'; import deepmerge from 'deepmerge'; import { produce } from 'immer'; -import { trimStart, trim, isEmpty } from 'lodash'; +import trim from 'lodash/trim'; +import trimStart from 'lodash/trimStart'; +import yaml from 'yaml'; -import { validateConfig } from '../constants/configSchema'; -import { selectDefaultSortableFields } from '../reducers/collections'; -import { getIntegrations, selectIntegration } from '../reducers/integrations'; import { resolveBackend } from '../backend'; -import { I18N, I18N_FIELD, I18N_STRUCTURE } from '../lib/i18n'; import { FILES, FOLDER } from '../constants/collectionTypes'; +import { validateConfig } from '../constants/configSchema'; +import { I18N, I18N_FIELD, I18N_STRUCTURE } from '../lib/i18n'; +import { selectDefaultSortableFields } from '../lib/util/collection.util'; +import { getIntegrations, selectIntegration } from '../reducers/integrations'; -import type { ThunkDispatch } from 'redux-thunk'; import type { AnyAction } from 'redux'; -import type { State } from '../types/redux'; +import type { ThunkDispatch } from 'redux-thunk'; import type { - CmsConfig, - CmsField, - CmsFieldBase, - CmsFieldObject, - CmsFieldList, - CmsI18nConfig, - CmsLocalBackend, - CmsCollection, + Collection, + Config, + Field, + BaseField, + ListField, + ObjectField, + I18nInfo, + LocalBackend, } from '../interface'; +import type { RootState } from '../store'; export const CONFIG_REQUEST = 'CONFIG_REQUEST'; export const CONFIG_SUCCESS = 'CONFIG_SUCCESS'; export const CONFIG_FAILURE = 'CONFIG_FAILURE'; -function isObjectField(field: CmsField): field is CmsFieldBase & CmsFieldObject { - return 'fields' in (field as CmsFieldObject); +function isObjectField(field: Field): field is BaseField & ObjectField { + return 'fields' in (field as ObjectField); } -function isFieldList(field: CmsField): field is CmsFieldBase & CmsFieldList { - return 'types' in (field as CmsFieldList) || 'field' in (field as CmsFieldList); +function isFieldList(field: Field): field is BaseField & ListField { + return 'types' in (field as ListField) || 'field' in (field as ListField); } -function traverseFieldsJS( - fields: Field[], - updater: (field: T) => T, -): Field[] { +function traverseFieldsJS( + fields: F[], + updater: (field: T) => T, +): F[] { return fields.map(field => { const newField = updater(field); if (isObjectField(newField)) { return { ...newField, fields: traverseFieldsJS(newField.fields, updater) }; - } else if (isFieldList(newField) && newField.field) { - return { ...newField, field: traverseFieldsJS([newField.field], updater)[0] }; } else if (isFieldList(newField) && newField.types) { return { ...newField, types: traverseFieldsJS(newField.types, updater) }; } @@ -68,7 +66,7 @@ function getConfigUrl() { return 'config.yml'; } -function setDefaultPublicFolderForField(field: T) { +function setDefaultPublicFolderForField(field: T) { if ('media_folder' in field && !('public_folder' in field)) { return { ...field, public_folder: field.media_folder }; } @@ -88,7 +86,7 @@ const WIDGET_KEY_MAP = { optionsLength: 'options_length', } as const; -function setSnakeCaseConfig(field: T) { +function setSnakeCaseConfig(field: T) { const deprecatedKeys = Object.keys(WIDGET_KEY_MAP).filter( camel => camel in field, ) as ReadonlyArray; @@ -104,7 +102,7 @@ function setSnakeCaseConfig(field: T) { return Object.assign({}, field, ...snakeValues) as T; } -function setI18nField(field: T) { +function setI18nField(field: T) { if (field[I18N] === true) { return { ...field, [I18N]: I18N_FIELD.TRANSLATE }; } else if (field[I18N] === false || !field[I18N]) { @@ -113,24 +111,21 @@ function setI18nField(field: T) { return field; } -function getI18nDefaults( - collectionOrFileI18n: boolean | CmsI18nConfig, - defaultI18n: CmsI18nConfig, -) { +function getI18nDefaults(collectionOrFileI18n: boolean | I18nInfo, defaultI18n: I18nInfo) { if (typeof collectionOrFileI18n === 'boolean') { return defaultI18n; } else { const locales = collectionOrFileI18n.locales || defaultI18n.locales; - const defaultLocale = collectionOrFileI18n.default_locale || locales[0]; - const mergedI18n: CmsI18nConfig = deepmerge(defaultI18n, collectionOrFileI18n); + const defaultLocale = collectionOrFileI18n.defaultLocale || locales[0]; + const mergedI18n: I18nInfo = deepmerge(defaultI18n, collectionOrFileI18n); mergedI18n.locales = locales; - mergedI18n.default_locale = defaultLocale; + mergedI18n.defaultLocale = defaultLocale; throwOnMissingDefaultLocale(mergedI18n); return mergedI18n; } } -function setI18nDefaultsForFields(collectionOrFileFields: CmsField[], hasI18n: boolean) { +function setI18nDefaultsForFields(collectionOrFileFields: Field[], hasI18n: boolean) { if (hasI18n) { return traverseFieldsJS(collectionOrFileFields, setI18nField); } else { @@ -142,7 +137,7 @@ function setI18nDefaultsForFields(collectionOrFileFields: CmsField[], hasI18n: b } } -function throwOnInvalidFileCollectionStructure(i18n?: CmsI18nConfig) { +function throwOnInvalidFileCollectionStructure(i18n?: I18nInfo) { if (i18n && i18n.structure !== I18N_STRUCTURE.SINGLE_FILE) { throw new Error( `i18n configuration for files collections is limited to ${I18N_STRUCTURE.SINGLE_FILE} structure`, @@ -150,24 +145,23 @@ function throwOnInvalidFileCollectionStructure(i18n?: CmsI18nConfig) { } } -function throwOnMissingDefaultLocale(i18n?: CmsI18nConfig) { - if (i18n && i18n.default_locale && !i18n.locales.includes(i18n.default_locale)) { +function throwOnMissingDefaultLocale(i18n?: I18nInfo) { + if (i18n && i18n.defaultLocale && !i18n.locales.includes(i18n.defaultLocale)) { throw new Error( `i18n locales '${i18n.locales.join(', ')}' are missing the default locale ${ - i18n.default_locale + i18n.defaultLocale }`, ); } } -function hasIntegration(config: CmsConfig, collection: CmsCollection) { - // TODO remove fromJS when Immutable is removed from the integrations state slice - const integrations = getIntegrations(fromJS(config)); +function hasIntegration(config: Config, collection: Collection) { + const integrations = getIntegrations(config); const integration = selectIntegration(integrations, collection.name, 'listEntries'); return !!integration; } -export function normalizeConfig(config: CmsConfig) { +export function normalizeConfig(config: Config) { const { collections = [] } = config; const normalizedCollections = collections.map(collection => { @@ -193,7 +187,7 @@ export function normalizeConfig(config: CmsConfig) { return { ...config, collections: normalizedCollections }; } -export function applyDefaults(originalConfig: CmsConfig) { +export function applyDefaults(originalConfig: Config) { return produce(originalConfig, config => { config.slug = config.slug || {}; config.collections = config.collections || []; @@ -225,7 +219,7 @@ export function applyDefaults(originalConfig: CmsConfig) { const i18n = config[I18N]; if (i18n) { - i18n.default_locale = i18n.default_locale || i18n.locales[0]; + i18n.defaultLocale = i18n.defaultLocale || i18n.locales[0]; } throwOnMissingDefaultLocale(i18n); @@ -233,10 +227,6 @@ export function applyDefaults(originalConfig: CmsConfig) { const backend = resolveBackend(config); for (const collection of config.collections) { - if (!('publish' in collection)) { - collection.publish = true; - } - let collectionI18n = collection[I18N]; if (i18n && collectionI18n) { @@ -251,7 +241,7 @@ export function applyDefaults(originalConfig: CmsConfig) { collection.fields = setI18nDefaultsForFields(collection.fields, Boolean(collectionI18n)); } - const { folder, files, view_filters, view_groups, meta } = collection; + const { folder, files, view_filters, view_groups } = collection; if (folder) { collection.type = FOLDER; @@ -270,16 +260,6 @@ export function applyDefaults(originalConfig: CmsConfig) { } collection.folder = trim(folder, '/'); - - if (meta && meta.path) { - const metaField = { - name: 'path', - meta: true, - required: true, - ...meta.path, - }; - collection.fields = [metaField, ...(collection.fields || [])]; - } } if (files) { @@ -288,7 +268,6 @@ export function applyDefaults(originalConfig: CmsConfig) { throwOnInvalidFileCollectionStructure(collectionI18n); delete collection.nested; - delete collection.meta; for (const file of files) { file.file = trimStart(file.file, '/'); @@ -322,8 +301,7 @@ export function applyDefaults(originalConfig: CmsConfig) { if (!collection.sortable_fields) { collection.sortable_fields = { fields: selectDefaultSortableFields( - // TODO remove fromJS when Immutable is removed from the collections state slice - fromJS(collection), + collection, backend, hasIntegration(config, collection), ), @@ -358,35 +336,29 @@ export function parseConfig(data: string) { typeof window.CMS_ENV === 'string' && config[window.CMS_ENV] ) { - const configKeys = Object.keys(config[window.CMS_ENV]) as ReadonlyArray; + const configKeys = Object.keys(config[window.CMS_ENV]) as ReadonlyArray; for (const key of configKeys) { - config[key] = config[window.CMS_ENV][key] as CmsConfig[keyof CmsConfig]; + config[key] = config[window.CMS_ENV][key] as Config[keyof Config]; } } - return config as Partial; + return config as Config; } -async function getConfigYaml(file: string, hasManualConfig: boolean) { +async function getConfigYaml(file: string): Promise { const response = await fetch(file, { credentials: 'same-origin' }).catch(error => error as Error); if (response instanceof Error || response.status !== 200) { - if (hasManualConfig) { - return {}; - } const message = response instanceof Error ? response.message : response.status; throw new Error(`Failed to load config.yml (${message})`); } - const contentType = response.headers.get('Content-Type') || 'Not-Found'; + const contentType = response.headers.get('Content-Type') ?? 'Not-Found'; const isYaml = contentType.indexOf('yaml') !== -1; if (!isYaml) { console.info(`Response for ${file} was not yaml. (Content-Type: ${contentType})`); - if (hasManualConfig) { - return {}; - } } return parseConfig(await response.text()); } -export function configLoaded(config: CmsConfig) { +export function configLoaded(config: Config) { return { type: CONFIG_SUCCESS, payload: config, @@ -407,7 +379,7 @@ export function configFailed(err: Error) { } as const; } -export async function detectProxyServer(localBackend?: boolean | CmsLocalBackend) { +export async function detectProxyServer(localBackend?: boolean | LocalBackend) { const allowedHosts = [ 'localhost', '127.0.0.1', @@ -448,14 +420,12 @@ export async function detectProxyServer(localBackend?: boolean | CmsLocalBackend } } -export async function handleLocalBackend(originalConfig: CmsConfig) { +export async function handleLocalBackend(originalConfig: Config) { if (!originalConfig.local_backend) { return originalConfig; } - const { - proxyUrl - } = await detectProxyServer(originalConfig.local_backend); + const { proxyUrl } = await detectProxyServer(originalConfig.local_backend); if (!proxyUrl) { return originalConfig; @@ -467,23 +437,16 @@ export async function handleLocalBackend(originalConfig: CmsConfig) { }); } -export function loadConfig(manualConfig: Partial = {}, onLoad: () => unknown) { +export function loadConfig(manualConfig: Config | undefined, onLoad: () => unknown) { if (window.CMS_CONFIG) { return configLoaded(window.CMS_CONFIG); } - return async (dispatch: ThunkDispatch) => { + return async (dispatch: ThunkDispatch) => { dispatch(configLoading()); try { const configUrl = getConfigUrl(); - const hasManualConfig = !isEmpty(manualConfig); - const configYaml = - manualConfig.load_config_file === false - ? {} - : await getConfigYaml(configUrl, hasManualConfig); - - // Merge manual config into the config.yml one - const mergedConfig = deepmerge(configYaml, manualConfig); + const mergedConfig = manualConfig ? manualConfig : await getConfigYaml(configUrl); validateConfig(mergedConfig); @@ -497,9 +460,12 @@ export function loadConfig(manualConfig: Partial = {}, onLoad: () => if (typeof onLoad === 'function') { onLoad(); } - } catch (err: any) { - dispatch(configFailed(err)); - throw err; + } catch (error: unknown) { + console.error(error); + if (error instanceof Error) { + dispatch(configFailed(error)); + } + throw error; } }; } diff --git a/src/actions/entries.ts b/src/actions/entries.ts index 14d455dd..f391b469 100644 --- a/src/actions/entries.ts +++ b/src/actions/entries.ts @@ -1,19 +1,16 @@ -import { fromJS, List, Map } from 'immutable'; -import { isEqual } from 'lodash'; +import isEqual from 'lodash/isEqual'; import { currentBackend } from '../backend'; import ValidationErrorTypes from '../constants/validationErrorTypes'; -import { getIntegrationProvider } from '../integrations'; +import { getSearchIntegrationProvider } from '../integrations'; import { SortDirection } from '../interface'; -import { getProcessSegment } from '../lib/formatters'; -import { duplicateDefaultI18nFields, hasI18n, I18N, I18N_FIELD, serializeI18n } from '../lib/i18n'; +import { duplicateDefaultI18nFields, hasI18n, I18N_FIELD, serializeI18n } from '../lib/i18n'; import { serializeValues } from '../lib/serializeEntryValues'; import { Cursor } from '../lib/util'; +import { selectFields, updateFieldByKey } from '../lib/util/collection.util'; import { selectIntegration, selectPublishedSlugs } from '../reducers'; -import { selectFields, updateFieldByKey } from '../reducers/collections'; import { selectCollectionEntriesCursor } from '../reducers/cursors'; -import { selectEntriesSortFields, selectEntryByPath, selectIsFetching } from '../reducers/entries'; -import { selectCustomPath } from '../reducers/entryDraft'; +import { selectEntriesSortFields, selectIsFetching } from '../reducers/entries'; import { navigateToEntry } from '../routing/history'; import { addSnackbar } from '../store/slices/snackbars'; import { createAssetProxy } from '../valueObjects/AssetProxy'; @@ -22,15 +19,26 @@ import { addAssets, getAsset } from './media'; import { loadMedia, waitForMediaLibraryToLoad } from './mediaLibrary'; import { waitUntil } from './waitUntil'; -import type { Set } from 'immutable'; import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; import type { Backend } from '../backend'; -import type { ViewFilter, ViewGroup } from '../interface'; -import type { ImplementationMediaFile } from '../lib/util'; -import type { Collection, Entry, EntryField, EntryFields, EntryMap, State } from '../types/redux'; +import type { CollectionViewStyle } from '../constants/collectionViews'; +import type { + Collection, + Entry, + EntryData, + EntryDraft, + Field, + FieldError, + I18nSettings, + ImplementationMediaFile, + ObjectValue, + ValueOrNestedValue, + ViewFilter, + ViewGroup, +} from '../interface'; +import type { RootState } from '../store'; import type AssetProxy from '../valueObjects/AssetProxy'; -import type { EntryValue } from '../valueObjects/Entry'; /* * Constant Declarations @@ -62,6 +70,7 @@ export const DRAFT_CHANGE_FIELD = 'DRAFT_CHANGE_FIELD'; export const DRAFT_VALIDATION_ERRORS = 'DRAFT_VALIDATION_ERRORS'; export const DRAFT_CLEAR_ERRORS = 'DRAFT_CLEAR_ERRORS'; export const DRAFT_LOCAL_BACKUP_RETRIEVED = 'DRAFT_LOCAL_BACKUP_RETRIEVED'; +export const DRAFT_LOCAL_BACKUP_DELETE = 'DRAFT_LOCAL_BACKUP_DELETE'; export const DRAFT_CREATE_FROM_LOCAL_BACKUP = 'DRAFT_CREATE_FROM_LOCAL_BACKUP'; export const DRAFT_CREATE_DUPLICATE_FROM_ENTRY = 'DRAFT_CREATE_DUPLICATE_FROM_ENTRY'; @@ -86,20 +95,20 @@ export function entryLoading(collection: Collection, slug: string) { return { type: ENTRY_REQUEST, payload: { - collection: collection.get('name'), + collection: collection.name, slug, }, - }; + } as const; } -export function entryLoaded(collection: Collection, entry: EntryValue) { +export function entryLoaded(collection: Collection, entry: Entry) { return { type: ENTRY_SUCCESS, payload: { - collection: collection.get('name'), + collection: collection.name, entry, }, - }; + } as const; } export function entryLoadError(error: Error, collection: Collection, slug: string) { @@ -107,24 +116,133 @@ export function entryLoadError(error: Error, collection: Collection, slug: strin type: ENTRY_FAILURE, payload: { error, - collection: collection.get('name'), + collection: collection.name, slug, }, - }; + } as const; } export function entriesLoading(collection: Collection) { return { type: ENTRIES_REQUEST, payload: { - collection: collection.get('name'), + collection: collection.name, }, - }; + } as const; +} + +export function filterEntriesRequest(collection: Collection, filter: ViewFilter) { + return { + type: FILTER_ENTRIES_REQUEST, + payload: { + collection: collection.name, + filter, + }, + } as const; +} + +export function filterEntriesSuccess(collection: Collection, filter: ViewFilter, entries: Entry[]) { + return { + type: FILTER_ENTRIES_SUCCESS, + payload: { + collection: collection.name, + filter, + entries, + }, + } as const; +} + +export function filterEntriesFailure(collection: Collection, filter: ViewFilter, error: unknown) { + return { + type: FILTER_ENTRIES_FAILURE, + payload: { + collection: collection.name, + filter, + error, + }, + } as const; +} + +export function groupEntriesRequest(collection: Collection, group: ViewGroup) { + return { + type: GROUP_ENTRIES_REQUEST, + payload: { + collection: collection.name, + group, + }, + } as const; +} + +export function groupEntriesSuccess(collection: Collection, group: ViewGroup, entries: Entry[]) { + return { + type: GROUP_ENTRIES_SUCCESS, + payload: { + collection: collection.name, + group, + entries, + }, + } as const; +} + +export function groupEntriesFailure(collection: Collection, group: ViewGroup, error: unknown) { + return { + type: GROUP_ENTRIES_FAILURE, + payload: { + collection: collection.name, + group, + error, + }, + } as const; +} + +export function sortEntriesRequest(collection: Collection, key: string, direction: SortDirection) { + return { + type: SORT_ENTRIES_REQUEST, + payload: { + collection: collection.name, + key, + direction, + }, + } as const; +} + +export function sortEntriesSuccess( + collection: Collection, + key: string, + direction: SortDirection, + entries: Entry[], +) { + return { + type: SORT_ENTRIES_SUCCESS, + payload: { + collection: collection.name, + key, + direction, + entries, + }, + } as const; +} + +export function sortEntriesFailure( + collection: Collection, + key: string, + direction: SortDirection, + error: unknown, +) { + return { + type: SORT_ENTRIES_FAILURE, + payload: { + collection: collection.name, + key, + direction, + error, + }, + } as const; } export function entriesLoaded( collection: Collection, - entries: EntryValue[], + entries: Entry[], pagination: number | null, cursor: Cursor, append = true, @@ -132,30 +250,42 @@ export function entriesLoaded( return { type: ENTRIES_SUCCESS, payload: { - collection: collection.get('name'), + collection: collection.name, entries, page: pagination, cursor: Cursor.create(cursor), append, }, - }; + } as const; } export function entriesFailed(collection: Collection, error: Error) { return { type: ENTRIES_FAILURE, error: 'Failed to load entries', + meta: { + collection: collection.name, + }, payload: error.toString(), - meta: { collection: collection.get('name') }, - }; + } as const; } -async function getAllEntries(state: State, collection: Collection) { - const backend = currentBackend(state.config); - const integration = selectIntegration(state, collection.get('name'), 'listEntries'); - const provider: Backend = integration - ? getIntegrationProvider(state.integrations, backend.getToken, integration) +async function getAllEntries(state: RootState, collection: Collection) { + const configState = state.config; + if (!configState.config) { + throw new Error('Config not loaded'); + } + + const backend = currentBackend(configState.config); + const integration = selectIntegration(state, collection.name, 'listEntries'); + const provider = integration + ? getSearchIntegrationProvider(state.integrations, backend.getToken, integration) : backend; + + if (!provider) { + return []; + } + const entries = await provider.listAllEntries(collection); return entries; } @@ -165,94 +295,52 @@ export function sortByField( key: string, direction: SortDirection = SortDirection.Ascending, ) { - return async (dispatch: ThunkDispatch, getState: () => State) => { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); // if we're already fetching we update the sort key, but skip loading entries - const isFetching = selectIsFetching(state.entries, collection.get('name')); - dispatch({ - type: SORT_ENTRIES_REQUEST, - payload: { - collection: collection.get('name'), - key, - direction, - }, - }); + const isFetching = selectIsFetching(state.entries, collection.name); + dispatch(sortEntriesRequest(collection, key, direction)); if (isFetching) { return; } try { const entries = await getAllEntries(state, collection); - dispatch({ - type: SORT_ENTRIES_SUCCESS, - payload: { - collection: collection.get('name'), - key, - direction, - entries, - }, - }); + dispatch(sortEntriesSuccess(collection, key, direction, entries)); } catch (error) { - dispatch({ - type: SORT_ENTRIES_FAILURE, - payload: { - collection: collection.get('name'), - key, - direction, - error, - }, - }); + console.error(error); + dispatch(sortEntriesFailure(collection, key, direction, error)); } }; } export function filterByField(collection: Collection, filter: ViewFilter) { - return async (dispatch: ThunkDispatch, getState: () => State) => { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); // if we're already fetching we update the filter key, but skip loading entries - const isFetching = selectIsFetching(state.entries, collection.get('name')); - dispatch({ - type: FILTER_ENTRIES_REQUEST, - payload: { - collection: collection.get('name'), - filter, - }, - }); + const isFetching = selectIsFetching(state.entries, collection.name); + dispatch(filterEntriesRequest(collection, filter)); if (isFetching) { return; } try { const entries = await getAllEntries(state, collection); - dispatch({ - type: FILTER_ENTRIES_SUCCESS, - payload: { - collection: collection.get('name'), - filter, - entries, - }, - }); + dispatch(filterEntriesSuccess(collection, filter, entries)); } catch (error) { - dispatch({ - type: FILTER_ENTRIES_FAILURE, - payload: { - collection: collection.get('name'), - filter, - error, - }, - }); + dispatch(filterEntriesFailure(collection, filter, error)); } }; } export function groupByField(collection: Collection, group: ViewGroup) { - return async (dispatch: ThunkDispatch, getState: () => State) => { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const isFetching = selectIsFetching(state.entries, collection.get('name')); + const isFetching = selectIsFetching(state.entries, collection.name); dispatch({ type: GROUP_ENTRIES_REQUEST, payload: { - collection: collection.get('name'), + collection: collection.name, group, }, }); @@ -262,19 +350,12 @@ export function groupByField(collection: Collection, group: ViewGroup) { try { const entries = await getAllEntries(state, collection); - dispatch({ - type: GROUP_ENTRIES_SUCCESS, - payload: { - collection: collection.get('name'), - group, - entries, - }, - }); + dispatch(groupEntriesSuccess(collection, group, entries)); } catch (error) { dispatch({ type: GROUP_ENTRIES_FAILURE, payload: { - collection: collection.get('name'), + collection: collection.name, group, error, }, @@ -283,182 +364,186 @@ export function groupByField(collection: Collection, group: ViewGroup) { }; } -export function changeViewStyle(viewStyle: string) { +export function changeViewStyle(viewStyle: CollectionViewStyle) { return { type: CHANGE_VIEW_STYLE, payload: { style: viewStyle, }, - }; + } as const; } -export function entryPersisting(collection: Collection, entry: EntryMap) { +export function entryPersisting(collection: Collection, entry: Entry) { return { type: ENTRY_PERSIST_REQUEST, payload: { - collectionName: collection.get('name'), - entrySlug: entry.get('slug'), + collectionName: collection.name, + entrySlug: entry.slug, }, - }; + } as const; } -export function entryPersisted(collection: Collection, entry: EntryMap, slug: string) { +export function entryPersisted(collection: Collection, entry: Entry, slug: string) { return { type: ENTRY_PERSIST_SUCCESS, payload: { - collectionName: collection.get('name'), - entrySlug: entry.get('slug'), + collectionName: collection.name, + entrySlug: entry.slug, /** * Pass slug from backend for newly created entries. */ slug, }, - }; + } as const; } -export function entryPersistFail(collection: Collection, entry: EntryMap, error: Error) { +export function entryPersistFail(collection: Collection, entry: Entry, error: Error) { return { type: ENTRY_PERSIST_FAILURE, error: 'Failed to persist entry', payload: { - collectionName: collection.get('name'), - entrySlug: entry.get('slug'), + collectionName: collection.name, + entrySlug: entry.slug, error: error.toString(), }, - }; + } as const; } export function entryDeleting(collection: Collection, slug: string) { return { type: ENTRY_DELETE_REQUEST, payload: { - collectionName: collection.get('name'), + collectionName: collection.name, entrySlug: slug, }, - }; + } as const; } export function entryDeleted(collection: Collection, slug: string) { return { type: ENTRY_DELETE_SUCCESS, payload: { - collectionName: collection.get('name'), + collectionName: collection.name, entrySlug: slug, }, - }; + } as const; } export function entryDeleteFail(collection: Collection, slug: string, error: Error) { return { type: ENTRY_DELETE_FAILURE, payload: { - collectionName: collection.get('name'), + collectionName: collection.name, entrySlug: slug, error: error.toString(), }, - }; + } as const; } -export function emptyDraftCreated(entry: EntryValue) { +export function emptyDraftCreated(entry: Entry) { return { type: DRAFT_CREATE_EMPTY, payload: entry, - }; + } as const; } /* * Exported simple Action Creators */ -export function createDraftFromEntry(entry: EntryValue) { +export function createDraftFromEntry(entry: Entry) { return { type: DRAFT_CREATE_FROM_ENTRY, payload: { entry }, - }; + } as const; } -export function draftDuplicateEntry(entry: EntryMap) { +export function draftDuplicateEntry(entry: Entry) { return { type: DRAFT_CREATE_DUPLICATE_FROM_ENTRY, - payload: createEntry(entry.get('collection'), '', '', { - data: entry.get('data'), - mediaFiles: entry.get('mediaFiles').toJS(), + payload: createEntry(entry.collection, '', '', { + data: entry.data, + mediaFiles: entry.mediaFiles, }), - }; + } as const; } export function discardDraft() { - return { type: DRAFT_DISCARD }; + return { type: DRAFT_DISCARD } as const; } export function changeDraftField({ + path, field, value, - metadata, - entries, + entry, i18n, }: { - field: EntryField; - value: string; - metadata: Record; - entries: EntryMap[]; - i18n?: { - currentLocale: string; - defaultLocale: string; - locales: string[]; - }; + path: string; + field: Field; + value: ValueOrNestedValue; + entry?: Entry | null; + i18n?: I18nSettings; }) { return { type: DRAFT_CHANGE_FIELD, - payload: { field, value, metadata, entries, i18n }, - }; + payload: { path, field, value, entry, i18n }, + } as const; } -export function changeDraftFieldValidation( - uniquefieldId: string, - errors: { type: string; parentIds: string[]; message: string }[], -) { +export function changeDraftFieldValidation(path: string, errors: FieldError[]) { return { type: DRAFT_VALIDATION_ERRORS, - payload: { uniquefieldId, errors }, - }; + payload: { path, errors }, + } as const; } export function clearFieldErrors() { - return { type: DRAFT_CLEAR_ERRORS }; + return { type: DRAFT_CLEAR_ERRORS } as const; } -export function localBackupRetrieved(entry: EntryValue) { +export function localBackupRetrieved(entry: Entry) { return { type: DRAFT_LOCAL_BACKUP_RETRIEVED, payload: { entry }, - }; + } as const; } export function loadLocalBackup() { return { type: DRAFT_CREATE_FROM_LOCAL_BACKUP, - }; + } as const; +} + +export function deleteDraftLocalBackup() { + return { + type: DRAFT_LOCAL_BACKUP_DELETE, + } as const; } export function addDraftEntryMediaFile(file: ImplementationMediaFile) { - return { type: ADD_DRAFT_ENTRY_MEDIA_FILE, payload: file }; + return { type: ADD_DRAFT_ENTRY_MEDIA_FILE, payload: file } as const; } export function removeDraftEntryMediaFile({ id }: { id: string }) { - return { type: REMOVE_DRAFT_ENTRY_MEDIA_FILE, payload: { id } }; + return { type: REMOVE_DRAFT_ENTRY_MEDIA_FILE, payload: { id } } as const; } -export function persistLocalBackup(entry: EntryMap, collection: Collection) { - return (_dispatch: ThunkDispatch, getState: () => State) => { +export function persistLocalBackup(entry: Entry, collection: Collection) { + return (_dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const backend = currentBackend(state.config); + const configState = state.config; + if (!configState.config) { + throw new Error('Config not loaded'); + } + + const backend = currentBackend(configState.config); return backend.persistLocalDraftBackup(entry, collection); }; } -export function createDraftDuplicateFromEntry(entry: EntryMap) { - return (dispatch: ThunkDispatch) => { +export function createDraftDuplicateFromEntry(entry: Entry) { + return (dispatch: ThunkDispatch) => { dispatch( waitUntil({ predicate: ({ type }) => type === DRAFT_CREATE_EMPTY, @@ -469,9 +554,14 @@ export function createDraftDuplicateFromEntry(entry: EntryMap) { } export function retrieveLocalBackup(collection: Collection, slug: string) { - return async (dispatch: ThunkDispatch, getState: () => State) => { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const backend = currentBackend(state.config); + const configState = state.config; + if (!configState.config) { + throw new Error('Config not loaded'); + } + + const backend = currentBackend(configState.config); const { entry } = await backend.getLocalDraftBackup(collection, slug); if (entry) { @@ -487,12 +577,7 @@ export function retrieveLocalBackup(collection: Collection, slug: string) { field: file.field, }); } else { - return getAsset({ - collection, - entry: fromJS(entry), - path: file.path, - field: file.field, - })(dispatch, getState); + return getAsset(collection, entry, file.path, file.field)(dispatch, getState); } }), ); @@ -504,9 +589,14 @@ export function retrieveLocalBackup(collection: Collection, slug: string) { } export function deleteLocalBackup(collection: Collection, slug: string) { - return (_dispatch: ThunkDispatch, getState: () => State) => { + return (_dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const backend = currentBackend(state.config); + const configState = state.config; + if (!configState.config) { + throw new Error('Config not loaded'); + } + + const backend = currentBackend(configState.config); return backend.deleteLocalDraftBackup(collection, slug); }; } @@ -516,7 +606,7 @@ export function deleteLocalBackup(collection: Collection, slug: string) { */ export function loadEntry(collection: Collection, slug: string, silent = false) { - return async (dispatch: ThunkDispatch, getState: () => State) => { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { await waitForMediaLibraryToLoad(dispatch, getState()); if (!silent) { dispatch(entryLoading(collection, slug)); @@ -526,74 +616,99 @@ export function loadEntry(collection: Collection, slug: string, silent = false) const loadedEntry = await tryLoadEntry(getState(), collection, slug); dispatch(entryLoaded(collection, loadedEntry)); dispatch(createDraftFromEntry(loadedEntry)); - } catch (error: any) { + } catch (error: unknown) { console.error(error); - dispatch( - addSnackbar({ - type: 'error', - message: { - key: 'ui.toast.onFailToLoadEntries', - details: error.message, - }, - }), - ); - dispatch(entryLoadError(error, collection, slug)); + if (error instanceof Error) { + dispatch( + addSnackbar({ + type: 'error', + message: { + key: 'ui.toast.onFailToLoadEntries', + details: error.message, + }, + }), + ); + dispatch(entryLoadError(error, collection, slug)); + } } }; } -export async function tryLoadEntry(state: State, collection: Collection, slug: string) { - const backend = currentBackend(state.config); +export async function tryLoadEntry(state: RootState, collection: Collection, slug: string) { + const configState = state.config; + if (!configState.config) { + throw new Error('Config not loaded'); + } + + const backend = currentBackend(configState.config); const loadedEntry = await backend.getEntry(state, collection, slug); return loadedEntry; } -const appendActions = fromJS({ - ['append_next']: { action: 'next', append: true }, -}); +interface AppendAction { + action: string; + append: boolean; +} + +const appendActions = { + append_next: { action: 'next', append: true }, +} as Record; function addAppendActionsToCursor(cursor: Cursor) { - return Cursor.create(cursor).updateStore('actions', (actions: Set) => { - return actions.union( - appendActions - .filter((v: Map) => actions.has(v.get('action') as string)) - .keySeq(), - ); - }); + return Cursor.create(cursor).updateStore(store => ({ + ...store, + actions: new Set( + ...store.actions, + ...(Object.entries(appendActions) + .filter(([_k, v]) => store.actions.has(v.action as string)) + .map(([k, _v]) => k) as string[]), + ), + })); } export function loadEntries(collection: Collection, page = 0) { - return async (dispatch: ThunkDispatch, getState: () => State) => { - if (collection.get('isFetching')) { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { + if (collection.isFetching) { return; } const state = getState(); - const sortFields = selectEntriesSortFields(state.entries, collection.get('name')); + const sortFields = selectEntriesSortFields(state.entries, collection.name); if (sortFields && sortFields.length > 0) { const field = sortFields[0]; - return dispatch(sortByField(collection, field.get('key'), field.get('direction'))); + return dispatch(sortByField(collection, field.key, field.direction)); } - const backend = currentBackend(state.config); - const integration = selectIntegration(state, collection.get('name'), 'listEntries'); + const configState = state.config; + if (!configState.config) { + throw new Error('Config not loaded'); + } + + const backend = currentBackend(configState.config); + const integration = selectIntegration(state, collection.name, 'listEntries'); const provider = integration - ? getIntegrationProvider(state.integrations, backend.getToken, integration) + ? getSearchIntegrationProvider(state.integrations, backend.getToken, integration) : backend; + + if (!provider) { + throw new Error('Provider not found'); + } + const append = !!(page && !isNaN(page) && page > 0); dispatch(entriesLoading(collection)); try { - const loadAllEntries = collection.has('nested') || hasI18n(collection); + const loadAllEntries = 'nested' in collection || hasI18n(collection); - let response: { - cursor: Cursor; - pagination: number; - entries: EntryValue[]; + const response: { + cursor?: Cursor; + pagination?: number; + entries: Entry[]; } = await (loadAllEntries ? // nested collections require all entries to construct the tree - provider.listAllEntries(collection).then((entries: EntryValue[]) => ({ entries })) + provider.listAllEntries(collection).then((entries: Entry[]) => ({ entries })) : provider.listEntries(collection, page)); - response = { + + const cleanResponse = { ...response, // The only existing backend using the pagination system is the // Algolia integration, which is also the only integration used @@ -601,7 +716,7 @@ export function loadEntries(collection: Collection, page = 0) { // determine whether or not this is using the old integer-based // pagination API. Other backends will simply store an empty // cursor, which behaves identically to no cursor at all. - cursor: integration + cursor: !('cursor' in response && response.cursor) ? Cursor.create({ actions: ['next'], meta: { usingOldPaginationAPI: true }, @@ -613,25 +728,30 @@ export function loadEntries(collection: Collection, page = 0) { dispatch( entriesLoaded( collection, - response.cursor.meta!.get('usingOldPaginationAPI') + cleanResponse.cursor.meta.usingOldPaginationAPI ? response.entries.reverse() : response.entries, - response.pagination, - addAppendActionsToCursor(response.cursor), + response.pagination ?? 1, + addAppendActionsToCursor(cleanResponse.cursor), append, ), ); - } catch (err: any) { - dispatch( - addSnackbar({ - type: 'error', - message: { - key: 'ui.toast.onFailToLoadEntries', - details: err, - }, - }), - ); - return Promise.reject(dispatch(entriesFailed(collection, err))); + } catch (error: unknown) { + console.error(error); + if (error instanceof Error) { + dispatch( + addSnackbar({ + type: 'error', + message: { + key: 'ui.toast.onFailToLoadEntries', + details: error.message, + }, + }), + ); + return Promise.reject(dispatch(entriesFailed(collection, error))); + } + + return Promise.reject(); } }; } @@ -644,45 +764,53 @@ function traverseCursor(backend: Backend, cursor: Cursor, action: string) { } export function traverseCollectionCursor(collection: Collection, action: string) { - return async (dispatch: ThunkDispatch, getState: () => State) => { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const collectionName = collection.get('name'); - if (state.entries.getIn(['pages', `${collectionName}`, 'isFetching'])) { + const collectionName = collection.name; + if (state.entries.pages?.[collectionName]?.isFetching) { return; } - const backend = currentBackend(state.config); - const { action: realAction, append } = appendActions.has(action) - ? appendActions.get(action).toJS() - : { action, append: false }; - const cursor = selectCollectionEntriesCursor(state.cursors, collection.get('name')); + const configState = state.config; + if (!configState.config) { + throw new Error('Config not loaded'); + } - // Handle cursors representing pages in the old, integer-based - // pagination API - if (cursor.meta!.get('usingOldPaginationAPI', false)) { - return dispatch(loadEntries(collection, cursor.data!.get('nextPage') as number)); + const backend = currentBackend(configState.config); + + const { action: realAction, append } = + action in appendActions ? appendActions[action] : { action, append: false }; + const cursor = selectCollectionEntriesCursor(state.cursors, collection.name); + + // Handle cursors representing pages in the old, integer-based pagination API + if (cursor.meta?.usingOldPaginationAPI ?? false) { + return dispatch(loadEntries(collection, cursor.data!.nextPage as number)); } try { dispatch(entriesLoading(collection)); const { entries, cursor: newCursor } = await traverseCursor(backend, cursor, realAction); - const pagination = newCursor.meta?.get('page'); + const pagination = newCursor.meta?.page as number | null; return dispatch( entriesLoaded(collection, entries, pagination, addAppendActionsToCursor(newCursor), append), ); - } catch (err: any) { - console.error(err); - dispatch( - addSnackbar({ - type: 'error', - message: { - key: 'ui.toast.onFailToLoadEntries', - details: err, - }, - }), - ); - return Promise.reject(dispatch(entriesFailed(collection, err))); + } catch (error: unknown) { + console.error(error); + if (error instanceof Error) { + dispatch( + addSnackbar({ + type: 'error', + message: { + key: 'ui.toast.onFailToLoadEntries', + details: error.message, + }, + }), + ); + return Promise.reject(dispatch(entriesFailed(collection, error))); + } + + return Promise.reject(); } }; } @@ -707,177 +835,151 @@ function processValue(unsafe: string) { return escapeHtml(unsafe); } -function getDataFields(fields: EntryFields) { - return fields.filter(f => !f!.get('meta')).toList(); -} - -function getMetaFields(fields: EntryFields) { - return fields.filter(f => f!.get('meta') === true).toList(); -} - export function createEmptyDraft(collection: Collection, search: string) { - return async (dispatch: ThunkDispatch, getState: () => State) => { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { const params = new URLSearchParams(search); params.forEach((value, key) => { - collection = updateFieldByKey(collection, key, field => - field.set('default', processValue(value)), - ); + collection = updateFieldByKey(collection, key, field => { + if ('default' in field) { + field.default = processValue(value); + } + return field; + }); }); - const fields = collection.get('fields', List()); - - const dataFields = getDataFields(fields); - const data = createEmptyDraftData(dataFields); - - const metaFields = getMetaFields(fields); - const meta = createEmptyDraftData(metaFields); + const fields = collection.fields ?? []; + const data = createEmptyDraftData(fields); const state = getState(); - const backend = currentBackend(state.config); + const configState = state.config; + if (!configState.config) { + throw new Error('Config not loaded'); + } - if (!collection.has('media_folder')) { + const backend = currentBackend(configState.config); + + if (!('media_folder' in collection)) { await waitForMediaLibraryToLoad(dispatch, getState()); } - const i18nFields = createEmptyDraftI18nData(collection, dataFields); + const i18nFields = createEmptyDraftI18nData(collection, fields); - let newEntry = createEntry(collection.get('name'), '', '', { + let newEntry = createEntry(collection.name, '', '', { data, i18n: i18nFields, mediaFiles: [], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - meta: meta as any, }); newEntry = await backend.processEntry(state, collection, newEntry); dispatch(emptyDraftCreated(newEntry)); }; } -interface DraftEntryData { - [name: string]: - | string - | null - | boolean - | List - | DraftEntryData - | DraftEntryData[] - | (string | DraftEntryData | boolean | List)[]; -} - export function createEmptyDraftData( - fields: EntryFields, - skipField: (field: EntryField) => boolean = () => false, + fields: Field[], + skipField: (field: Field) => boolean = () => false, ) { - return fields.reduce( - ( - reduction: DraftEntryData | string | undefined | boolean | List, - value: EntryField | undefined | boolean, - ) => { - const acc = reduction as DraftEntryData; - const item = value as EntryField; - - if (skipField(item)) { - return acc; - } - - const subfields = item.get('field') || item.get('fields'); - const list = item.get('widget') == 'list'; - const name = item.get('name'); - const defaultValue = item.get('default', null); - - function isEmptyDefaultValue(val: unknown) { - return [[{}], {}].some(e => isEqual(val, e)); - } - - const hasSubfields = List.isList(subfields) || Map.isMap(subfields); - if (hasSubfields) { - if (list && List.isList(defaultValue)) { - acc[name] = defaultValue; - } else { - const asList = List.isList(subfields) - ? (subfields as EntryFields) - : List([subfields as EntryField]); - - const subDefaultValue = list - ? [createEmptyDraftData(asList, skipField)] - : createEmptyDraftData(asList, skipField); - - if (!isEmptyDefaultValue(subDefaultValue)) { - acc[name] = subDefaultValue; - } - } - return acc; - } - - if (defaultValue !== null) { - acc[name] = defaultValue; - } - + return fields.reduce((acc, item) => { + if (skipField(item)) { return acc; - }, - {} as DraftEntryData, - ); + } + + const subfields = 'fields' in item && item.fields; + const list = item.widget == 'list'; + const name = item.name; + const defaultValue = (('default' in item ? item.default : null) ?? null) as EntryData; + + function isEmptyDefaultValue(val: EntryData | EntryData[]) { + return [[{}], {}].some(e => isEqual(val, e)); + } + + if (subfields) { + if (list && Array.isArray(defaultValue)) { + acc[name] = defaultValue; + } else { + const asList = Array.isArray(subfields) ? subfields : [subfields]; + + const subDefaultValue = Array.isArray(subfields) + ? [createEmptyDraftData(asList, skipField)] + : createEmptyDraftData(asList, skipField); + + if (!isEmptyDefaultValue(subDefaultValue)) { + acc[name] = subDefaultValue; + } + } + return acc; + } + + if (defaultValue !== null) { + acc[name] = defaultValue; + } + + return acc; + }, {} as ObjectValue); } -function createEmptyDraftI18nData(collection: Collection, dataFields: EntryFields) { +function createEmptyDraftI18nData(collection: Collection, dataFields: Field[]) { if (!hasI18n(collection)) { return {}; } - function skipField(field: EntryField) { - return field.get(I18N) !== I18N_FIELD.DUPLICATE && field.get(I18N) !== I18N_FIELD.TRANSLATE; + function skipField(field: Field) { + return field.i18n !== I18N_FIELD.DUPLICATE && field.i18n !== I18N_FIELD.TRANSLATE; } const i18nData = createEmptyDraftData(dataFields, skipField); return duplicateDefaultI18nFields(collection, i18nData); } -export function getMediaAssets({ entry }: { entry: EntryMap }) { - const filesArray = entry.get('mediaFiles').toArray(); +export function getMediaAssets({ entry }: { entry: Entry }) { + const filesArray = entry.mediaFiles; const assets = filesArray - .filter(file => file.get('draft')) + .filter(file => file.draft) .map(file => createAssetProxy({ - path: file.get('path'), - file: file.get('file'), - url: file.get('url'), - field: file.get('field'), + path: file.path, + file: file.file, + url: file.url, + field: file.field, }), ); return assets; } -export function getSerializedEntry(collection: Collection, entry: Entry) { +export function getSerializedEntry(collection: Collection, entry: Entry): Entry { /** * Serialize the values of any fields with registered serializers, and * update the entry and entryDraft with the serialized values. */ - const fields = selectFields(collection, entry.get('slug')); + const fields = selectFields(collection, entry.slug); // eslint-disable-next-line @typescript-eslint/no-explicit-any function serializeData(data: any) { return serializeValues(data, fields); } - const serializedData = serializeData(entry.get('data')); - let serializedEntry = entry.set('data', serializedData); + let serializedEntry: Entry = { + ...entry, + data: serializeData(entry.data), + }; + if (hasI18n(collection)) { serializedEntry = serializeI18n(collection, serializedEntry, serializeData); } + return serializedEntry; } export function persistEntry(collection: Collection) { - return async (dispatch: ThunkDispatch, getState: () => State) => { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); const entryDraft = state.entryDraft; - const fieldsErrors = entryDraft.get('fieldsErrors'); - const usedSlugs = selectPublishedSlugs(state, collection.get('name')); + const fieldsErrors = entryDraft.fieldsErrors; + const usedSlugs = selectPublishedSlugs(state, collection.name); // Early return if draft contains validation errors - if (!fieldsErrors.isEmpty()) { - const hasPresenceErrors = fieldsErrors.some(errors => + if (Object.keys(fieldsErrors).length > 0) { + const hasPresenceErrors = Object.values(fieldsErrors).find(errors => errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE), ); @@ -890,25 +992,50 @@ export function persistEntry(collection: Collection) { }, }), ); + } else { + const firstErrorMessage = Object.values(fieldsErrors).flatMap(errors => + errors.map(error => error.message), + )[0]; + + if (firstErrorMessage) { + dispatch( + addSnackbar({ + type: 'error', + message: firstErrorMessage, + }), + ); + } } return Promise.reject(); } - const backend = currentBackend(state.config); - const entry = entryDraft.get('entry'); + const configState = state.config; + if (!configState.config) { + throw new Error('Config not loaded'); + } + + const backend = currentBackend(configState.config); + const entry = entryDraft.entry; + if (!entry) { + return Promise.reject(); + } + const assetProxies = getMediaAssets({ entry, }); const serializedEntry = getSerializedEntry(collection, entry); - const serializedEntryDraft = entryDraft.set('entry', serializedEntry); + const newEntryDraft: EntryDraft = { + ...(entryDraft as EntryDraft), + entry: serializedEntry, + }; dispatch(entryPersisting(collection, serializedEntry)); return backend .persistEntry({ - config: state.config, + config: configState.config, collection, - entryDraft: serializedEntryDraft, + entryDraft: newEntryDraft, assetProxies, usedSlugs, }) @@ -927,12 +1054,12 @@ export function persistEntry(collection: Collection) { await dispatch(loadMedia()); } dispatch(entryPersisted(collection, serializedEntry, newSlug)); - if (collection.has('nested')) { + if ('nested' in collection) { await dispatch(loadEntries(collection)); } - if (entry.get('slug') !== newSlug) { + if (entry.slug !== newSlug) { await dispatch(loadEntry(collection, newSlug)); - navigateToEntry(collection.get('name'), newSlug); + navigateToEntry(collection.name, newSlug); } else { await dispatch(loadEntry(collection, newSlug, true)); } @@ -954,9 +1081,14 @@ export function persistEntry(collection: Collection) { } export function deleteEntry(collection: Collection, slug: string) { - return (dispatch: ThunkDispatch, getState: () => State) => { + return (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const backend = currentBackend(state.config); + const configState = state.config; + if (!configState.config) { + throw new Error('Config not loaded'); + } + + const backend = currentBackend(configState.config); dispatch(entryDeleting(collection, slug)); return backend @@ -980,52 +1112,39 @@ export function deleteEntry(collection: Collection, slug: string) { }; } -function getPathError( - path: string | undefined, - key: string, - t: (key: string, args: Record) => string, -) { - return { - error: { - type: ValidationErrorTypes.CUSTOM, - message: t(`editor.editorControlPane.widget.${key}`, { - path, - }), - }, - }; -} - -export function validateMetaField( - state: State, - collection: Collection, - field: EntryField, - value: string | undefined, - t: (key: string, args: Record) => string, -) { - if (field.get('meta') && field.get('name') === 'path') { - if (!value) { - return getPathError(value, 'invalidPath', t); - } - const sanitizedPath = (value as string) - .split('/') - .map(getProcessSegment(state.config.slug)) - .join('/'); - - if (value !== sanitizedPath) { - return getPathError(value, 'invalidPath', t); - } - - const customPath = selectCustomPath(collection, fromJS({ entry: { meta: { path: value } } })); - const existingEntry = customPath - ? selectEntryByPath(state.entries, collection.get('name'), customPath) - : undefined; - - const existingEntryPath = existingEntry?.get('path'); - const draftPath = state.entryDraft?.getIn(['entry', 'path']); - - if (existingEntryPath && existingEntryPath !== draftPath) { - return getPathError(value, 'pathExists', t); - } - } - return { error: false }; -} +export type EntriesAction = ReturnType< + | typeof entryLoading + | typeof entryLoaded + | typeof entryLoadError + | typeof entriesLoading + | typeof entriesLoaded + | typeof entriesFailed + | typeof changeViewStyle + | typeof entryPersisting + | typeof entryPersisted + | typeof entryPersistFail + | typeof entryDeleting + | typeof entryDeleted + | typeof entryDeleteFail + | typeof emptyDraftCreated + | typeof createDraftFromEntry + | typeof draftDuplicateEntry + | typeof discardDraft + | typeof changeDraftField + | typeof changeDraftFieldValidation + | typeof clearFieldErrors + | typeof localBackupRetrieved + | typeof loadLocalBackup + | typeof deleteDraftLocalBackup + | typeof addDraftEntryMediaFile + | typeof removeDraftEntryMediaFile + | typeof filterEntriesRequest + | typeof filterEntriesSuccess + | typeof filterEntriesFailure + | typeof groupEntriesRequest + | typeof groupEntriesSuccess + | typeof groupEntriesFailure + | typeof sortEntriesRequest + | typeof sortEntriesSuccess + | typeof sortEntriesFailure +>; diff --git a/src/actions/media.ts b/src/actions/media.ts index feeb224a..5f630dd5 100644 --- a/src/actions/media.ts +++ b/src/actions/media.ts @@ -1,13 +1,14 @@ import { isAbsolutePath } from '../lib/util'; -import { createAssetProxy } from '../valueObjects/AssetProxy'; -import { selectMediaFilePath } from '../reducers/entries'; +import { selectMediaFilePath } from '../lib/util/media.util'; import { selectMediaFileByPath } from '../reducers/mediaLibrary'; -import { getMediaFile, waitForMediaLibraryToLoad, getMediaDisplayURL } from './mediaLibrary'; +import { createAssetProxy } from '../valueObjects/AssetProxy'; +import { getMediaDisplayURL, getMediaFile, waitForMediaLibraryToLoad } from './mediaLibrary'; -import type AssetProxy from '../valueObjects/AssetProxy'; -import type { Collection, State, EntryMap, EntryField } from '../types/redux'; -import type { ThunkDispatch } from 'redux-thunk'; import type { AnyAction } from 'redux'; +import type { ThunkDispatch } from 'redux-thunk'; +import type { Field, Collection, Entry } from '../interface'; +import type { RootState } from '../store'; +import type AssetProxy from '../valueObjects/AssetProxy'; export const ADD_ASSETS = 'ADD_ASSETS'; export const ADD_ASSET = 'ADD_ASSET'; @@ -42,7 +43,7 @@ export function loadAssetFailure(path: string, error: Error) { } export function loadAsset(resolvedPath: string) { - return async (dispatch: ThunkDispatch, getState: () => State) => { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { try { dispatch(loadAssetRequest(resolvedPath)); // load asset url from backend @@ -59,19 +60,15 @@ export function loadAsset(resolvedPath: string) { dispatch(addAsset(asset)); } dispatch(loadAssetSuccess(resolvedPath)); - } catch (e: any) { - dispatch(loadAssetFailure(resolvedPath, e)); + } catch (error: unknown) { + console.error(error); + if (error instanceof Error) { + dispatch(loadAssetFailure(resolvedPath, error)); + } } }; } -interface GetAssetArgs { - collection: Collection; - entry: EntryMap; - path: string; - field?: EntryField; -} - const emptyAsset = createAssetProxy({ path: 'empty.svg', file: new File([``], 'empty.svg', { @@ -79,25 +76,18 @@ const emptyAsset = createAssetProxy({ }), }); -export function boundGetAsset( - dispatch: ThunkDispatch, - collection: Collection, - entry: EntryMap, -) { - function bound(path: string, field: EntryField) { - const asset = dispatch(getAsset({ collection, entry, path, field })); - return asset; - } - - return bound; -} - -export function getAsset({ collection, entry, path, field }: GetAssetArgs) { - return (dispatch: ThunkDispatch, getState: () => State) => { - if (!path) return emptyAsset; +export function getAsset(collection: Collection, entry: Entry, path: string, field?: Field) { + return (dispatch: ThunkDispatch, getState: () => RootState) => { + if (!path) { + return emptyAsset; + } const state = getState(); - const resolvedPath = selectMediaFilePath(state.config, collection, entry, path, field); + if (!state.config.config) { + return emptyAsset; + } + + const resolvedPath = selectMediaFilePath(state.config.config, collection, entry, path, field); let { asset, isLoading, error } = state.medias[resolvedPath] || {}; if (isLoading) { diff --git a/src/actions/mediaLibrary.ts b/src/actions/mediaLibrary.ts index 26d03642..866c5791 100644 --- a/src/actions/mediaLibrary.ts +++ b/src/actions/mediaLibrary.ts @@ -1,33 +1,28 @@ -import { Map } from 'immutable'; - import { currentBackend } from '../backend'; import confirm from '../components/UI/Confirm'; -import { getIntegrationProvider } from '../integrations'; +import { getMediaIntegrationProvider } from '../integrations'; import { sanitizeSlug } from '../lib/urlHelper'; import { basename, getBlobSHA } from '../lib/util'; import { selectIntegration } from '../reducers'; -import { - selectEditingDraft, - selectMediaFilePath, - selectMediaFilePublicPath, -} from '../reducers/entries'; +import { selectEditingDraft } from '../reducers/entries'; import { selectMediaDisplayURL, selectMediaFiles } from '../reducers/mediaLibrary'; import { addSnackbar } from '../store/slices/snackbars'; import { createAssetProxy } from '../valueObjects/AssetProxy'; import { addDraftEntryMediaFile, removeDraftEntryMediaFile } from './entries'; import { addAsset, removeAsset } from './media'; import { waitUntilWithTimeout } from './waitUntil'; +import { selectMediaFilePath, selectMediaFilePublicPath } from '../lib/util/media.util'; import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; -import type { ImplementationMediaFile } from '../lib/util'; import type { + Field, DisplayURLState, - EntryField, + ImplementationMediaFile, MediaFile, MediaLibraryInstance, - State, -} from '../types/redux'; +} from '../interface'; +import type { RootState } from '../store'; import type AssetProxy from '../valueObjects/AssetProxy'; export const MEDIA_LIBRARY_OPEN = 'MEDIA_LIBRARY_OPEN'; @@ -60,21 +55,21 @@ export function createMediaLibrary(instance: MediaLibraryInstance) { } export function clearMediaControl(id: string) { - return (_dispatch: ThunkDispatch, getState: () => State) => { + return (_dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const mediaLibrary = state.mediaLibrary.get('externalLibrary'); + const mediaLibrary = state.mediaLibrary.externalLibrary; if (mediaLibrary) { - mediaLibrary.onClearControl({ id }); + mediaLibrary.onClearControl?.({ id }); } }; } export function removeMediaControl(id: string) { - return (_dispatch: ThunkDispatch, getState: () => State) => { + return (_dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const mediaLibrary = state.mediaLibrary.get('externalLibrary'); + const mediaLibrary = state.mediaLibrary.externalLibrary; if (mediaLibrary) { - mediaLibrary.onRemoveControl({ id }); + mediaLibrary.onRemoveControl?.({ id }); } }; } @@ -84,41 +79,46 @@ export function openMediaLibrary( controlID?: string; forImage?: boolean; privateUpload?: boolean; - value?: string; + value?: string | string[]; allowMultiple?: boolean; - config?: Map; - field?: EntryField; + replaceIndex?: number; + config?: Record; + field?: Field; } = {}, ) { - return (dispatch: ThunkDispatch, getState: () => State) => { + return (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const mediaLibrary = state.mediaLibrary.get('externalLibrary'); + const mediaLibrary = state.mediaLibrary.externalLibrary; if (mediaLibrary) { - const { controlID: id, value, config = Map(), allowMultiple, forImage } = payload; - mediaLibrary.show({ id, value, config: config.toJS(), allowMultiple, imagesOnly: forImage }); + const { controlID: id, value, config = {}, allowMultiple, forImage } = payload; + mediaLibrary.show({ id, value, config, allowMultiple, imagesOnly: forImage }); } dispatch(mediaLibraryOpened(payload)); }; } export function closeMediaLibrary() { - return (dispatch: ThunkDispatch, getState: () => State) => { + return (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const mediaLibrary = state.mediaLibrary.get('externalLibrary'); + const mediaLibrary = state.mediaLibrary.externalLibrary; if (mediaLibrary) { - mediaLibrary.hide(); + mediaLibrary.hide?.(); } dispatch(mediaLibraryClosed()); }; } -export function insertMedia(mediaPath: string | string[], field: EntryField | undefined) { - return (dispatch: ThunkDispatch, getState: () => State) => { +export function insertMedia(mediaPath: string | string[], field: Field | undefined) { + return (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const config = state.config; - const entry = state.entryDraft.get('entry'); - const collectionName = state.entryDraft.getIn(['entry', 'collection']); - const collection = state.collections.get(collectionName); + const config = state.config.config; + const entry = state.entryDraft.entry; + const collectionName = state.entryDraft.entry?.collection; + if (!collectionName || !config) { + return; + } + + const collection = state.collections[collectionName]; if (Array.isArray(mediaPath)) { mediaPath = mediaPath.map(path => selectMediaFilePublicPath(config, collection, path, entry, field), @@ -137,13 +137,26 @@ export function removeInsertedMedia(controlID: string) { export function loadMedia( opts: { delay?: number; query?: string; page?: number; privateUpload?: boolean } = {}, ) { - const { delay = 0, query = '', page = 1, privateUpload } = opts; - return async (dispatch: ThunkDispatch, getState: () => State) => { + const { delay = 0, query = '', page = 1, privateUpload = false } = opts; + return async (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const backend = currentBackend(state.config); + const config = state.config.config; + if (!config) { + return; + } + + const backend = currentBackend(config); const integration = selectIntegration(state, null, 'assetStore'); if (integration) { - const provider = getIntegrationProvider(state.integrations, backend.getToken, integration); + const provider = getMediaIntegrationProvider( + state.integrations, + backend.getToken, + integration, + ); + if (!provider) { + throw new Error('Provider not found'); + } + dispatch(mediaLoading(page)); try { const files = await provider.retrieve(query, page, privateUpload); @@ -213,12 +226,17 @@ function createMediaFileFromAsset({ export function persistMedia(file: File, opts: MediaOptions = {}) { const { privateUpload, field } = opts; - return async (dispatch: ThunkDispatch, getState: () => State) => { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const backend = currentBackend(state.config); + const config = state.config.config; + if (!config) { + return; + } + + const backend = currentBackend(config); const integration = selectIntegration(state, null, 'assetStore'); const files: MediaFile[] = selectMediaFiles(state, field); - const fileName = sanitizeSlug(file.name.toLowerCase(), state.config.slug); + const fileName = sanitizeSlug(file.name.toLowerCase(), config.slug); const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName); const editingDraft = selectEditingDraft(state.entryDraft); @@ -254,11 +272,15 @@ export function persistMedia(file: File, opts: MediaOptions = {}) { let assetProxy: AssetProxy; if (integration) { try { - const provider = getIntegrationProvider( + const provider = getMediaIntegrationProvider( state.integrations, backend.getToken, integration, ); + if (!provider) { + throw new Error('Provider not found'); + } + const response = await provider.upload(file, privateUpload); assetProxy = createAssetProxy({ url: response.asset.url, @@ -273,9 +295,13 @@ export function persistMedia(file: File, opts: MediaOptions = {}) { } else if (privateUpload) { throw new Error('The Private Upload option is only available for Asset Store Integration'); } else { - const entry = state.entryDraft.get('entry'); - const collection = state.collections.get(entry?.get('collection')); - const path = selectMediaFilePath(state.config, collection, entry, fileName, field); + const entry = state.entryDraft.entry; + if (!entry?.collection) { + return; + } + + const collection = state.collections[entry?.collection]; + const path = selectMediaFilePath(config, collection, entry, fileName, field); assetProxy = createAssetProxy({ file, path, @@ -296,11 +322,11 @@ export function persistMedia(file: File, opts: MediaOptions = {}) { id, file, assetProxy, - draft: editingDraft, + draft: Boolean(editingDraft), }); return dispatch(addDraftEntryMediaFile(mediaFile)); } else { - mediaFile = await backend.persistMedia(state.config, assetProxy); + mediaFile = await backend.persistMedia(config, assetProxy); } return dispatch(mediaPersisted(mediaFile, { privateUpload })); @@ -322,28 +348,45 @@ export function persistMedia(file: File, opts: MediaOptions = {}) { export function deleteMedia(file: MediaFile, opts: MediaOptions = {}) { const { privateUpload } = opts; - return async (dispatch: ThunkDispatch, getState: () => State) => { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { const state = getState(); - const backend = currentBackend(state.config); + const config = state.config.config; + if (!config) { + return; + } + + const backend = currentBackend(config); const integration = selectIntegration(state, null, 'assetStore'); if (integration) { - const provider = getIntegrationProvider(state.integrations, backend.getToken, integration); + const provider = getMediaIntegrationProvider( + state.integrations, + backend.getToken, + integration, + ); + if (!provider) { + throw new Error('Provider not found'); + } + dispatch(mediaDeleting()); try { await provider.delete(file.id); return dispatch(mediaDeleted(file, { privateUpload })); - } catch (error: any) { + } catch (error: unknown) { console.error(error); - dispatch( - addSnackbar({ - type: 'error', - message: { - key: 'ui.toast.onFailToDeleteMedia', - details: error.message, - }, - }), - ); + + if (error instanceof Error) { + dispatch( + addSnackbar({ + type: 'error', + message: { + key: 'ui.toast.onFailToDeleteMedia', + details: error.message, + }, + }), + ); + } + return dispatch(mediaDeleteFailed({ privateUpload })); } } @@ -358,56 +401,72 @@ export function deleteMedia(file: MediaFile, opts: MediaOptions = {}) { dispatch(mediaDeleting()); dispatch(removeAsset(file.path)); - await backend.deleteMedia(state.config, file.path); + await backend.deleteMedia(config, file.path); dispatch(mediaDeleted(file)); if (editingDraft) { dispatch(removeDraftEntryMediaFile({ id: file.id })); } } - } catch (error: any) { + } catch (error: unknown) { console.error(error); - dispatch( - addSnackbar({ - type: 'error', - message: { - key: 'ui.toast.onFailToDeleteMedia', - details: error.message, - }, - }), - ); + + if (error instanceof Error) { + dispatch( + addSnackbar({ + type: 'error', + message: { + key: 'ui.toast.onFailToDeleteMedia', + details: error.message, + }, + }), + ); + } + return dispatch(mediaDeleteFailed()); } }; } -export async function getMediaFile(state: State, path: string) { - const backend = currentBackend(state.config); +export async function getMediaFile(state: RootState, path: string) { + const config = state.config.config; + if (!config) { + return { url: '' }; + } + + const backend = currentBackend(config); const { url } = await backend.getMediaFile(path); return { url }; } export function loadMediaDisplayURL(file: MediaFile) { - return async (dispatch: ThunkDispatch, getState: () => State) => { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { const { displayURL, id } = file; const state = getState(); + const config = state.config.config; + if (!config) { + return Promise.reject(); + } + const displayURLState: DisplayURLState = selectMediaDisplayURL(state, id); if ( !id || !displayURL || - displayURLState.get('url') || - displayURLState.get('isFetching') || - displayURLState.get('err') + displayURLState.url || + displayURLState.isFetching || + displayURLState.err ) { return Promise.resolve(); } + if (typeof displayURL === 'string') { dispatch(mediaDisplayURLRequest(id)); dispatch(mediaDisplayURLSuccess(id, displayURL)); return; } + try { - const backend = currentBackend(state.config); + const backend = currentBackend(config); dispatch(mediaDisplayURLRequest(id)); const newURL = await backend.getMediaDisplayURL(displayURL); if (newURL) { @@ -415,9 +474,12 @@ export function loadMediaDisplayURL(file: MediaFile) { } else { throw new Error('No display URL was returned!'); } - } catch (err: any) { - console.error(err); - dispatch(mediaDisplayURLFailure(id, err)); + } catch (error: unknown) { + console.error(error); + + if (error instanceof Error) { + dispatch(mediaDisplayURLFailure(id, error)); + } } }; } @@ -426,11 +488,11 @@ function mediaLibraryOpened(payload: { controlID?: string; forImage?: boolean; privateUpload?: boolean; - value?: string; + value?: string | string[]; replaceIndex?: number; allowMultiple?: boolean; - config?: Map; - field?: EntryField; + config?: Record; + field?: Field; }) { return { type: MEDIA_LIBRARY_OPEN, payload } as const; } @@ -450,9 +512,9 @@ export function mediaLoading(page: number) { } as const; } -interface MediaOptions { +export interface MediaOptions { privateUpload?: boolean; - field?: EntryField; + field?: Field; page?: number; canPaginate?: boolean; dynamicSearch?: boolean; @@ -524,10 +586,10 @@ export function mediaDisplayURLFailure(key: string, err: Error) { } export async function waitForMediaLibraryToLoad( - dispatch: ThunkDispatch, - state: State, + dispatch: ThunkDispatch, + state: RootState, ) { - if (state.mediaLibrary.get('isLoading') !== false && !state.mediaLibrary.get('externalLibrary')) { + if (state.mediaLibrary.isLoading !== false && !state.mediaLibrary.externalLibrary) { await waitUntilWithTimeout(dispatch, resolve => ({ predicate: ({ type }) => type === MEDIA_LOAD_SUCCESS || type === MEDIA_LOAD_FAILURE, run: () => resolve(), @@ -536,17 +598,17 @@ export async function waitForMediaLibraryToLoad( } export async function getMediaDisplayURL( - dispatch: ThunkDispatch, - state: State, + dispatch: ThunkDispatch, + state: RootState, file: MediaFile, ) { const displayURLState: DisplayURLState = selectMediaDisplayURL(state, file.id); let url: string | null | undefined; - if (displayURLState.get('url')) { + if (displayURLState.url) { // url was already loaded - url = displayURLState.get('url'); - } else if (displayURLState.get('err')) { + url = displayURLState.url; + } else if (displayURLState.err) { // url loading had an error url = null; } else { @@ -558,7 +620,7 @@ export async function getMediaDisplayURL( run: (_dispatch, _getState, action) => resolve(action.payload.url), })); - if (!displayURLState.get('isFetching')) { + if (!displayURLState.isFetching) { // load display url dispatch(loadMediaDisplayURL(file)); } diff --git a/src/actions/scroll.ts b/src/actions/scroll.ts index b4c44a25..90cfd9ec 100644 --- a/src/actions/scroll.ts +++ b/src/actions/scroll.ts @@ -1,6 +1,6 @@ import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; -import type { State } from '../types/redux'; +import type { RootState } from '../store'; export const SCROLL_SYNC_ENABLED = 'cms.scroll-sync-enabled'; @@ -21,8 +21,10 @@ export function loadScroll() { } export function toggleScroll() { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - return async (dispatch: ThunkDispatch, _getState: () => State) => { + return async ( + dispatch: ThunkDispatch, + _getState: () => RootState, + ) => { return dispatch(togglingScroll()); }; } diff --git a/src/actions/search.ts b/src/actions/search.ts index 180ac15c..0c61f1e3 100644 --- a/src/actions/search.ts +++ b/src/actions/search.ts @@ -1,13 +1,13 @@ -import { isEqual } from 'lodash'; +import isEqual from 'lodash/isEqual'; import { currentBackend } from '../backend'; -import { getIntegrationProvider } from '../integrations'; +import { getSearchIntegrationProvider } from '../integrations'; import { selectIntegration } from '../reducers'; -import type { State } from '../types/redux'; import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; -import type { EntryValue } from '../valueObjects/Entry'; +import type { Entry, SearchQueryResponse } from '../interface'; +import type { RootState } from '../store'; /* * Constant Declarations @@ -33,7 +33,7 @@ export function searchingEntries(searchTerm: string, searchCollections: string[] } as const; } -export function searchSuccess(entries: EntryValue[], page: number) { +export function searchSuccess(entries: Entry[], page: number) { return { type: SEARCH_ENTRIES_SUCCESS, payload: { @@ -59,17 +59,7 @@ export function querying(searchTerm: string) { } as const; } -type SearchResponse = { - entries: EntryValue[]; - pagination: number; -}; - -type QueryResponse = { - hits: EntryValue[]; - query: string; -}; - -export function querySuccess(namespace: string, hits: EntryValue[]) { +export function querySuccess(namespace: string, hits: Entry[]) { return { type: QUERY_SUCCESS, payload: { @@ -100,11 +90,19 @@ export function clearSearch() { // SearchEntries will search for complete entries in all collections. export function searchEntries(searchTerm: string, searchCollections: string[], page = 0) { - return async (dispatch: ThunkDispatch, getState: () => State) => { + return async ( + dispatch: ThunkDispatch, + getState: () => RootState, + ) => { const state = getState(); const { search } = state; - const backend = currentBackend(state.config); - const allCollections = searchCollections || state.collections.keySeq().toArray(); + const configState = state.config; + if (!configState.config) { + return; + } + + const backend = currentBackend(configState.config); + const allCollections = searchCollections || Object.keys(state.collections); const collections = allCollections.filter(collection => selectIntegration(state, collection, 'search'), ); @@ -124,24 +122,30 @@ export function searchEntries(searchTerm: string, searchCollections: string[], p dispatch(searchingEntries(searchTerm, allCollections, page)); const searchPromise = integration - ? getIntegrationProvider(state.integrations, backend.getToken, integration).search( + ? getSearchIntegrationProvider(state.integrations, backend.getToken, integration)?.search( collections, searchTerm, page, ) : backend.search( - state.collections - .filter((_, key: string) => allCollections.indexOf(key) !== -1) - .valueSeq() - .toArray(), + Object.entries(state.collections) + .filter(([key, _value]) => allCollections.indexOf(key) !== -1) + .map(([_key, value]) => value), searchTerm, ); try { - const response: SearchResponse = await searchPromise; + const response = await searchPromise; + if (!response) { + return dispatch(searchFailure(new Error(`No integration found for name "${integration}"`))); + } + return dispatch(searchSuccess(response.entries, response.pagination)); - } catch (error: any) { - return dispatch(searchFailure(error)); + } catch (error: unknown) { + console.error(error); + if (error instanceof Error) { + return dispatch(searchFailure(error)); + } } }; } @@ -156,29 +160,40 @@ export function query( file?: string, limit?: number, ) { - return async (dispatch: ThunkDispatch, getState: () => State) => { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { dispatch(querying(searchTerm)); const state = getState(); - const backend = currentBackend(state.config); + const configState = state.config; + if (!configState.config) { + return dispatch(queryFailure(new Error('Config not found'))); + } + + const backend = currentBackend(configState.config); const integration = selectIntegration(state, collectionName, 'search'); - const collection = state.collections.find( - collection => collection.get('name') === collectionName, + const collection = Object.values(state.collections).find( + collection => collection.name === collectionName, ); + if (!collection) { + return dispatch(queryFailure(new Error('Collection not found'))); + } const queryPromise = integration - ? getIntegrationProvider(state.integrations, backend.getToken, integration).searchBy( - searchFields.map(f => `data.${f}`), + ? getSearchIntegrationProvider(state.integrations, backend.getToken, integration)?.searchBy( + JSON.stringify(searchFields.map(f => `data.${f}`)), collectionName, searchTerm, ) : backend.query(collection, searchFields, searchTerm, file, limit); try { - const response: QueryResponse = await queryPromise; + const response: SearchQueryResponse = await queryPromise; return dispatch(querySuccess(namespace, response.hits)); - } catch (error: any) { - return dispatch(queryFailure(error)); + } catch (error: unknown) { + console.error(error); + if (error instanceof Error) { + return dispatch(queryFailure(error)); + } } }; } diff --git a/src/actions/status.ts b/src/actions/status.ts index 8c09dffd..9a3768f4 100644 --- a/src/actions/status.ts +++ b/src/actions/status.ts @@ -3,7 +3,7 @@ import { addSnackbar, removeSnackbarById } from '../store/slices/snackbars'; import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; -import type { State } from '../types/redux'; +import type { RootState } from '../store'; export const STATUS_REQUEST = 'STATUS_REQUEST'; export const STATUS_SUCCESS = 'STATUS_SUCCESS'; @@ -33,20 +33,21 @@ export function statusFailure(error: Error) { } export function checkBackendStatus() { - return async (dispatch: ThunkDispatch, getState: () => State) => { + return async (dispatch: ThunkDispatch, getState: () => RootState) => { try { const state = getState(); - if (state.status.isFetching) { + const config = state.config.config; + if (state.status.isFetching || !config) { return; } dispatch(statusRequest()); - const backend = currentBackend(state.config); + const backend = currentBackend(config); const status = await backend.status(); const backendDownKey = 'ui.toast.onBackendDown'; const previousBackendDownNotifs = state.snackbar.messages.filter( - n => n.message?.key === backendDownKey, + n => typeof n.message !== 'string' && n.message.key === backendDownKey, ); if (status.api.status === false) { @@ -69,7 +70,9 @@ export function checkBackendStatus() { const authError = status.auth.status === false; if (authError) { const key = 'ui.toast.onLoggedOut'; - const existingNotification = state.snackbar.messages.find(n => n.message?.key === key); + const existingNotification = state.snackbar.messages.find( + n => typeof n.message !== 'string' && n.message.key === key, + ); if (!existingNotification) { dispatch( addSnackbar({ @@ -81,8 +84,11 @@ export function checkBackendStatus() { } dispatch(statusSuccess(status)); - } catch (error: any) { - dispatch(statusFailure(error)); + } catch (error: unknown) { + console.error(error); + if (error instanceof Error) { + dispatch(statusFailure(error)); + } } }; } diff --git a/src/actions/waitUntil.ts b/src/actions/waitUntil.ts index bd842f7a..df2382fd 100644 --- a/src/actions/waitUntil.ts +++ b/src/actions/waitUntil.ts @@ -1,9 +1,9 @@ import { WAIT_UNTIL_ACTION } from '../store/middleware/waitUntilAction'; -import type { WaitActionArgs } from '../store/middleware/waitUntilAction'; -import type { ThunkDispatch } from 'redux-thunk'; import type { AnyAction } from 'redux'; -import type { State } from '../types/redux'; +import type { ThunkDispatch } from 'redux-thunk'; +import type { RootState } from '../store'; +import type { WaitActionArgs } from '../store/middleware/waitUntilAction'; export function waitUntil({ predicate, run }: WaitActionArgs) { return { @@ -14,17 +14,17 @@ export function waitUntil({ predicate, run }: WaitActionArgs) { } export async function waitUntilWithTimeout( - dispatch: ThunkDispatch, + dispatch: ThunkDispatch, waitActionArgs: (resolve: (value?: T) => void) => WaitActionArgs, timeout = 30000, -): Promise { +): Promise { let waitDone = false; const waitPromise = new Promise(resolve => { dispatch(waitUntil(waitActionArgs(resolve))); }); - const timeoutPromise = new Promise(resolve => { + const timeoutPromise = new Promise(resolve => { setTimeout(() => { if (waitDone) { resolve(null); diff --git a/src/backend.ts b/src/backend.ts index 56da1e7f..7de38136 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -1,7 +1,9 @@ import * as fuzzy from 'fuzzy'; -import { fromJS, List, Set } from 'immutable'; -import { attempt, flatten, get, isError, set, trim, uniq } from 'lodash'; -import { basename, dirname, extname, join } from 'path'; +import attempt from 'lodash/attempt'; +import flatten from 'lodash/flatten'; +import get from 'lodash/get'; +import isError from 'lodash/isError'; +import uniq from 'lodash/uniq'; import { FILES, FOLDER } from './constants/collectionTypes'; import { resolveFormat } from './formats/formats'; @@ -14,7 +16,7 @@ import { getI18nFiles, getI18nFilesDepth, groupEntries, - hasI18n + hasI18n, } from './lib/i18n'; import { getBackend, invokeEvent } from './lib/registry'; import { sanitizeChar } from './lib/urlHelper'; @@ -24,9 +26,8 @@ import { Cursor, CURSOR_COMPATIBILITY_SYMBOL, getPathDepth, - localForage + localForage, } from './lib/util'; -import { stringTemplate } from './lib/widgets'; import { selectAllowDeletion, selectAllowNewEntries, @@ -35,42 +36,42 @@ import { selectFieldsComments, selectFileEntryLabel, selectFolderEntryExtension, - selectHasMetaPath, selectInferedField, - selectMediaFolders -} from './reducers/collections'; -import { selectMediaFilePath } from './reducers/entries'; -import { selectCustomPath } from './reducers/entryDraft'; + selectMediaFolders, +} from './lib/util/collection.util'; +import { selectMediaFilePath } from './lib/util/media.util'; +import { set } from './lib/util/object.util'; import { selectIntegration } from './reducers/integrations'; import { createEntry } from './valueObjects/Entry'; +import { dateParsers, expandPath, extractTemplateVars } from './lib/widgets/stringTemplate'; -import type { Map } from 'immutable'; -import type { CmsConfig, ImplementationEntry } from './interface'; import type { - AsyncLock, + BackendClass, + BackendInitializer, + Collection, + CollectionFile, + Config, Credentials, DataFile, DisplayURL, - Implementation as BackendImplementation, - User -} from './lib/util'; -import type { - Collection, - CollectionFile, + Entry, + EntryData, EntryDraft, - EntryField, - EntryMap, + Field, FilterRule, - State -} from './types/redux'; + ImplementationEntry, + SearchQueryResponse, + SearchResponse, + User, +} from './interface'; +import type { AllowedEvent } from './lib/registry'; +import type { AsyncLock } from './lib/util'; +import type { RootState } from './store'; import type AssetProxy from './valueObjects/AssetProxy'; -import type { EntryValue } from './valueObjects/Entry'; - -const { extractTemplateVars, dateParsers, expandPath } = stringTemplate; function updateAssetProxies( assetProxies: AssetProxy[], - config: CmsConfig, + config: Config, collection: Collection, entryDraft: EntryDraft, path: string, @@ -78,13 +79,8 @@ function updateAssetProxies( assetProxies.map(asset => { // update media files path based on entry path const oldPath = asset.path; - const newPath = selectMediaFilePath( - config, - collection, - entryDraft.get('entry').set('path', path), - oldPath, - asset.field, - ); + entryDraft.entry.path = path; + const newPath = selectMediaFilePath(config, collection, entryDraft.entry, oldPath, asset.field); asset.path = newPath; }); } @@ -115,15 +111,15 @@ function getEntryBackupKey(collectionName?: string, slug?: string) { return `${baseKey}.${collectionName}${suffix}`; } -function getEntryField(field: string, entry: EntryValue) { +function getEntryField(field: string, entry: Entry): string { const value = get(entry.data, field); if (value) { return String(value); } else { const firstFieldPart = field.split('.')[0]; - if (entry[firstFieldPart as keyof EntryValue]) { + if (entry[firstFieldPart as keyof Entry]) { // allows searching using entry.slug/entry.path etc. - return entry[firstFieldPart as keyof EntryValue]; + return String(entry[firstFieldPart as keyof Entry]); } else { return ''; } @@ -131,7 +127,7 @@ function getEntryField(field: string, entry: EntryValue) { } export function extractSearchFields(searchFields: string[]) { - return (entry: EntryValue) => + return (entry: Entry) => searchFields.reduce((acc, field) => { const value = getEntryField(field, entry); if (value) { @@ -142,7 +138,7 @@ export function extractSearchFields(searchFields: string[]) { }, ''); } -export function expandSearchEntries(entries: EntryValue[], searchFields: string[]) { +export function expandSearchEntries(entries: Entry[], searchFields: string[]) { // expand the entries for the purpose of the search const expandedEntries = entries.reduce((acc, e) => { const expandedFields = searchFields.reduce((acc, f) => { @@ -156,12 +152,12 @@ export function expandSearchEntries(entries: EntryValue[], searchFields: string[ } return acc; - }, [] as (EntryValue & { field: string })[]); + }, [] as (Entry & { field: string })[]); return expandedEntries; } -export function mergeExpandedEntries(entries: (EntryValue & { field: string })[]) { +export function mergeExpandedEntries(entries: (Entry & { field: string })[]) { // merge the search results by slug and only keep data that matched the search const fields = entries.map(f => f.field); const arrayPaths: Record> = {}; @@ -171,11 +167,12 @@ export function mergeExpandedEntries(entries: (EntryValue & { field: string })[] // eslint-disable-next-line @typescript-eslint/no-unused-vars const { field, ...rest } = e; acc[e.slug] = rest; - arrayPaths[e.slug] = Set(); + arrayPaths[e.slug] = new Set(); } const nestedFields = e.field.split('.'); - let value = acc[e.slug].data; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let value = acc[e.slug].data as any; for (let i = 0; i < nestedFields.length; i++) { value = value[nestedFields[i]]; if (Array.isArray(value)) { @@ -185,13 +182,13 @@ export function mergeExpandedEntries(entries: (EntryValue & { field: string })[] } return acc; - }, {} as Record); + }, {} as Record); // this keeps the search score sorting order designated by the order in entries // and filters non matching items Object.keys(merged).forEach(slug => { - const data = merged[slug].data; - for (const path of arrayPaths[slug].toArray()) { + let data = merged[slug].data ?? {}; + for (const path of arrayPaths[slug]) { const array = get(data, path) as unknown[]; const filtered = array.filter((_, index) => { return fields.some(f => `${f}.`.startsWith(`${path}.${index}.`)); @@ -208,26 +205,19 @@ export function mergeExpandedEntries(entries: (EntryValue & { field: string })[] return matchingFieldIndexA - matchingFieldIndexB; }); - set(data, path, filtered); + data = set(data, path, filtered); } }); return Object.values(merged); } -function sortByScore(a: fuzzy.FilterResult, b: fuzzy.FilterResult) { +function sortByScore(a: fuzzy.FilterResult, b: fuzzy.FilterResult) { if (a.score > b.score) return -1; if (a.score < b.score) return 1; return 0; } -export function slugFromCustomPath(collection: Collection, customPath: string) { - const folderPath = collection.get('folder', '') as string; - const entryPath = customPath.toLowerCase().replace(folderPath.toLowerCase(), ''); - const slug = join(dirname(trim(entryPath, '/')), basename(entryPath, extname(customPath))); - return slug; -} - interface AuthStore { retrieve: () => User; store: (user: User) => void; @@ -236,7 +226,7 @@ interface AuthStore { interface BackendOptions { backendName: string; - config: CmsConfig; + config: Config; authStore?: AuthStore; } @@ -249,7 +239,10 @@ export interface MediaFile { draft?: boolean; url?: string; file?: File; - field?: EntryField; + field?: Field; + queryOrder?: unknown; + isViewableImage?: boolean; + type?: string; } interface BackupEntry { @@ -260,34 +253,17 @@ interface BackupEntry { } interface PersistArgs { - config: CmsConfig; + config: Config; collection: Collection; entryDraft: EntryDraft; assetProxies: AssetProxy[]; - usedSlugs: List; + usedSlugs: string[]; status?: string; } -interface ImplementationInitOptions { - updateUserCredentials: (credentials: Credentials) => void; -} - -type Implementation = BackendImplementation & { - init: (config: CmsConfig, options: ImplementationInitOptions) => Implementation; -}; - -function prepareMetaPath(path: string, collection: Collection) { - if (!selectHasMetaPath(collection)) { - return path; - } - const dir = dirname(path); - return dir.slice(collection.get('folder')!.length + 1) || '/'; -} - function collectionDepth(collection: Collection) { let depth; - depth = - collection.get('nested')?.get('depth') || getPathDepth(collection.get('path', '') as string); + depth = collection.nested?.depth || getPathDepth(collection.path ?? ''); if (hasI18n(collection)) { depth = getI18nFilesDepth(collection, depth); @@ -297,14 +273,17 @@ function collectionDepth(collection: Collection) { } export class Backend { - implementation: Implementation; + implementation: BackendClass; backendName: string; - config: CmsConfig; + config: Config; authStore?: AuthStore; user?: User | null; backupSync: AsyncLock; - constructor(implementation: Implementation, { backendName, authStore, config }: BackendOptions) { + constructor( + implementation: BackendInitializer, + { backendName, authStore, config }: BackendOptions, + ) { // We can't reliably run this on exit, so we do cleanup on load. this.deleteAnonymousBackup(); this.config = config; @@ -411,18 +390,12 @@ export class Backend { async generateUniqueSlug( collection: Collection, - entryData: Map, - config: CmsConfig, - usedSlugs: List, - customPath: string | undefined, + entryData: EntryData, + config: Config, + usedSlugs: string[], ) { const slugConfig = config.slug; - let slug: string; - if (customPath) { - slug = slugFromCustomPath(collection, customPath); - } else { - slug = slugFormatter(collection, entryData, slugConfig); - } + const slug = slugFormatter(collection, entryData, slugConfig); let i = 1; let uniqueSlug = slug; @@ -436,10 +409,10 @@ export class Backend { return uniqueSlug; } - processEntries(loadedEntries: ImplementationEntry[], collection: Collection) { + processEntries(loadedEntries: ImplementationEntry[], collection: Collection): Entry[] { const entries = loadedEntries.map(loadedEntry => createEntry( - collection.get('name'), + collection.name, selectEntrySlug(collection, loadedEntry.file.path), loadedEntry.file.path, { @@ -447,13 +420,12 @@ export class Backend { label: loadedEntry.file.label, author: loadedEntry.file.author, updatedOn: loadedEntry.file.updatedOn, - meta: { path: prepareMetaPath(loadedEntry.file.path, collection) }, }, ), ); const formattedEntries = entries.map(this.entryWithFormat(collection)); // If this collection has a "filter" property, filter entries accordingly - const collectionFilter = collection.get('filter'); + const collectionFilter = collection.filter; const filteredEntries = collectionFilter ? this.filterEntries({ entries: formattedEntries }, collectionFilter) : formattedEntries; @@ -470,24 +442,17 @@ export class Backend { async listEntries(collection: Collection) { const extension = selectFolderEntryExtension(collection); let listMethod: () => Promise; - const collectionType = collection.get('type'); + const collectionType = collection.type; if (collectionType === FOLDER) { listMethod = () => { const depth = collectionDepth(collection); - return this.implementation.entriesByFolder( - collection.get('folder') as string, - extension, - depth, - ); + return this.implementation.entriesByFolder(collection.folder as string, extension, depth); }; } else if (collectionType === FILES) { - const files = collection - .get('files')! - .map(collectionFile => ({ - path: collectionFile!.get('file'), - label: collectionFile!.get('label'), - })) - .toArray(); + const files = collection.files!.map(collectionFile => ({ + path: collectionFile!.file, + label: collectionFile!.label, + })); listMethod = () => this.implementation.entriesByFiles(files); } else { throw new Error(`Unknown collection type: ${collectionType}`); @@ -506,7 +471,7 @@ export class Backend { }); return { entries: this.processEntries(loadedEntries, collection), - pagination: cursor.meta?.get('page'), + pagination: cursor.meta?.page, cursor, }; } @@ -517,18 +482,18 @@ export class Backend { // returns all the collected entries. Used to retrieve all entries // for local searches and queries. async listAllEntries(collection: Collection) { - if (collection.get('folder') && this.implementation.allEntriesByFolder) { + if (collection.folder && this.implementation.allEntriesByFolder) { const depth = collectionDepth(collection); const extension = selectFolderEntryExtension(collection); return this.implementation - .allEntriesByFolder(collection.get('folder') as string, extension, depth) + .allEntriesByFolder(collection.folder as string, extension, depth) .then(entries => this.processEntries(entries, collection)); } const response = await this.listEntries(collection); const { entries } = response; let { cursor } = response; - while (cursor && cursor.actions!.includes('next')) { + while (cursor && cursor.actions?.has('next')) { const { entries: newEntries, cursor: newCursor } = await this.traverseCursor(cursor, 'next'); entries.push(...newEntries); cursor = newCursor; @@ -536,25 +501,22 @@ export class Backend { return entries; } - async search(collections: Collection[], searchTerm: string) { + async search(collections: Collection[], searchTerm: string): Promise { // Perform a local search by requesting all entries. For each // collection, load it, search, and call onCollectionResults with // its results. const errors: Error[] = []; const collectionEntriesRequests = collections .map(async collection => { - const summary = collection.get('summary', '') as string; + const summary = collection.summary ?? ''; const summaryFields = extractTemplateVars(summary); // TODO: pass search fields in as an argument let searchFields: (string | null | undefined)[] = []; - if (collection.get('type') === FILES) { - collection.get('files')?.forEach(f => { - const topLevelFields = f! - .get('fields') - .map(f => f!.get('name')) - .toArray(); + if (collection.type === FILES) { + collection.files?.forEach(f => { + const topLevelFields = f!.fields.map(f => f!.name); searchFields = [...searchFields, ...topLevelFields]; }); } else { @@ -579,7 +541,7 @@ export class Backend { .map(p => p.catch(err => { errors.push(err); - return [] as fuzzy.FilterResult[]; + return [] as fuzzy.FilterResult[]; }), ); @@ -592,10 +554,10 @@ export class Backend { } const hits = entries - .filter(({ score }: fuzzy.FilterResult) => score > 5) + .filter(({ score }: fuzzy.FilterResult) => score > 5) .sort(sortByScore) - .map((f: fuzzy.FilterResult) => f.original); - return { entries: hits }; + .map((f: fuzzy.FilterResult) => f.original); + return { entries: hits, pagination: 1 }; } async query( @@ -604,7 +566,7 @@ export class Backend { searchTerm: string, file?: string, limit?: number, - ) { + ): Promise { let entries = await this.listAllEntries(collection); if (file) { entries = entries.filter(e => e.slug === file); @@ -629,11 +591,11 @@ export class Backend { return { query: searchTerm, hits: merged }; } - traverseCursor(cursor: Cursor, action: string) { + traverseCursor(cursor: Cursor, action: string): Promise<{ entries: Entry[]; cursor: Cursor }> { const [data, unwrappedCursor] = cursor.unwrapData(); // TODO: stop assuming all cursors are for collections - const collection = data.get('collection') as Collection; - return this.implementation!.traverseCursor!(unwrappedCursor, action).then( + const collection = data.collection as Collection; + return this.implementation.traverseCursor!(unwrappedCursor, action).then( async ({ entries, cursor: newCursor }) => ({ entries: this.processEntries(entries, collection), cursor: Cursor.create(newCursor).wrapData({ @@ -644,11 +606,14 @@ export class Backend { ); } - async getLocalDraftBackup(collection: Collection, slug: string) { - const key = getEntryBackupKey(collection.get('name'), slug); + async getLocalDraftBackup( + collection: Collection, + slug: string, + ): Promise<{ entry: Entry | null }> { + const key = getEntryBackupKey(collection.name, slug); const backup = await localForage.getItem(key); if (!backup || !backup.raw.trim()) { - return {}; + return { entry: null }; } const { raw, path } = backup; let { mediaFiles = [] } = backup; @@ -665,16 +630,15 @@ export class Backend { const formatRawData = (raw: string) => { return this.entryWithFormat(collection)( - createEntry(collection.get('name'), slug, path, { + createEntry(collection.name, slug, path, { raw, label, mediaFiles, - meta: { path: prepareMetaPath(path, collection) }, }), ); }; - const entry: EntryValue = formatRawData(raw); + const entry: Entry = formatRawData(raw); if (hasI18n(collection) && backup.i18n) { const i18n = formatI18nBackup(backup.i18n, formatRawData); entry.i18n = i18n; @@ -683,10 +647,10 @@ export class Backend { return { entry }; } - async persistLocalDraftBackup(entry: EntryMap, collection: Collection) { + async persistLocalDraftBackup(entry: Entry, collection: Collection) { try { await this.backupSync.acquire(); - const key = getEntryBackupKey(collection.get('name'), entry.get('slug')); + const key = getEntryBackupKey(collection.name, entry.slug); const raw = this.entryToRaw(collection, entry); if (!raw.trim()) { @@ -694,17 +658,14 @@ export class Backend { } const mediaFiles = await Promise.all( - entry - .get('mediaFiles') - .toJS() - .map(async (file: MediaFile) => { - // make sure to serialize the file - if (file.url?.startsWith('blob:')) { - const blob = await fetch(file.url as string).then(res => res.blob()); - return { ...file, file: blobToFileObj(file.name, blob) }; - } - return file; - }), + entry.mediaFiles.map(async (file: MediaFile) => { + // make sure to serialize the file + if (file.url?.startsWith('blob:')) { + const blob = await fetch(file.url as string).then(res => res.blob()); + return { ...file, file: blobToFileObj(file.name, blob) }; + } + return file; + }), ); let i18n; @@ -714,7 +675,7 @@ export class Backend { await localForage.setItem(key, { raw, - path: entry.get('path'), + path: entry.path, mediaFiles, ...(i18n && { i18n }), }); @@ -730,9 +691,9 @@ export class Backend { async deleteLocalDraftBackup(collection: Collection, slug: string) { try { await this.backupSync.acquire(); - await localForage.removeItem(getEntryBackupKey(collection.get('name'), slug)); + await localForage.removeItem(getEntryBackupKey(collection.name, slug)); // delete new entry backup if not deleted - slug && (await localForage.removeItem(getEntryBackupKey(collection.get('name')))); + slug && (await localForage.removeItem(getEntryBackupKey(collection.name))); const result = await this.deleteAnonymousBackup(); return result; } catch (e) { @@ -748,18 +709,17 @@ export class Backend { return localForage.removeItem(getEntryBackupKey()); } - async getEntry(state: State, collection: Collection, slug: string) { + async getEntry(state: RootState, collection: Collection, slug: string) { const path = selectEntryPath(collection, slug) as string; const label = selectFileEntryLabel(collection, slug); const extension = selectFolderEntryExtension(collection); const getEntryValue = async (path: string) => { const loadedEntry = await this.implementation.getEntry(path); - let entry = createEntry(collection.get('name'), slug, loadedEntry.file.path, { + let entry = createEntry(collection.name, slug, loadedEntry.file.path, { raw: loadedEntry.data, label, mediaFiles: [], - meta: { path: prepareMetaPath(loadedEntry.file.path, collection) }, }); entry = this.entryWithFormat(collection)(entry); @@ -768,7 +728,7 @@ export class Backend { return entry; }; - let entryValue: EntryValue; + let entryValue: Entry; if (hasI18n(collection)) { entryValue = await getI18nEntry(collection, extension, path, slug, getEntryValue); } else { @@ -798,27 +758,34 @@ export class Backend { } entryWithFormat(collection: Collection) { - return (entry: EntryValue): EntryValue => { + return (entry: Entry): Entry => { const format = resolveFormat(collection, entry); if (entry && entry.raw !== undefined) { const data = (format && attempt(format.fromFile.bind(format, entry.raw))) || {}; - if (isError(data)) console.error(data); + if (isError(data)) { + console.error(data); + } return Object.assign(entry, { data: isError(data) ? {} : data }); } return format.fromFile(entry); }; } - async processEntry(state: State, collection: Collection, entry: EntryValue) { + async processEntry(state: RootState, collection: Collection, entry: Entry) { + const configState = state.config; + if (!configState.config) { + throw new Error('Config not loaded'); + } + const integration = selectIntegration(state.integrations, null, 'assetStore'); - const mediaFolders = selectMediaFolders(state.config, collection, fromJS(entry)); + const mediaFolders = selectMediaFolders(configState.config, collection, entry); if (mediaFolders.length > 0 && !integration) { const files = await Promise.all( mediaFolders.map(folder => this.implementation.getMedia(folder)), ); entry.mediaFiles = entry.mediaFiles.concat(...files); } else { - entry.mediaFiles = entry.mediaFiles.concat(state.mediaLibrary.get('files') || []); + entry.mediaFiles = entry.mediaFiles.concat(state.mediaLibrary.files || []); } return entry; @@ -832,12 +799,18 @@ export class Backend { usedSlugs, status, }: PersistArgs) { - const modifiedData = await this.invokePreSaveEvent(draft.get('entry')); - const entryDraft = (modifiedData && draft.setIn(['entry', 'data'], modifiedData)) || draft; + const modifiedData = await this.invokePreSaveEvent(draft.entry); + const entryDraft = modifiedData + ? { + ...draft, + entry: { + ...draft.entry, + data: modifiedData, + }, + } + : draft; - const newEntry = entryDraft.getIn(['entry', 'newRecord']) || false; - - const customPath = selectCustomPath(collection, entryDraft); + const newEntry = entryDraft.entry.newRecord ?? false; let dataFile: DataFile; if (newEntry) { @@ -846,26 +819,24 @@ export class Backend { } const slug = await this.generateUniqueSlug( collection, - entryDraft.getIn(['entry', 'data']), + entryDraft.entry.data, config, usedSlugs, - customPath, ); - const path = customPath || (selectEntryPath(collection, slug) as string); + const path = selectEntryPath(collection, slug) ?? ''; dataFile = { path, slug, - raw: this.entryToRaw(collection, entryDraft.get('entry')), + raw: this.entryToRaw(collection, entryDraft.entry), }; updateAssetProxies(assetProxies, config, collection, entryDraft, path); } else { - const slug = entryDraft.getIn(['entry', 'slug']); + const slug = entryDraft.entry.slug; dataFile = { - path: entryDraft.getIn(['entry', 'path']), - slug: customPath ? slugFromCustomPath(collection, customPath) : slug, - raw: this.entryToRaw(collection, entryDraft.get('entry')), - newPath: customPath, + path: entryDraft.entry.path, + slug, + raw: this.entryToRaw(collection, entryDraft.entry), }; } @@ -877,8 +848,8 @@ export class Backend { dataFiles = getI18nFiles( collection, extension, - entryDraft.get('entry'), - (draftData: EntryMap) => this.entryToRaw(collection, draftData), + entryDraft.entry, + (draftData: Entry) => this.entryToRaw(collection, draftData), path, slug, newPath, @@ -894,7 +865,7 @@ export class Backend { authorName: user.name, }); - const collectionName = collection.get('name'); + const collectionName = collection.name; const updatedOptions = { status }; const opts = { @@ -904,7 +875,7 @@ export class Backend { ...updatedOptions, }; - await this.invokePrePublishEvent(entryDraft.get('entry')); + await this.invokePrePublishEvent(entryDraft.entry); await this.implementation.persistEntry( { @@ -914,34 +885,34 @@ export class Backend { opts, ); - await this.invokePostSaveEvent(entryDraft.get('entry')); - await this.invokePostPublishEvent(entryDraft.get('entry')); + await this.invokePostSaveEvent(entryDraft.entry); + await this.invokePostPublishEvent(entryDraft.entry); return slug; } - async invokeEventWithEntry(event: string, entry: EntryMap) { - const { login, name } = (await this.currentUser()) as User; + async invokeEventWithEntry(event: AllowedEvent, entry: Entry) { + const { login, name = '' } = (await this.currentUser()) as User; return await invokeEvent({ name: event, data: { entry, author: { login, name } } }); } - async invokePrePublishEvent(entry: EntryMap) { + async invokePrePublishEvent(entry: Entry) { await this.invokeEventWithEntry('prePublish', entry); } - async invokePostPublishEvent(entry: EntryMap) { + async invokePostPublishEvent(entry: Entry) { await this.invokeEventWithEntry('postPublish', entry); } - async invokePreSaveEvent(entry: EntryMap) { + async invokePreSaveEvent(entry: Entry) { return await this.invokeEventWithEntry('preSave', entry); } - async invokePostSaveEvent(entry: EntryMap) { + async invokePostSaveEvent(entry: Entry) { await this.invokeEventWithEntry('postSave', entry); } - async persistMedia(config: CmsConfig, file: AssetProxy) { + async persistMedia(config: Config, file: AssetProxy) { const user = (await this.currentUser()) as User; const options = { commitMessage: commitMessageFormatter('uploadMedia', config, { @@ -953,8 +924,12 @@ export class Backend { return this.implementation.persistMedia(file, options); } - async deleteEntry(state: State, collection: Collection, slug: string) { - const config = state.config; + async deleteEntry(state: RootState, collection: Collection, slug: string) { + const configState = state.config; + if (!configState.config) { + throw new Error('Config not loaded'); + } + const path = selectEntryPath(collection, slug) as string; const extension = selectFolderEntryExtension(collection) as string; @@ -963,7 +938,7 @@ export class Backend { } const user = (await this.currentUser()) as User; - const commitMessage = commitMessageFormatter('delete', config, { + const commitMessage = commitMessageFormatter('delete', configState.config, { collection, slug, path, @@ -978,7 +953,7 @@ export class Backend { await this.implementation.deleteFiles(paths, commitMessage); } - async deleteMedia(config: CmsConfig, path: string) { + async deleteMedia(config: Config, path: string) { const user = (await this.currentUser()) as User; const commitMessage = commitMessageFormatter('deleteMedia', config, { path, @@ -988,49 +963,43 @@ export class Backend { return this.implementation.deleteFiles([path], commitMessage); } - entryToRaw(collection: Collection, entry: EntryMap): string { - const format = resolveFormat(collection, entry.toJS()); + entryToRaw(collection: Collection, entry: Entry): string { + const format = resolveFormat(collection, entry); const fieldsOrder = this.fieldsOrder(collection, entry); const fieldsComments = selectFieldsComments(collection, entry); - return format && format.toFile(entry.get('data').toJS(), fieldsOrder, fieldsComments); + return format && format.toFile(entry.data, fieldsOrder, fieldsComments); } - fieldsOrder(collection: Collection, entry: EntryMap) { - const fields = collection.get('fields'); + fieldsOrder(collection: Collection, entry: Entry) { + const fields = collection.fields; if (fields) { - return collection - .get('fields') - .map(f => f!.get('name')) - .toArray(); + return collection.fields.map(f => f!.name); } - const files = collection.get('files'); - const file = (files || List()) - .filter(f => f!.get('name') === entry.get('slug')) - .get(0); + const files = collection.files; + const file: CollectionFile | null = + (files ?? []).filter(f => f!.name === entry.slug)?.[0] ?? null; if (file == null) { - throw new Error(`No file found for ${entry.get('slug')} in ${collection.get('name')}`); + throw new Error(`No file found for ${entry.slug} in ${collection.name}`); } - return file - .get('fields') - .map(f => f!.get('name')) - .toArray(); + return file.fields.map(f => f.name); } - filterEntries(collection: { entries: EntryValue[] }, filterRule: FilterRule) { + filterEntries(collection: { entries: Entry[] }, filterRule: FilterRule) { return collection.entries.filter(entry => { - const fieldValue = entry.data[filterRule.get('field')]; - if (Array.isArray(fieldValue)) { - return fieldValue.includes(filterRule.get('value')); - } - return fieldValue === filterRule.get('value'); + const fieldValue = entry.data?.[filterRule.field]; + // TODO Investigate when the value could be a string array + // if (Array.isArray(fieldValue)) { + // return fieldValue.includes(filterRule.value); + // } + return fieldValue === filterRule.value; }); } } -export function resolveBackend(config: CmsConfig) { - if (!config.backend.name) { +export function resolveBackend(config?: Config) { + if (!config?.backend.name) { throw new Error('No backend defined in configuration'); } @@ -1048,7 +1017,7 @@ export function resolveBackend(config: CmsConfig) { export const currentBackend = (function () { let backend: Backend; - return (config: CmsConfig) => { + return (config: Config) => { if (backend) { return backend; } diff --git a/src/backends/azure/API.ts b/src/backends/azure/API.ts index 68d9838e..1a6c4a1b 100644 --- a/src/backends/azure/API.ts +++ b/src/backends/azure/API.ts @@ -1,14 +1,24 @@ import { Base64 } from 'js-base64'; -import { partial, result, trim, trimStart } from 'lodash'; +import partial from 'lodash/partial'; +import result from 'lodash/result'; +import trim from 'lodash/trim'; +import trimStart from 'lodash/trimStart'; import { basename, dirname } from 'path'; import { - APIError, localForage, readFile, readFileMetadata, requestWithBackoff, - responseParser, unsentRequest + APIError, + localForage, + readFile, + readFileMetadata, + requestWithBackoff, + responseParser, + unsentRequest, } from '../../lib/util'; -import type { Map } from 'immutable'; -import type { ApiRequest, AssetProxy, DataFile, PersistOptions } from '../../lib/util'; +import type { DataFile, PersistOptions } from '../../interface'; +import type { ApiRequest } from '../../lib/util'; +import type { ApiRequestObject } from '../../lib/util/API'; +import type AssetProxy from '../../valueObjects/AssetProxy'; export const API_NAME = 'Azure DevOps'; @@ -28,23 +38,6 @@ type AzureGitItem = { path: string; }; -type AzurePullRequestCommit = { commitId: string }; - -enum AzureCommitStatusState { - ERROR = 'error', - FAILED = 'failed', - NOT_APPLICABLE = 'notApplicable', - NOT_SET = 'notSet', - PENDING = 'pending', - SUCCEEDED = 'succeeded', -} - -type AzureCommitStatus = { - context: { genre?: string | null; name: string }; - state: AzureCommitStatusState; - targetUrl: string; -}; - // This does not match Azure documentation, but it is what comes back from some calls // PullRequest as an example is documented as returning PullRequest[], but it actually // returns that inside of this value prop in the json @@ -68,30 +61,6 @@ enum AzureObjectType { TREE = 'tree', } -// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/diffs/get?view=azure-devops-rest-6.1#gitcommitdiffs -interface AzureGitCommitDiffs { - changes: AzureGitChange[]; -} - -// https://docs.microsoft.com/en-us/rest/api/azure/devops/git/diffs/get?view=azure-devops-rest-6.1#gitchange -interface AzureGitChange { - changeId: number; - item: AzureGitChangeItem; - changeType: AzureCommitChangeType; - originalPath: string; - url: string; -} - -interface AzureGitChangeItem { - objectId: string; - originalObjectId: string; - gitObjectType: string; - commitId: string; - path: string; - isFolder: string; - url: string; -} - type AzureRef = { name: string; objectId: string; @@ -105,10 +74,6 @@ type AzureCommit = { }; }; -function delay(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - function getChangeItem(item: AzureCommitItem) { switch (item.action) { case AzureCommitChangeType.ADD: @@ -186,10 +151,11 @@ export default class API { return withHeaders; }; - withAzureFeatures = (req: Map>) => { - if (req.hasIn(['params', API_VERSION])) { + withAzureFeatures = (req: ApiRequestObject) => { + if (API_VERSION in (req.params ?? {})) { return req; } + const withParams = unsentRequest.withParams( { [API_VERSION]: `${this.apiVersion}`, @@ -203,7 +169,7 @@ export default class API { buildRequest = (req: ApiRequest) => { const withHeaders = this.withHeaders(req); const withAzureFeatures = this.withAzureFeatures(withHeaders); - if (withAzureFeatures.has('cache')) { + if ('cache' in withAzureFeatures) { return withAzureFeatures; } else { const withNoCache = unsentRequest.withNoCache(withAzureFeatures); @@ -214,8 +180,12 @@ export default class API { request = (req: ApiRequest): Promise => { try { return requestWithBackoff(this, req); - } catch (err: any) { - throw new APIError(err.message, null, API_NAME); + } catch (error: unknown) { + if (error instanceof Error) { + throw new APIError(error.message, null, API_NAME); + } + + throw new APIError('Unknown api error', null, API_NAME); } }; @@ -261,7 +231,7 @@ export default class API { params: { 'searchCriteria.itemPath': path, 'searchCriteria.itemVersion.version': branch, - 'searchCriteria.$top': 1, + 'searchCriteria.$top': '1', }, }); const [commit] = value; diff --git a/src/backends/azure/AuthenticationPage.js b/src/backends/azure/AuthenticationPage.js deleted file mode 100644 index cadee864..00000000 --- a/src/backends/azure/AuthenticationPage.js +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; - -import { AuthenticationPage, Icon } from '../../ui'; -import { ImplicitAuthenticator } from '../../lib/auth'; -import alert from '../../components/UI/Alert'; - -const LoginButtonIcon = styled(Icon)` - margin-right: 18px; -`; - -export default class AzureAuthenticationPage extends React.Component { - static propTypes = { - onLogin: PropTypes.func.isRequired, - inProgress: PropTypes.bool, - base_url: PropTypes.string, - siteId: PropTypes.string, - authEndpoint: PropTypes.string, - config: PropTypes.object.isRequired, - clearHash: PropTypes.func, - t: PropTypes.func.isRequired, - }; - - state = {}; - - componentDidMount() { - this.auth = new ImplicitAuthenticator({ - base_url: `https://login.microsoftonline.com/${this.props.config.backend.tenant_id}`, - auth_endpoint: 'oauth2/authorize', - app_id: this.props.config.backend.app_id, - clearHash: this.props.clearHash, - }); - // Complete implicit authentication if we were redirected back to from the provider. - this.auth.completeAuth((err, data) => { - if (err) { - alert({ - title: 'auth.errors.authTitle', - body: { key: 'auth.errors.authBody', options: { details: err } }, - }); - return; - } - this.props.onLogin(data); - }); - } - - handleLogin = e => { - e.preventDefault(); - this.auth.authenticate( - { - scope: 'vso.code_full,user.read', - resource: '499b84ac-1321-427f-aa17-267ca6975798', - prompt: 'select_account', - }, - (err, data) => { - if (err) { - this.setState({ loginError: err.toString() }); - return; - } - this.props.onLogin(data); - }, - ); - }; - - render() { - const { inProgress, config, t } = this.props; - - return ( - ( - - - {inProgress ? t('auth.loggingIn') : t('auth.loginWithAzure')} - - )} - t={t} - /> - ); - } -} diff --git a/src/backends/azure/AuthenticationPage.tsx b/src/backends/azure/AuthenticationPage.tsx new file mode 100644 index 00000000..924ccfe5 --- /dev/null +++ b/src/backends/azure/AuthenticationPage.tsx @@ -0,0 +1,80 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import alert from '../../components/UI/Alert'; +import AuthenticationPage from '../../components/UI/AuthenticationPage'; +import Icon from '../../components/UI/Icon'; +import { ImplicitAuthenticator } from '../../lib/auth'; + +import type { MouseEvent } from 'react'; +import type { AuthenticationPageProps, TranslatedProps } from '../../interface'; + +const AzureAuthenticationPage = ({ + inProgress = false, + config, + clearHash, + onLogin, + t, +}: TranslatedProps) => { + const [loginError, setLoginError] = useState(null); + + const auth = useMemo( + () => + new ImplicitAuthenticator({ + base_url: `https://login.microsoftonline.com/${config.backend.tenant_id}`, + auth_endpoint: 'oauth2/authorize', + app_id: config.backend.app_id, + clearHash, + }), + [clearHash, config.backend.app_id, config.backend.tenant_id], + ); + + useEffect(() => { + // Complete implicit authentication if we were redirected back to from the provider. + auth.completeAuth((err, data) => { + if (err) { + alert({ + title: 'auth.errors.authTitle', + body: { key: 'auth.errors.authBody', options: { details: err } }, + }); + return; + } else if (data) { + onLogin(data); + } + }); + }, [auth, onLogin]); + + const handleLogin = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + auth.authenticate( + { + scope: 'vso.code_full,user.read', + resource: '499b84ac-1321-427f-aa17-267ca6975798', + prompt: 'select_account', + }, + (err, data) => { + if (err) { + setLoginError(err.toString()); + } else if (data) { + onLogin(data); + } + }, + ); + }, + [auth, onLogin], + ); + + return ( + } + buttonContent={inProgress ? t('auth.loggingIn') : t('auth.loginWithAzure')} + t={t} + /> + ); +}; + +export default AzureAuthenticationPage; diff --git a/src/backends/azure/implementation.ts b/src/backends/azure/implementation.ts index 94533576..5e001af9 100644 --- a/src/backends/azure/implementation.ts +++ b/src/backends/azure/implementation.ts @@ -1,19 +1,36 @@ -import { trim, trimStart } from 'lodash'; +import trim from 'lodash/trim'; +import trimStart from 'lodash/trimStart'; import semaphore from 'semaphore'; +import { BackendClass } from '../../interface'; import { - asyncLock, basename, entriesByFiles, entriesByFolder, filterByExtension, getBlobSHA, getMediaAsBlob, getMediaDisplayURL + asyncLock, + basename, + entriesByFiles, + entriesByFolder, + filterByExtension, + getBlobSHA, + getMediaAsBlob, + getMediaDisplayURL, } from '../../lib/util'; import API, { API_NAME } from './API'; import AuthenticationPage from './AuthenticationPage'; import type { Semaphore } from 'semaphore'; import type { - AssetProxy, AsyncLock, Config, Credentials, DisplayURL, - Entry, Implementation, + BackendEntry, + BackendInitializerOptions, + Config, + Credentials, + DisplayURL, + ImplementationEntry, ImplementationFile, - ImplementationMediaFile, PersistOptions, User -} from '../../lib/util'; + ImplementationMediaFile, + PersistOptions, + User, +} from '../../interface'; +import type { AsyncLock, Cursor } from '../../lib/util'; +import type AssetProxy from '../../valueObjects/AssetProxy'; const MAX_CONCURRENT_DOWNLOADS = 10; @@ -37,10 +54,10 @@ function parseAzureRepo(config: Config) { }; } -export default class Azure implements Implementation { +export default class Azure extends BackendClass { lock: AsyncLock; api?: API; - options: {}; + options: BackendInitializerOptions; repo: { org: string; project: string; @@ -54,7 +71,8 @@ export default class Azure implements Implementation { _mediaDisplayURLSem?: Semaphore; - constructor(config: Config, options = {}) { + constructor(config: Config, options: BackendInitializerOptions) { + super(config, options); this.options = { ...options, }; @@ -72,7 +90,10 @@ export default class Azure implements Implementation { return true; } - async status() { + async status(): Promise<{ + auth: { status: boolean }; + api: { status: boolean; statusPage: string }; + }> { const auth = (await this.api!.user() .then(user => !!user) @@ -196,7 +217,7 @@ export default class Azure implements Implementation { }; } - async persistEntry(entry: Entry, options: PersistOptions): Promise { + async persistEntry(entry: BackendEntry, options: PersistOptions): Promise { const mediaFiles: AssetProxy[] = entry.assets; await this.api!.persistFiles(entry.dataFiles, mediaFiles, options); } @@ -229,4 +250,16 @@ export default class Azure implements Implementation { async deleteFiles(paths: string[], commitMessage: string) { await this.api!.deleteFiles(paths, commitMessage); } + + traverseCursor(): Promise<{ entries: ImplementationEntry[]; cursor: Cursor }> { + throw new Error('Not supported'); + } + + allEntriesByFolder( + _folder: string, + _extension: string, + _depth: number, + ): Promise { + throw new Error('Not supported'); + } } diff --git a/src/backends/azure/index.ts b/src/backends/azure/index.ts index 4b34fcfd..50d38206 100644 --- a/src/backends/azure/index.ts +++ b/src/backends/azure/index.ts @@ -1,10 +1,3 @@ -import AzureBackend from './implementation'; -import API from './API'; -import AuthenticationPage from './AuthenticationPage'; - -export const StaticCmsBackendAzure = { - AzureBackend, - API, - AuthenticationPage, -}; -export { AzureBackend, API, AuthenticationPage }; +export { default as AzureBackend } from './implementation'; +export { default as API } from './API'; +export { default as AuthenticationPage } from './AuthenticationPage'; diff --git a/src/backends/bitbucket/API.ts b/src/backends/bitbucket/API.ts index fca1ad00..098787bd 100644 --- a/src/backends/bitbucket/API.ts +++ b/src/backends/bitbucket/API.ts @@ -1,14 +1,25 @@ -import { flow, get } from 'lodash'; +import flow from 'lodash/flow'; +import get from 'lodash/get'; import { dirname } from 'path'; import { parse } from 'what-the-diff'; import { - APIError, basename, - Cursor, localForage, readFile, readFileMetadata, requestWithBackoff, responseParser, - then, throwOnConflictingBranches, unsentRequest + APIError, + basename, + Cursor, + localForage, + readFile, + readFileMetadata, + requestWithBackoff, + responseParser, + then, + throwOnConflictingBranches, + unsentRequest, } from '../../lib/util'; -import type { ApiRequest, AssetProxy, DataFile, FetchError, PersistOptions } from '../../lib/util'; +import type { DataFile, PersistOptions } from '../../interface'; +import type { ApiRequest, FetchError } from '../../lib/util'; +import type AssetProxy from '../../valueObjects/AssetProxy'; interface Config { apiRoot?: string; @@ -99,7 +110,7 @@ export default class API { buildRequest = (req: ApiRequest) => { const withRoot = unsentRequest.withRoot(this.apiRoot)(req); - if (withRoot.has('cache')) { + if ('cache' in withRoot) { return withRoot; } else { const withNoCache = unsentRequest.withNoCache(withRoot); @@ -110,8 +121,12 @@ export default class API { request = (req: ApiRequest): Promise => { try { return requestWithBackoff(this, req); - } catch (err: any) { - throw new APIError(err.message, null, API_NAME); + } catch (error: unknown) { + if (error instanceof Error) { + throw new APIError(error.message, null, API_NAME); + } + + throw new APIError('Unknown api error', null, API_NAME); } }; @@ -217,7 +232,7 @@ export default class API { async isShaExistsInBranch(branch: string, sha: string) { const { values }: { values: BitBucketCommit[] } = await this.requestJSON({ url: `${this.repoURL}/commits`, - params: { include: branch, pagelen: 100 }, + params: { include: branch, pagelen: '100' }, }).catch(e => { console.info(`Failed getting commits for branch '${branch}'`, e); return []; @@ -251,8 +266,8 @@ export default class API { const result: BitBucketSrcResult = await this.requestJSON({ url: `${this.repoURL}/src/${node}/${path}`, params: { - max_depth: depth, - pagelen, + max_depth: `${depth}`, + pagelen: `${pagelen}`, }, }).catch(replace404WithEmptyResponse); const { entries, cursor } = this.getEntriesAndCursor(result); @@ -277,7 +292,7 @@ export default class API { cursor: newCursor, entries: this.processFiles(entries), })), - ])(cursor.data!.getIn(['links', action])); + ])((cursor.data?.links as Record)[action]); listAllFiles = async (path: string, depth: number, branch: string) => { const { cursor: initialCursor, entries: initialEntries } = await this.listFiles( @@ -367,11 +382,13 @@ export default class API { method: 'POST', body: formData, }); - } catch (error: any) { - const message = error.message || ''; - // very descriptive message from Bitbucket - if (parentSha && message.includes('Something went wrong')) { - await throwOnConflictingBranches(branch, name => this.getBranch(name), API_NAME); + } catch (error: unknown) { + if (error instanceof Error) { + const message = error.message || ''; + // very descriptive message from Bitbucket + if (parentSha && message.includes('Something went wrong')) { + await throwOnConflictingBranches(branch, name => this.getBranch(name), API_NAME); + } } throw error; } @@ -379,7 +396,20 @@ export default class API { return files; } - async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) { + async persistFiles( + dataFiles: DataFile[], + mediaFiles: ( + | { + fileObj: File; + size: number; + sha: string; + raw: string; + path: string; + } + | AssetProxy + )[], + options: PersistOptions, + ) { const files = [...dataFiles, ...mediaFiles]; return this.uploadFiles(files, { commitMessage: options.commitMessage, branch: this.branch }); } @@ -391,7 +421,7 @@ export default class API { const rawDiff = await this.requestText({ url: `${this.repoURL}/diff/${source}..${destination}`, params: { - binary: false, + binary: 'false', }, }); @@ -424,8 +454,9 @@ export default class API { const { name, email } = this.commitAuthor; body.append('author', `${name} <${email}>`); } - return flow([unsentRequest.withMethod('POST'), unsentRequest.withBody(body), this.request])( - `${this.repoURL}/src`, + + return this.request( + unsentRequest.withBody(body, unsentRequest.withMethod('POST', `${this.repoURL}/src`)), ); }; } diff --git a/src/backends/bitbucket/AuthenticationPage.js b/src/backends/bitbucket/AuthenticationPage.js deleted file mode 100644 index f5a05660..00000000 --- a/src/backends/bitbucket/AuthenticationPage.js +++ /dev/null @@ -1,95 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; - -import { AuthenticationPage, Icon } from '../../ui'; -import { NetlifyAuthenticator, ImplicitAuthenticator } from '../../lib/auth'; - -const LoginButtonIcon = styled(Icon)` - margin-right: 18px; -`; - -export default class BitbucketAuthenticationPage extends React.Component { - static propTypes = { - onLogin: PropTypes.func.isRequired, - inProgress: PropTypes.bool, - base_url: PropTypes.string, - siteId: PropTypes.string, - authEndpoint: PropTypes.string, - config: PropTypes.object.isRequired, - clearHash: PropTypes.func, - t: PropTypes.func.isRequired, - }; - - state = {}; - - componentDidMount() { - const { auth_type: authType = '' } = this.props.config.backend; - - if (authType === 'implicit') { - const { - base_url = 'https://bitbucket.org', - auth_endpoint = 'site/oauth2/authorize', - app_id = '', - } = this.props.config.backend; - - this.auth = new ImplicitAuthenticator({ - base_url, - auth_endpoint, - app_id, - clearHash: this.props.clearHash, - }); - // Complete implicit authentication if we were redirected back to from the provider. - this.auth.completeAuth((err, data) => { - if (err) { - this.setState({ loginError: err.toString() }); - return; - } - this.props.onLogin(data); - }); - this.authSettings = { scope: 'repository:write' }; - } else { - this.auth = new NetlifyAuthenticator({ - base_url: this.props.base_url, - site_id: - document.location.host.split(':')[0] === 'localhost' - ? 'cms.netlify.com' - : this.props.siteId, - auth_endpoint: this.props.authEndpoint, - }); - this.authSettings = { provider: 'bitbucket', scope: 'repo' }; - } - } - - handleLogin = e => { - e.preventDefault(); - this.auth.authenticate(this.authSettings, (err, data) => { - if (err) { - this.setState({ loginError: err.toString() }); - return; - } - this.props.onLogin(data); - }); - }; - - render() { - const { inProgress, config, t } = this.props; - - return ( - ( - - - {inProgress ? t('auth.loggingIn') : t('auth.loginWithBitbucket')} - - )} - t={t} - /> - ); - } -} diff --git a/src/backends/bitbucket/AuthenticationPage.tsx b/src/backends/bitbucket/AuthenticationPage.tsx new file mode 100644 index 00000000..b24889f3 --- /dev/null +++ b/src/backends/bitbucket/AuthenticationPage.tsx @@ -0,0 +1,96 @@ +import { styled } from '@mui/material/styles'; +import React, { useCallback, useMemo, useState } from 'react'; + +import AuthenticationPage from '../../components/UI/AuthenticationPage'; +import Icon from '../../components/UI/Icon'; +import { ImplicitAuthenticator, NetlifyAuthenticator } from '../../lib/auth'; + +import type { MouseEvent } from 'react'; +import type { AuthenticationPageProps, TranslatedProps } from '../../interface'; + +const LoginButtonIcon = styled(Icon)` + margin-right: 18px; +`; + +const BitbucketAuthenticationPage = ({ + inProgress = false, + config, + base_url, + siteId, + authEndpoint, + clearHash, + onLogin, + t, +}: TranslatedProps) => { + const [loginError, setLoginError] = useState(null); + + const [auth, authSettings] = useMemo(() => { + const { auth_type: authType = '' } = config.backend; + + if (authType === 'implicit') { + const { + base_url = 'https://bitbucket.org', + auth_endpoint = 'site/oauth2/authorize', + app_id = '', + } = config.backend; + + const implicityAuth = new ImplicitAuthenticator({ + base_url, + auth_endpoint, + app_id, + clearHash, + }); + + // Complete implicit authentication if we were redirected back to from the provider. + implicityAuth.completeAuth((err, data) => { + if (err) { + setLoginError(err.toString()); + return; + } else if (data) { + onLogin(data); + } + }); + + return [implicityAuth, { scope: 'repository:write' }]; + } else { + return [ + new NetlifyAuthenticator({ + base_url, + site_id: + document.location.host.split(':')[0] === 'localhost' ? 'cms.netlify.com' : siteId, + auth_endpoint: authEndpoint, + }), + { provider: 'bitbucket', scope: 'repo' }, + ] as const; + } + }, [authEndpoint, base_url, clearHash, config.backend, onLogin, siteId]); + + const handleLogin = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + auth.authenticate(authSettings, (err, data) => { + if (err) { + setLoginError(err.toString()); + } else if (data) { + onLogin(data); + } + }); + }, + [auth, authSettings, onLogin], + ); + + return ( + } + buttonContent={inProgress ? t('auth.loggingIn') : t('auth.loginWithBitbucket')} + t={t} + /> + ); +}; + +export default BitbucketAuthenticationPage; diff --git a/src/backends/bitbucket/implementation.ts b/src/backends/bitbucket/implementation.ts index e4c0ce33..ecb13f02 100644 --- a/src/backends/bitbucket/implementation.ts +++ b/src/backends/bitbucket/implementation.ts @@ -1,5 +1,5 @@ import { stripIndent } from 'common-tags'; -import { trimStart } from 'lodash'; +import trimStart from 'lodash/trimStart'; import semaphore from 'semaphore'; import { NetlifyAuthenticator } from '../../lib/auth'; @@ -29,20 +29,17 @@ import { GitLfsClient } from './git-lfs-client'; import type { Semaphore } from 'semaphore'; import type { - ApiRequest, - AssetProxy, - AsyncLock, + BackendEntry, + BackendClass, Config, Credentials, - Cursor, DisplayURL, - Entry, - FetchError, - Implementation, ImplementationFile, PersistOptions, User, -} from '../../lib/util'; +} from '../../interface'; +import type { ApiRequest, AsyncLock, Cursor, FetchError } from '../../lib/util'; +import type AssetProxy from '../../valueObjects/AssetProxy'; const MAX_CONCURRENT_DOWNLOADS = 10; @@ -56,7 +53,7 @@ type BitbucketStatusComponent = { }; // Implementation wrapper class -export default class BitbucketBackend implements Implementation { +export default class BitbucketBackend implements BackendClass { lock: AsyncLock; api: API | null; updateUserCredentials: (args: { token: string; refresh_token: string }) => Promise; @@ -71,7 +68,7 @@ export default class BitbucketBackend implements Implementation { baseUrl: string; siteId: string; token: string | null; - mediaFolder: string; + mediaFolder?: string; refreshToken?: string; refreshedTokenPromise?: Promise; authenticator?: NetlifyAuthenticator; @@ -231,7 +228,7 @@ export default class BitbucketBackend implements Implementation { this.refreshedTokenPromise = this.authenticator!.refresh({ provider: 'bitbucket', refresh_token: this.refreshToken as string, - }).then(({ token, refresh_token }) => { + })?.then(({ token, refresh_token }: { token: string; refresh_token: string }) => { this.token = token; this.refreshToken = refresh_token; this.refreshedTokenPromise = undefined; @@ -354,7 +351,10 @@ export default class BitbucketBackend implements Implementation { })); } - getMedia(mediaFolder = this.mediaFolder) { + async getMedia(mediaFolder = this.mediaFolder) { + if (!mediaFolder) { + return []; + } return this.api!.listAllFiles(mediaFolder, 1, this.branch).then(files => files.map(({ id, name, path }) => ({ id, name, path, displayURL: { id, path } })), ); @@ -412,7 +412,7 @@ export default class BitbucketBackend implements Implementation { }; } - async persistEntry(entry: Entry, options: PersistOptions) { + async persistEntry(entry: BackendEntry, options: PersistOptions) { const client = await this.getLargeMediaClient(); // persistEntry is a transactional operation return runWithLock( @@ -429,7 +429,18 @@ export default class BitbucketBackend implements Implementation { ); } - async persistMedia(mediaFile: AssetProxy, options: PersistOptions) { + async persistMedia( + mediaFile: + | { + fileObj: File; + size: number; + sha: string; + raw: string; + path: string; + } + | AssetProxy, + options: PersistOptions, + ) { const { fileObj, path } = mediaFile; const displayURL = URL.createObjectURL(fileObj as Blob); const client = await this.getLargeMediaClient(); @@ -445,7 +456,18 @@ export default class BitbucketBackend implements Implementation { }; } - async _persistMedia(mediaFile: AssetProxy, options: PersistOptions) { + async _persistMedia( + mediaFile: + | { + fileObj: File; + size: number; + sha: string; + raw: string; + path: string; + } + | AssetProxy, + options: PersistOptions, + ) { const fileObj = mediaFile.fileObj as File; const [id] = await Promise.all([ @@ -472,7 +494,7 @@ export default class BitbucketBackend implements Implementation { traverseCursor(cursor: Cursor, action: string) { return this.api!.traverseCursor(cursor, action).then(async ({ entries, cursor: newCursor }) => { - const extension = cursor.meta?.get('extension'); + const extension = cursor.meta?.extension as string | undefined; if (extension) { entries = entries.filter(e => filterByExtension(e, extension)); newCursor = newCursor.mergeMeta({ extension }); diff --git a/src/backends/bitbucket/index.ts b/src/backends/bitbucket/index.ts index 12cfc816..f45726a3 100644 --- a/src/backends/bitbucket/index.ts +++ b/src/backends/bitbucket/index.ts @@ -1,10 +1,3 @@ -import BitbucketBackend from './implementation'; -import API from './API'; -import AuthenticationPage from './AuthenticationPage'; - -export const StaticCmsBackendBitbucket = { - BitbucketBackend, - API, - AuthenticationPage, -}; -export { BitbucketBackend, API, AuthenticationPage }; +export { default as BitbucketBackend } from './implementation'; +export { default as API } from './API'; +export { default as AuthenticationPage } from './AuthenticationPage'; diff --git a/src/backends/git-gateway/AuthenticationPage.js b/src/backends/git-gateway/AuthenticationPage.js deleted file mode 100644 index ca92d96d..00000000 --- a/src/backends/git-gateway/AuthenticationPage.js +++ /dev/null @@ -1,230 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import styled from '@emotion/styled'; -import { partial } from 'lodash'; - -import { - AuthenticationPage, - buttons, - shadows, - colors, - colorsRaw, - lengths, - zIndex, -} from '../../ui'; - -const LoginButton = styled.button` - ${buttons.button}; - ${shadows.dropDeep}; - ${buttons.default}; - ${buttons.gray}; - - padding: 0 30px; - display: block; - margin-top: 20px; - margin-left: auto; -`; - -const AuthForm = styled.form` - width: 350px; - margin-top: -80px; -`; - -const AuthInput = styled.input` - background-color: ${colorsRaw.white}; - border-radius: ${lengths.borderRadius}; - - font-size: 14px; - padding: 10px; - margin-bottom: 15px; - margin-top: 6px; - width: 100%; - position: relative; - z-index: ${zIndex.zIndex1}; - border: 1px solid ${colorsRaw.gray}; - - &:focus { - outline: none; - box-shadow: inset 0 0 0 2px ${colors.active}; - border: 1px solid transparent; - } -`; - -const ErrorMessage = styled.p` - color: ${colors.errorText}; -`; - -let component = null; - -if (window.netlifyIdentity) { - window.netlifyIdentity.on('login', user => { - component && component.handleIdentityLogin(user); - }); - window.netlifyIdentity.on('logout', () => { - component && component.handleIdentityLogout(); - }); - window.netlifyIdentity.on('error', err => { - component && component.handleIdentityError(err); - }); -} - -export default class GitGatewayAuthenticationPage extends React.Component { - static authClient; - - constructor(props) { - super(props); - component = this; - } - - componentDidMount() { - if (!this.loggedIn && window.netlifyIdentity && window.netlifyIdentity.currentUser()) { - this.props.onLogin(window.netlifyIdentity.currentUser()); - window.netlifyIdentity.close(); - } - } - - componentWillUnmount() { - component = null; - } - - handleIdentityLogin = user => { - this.props.onLogin(user); - window.netlifyIdentity.close(); - }; - - handleIdentityLogout = () => { - window.netlifyIdentity.open(); - }; - - handleIdentityError = err => { - if (err?.message?.match(/^Failed to load settings from.+\.netlify\/identity$/)) { - window.netlifyIdentity.close(); - this.setState({ - errors: { identity: this.props.t('auth.errors.identitySettings') }, - }); - } - }; - - handleIdentity = () => { - const user = window.netlifyIdentity.currentUser(); - if (user) { - this.props.onLogin(user); - } else { - window.netlifyIdentity.open(); - } - }; - - static propTypes = { - onLogin: PropTypes.func.isRequired, - inProgress: PropTypes.bool.isRequired, - error: PropTypes.node, - config: PropTypes.object.isRequired, - t: PropTypes.func.isRequired, - }; - - state = { email: '', password: '', errors: {} }; - - handleChange = (name, e) => { - this.setState({ ...this.state, [name]: e.target.value }); - }; - - handleLogin = async e => { - e.preventDefault(); - - const { email, password } = this.state; - const { t } = this.props; - const errors = {}; - if (!email) { - errors.email = t('auth.errors.email'); - } - if (!password) { - errors.password = t('auth.errors.password'); - } - - if (Object.keys(errors).length > 0) { - this.setState({ errors }); - return; - } - - try { - const client = await GitGatewayAuthenticationPage.authClient(); - const user = await client.login(this.state.email, this.state.password, true); - this.props.onLogin(user); - } catch (error) { - this.setState({ - errors: { server: error.description || error.msg || error }, - loggingIn: false, - }); - } - }; - - render() { - const { errors } = this.state; - const { error, inProgress, config, t } = this.props; - - if (window.netlifyIdentity) { - if (errors.identity) { - return ( - ( - - {errors.identity} - - )} - t={t} - /> - ); - } else { - return ( - t('auth.loginWithNetlifyIdentity')} - t={t} - /> - ); - } - } - - return ( - ( - - {!error ? null : {error}} - {!errors.server ? null : {String(errors.server)}} - {errors.email || null} - - {errors.password || null} - - - {inProgress ? t('auth.loggingIn') : t('auth.login')} - - - )} - t={t} - /> - ); - } -} diff --git a/src/backends/git-gateway/AuthenticationPage.tsx b/src/backends/git-gateway/AuthenticationPage.tsx new file mode 100644 index 00000000..a6be7d42 --- /dev/null +++ b/src/backends/git-gateway/AuthenticationPage.tsx @@ -0,0 +1,225 @@ +import Button from '@mui/material/Button'; +import { styled } from '@mui/material/styles'; +import TextField from '@mui/material/TextField'; +import React, { useCallback, useEffect, useState } from 'react'; + +import AuthenticationPage from '../../components/UI/AuthenticationPage'; +import { colors } from '../../components/UI/styles'; + +import type { ChangeEvent, FormEvent } from 'react'; +import type { AuthenticationPageProps, TranslatedProps, User } from '../../interface'; + +const StyledAuthForm = styled('form')` + width: 350px; + display: flex; + flex-direction: column; + gap: 16px; +`; + +const ErrorMessage = styled('div')` + color: ${colors.errorText}; +`; + +function useNetlifyIdentifyEvent(eventName: 'login', callback: (login: User) => void): void; +function useNetlifyIdentifyEvent(eventName: 'logout', callback: () => void): void; +function useNetlifyIdentifyEvent(eventName: 'error', callback: (err: Error) => void): void; +function useNetlifyIdentifyEvent( + eventName: 'login' | 'logout' | 'error', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + callback: (input?: any) => void, +): void { + useEffect(() => { + window.netlifyIdentity?.on(eventName, callback); + }, [callback, eventName]); +} + +export interface GitGatewayAuthenticationPageProps + extends TranslatedProps { + handleAuth: (email: string, password: string) => Promise; +} + +const GitGatewayAuthenticationPage = ({ + inProgress = false, + config, + onLogin, + handleAuth, + t, +}: GitGatewayAuthenticationPageProps) => { + const [loggedIn, setLoggedIn] = useState(false); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [errors, setErrors] = useState<{ + identity?: string; + server?: string; + email?: string; + password?: string; + }>({}); + + useEffect(() => { + if (!loggedIn && window.netlifyIdentity && window.netlifyIdentity.currentUser()) { + onLogin(window.netlifyIdentity.currentUser()); + window.netlifyIdentity.close(); + } + }, [loggedIn, onLogin]); + + const handleIdentityLogin = useCallback( + (user: User) => { + onLogin(user); + window.netlifyIdentity?.close(); + }, + [onLogin], + ); + + useNetlifyIdentifyEvent('login', handleIdentityLogin); + + const handleIdentityLogout = useCallback(() => { + window.netlifyIdentity?.open(); + }, []); + + useNetlifyIdentifyEvent('logout', handleIdentityLogout); + + const handleIdentityError = useCallback( + (err: Error) => { + if (err?.message?.match(/^Failed to load settings from.+\.netlify\/identity$/)) { + window.netlifyIdentity?.close(); + setErrors({ identity: t('auth.errors.identitySettings') }); + } + }, + [t], + ); + + useNetlifyIdentifyEvent('error', handleIdentityError); + + const handleIdentity = useCallback(() => { + const user = window.netlifyIdentity?.currentUser(); + if (user) { + onLogin(user); + } else { + window.netlifyIdentity?.open(); + } + }, [onLogin]); + + const handleEmailChange = useCallback((event: ChangeEvent) => { + setEmail(event.target.value); + }, []); + + const handlePasswordChange = useCallback((event: ChangeEvent) => { + setPassword(event.target.value); + }, []); + + const handleLogin = useCallback( + async (e: FormEvent) => { + e.preventDefault(); + + const validationErrors: typeof errors = {}; + if (!email) { + validationErrors.email = t('auth.errors.email'); + } + if (!password) { + validationErrors.password = t('auth.errors.password'); + } + + if (Object.keys(validationErrors).length > 0) { + setErrors(validationErrors); + return; + } + + let response: User | string; + try { + response = await handleAuth(email, password); + } catch (e: unknown) { + if (e instanceof Error) { + response = e.message; + } else { + response = 'Unknown authentication error'; + } + } + + if (typeof response === 'string') { + setErrors({ server: response }); + setLoggedIn(false); + return; + } + + onLogin(response); + }, + [email, handleAuth, onLogin, password, t], + ); + + if (window.netlifyIdentity) { + if (errors.identity) { + return ( + + {errors.identity} + + } + t={t} + /> + ); + } else { + return ( + + ); + } + } + + return ( + + {!errors.server ? null : {String(errors.server)}} + + + + + } + t={t} + /> + ); +}; + +export default GitGatewayAuthenticationPage; diff --git a/src/backends/git-gateway/implementation.ts b/src/backends/git-gateway/implementation.tsx similarity index 85% rename from src/backends/git-gateway/implementation.ts rename to src/backends/git-gateway/implementation.tsx index 576c28b4..d7649669 100644 --- a/src/backends/git-gateway/implementation.ts +++ b/src/backends/git-gateway/implementation.tsx @@ -1,12 +1,21 @@ +import React, { useCallback } from 'react'; import GoTrue from 'gotrue-js'; import ini from 'ini'; import jwtDecode from 'jwt-decode'; -import { get, intersection, pick } from 'lodash'; +import get from 'lodash/get'; +import intersection from 'lodash/intersection'; +import pick from 'lodash/pick'; import { - AccessTokenError, APIError, basename, - entriesByFiles, getLargeMediaFilteredMediaFiles, getLargeMediaPatternsFromGitAttributesFile, - getPointerFileForMediaFileObj, parsePointerFile, unsentRequest + AccessTokenError, + APIError, + basename, + entriesByFiles, + getLargeMediaFilteredMediaFiles, + getLargeMediaPatternsFromGitAttributesFile, + getPointerFileForMediaFileObj, + parsePointerFile, + unsentRequest, } from '../../lib/util'; import { API as BitBucketAPI, BitbucketBackend } from '../bitbucket'; import { GitHubBackend } from '../github'; @@ -16,11 +25,22 @@ import GitHubAPI from './GitHubAPI'; import GitLabAPI from './GitLabAPI'; import { getClient } from './netlify-lfs-client'; +import type { ApiRequest, Cursor } from '../../lib/util'; import type { - ApiRequest, - AssetProxy, Config, Credentials, Cursor, DisplayURL, DisplayURLObject, Entry, Implementation, ImplementationFile, PersistOptions, User -} from '../../lib/util'; + Config, + Credentials, + DisplayURL, + DisplayURLObject, + BackendEntry, + BackendClass, + ImplementationFile, + PersistOptions, + User, + TranslatedProps, + AuthenticationPageProps, +} from '../../interface'; import type { Client } from './netlify-lfs-client'; +import type AssetProxy from '../../valueObjects/AssetProxy'; const STATUS_PAGE = 'https://www.netlifystatus.com'; const GIT_GATEWAY_STATUS_ENDPOINT = `${STATUS_PAGE}/api/v2/components.json`; @@ -34,15 +54,20 @@ type GitGatewayStatus = { type NetlifyIdentity = { logout: () => void; currentUser: () => User; - on: (event: string, args: unknown) => void; + on: ( + eventName: 'init' | 'login' | 'logout' | 'error', + callback: (input?: unknown) => void, + ) => void; init: () => void; store: { user: unknown; modal: { page: string }; saving: boolean }; + open: () => void; + close: () => void; }; type AuthClient = { logout: () => void; currentUser: () => unknown; - login?(email: string, password: string, remember?: boolean): Promise; + login?: (email: string, password: string, remember?: boolean) => Promise; clearStore: () => void; }; @@ -109,11 +134,11 @@ interface NetlifyUser extends Credentials { user_metadata: { full_name: string; avatar_url: string }; } -export default class GitGateway implements Implementation { +export default class GitGateway implements BackendClass { config: Config; api?: GitHubAPI | GitLabAPI | BitBucketAPI; branch: string; - mediaFolder: string; + mediaFolder?: string; transformImages: boolean; gatewayUrl: string; netlifyLargeMediaURL: string; @@ -158,7 +183,6 @@ export default class GitGateway implements Implementation { } this.backend = null; - AuthenticationPage.authClient = () => this.getAuthClient(); } isGitBackend() { @@ -227,7 +251,6 @@ export default class GitGateway implements Implementation { clearStore: () => undefined, }; } - return this.authClient; } requestFunction = (req: ApiRequest) => @@ -244,8 +267,12 @@ export default class GitGateway implements Implementation { const func = user.jwt.bind(user); const token = await func(); return token; - } catch (error: any) { - throw new AccessTokenError(`Failed getting access token: ${error.message}`); + } catch (error: unknown) { + if (error instanceof Error) { + throw new AccessTokenError(`Failed getting access token: ${error.message}`); + } + + throw new AccessTokenError('Failed getting access token'); } }; return this.tokenPromise!().then(async token => { @@ -335,22 +362,48 @@ export default class GitGateway implements Implementation { } async restoreUser() { const client = await this.getAuthClient(); - const user = client.currentUser(); - if (!user) return Promise.reject(); + const user = client?.currentUser(); + if (!user) { + return Promise.reject(); + } return this.authenticate(user as Credentials); } + authComponent() { - return AuthenticationPage; + const WrappedAuthenticationPage = (props: TranslatedProps) => { + const handleAuth = useCallback( + async (email: string, password: string): Promise => { + try { + const authClient = await this.getAuthClient(); + if (!authClient) { + return 'Auth client not started'; + } + + if (!authClient.login) { + return 'Auth client login function not found'; + } + + return authClient.login(email, password, true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + return error.description || error.msg || error; + } + }, + [], + ); + + return ; + }; + WrappedAuthenticationPage.displayName = 'AuthenticationPage'; + return WrappedAuthenticationPage; } async logout() { const client = await this.getAuthClient(); try { - client.logout(); + client?.logout(); } catch (e) { - // due to a bug in the identity widget (gotrue-js actually) the store is not reset if logout fails - // TODO: remove after https://github.com/netlify/gotrue-js/pull/83 is merged - client.clearStore(); + console.error(e); } } getToken() { @@ -490,10 +543,10 @@ export default class GitGateway implements Implementation { return this.backend!.getMediaFile(path); } - async persistEntry(entry: Entry, options: PersistOptions) { + async persistEntry(entry: BackendEntry, options: PersistOptions) { const client = await this.getLargeMediaClient(); if (client.enabled) { - const assets = await getLargeMediaFilteredMediaFiles(client, entry.assets); + const assets = (await getLargeMediaFilteredMediaFiles(client, entry.assets)) as any; return this.backend!.persistEntry({ ...entry, assets }, options); } else { return this.backend!.persistEntry(entry, options); @@ -507,11 +560,11 @@ export default class GitGateway implements Implementation { const fixedPath = path.startsWith('/') ? path.slice(1) : path; const isLargeMedia = await this.isLargeMediaFile(fixedPath); if (isLargeMedia) { - const persistMediaArgument = await getPointerFileForMediaFileObj( + const persistMediaArgument = (await getPointerFileForMediaFileObj( client, fileObj as File, path, - ); + )) as any; return { ...(await this.backend!.persistMedia(persistMediaArgument, options)), displayURL, diff --git a/src/backends/git-gateway/index.ts b/src/backends/git-gateway/index.ts index aa4c1e79..14ea029f 100644 --- a/src/backends/git-gateway/index.ts +++ b/src/backends/git-gateway/index.ts @@ -1,8 +1,2 @@ -import GitGatewayBackend from './implementation'; -import AuthenticationPage from './AuthenticationPage'; - -export const StaticCmsBackendGitGateway = { - GitGatewayBackend, - AuthenticationPage, -}; -export { GitGatewayBackend, AuthenticationPage }; +export { default as GitGatewayBackend } from './implementation'; +export { default as AuthenticationPage } from './AuthenticationPage'; diff --git a/src/backends/git-gateway/netlify-lfs-client.ts b/src/backends/git-gateway/netlify-lfs-client.ts index 6988ca5b..1ce964ed 100644 --- a/src/backends/git-gateway/netlify-lfs-client.ts +++ b/src/backends/git-gateway/netlify-lfs-client.ts @@ -1,5 +1,6 @@ import { flow, fromPairs, map } from 'lodash/fp'; -import { isPlainObject, isEmpty } from 'lodash'; +import isPlainObject from 'lodash/isPlainObject'; +import isEmpty from 'lodash/isEmpty'; import minimatch from 'minimatch'; import { unsentRequest } from '../../lib/util'; @@ -47,8 +48,7 @@ async function resourceExists( return false; } - // TODO: what kind of error to throw here? APIError doesn't seem - // to fit + // TODO: what kind of error to throw here? APIError doesn't seem to fit } function getTransofrmationsParams(t: boolean | ImageTransformations) { diff --git a/src/backends/github/API.ts b/src/backends/github/API.ts index 5d23a12c..842b3489 100644 --- a/src/backends/github/API.ts +++ b/src/backends/github/API.ts @@ -1,7 +1,11 @@ import { Base64 } from 'js-base64'; -import { initial, last, partial, result, trim, trimStart } from 'lodash'; +import initial from 'lodash/initial'; +import last from 'lodash/last'; +import partial from 'lodash/partial'; +import result from 'lodash/result'; +import trim from 'lodash/trim'; +import trimStart from 'lodash/trimStart'; import { dirname } from 'path'; -import semaphore from 'semaphore'; import { APIError, @@ -17,11 +21,12 @@ import { import type { Octokit } from '@octokit/rest'; import type { Semaphore } from 'semaphore'; -import type { ApiRequest, AssetProxy, DataFile, FetchError, PersistOptions } from '../../lib/util'; +import type { DataFile, PersistOptions } from '../../interface'; +import type { ApiRequest, FetchError } from '../../lib/util'; +import type AssetProxy from '../../valueObjects/AssetProxy'; type GitHubUser = Octokit.UsersGetAuthenticatedResponse; type GitCreateTreeParamsTree = Octokit.GitCreateTreeParamsTree; -type GitHubCompareCommit = Octokit.ReposCompareCommitsResponseCommitsItem; type GitHubAuthor = Octokit.GitCreateCommitResponseAuthor; type GitHubCommitter = Octokit.GitCreateCommitResponseCommitter; @@ -35,25 +40,10 @@ export interface Config { originRepo?: string; } -interface TreeFile { - type: 'blob' | 'tree'; - sha: string; - path: string; - raw?: string; -} - type Override = Pick> & U; type TreeEntry = Override; -type GitHubCompareCommits = GitHubCompareCommit[]; - -type GitHubCompareFile = Octokit.ReposCompareCommitsResponseFilesItem & { - previous_filename?: string; -}; - -type GitHubCompareFiles = GitHubCompareFile[]; - interface MetaDataObjects { entry: { path: string; sha: string }; files: MediaFile[]; @@ -270,115 +260,6 @@ export default class API { return parseContentKey(contentKey); } - checkMetadataRef() { - return this.request(`${this.repoURL}/git/refs/meta/_static_cms`) - .then(response => response.object) - .catch(() => { - // Meta ref doesn't exist - const readme = { - raw: '# Static CMS\n\nThis tree is used by the Static CMS to store metadata information for specific files and branches.', - }; - - return this.uploadBlob(readme) - .then(item => - this.request(`${this.repoURL}/git/trees`, { - method: 'POST', - body: JSON.stringify({ - tree: [{ path: 'README.md', mode: '100644', type: 'blob', sha: item.sha }], - }), - }), - ) - .then(tree => this.commit('First Commit', tree)) - .then(response => this.createRef('meta', '_static_cms', response.sha)) - .then(response => response.object); - }); - } - - async storeMetadata(key: string, data: Metadata) { - // semaphore ensures metadata updates are always ordered, even if - // calls to storeMetadata are not. concurrent metadata updates - // will result in the metadata branch being unable to update. - if (!this._metadataSemaphore) { - this._metadataSemaphore = semaphore(1); - } - return new Promise((resolve, reject) => - this._metadataSemaphore?.take(async () => { - try { - const branchData = await this.checkMetadataRef(); - const file = { path: `${key}.json`, raw: JSON.stringify(data) }; - - await this.uploadBlob(file); - const changeTree = await this.updateTree(branchData.sha, [file as TreeFile]); - const { sha } = await this.commit(`Updating “${key}” metadata`, changeTree); - await this.patchRef('meta', '_static_cms', sha); - await localForage.setItem(`gh.meta.${key}`, { - expires: Date.now() + 300000, // In 5 minutes - data, - }); - this._metadataSemaphore?.leave(); - resolve(); - } catch (err) { - reject(err); - } - }), - ); - } - - deleteMetadata(key: string) { - if (!this._metadataSemaphore) { - this._metadataSemaphore = semaphore(1); - } - return new Promise(resolve => - this._metadataSemaphore?.take(async () => { - try { - const branchData = await this.checkMetadataRef(); - const file = { path: `${key}.json`, sha: null }; - - const changeTree = await this.updateTree(branchData.sha, [file]); - const { sha } = await this.commit(`Deleting “${key}” metadata`, changeTree); - await this.patchRef('meta', '_static_cms', sha); - this._metadataSemaphore?.leave(); - resolve(); - } catch (err) { - this._metadataSemaphore?.leave(); - resolve(); - } - }), - ); - } - - async retrieveMetadataOld(key: string): Promise { - console.info( - '%c Checking for MetaData files', - 'line-height: 30px;text-align: center;font-weight: bold', - ); - - const metadataRequestOptions = { - params: { ref: 'refs/meta/_static_cms' }, - headers: { Accept: 'application/vnd.github.v3.raw' }, - }; - - function errorHandler(err: Error) { - if (err.message === 'Not Found') { - console.info( - '%c %s does not have metadata', - 'line-height: 30px;text-align: center;font-weight: bold', - key, - ); - } - throw err; - } - - const result = await this.request( - `${this.repoURL}/contents/${key}.json`, - metadataRequestOptions, - ) - .then((response: string) => JSON.parse(response)) - .catch(errorHandler); - - return result; - } - async readFile( path: string, sha?: string | null, @@ -479,14 +360,12 @@ export default class API { } async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) { - const files = mediaFiles.concat(dataFiles); + const files: (DataFile | AssetProxy)[] = mediaFiles.concat(dataFiles as any); const uploadPromises = files.map(file => this.uploadBlob(file)); await Promise.all(uploadPromises); return this.getDefaultBranch() - .then(branchData => - this.updateTree(branchData.commit.sha, files as { sha: string; path: string }[]), - ) + .then(branchData => this.updateTree(branchData.commit.sha, files as any)) .then(changeTree => this.commit(options.commitMessage, changeTree)) .then(response => this.patchBranch(this.branch, response.sha)); } diff --git a/src/backends/github/AuthenticationPage.js b/src/backends/github/AuthenticationPage.js deleted file mode 100644 index e7c1c616..00000000 --- a/src/backends/github/AuthenticationPage.js +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; - -import { AuthenticationPage, Icon } from '../../ui'; -import { NetlifyAuthenticator } from '../../lib/auth'; - -const LoginButtonIcon = styled(Icon)` - margin-right: 18px; -`; - -export default class GitHubAuthenticationPage extends React.Component { - static propTypes = { - onLogin: PropTypes.func.isRequired, - inProgress: PropTypes.bool, - base_url: PropTypes.string, - siteId: PropTypes.string, - authEndpoint: PropTypes.string, - config: PropTypes.object.isRequired, - clearHash: PropTypes.func, - t: PropTypes.func.isRequired, - }; - - state = {}; - - handleLogin = e => { - e.preventDefault(); - const cfg = { - base_url: this.props.base_url, - site_id: - document.location.host.split(':')[0] === 'localhost' - ? 'cms.netlify.com' - : this.props.siteId, - auth_endpoint: this.props.authEndpoint, - }; - const auth = new NetlifyAuthenticator(cfg); - - const { auth_scope: authScope = '' } = - this.props.config.backend; - - const scope = authScope || 'repo'; - auth.authenticate({ provider: 'github', scope }, (err, data) => { - if (err) { - this.setState({ loginError: err.toString() }); - return; - } - this.props.onLogin(data); - }); - }; - - renderLoginButton = () => { - const { inProgress, t } = this.props; - return inProgress ? ( - t('auth.loggingIn') - ) : ( - - - {t('auth.loginWithGitHub')} - - ); - }; - - getAuthenticationPageRenderArgs() { - return { - renderButtonContent: this.renderLoginButton, - }; - } - - render() { - const { inProgress, config, t } = this.props; - const { loginError } = this.state; - - return ( - - ); - } -} diff --git a/src/backends/github/AuthenticationPage.tsx b/src/backends/github/AuthenticationPage.tsx new file mode 100644 index 00000000..fc5e23fd --- /dev/null +++ b/src/backends/github/AuthenticationPage.tsx @@ -0,0 +1,64 @@ +import { styled } from '@mui/material/styles'; +import React, { useCallback, useState } from 'react'; + +import AuthenticationPage from '../../components/UI/AuthenticationPage'; +import Icon from '../../components/UI/Icon'; +import { NetlifyAuthenticator } from '../../lib/auth'; + +import type { MouseEvent } from 'react'; +import type { AuthenticationPageProps, TranslatedProps } from '../../interface'; + +const LoginButtonIcon = styled(Icon)` + margin-right: 18px; +`; + +const GitHubAuthenticationPage = ({ + inProgress = false, + config, + base_url, + siteId, + authEndpoint, + onLogin, + t, +}: TranslatedProps) => { + const [loginError, setLoginError] = useState(null); + + const handleLogin = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + const cfg = { + base_url, + site_id: document.location.host.split(':')[0] === 'localhost' ? 'cms.netlify.com' : siteId, + auth_endpoint: authEndpoint, + }; + const auth = new NetlifyAuthenticator(cfg); + + const { auth_scope: authScope = '' } = config.backend; + + const scope = authScope || 'repo'; + auth.authenticate({ provider: 'github', scope }, (err, data) => { + if (err) { + setLoginError(err.toString()); + } else if (data) { + onLogin(data); + } + }); + }, + [authEndpoint, base_url, config.backend, onLogin, siteId], + ); + + return ( + } + buttonContent={t('auth.loginWithGitHub')} + t={t} + /> + ); +}; + +export default GitHubAuthenticationPage; diff --git a/src/backends/github/GraphQLAPI.ts b/src/backends/github/GraphQLAPI.ts index a771f11a..c26e5639 100644 --- a/src/backends/github/GraphQLAPI.ts +++ b/src/backends/github/GraphQLAPI.ts @@ -1,14 +1,11 @@ -import { - InMemoryCache, IntrospectionFragmentMatcher -} from 'apollo-cache-inmemory'; +import { InMemoryCache, IntrospectionFragmentMatcher } from 'apollo-cache-inmemory'; import { ApolloClient } from 'apollo-client'; import { setContext } from 'apollo-link-context'; import { createHttpLink } from 'apollo-link-http'; -import { trim, trimStart } from 'lodash'; +import trim from 'lodash/trim'; +import trimStart from 'lodash/trimStart'; -import { - APIError, localForage, readFile, throwOnConflictingBranches -} from '../../lib/util'; +import { APIError, localForage, readFile, throwOnConflictingBranches } from '../../lib/util'; import API, { API_NAME } from './API'; import introspectionQueryResultData from './fragmentTypes'; import * as mutations from './mutations'; @@ -128,7 +125,7 @@ export default class GraphQLAPI extends API { // https://developer.github.com/v4/enum/repositorypermission/ const { viewerPermission } = data.repository; return ['ADMIN', 'MAINTAIN', 'WRITE'].includes(viewerPermission); - } catch (error) { + } catch (error: any) { console.error('Problem fetching repo data from GitHub'); throw error; } diff --git a/src/backends/github/fragmentTypes.js b/src/backends/github/fragmentTypes.ts similarity index 100% rename from src/backends/github/fragmentTypes.js rename to src/backends/github/fragmentTypes.ts diff --git a/src/backends/github/implementation.tsx b/src/backends/github/implementation.tsx index e27a32f0..9c99be78 100644 --- a/src/backends/github/implementation.tsx +++ b/src/backends/github/implementation.tsx @@ -1,6 +1,5 @@ import { stripIndent } from 'common-tags'; import trimStart from 'lodash/trimStart'; -import * as React from 'react'; import semaphore from 'semaphore'; import { @@ -25,17 +24,17 @@ import GraphQLAPI from './GraphQLAPI'; import type { Octokit } from '@octokit/rest'; import type { Semaphore } from 'semaphore'; import type { - AssetProxy, - AsyncLock, + BackendEntry, + BackendClass, Config, Credentials, DisplayURL, - Entry, - Implementation, ImplementationFile, PersistOptions, User, -} from '../../lib/util'; +} from '../../interface'; +import type { AsyncLock } from '../../lib/util'; +import type AssetProxy from '../../valueObjects/AssetProxy'; type GitHubUser = Octokit.UsersGetAuthenticatedResponse; @@ -54,7 +53,7 @@ type GitHubStatusComponent = { status: string; }; -export default class GitHub implements Implementation { +export default class GitHub implements BackendClass { lock: AsyncLock; api: API | null; options: { @@ -65,7 +64,7 @@ export default class GitHub implements Implementation { repo?: string; branch: string; apiRoot: string; - mediaFolder: string; + mediaFolder?: string; token: string | null; useGraphql: boolean; _currentUserPromise?: Promise; @@ -136,11 +135,7 @@ export default class GitHub implements Implementation { } authComponent() { - const wrappedAuthenticationPage = (props: Record) => ( - - ); - wrappedAuthenticationPage.displayName = 'AuthenticationPage'; - return wrappedAuthenticationPage; + return AuthenticationPage; } restoreUser(user: User) { @@ -324,7 +319,10 @@ export default class GitHub implements Implementation { .catch(() => ({ file: { path, id: null }, data: '' })); } - getMedia(mediaFolder = this.mediaFolder) { + async getMedia(mediaFolder = this.mediaFolder) { + if (!mediaFolder) { + return []; + } return this.api!.listFiles(mediaFolder).then(files => files.map(({ id, name, size, path }) => { // load media using getMediaDisplayURL to avoid token expiration with GitHub raw content urls @@ -362,7 +360,7 @@ export default class GitHub implements Implementation { ); } - persistEntry(entry: Entry, options: PersistOptions) { + persistEntry(entry: BackendEntry, options: PersistOptions) { // persistEntry is a transactional operation return runWithLock( this.lock, @@ -394,8 +392,8 @@ export default class GitHub implements Implementation { } async traverseCursor(cursor: Cursor, action: string) { - const meta = cursor.meta!; - const files = cursor.data!.get('files')!.toJS() as ApiFile[]; + const meta = cursor.meta; + const files = (cursor.data?.files ?? []) as ApiFile[]; let result: { cursor: Cursor; files: ApiFile[] }; switch (action) { @@ -404,15 +402,15 @@ export default class GitHub implements Implementation { break; } case 'last': { - result = this.getCursorAndFiles(files, meta.get('pageCount')); + result = this.getCursorAndFiles(files, (meta?.['pageCount'] as number) ?? 1); break; } case 'next': { - result = this.getCursorAndFiles(files, meta.get('page') + 1); + result = this.getCursorAndFiles(files, (meta?.['page'] as number) + 1 ?? 1); break; } case 'prev': { - result = this.getCursorAndFiles(files, meta.get('page') - 1); + result = this.getCursorAndFiles(files, (meta?.['page'] as number) - 1 ?? 1); break; } default: { diff --git a/src/backends/github/index.ts b/src/backends/github/index.ts index 8b26ebfc..6d7f175e 100644 --- a/src/backends/github/index.ts +++ b/src/backends/github/index.ts @@ -1,10 +1,3 @@ -import GitHubBackend from './implementation'; -import API from './API'; -import AuthenticationPage from './AuthenticationPage'; - -export const StaticCmsBackendGithub = { - GitHubBackend, - API, - AuthenticationPage, -}; -export { GitHubBackend, API, AuthenticationPage }; +export { default as GitHubBackend } from './implementation'; +export { default as API } from './API'; +export { default as AuthenticationPage } from './AuthenticationPage'; diff --git a/src/backends/github/scripts/createFragmentTypes.js b/src/backends/github/scripts/createFragmentTypes.ts similarity index 83% rename from src/backends/github/scripts/createFragmentTypes.js rename to src/backends/github/scripts/createFragmentTypes.ts index 03b31887..be4f38ec 100644 --- a/src/backends/github/scripts/createFragmentTypes.js +++ b/src/backends/github/scripts/createFragmentTypes.ts @@ -1,6 +1,6 @@ -const fetch = require('node-fetch'); -const fs = require('fs'); -const path = require('path'); +import fs from 'fs'; +import fetch from 'node-fetch'; +import path from 'path'; const API_HOST = process.env.GITHUB_HOST || 'https://api.github.com'; const API_TOKEN = process.env.GITHUB_API_TOKEN; @@ -32,7 +32,9 @@ fetch(`${API_HOST}/graphql`, { .then(result => result.json()) .then(result => { // here we're filtering out any type information unrelated to unions or interfaces - const filteredData = result.data.__schema.types.filter(type => type.possibleTypes !== null); + const filteredData = result.data.__schema.types.filter( + (type: { possibleTypes: string[] | null }) => type.possibleTypes !== null, + ); result.data.__schema.types = filteredData; fs.writeFile( path.join(__dirname, '..', 'src', 'fragmentTypes.js'), diff --git a/src/backends/github/types/semaphore.d.ts b/src/backends/github/types/semaphore.d.ts deleted file mode 100644 index 8c09e2a0..00000000 --- a/src/backends/github/types/semaphore.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare module 'semaphore' { - export type Semaphore = { take: (f: Function) => void; leave: () => void }; - const semaphore: (count: number) => Semaphore; - export default semaphore; -} diff --git a/src/backends/gitlab/API.ts b/src/backends/gitlab/API.ts index 128c648f..36e554a0 100644 --- a/src/backends/gitlab/API.ts +++ b/src/backends/gitlab/API.ts @@ -2,36 +2,36 @@ import { InMemoryCache } from 'apollo-cache-inmemory'; import { ApolloClient } from 'apollo-client'; import { setContext } from 'apollo-link-context'; import { createHttpLink } from 'apollo-link-http'; -import { Map } from 'immutable'; import { Base64 } from 'js-base64'; -import { flow, partial, result, trimStart } from 'lodash'; +import partial from 'lodash/partial'; +import result from 'lodash/result'; +import trimStart from 'lodash/trimStart'; import { dirname } from 'path'; import { - APIError, Cursor, localForage, parseLinkHeader, readFile, + APIError, + Cursor, + localForage, + parseLinkHeader, + readFile, readFileMetadata, requestWithBackoff, - responseParser, then, + responseParser, throwOnConflictingBranches, - unsentRequest + unsentRequest, } from '../../lib/util'; import * as queries from './queries'; -const NO_CACHE = 'no-cache'; - import type { NormalizedCacheObject } from 'apollo-cache-inmemory'; import type { ApolloQueryResult } from 'apollo-client'; -import type { - ApiRequest, - AssetProxy, - DataFile, - FetchError, - ImplementationFile, - PersistOptions -} from '../../lib/util'; +import type { DataFile, ImplementationFile, PersistOptions } from '../../interface'; +import type { ApiRequest, FetchError } from '../../lib/util'; +import type AssetProxy from '../../valueObjects/AssetProxy'; export const API_NAME = 'GitLab'; +const NO_CACHE = 'no-cache'; + export interface Config { apiRoot?: string; graphQLAPIRoot?: string; @@ -203,7 +203,7 @@ export default class API { const withRoot: ApiRequest = unsentRequest.withRoot(this.apiRoot)(req); const withAuthorizationHeaders = await this.withAuthorizationHeaders(withRoot); - if (withAuthorizationHeaders.has('cache')) { + if ('cache' in withAuthorizationHeaders) { return withAuthorizationHeaders; } else { const withNoCache: ApiRequest = unsentRequest.withNoCache(withAuthorizationHeaders); @@ -214,8 +214,11 @@ export default class API { request = async (req: ApiRequest): Promise => { try { return requestWithBackoff(this, req); - } catch (err: any) { - throw new APIError(err.message, null, API_NAME); + } catch (error: unknown) { + if (error instanceof Error) { + throw new APIError(error.message, null, API_NAME); + } + throw error; } }; @@ -310,16 +313,14 @@ export default class API { const pageSize = parseInt(headers.get('X-Per-Page') as string, 10); const count = parseInt(headers.get('X-Total') as string, 10); const links = parseLinkHeader(headers.get('Link')); - const actions = Map(links) - .keySeq() - .flatMap(key => - (key === 'prev' && page > 1) || - (key === 'next' && page < pageCount) || - (key === 'first' && page > 1) || - (key === 'last' && page < pageCount) - ? [key] - : [], - ); + const actions = Object.keys(links).flatMap(key => + (key === 'prev' && page > 1) || + (key === 'next' && page < pageCount) || + (key === 'first' && page > 1) || + (key === 'last' && page < pageCount) + ? [key] + : [], + ); return Cursor.create({ actions, meta: { page, count, pageSize, pageCount }, @@ -329,38 +330,34 @@ export default class API { getCursor = ({ headers }: { headers: Headers }) => this.getCursorFromHeaders(headers); - // Gets a cursor without retrieving the entries by using a HEAD - // request + // Gets a cursor without retrieving the entries by using a HEAD request fetchCursor = (req: ApiRequest) => - flow([unsentRequest.withMethod('HEAD'), this.request, then(this.getCursor)])(req); + this.request(unsentRequest.withMethod('HEAD', req)).then(value => this.getCursor(value)); fetchCursorAndEntries = ( req: ApiRequest, ): Promise<{ entries: FileEntry[]; cursor: Cursor; - }> => - flow([ - unsentRequest.withMethod('GET'), - this.request, - p => - Promise.all([ - p.then(this.getCursor), - p.then(this.responseToJSON).catch((e: FetchError) => { - if (e.status === 404) { - return []; - } else { - throw e; - } - }), - ]), - then(([cursor, entries]: [Cursor, {}[]]) => ({ cursor, entries })), - ])(req); + }> => { + const request = this.request(unsentRequest.withMethod('GET', req)); + + return Promise.all([ + request.then(this.getCursor), + request.then(this.responseToJSON).catch((e: FetchError) => { + if (e.status === 404) { + return []; + } else { + throw e; + } + }), + ]).then(([cursor, entries]) => ({ cursor, entries })); + }; listFiles = async (path: string, recursive = false) => { const { entries, cursor } = await this.fetchCursorAndEntries({ url: `${this.repoURL}/repository/tree`, - params: { path, ref: this.branch, recursive }, + params: { path, ref: this.branch, recursive: `${recursive}` }, }); return { files: entries.filter(({ type }) => type === 'blob'), @@ -369,7 +366,7 @@ export default class API { }; traverseCursor = async (cursor: Cursor, action: string) => { - const link = cursor.data!.getIn(['links', action]); + const link = (cursor.data?.links as Record)[action]; const { entries, cursor: newCursor } = await this.fetchCursorAndEntries(link); return { entries: entries.filter(({ type }) => type === 'blob'), @@ -478,11 +475,11 @@ export default class API { let { cursor, entries: initialEntries } = await this.fetchCursorAndEntries({ url: `${this.repoURL}/repository/tree`, // Get the maximum number of entries per page - params: { path, ref: branch, per_page: 100, recursive }, + params: { path, ref: branch, per_page: '100', recursive: `${recursive}` }, }); entries.push(...initialEntries); while (cursor && cursor.actions!.has('next')) { - const link = cursor.data!.getIn(['links', 'next']); + const link = (cursor.data?.links as Record).next; const { cursor: newCursor, entries: newEntries } = await this.fetchCursorAndEntries(link); entries.push(...newEntries); cursor = newCursor; @@ -533,10 +530,12 @@ export default class API { body: JSON.stringify(commitParams), }); return result; - } catch (error: any) { - const message = error.message || ''; - if (newBranch && message.includes(`Could not update ${branch}`)) { - await throwOnConflictingBranches(branch, name => this.getBranch(name), API_NAME); + } catch (error: unknown) { + if (error instanceof Error) { + const message = error.message || ''; + if (newBranch && message.includes(`Could not update ${branch}`)) { + await throwOnConflictingBranches(branch, name => this.getBranch(name), API_NAME); + } } throw error; } @@ -618,7 +617,7 @@ export default class API { params: { ref: branch }, }); - const blobId = request.headers.get('X-Gitlab-Blob-Id') as string; + const blobId = request.headers.get('X - Gitlab - Blob - Id') as string; return blobId; } diff --git a/src/backends/gitlab/AuthenticationPage.js b/src/backends/gitlab/AuthenticationPage.js deleted file mode 100644 index c859fbca..00000000 --- a/src/backends/gitlab/AuthenticationPage.js +++ /dev/null @@ -1,104 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; - -import { AuthenticationPage, Icon } from '../../ui'; -import { - NetlifyAuthenticator, - ImplicitAuthenticator, - PkceAuthenticator, -} from '../../lib/auth'; - -const LoginButtonIcon = styled(Icon)` - margin-right: 18px; -`; - -const clientSideAuthenticators = { - pkce: ({ base_url, auth_endpoint, app_id, auth_token_endpoint }) => - new PkceAuthenticator({ base_url, auth_endpoint, app_id, auth_token_endpoint }), - - implicit: ({ base_url, auth_endpoint, app_id, clearHash }) => - new ImplicitAuthenticator({ base_url, auth_endpoint, app_id, clearHash }), -}; - -export default class GitLabAuthenticationPage extends React.Component { - static propTypes = { - onLogin: PropTypes.func.isRequired, - inProgress: PropTypes.bool, - base_url: PropTypes.string, - siteId: PropTypes.string, - authEndpoint: PropTypes.string, - config: PropTypes.object.isRequired, - clearHash: PropTypes.func, - t: PropTypes.func.isRequired, - }; - - state = {}; - - componentDidMount() { - const { - auth_type: authType = '', - base_url = 'https://gitlab.com', - auth_endpoint = 'oauth/authorize', - app_id = '', - } = this.props.config.backend; - - if (clientSideAuthenticators[authType]) { - this.auth = clientSideAuthenticators[authType]({ - base_url, - auth_endpoint, - app_id, - auth_token_endpoint: 'oauth/token', - clearHash: this.props.clearHash, - }); - // Complete implicit authentication if we were redirected back to from the provider. - this.auth.completeAuth((err, data) => { - if (err) { - this.setState({ loginError: err.toString() }); - return; - } - this.props.onLogin(data); - }); - } else { - this.auth = new NetlifyAuthenticator({ - base_url: this.props.base_url, - site_id: - document.location.host.split(':')[0] === 'localhost' - ? 'cms.netlify.com' - : this.props.siteId, - auth_endpoint: this.props.authEndpoint, - }); - } - } - - handleLogin = e => { - e.preventDefault(); - this.auth.authenticate({ provider: 'gitlab', scope: 'api' }, (err, data) => { - if (err) { - this.setState({ loginError: err.toString() }); - return; - } - this.props.onLogin(data); - }); - }; - - render() { - const { inProgress, config, t } = this.props; - return ( - ( - - {' '} - {inProgress ? t('auth.loggingIn') : t('auth.loginWithGitLab')} - - )} - t={t} - /> - ); - } -} diff --git a/src/backends/gitlab/AuthenticationPage.tsx b/src/backends/gitlab/AuthenticationPage.tsx new file mode 100644 index 00000000..3b5a502f --- /dev/null +++ b/src/backends/gitlab/AuthenticationPage.tsx @@ -0,0 +1,100 @@ +import { styled } from '@mui/material/styles'; +import React, { useCallback, useMemo, useState } from 'react'; + +import AuthenticationPage from '../../components/UI/AuthenticationPage'; +import Icon from '../../components/UI/Icon'; +import { ImplicitAuthenticator, NetlifyAuthenticator, PkceAuthenticator } from '../../lib/auth'; +import { isNotEmpty } from '../../lib/util/string.util'; + +import type { MouseEvent } from 'react'; +import type { + AuthenticationPageProps, + AuthenticatorConfig, + TranslatedProps +} from '../../interface'; + +const LoginButtonIcon = styled(Icon)` + margin-right: 18px; +`; + +const clientSideAuthenticators = { + pkce: (config: AuthenticatorConfig) => new PkceAuthenticator(config), + implicit: (config: AuthenticatorConfig) => new ImplicitAuthenticator(config), +} as const; + +const GitLabAuthenticationPage = ({ + inProgress = false, + config, + siteId, + authEndpoint, + clearHash, + onLogin, + t, +}: TranslatedProps) => { + const [loginError, setLoginError] = useState(null); + + const auth = useMemo(() => { + const { + auth_type: authType = '', + base_url = 'https://gitlab.com', + auth_endpoint = 'oauth/authorize', + app_id = '', + } = config.backend; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (isNotEmpty(authType) && authType in clientSideAuthenticators) { + const clientSizeAuth = clientSideAuthenticators[ + authType as keyof typeof clientSideAuthenticators + ]({ + base_url, + auth_endpoint, + app_id, + auth_token_endpoint: 'oauth/token', + clearHash, + }); + // Complete implicit authentication if we were redirected back to from the provider. + clientSizeAuth.completeAuth((err, data) => { + if (err) { + setLoginError(err.toString()); + } else if (data) { + onLogin(data); + } + }); + return clientSizeAuth; + } else { + return new NetlifyAuthenticator({ + base_url, + site_id: document.location.host.split(':')[0] === 'localhost' ? 'cms.netlify.com' : siteId, + auth_endpoint: authEndpoint, + }); + } + }, [authEndpoint, clearHash, config.backend, onLogin, siteId]); + + const handleLogin = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + auth.authenticate({ provider: 'gitlab', scope: 'api' }, err => { + if (err) { + setLoginError(err.toString()); + return; + } + }); + }, + [auth], + ); + + return ( + } + buttonContent={inProgress ? t('auth.loggingIn') : t('auth.loginWithGitLab')} + t={t} + /> + ); +}; + +export default GitLabAuthenticationPage; diff --git a/src/backends/gitlab/implementation.ts b/src/backends/gitlab/implementation.ts index ed24eda8..18e55c8c 100644 --- a/src/backends/gitlab/implementation.ts +++ b/src/backends/gitlab/implementation.ts @@ -1,5 +1,5 @@ import { stripIndent } from 'common-tags'; -import { trim } from 'lodash'; +import trim from 'lodash/trim'; import trimStart from 'lodash/trimStart'; import semaphore from 'semaphore'; @@ -22,23 +22,22 @@ import API, { API_NAME } from './API'; import AuthenticationPage from './AuthenticationPage'; import type { Semaphore } from 'semaphore'; +import type { AsyncLock, Cursor } from '../../lib/util'; import type { - AssetProxy, - AsyncLock, Config, Credentials, - Cursor, DisplayURL, - Entry, - Implementation, + BackendEntry, + BackendClass, ImplementationFile, PersistOptions, User, -} from '../../lib/util'; +} from '../../interface'; +import type AssetProxy from '../../valueObjects/AssetProxy'; const MAX_CONCURRENT_DOWNLOADS = 10; -export default class GitLab implements Implementation { +export default class GitLab implements BackendClass { lock: AsyncLock; api: API | null; options: { @@ -49,7 +48,7 @@ export default class GitLab implements Implementation { branch: string; apiRoot: string; token: string | null; - mediaFolder: string; + mediaFolder?: string; useGraphQL: boolean; graphQLAPIRoot: string; @@ -224,7 +223,10 @@ export default class GitLab implements Implementation { })); } - getMedia(mediaFolder = this.mediaFolder) { + async getMedia(mediaFolder = this.mediaFolder) { + if (!mediaFolder) { + return []; + } return this.api!.listAllFiles(mediaFolder).then(files => files.map(({ id, name, path }) => { return { id, name, path, displayURL: { id, name, path } }; @@ -259,7 +261,7 @@ export default class GitLab implements Implementation { }; } - async persistEntry(entry: Entry, options: PersistOptions) { + async persistEntry(entry: BackendEntry, options: PersistOptions) { // persistEntry is a transactional operation return runWithLock( this.lock, @@ -297,9 +299,9 @@ export default class GitLab implements Implementation { traverseCursor(cursor: Cursor, action: string) { return this.api!.traverseCursor(cursor, action).then(async ({ entries, cursor: newCursor }) => { const [folder, depth, extension] = [ - cursor.meta?.get('folder') as string, - cursor.meta?.get('depth') as number, - cursor.meta?.get('extension') as string, + cursor.meta?.folder as string, + cursor.meta?.depth as number, + cursor.meta?.extension as string, ]; if (folder && depth && extension) { entries = entries.filter(f => this.filterFile(folder, f, extension, depth)); diff --git a/src/backends/gitlab/index.ts b/src/backends/gitlab/index.ts index 73dc7bfc..13adc82e 100644 --- a/src/backends/gitlab/index.ts +++ b/src/backends/gitlab/index.ts @@ -1,10 +1,3 @@ -import GitLabBackend from './implementation'; -import API from './API'; -import AuthenticationPage from './AuthenticationPage'; - -export const StaticCmsBackendGitlab = { - GitLabBackend, - API, - AuthenticationPage, -}; -export { GitLabBackend, API, AuthenticationPage }; +export { default as GitLabBackend } from './implementation'; +export { default as API } from './API'; +export { default as AuthenticationPage } from './AuthenticationPage'; diff --git a/src/backends/index.tsx b/src/backends/index.tsx index 8d56e340..3eca0a40 100644 --- a/src/backends/index.tsx +++ b/src/backends/index.tsx @@ -1,17 +1,7 @@ -import { AzureBackend } from './azure'; -import { BitbucketBackend } from './bitbucket'; -import { GitGatewayBackend } from './git-gateway'; -import { GitHubBackend } from './github'; -import { GitLabBackend } from './gitlab'; -import { ProxyBackend } from './proxy'; -import { TestBackend } from './test'; - -export { - AzureBackend, - BitbucketBackend, - GitGatewayBackend, - GitHubBackend, - GitLabBackend, - ProxyBackend, - TestBackend, -}; +export { AzureBackend } from './azure'; +export { BitbucketBackend } from './bitbucket'; +export { GitGatewayBackend } from './git-gateway'; +export { GitHubBackend } from './github'; +export { GitLabBackend } from './gitlab'; +export { ProxyBackend } from './proxy'; +export { TestBackend } from './test'; diff --git a/src/backends/proxy/AuthenticationPage.js b/src/backends/proxy/AuthenticationPage.js deleted file mode 100644 index d7ca98a2..00000000 --- a/src/backends/proxy/AuthenticationPage.js +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; - -import { Icon, buttons, shadows, GoBackButton } from '../../ui'; - -const StyledAuthenticationPage = styled.section` - display: flex; - flex-flow: column nowrap; - align-items: center; - justify-content: center; - height: 100vh; -`; - -const PageLogoIcon = styled(Icon)` - color: #c4c6d2; - margin-top: -300px; -`; - -const LoginButton = styled.button` - ${buttons.button}; - ${shadows.dropDeep}; - ${buttons.default}; - ${buttons.gray}; - - padding: 0 30px; - margin-top: -40px; - display: flex; - align-items: center; - position: relative; - - ${Icon} { - margin-right: 18px; - } -`; - -export default class AuthenticationPage extends React.Component { - static propTypes = { - onLogin: PropTypes.func.isRequired, - inProgress: PropTypes.bool, - config: PropTypes.object.isRequired, - t: PropTypes.func.isRequired, - }; - - handleLogin = e => { - e.preventDefault(); - this.props.onLogin(this.state); - }; - - render() { - const { config, inProgress, t } = this.props; - - return ( - - - - {inProgress ? t('auth.loggingIn') : t('auth.login')} - - {config.site_url && } - - ); - } -} diff --git a/src/backends/proxy/AuthenticationPage.tsx b/src/backends/proxy/AuthenticationPage.tsx new file mode 100644 index 00000000..28fc02da --- /dev/null +++ b/src/backends/proxy/AuthenticationPage.tsx @@ -0,0 +1,48 @@ +import Button from '@mui/material/Button'; +import { styled } from '@mui/material/styles'; +import React, { useCallback } from 'react'; + +import GoBackButton from '../../components/UI/GoBackButton'; +import Icon from '../../components/UI/Icon'; + +import type { MouseEvent } from 'react'; +import type { AuthenticationPageProps, TranslatedProps } from '../../interface'; + +const StyledAuthenticationPage = styled('section')` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; +`; + +const PageLogoIcon = styled(Icon)` + color: #c4c6d2; +`; + +const AuthenticationPage = ({ + inProgress = false, + config, + onLogin, + t, +}: TranslatedProps) => { + const handleLogin = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + onLogin({ token: 'fake_token' }); + }, + [onLogin], + ); + + return ( + + + + {config.site_url && } + + ); +}; + +export default AuthenticationPage; diff --git a/src/backends/proxy/implementation.ts b/src/backends/proxy/implementation.ts index ddf46fb5..6d7b0b54 100644 --- a/src/backends/proxy/implementation.ts +++ b/src/backends/proxy/implementation.ts @@ -2,15 +2,17 @@ import { APIError, basename, blobToFileObj, unsentRequest } from '../../lib/util import AuthenticationPage from './AuthenticationPage'; import type { - AssetProxy, + BackendEntry, + BackendClass, Config, - Entry, - Implementation, DisplayURL, + ImplementationEntry, ImplementationFile, PersistOptions, User, -} from '../../lib/util'; +} from '../../interface'; +import type { Cursor } from '../../lib/util'; +import type AssetProxy from '../../valueObjects/AssetProxy'; async function serializeAsset(assetProxy: AssetProxy) { const base64content = await assetProxy.toBase64!(); @@ -42,9 +44,9 @@ function deserializeMediaFile({ id, content, encoding, path, name }: MediaFile) return { id, name, path, file, size: file.size, url, displayURL: url }; } -export default class ProxyBackend implements Implementation { +export default class ProxyBackend implements BackendClass { proxyUrl: string; - mediaFolder: string; + mediaFolder?: string; options: {}; branch: string; @@ -124,7 +126,7 @@ export default class ProxyBackend implements Implementation { }); } - async persistEntry(entry: Entry, options: PersistOptions) { + async persistEntry(entry: BackendEntry, options: PersistOptions) { const assets = await Promise.all(entry.assets.map(serializeAsset)); return this.request({ action: 'persistEntry', @@ -179,4 +181,16 @@ export default class ProxyBackend implements Implementation { params: { branch: this.branch, paths, options: { commitMessage } }, }); } + + traverseCursor(): Promise<{ entries: ImplementationEntry[]; cursor: Cursor }> { + throw new Error('Not supported'); + } + + allEntriesByFolder( + _folder: string, + _extension: string, + _depth: number, + ): Promise { + throw new Error('Not supported'); + } } diff --git a/src/backends/proxy/index.ts b/src/backends/proxy/index.ts index e074802c..25889c70 100644 --- a/src/backends/proxy/index.ts +++ b/src/backends/proxy/index.ts @@ -1,8 +1,2 @@ -import ProxyBackend from './implementation'; -import AuthenticationPage from './AuthenticationPage'; - -export const StaticCmsBackendProxy = { - ProxyBackend, - AuthenticationPage, -}; -export { ProxyBackend, AuthenticationPage }; +export { default as ProxyBackend } from './implementation'; +export { default as AuthenticationPage } from './AuthenticationPage'; diff --git a/src/backends/test/AuthenticationPage.js b/src/backends/test/AuthenticationPage.js deleted file mode 100644 index 0041acd2..00000000 --- a/src/backends/test/AuthenticationPage.js +++ /dev/null @@ -1,73 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; - -import { Icon, buttons, shadows, GoBackButton } from '../../ui'; - -const StyledAuthenticationPage = styled.section` - display: flex; - flex-flow: column nowrap; - align-items: center; - justify-content: center; - height: 100vh; -`; - -const PageLogoIcon = styled(Icon)` - color: #c4c6d2; - margin-top: -300px; -`; - -const LoginButton = styled.button` - ${buttons.button}; - ${shadows.dropDeep}; - ${buttons.default}; - ${buttons.gray}; - - padding: 0 30px; - margin-top: -40px; - display: flex; - align-items: center; - position: relative; - - ${Icon} { - margin-right: 18px; - } -`; - -export default class AuthenticationPage extends React.Component { - static propTypes = { - onLogin: PropTypes.func.isRequired, - inProgress: PropTypes.bool, - config: PropTypes.object.isRequired, - t: PropTypes.func.isRequired, - }; - - componentDidMount() { - /** - * Allow login screen to be skipped for demo purposes. - */ - const skipLogin = this.props.config.backend.login === false; - if (skipLogin) { - this.props.onLogin(this.state); - } - } - - handleLogin = e => { - e.preventDefault(); - this.props.onLogin(this.state); - }; - - render() { - const { config, inProgress, t } = this.props; - - return ( - - - - {inProgress ? t('auth.loggingIn') : t('auth.login')} - - {config.site_url && } - - ); - } -} diff --git a/src/backends/test/AuthenticationPage.tsx b/src/backends/test/AuthenticationPage.tsx new file mode 100644 index 00000000..9141e307 --- /dev/null +++ b/src/backends/test/AuthenticationPage.tsx @@ -0,0 +1,63 @@ +import Button from '@mui/material/Button'; +import { styled } from '@mui/material/styles'; +import React, { useCallback, useEffect } from 'react'; + +import GoBackButton from '../../components/UI/GoBackButton'; +import Icon from '../../components/UI/Icon'; + +import type { MouseEvent } from 'react'; +import type { AuthenticationPageProps, TranslatedProps } from '../../interface'; + +const StyledAuthenticationPage = styled('section')` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; +`; + +const PageLogoIcon = styled(Icon)` + color: #c4c6d2; +`; + +const AuthenticationPage = ({ + inProgress = false, + config, + onLogin, + t, +}: TranslatedProps) => { + useEffect(() => { + /** + * Allow login screen to be skipped for demo purposes. + */ + const skipLogin = config.backend.login === false; + if (skipLogin) { + onLogin({ token: 'fake_token' }); + } + }, [config.backend.login, onLogin]); + + const handleLogin = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + onLogin({ token: 'fake_token' }); + }, + [onLogin], + ); + + return ( + + + + {config.site_url && } + + ); +}; + +export default AuthenticationPage; diff --git a/src/backends/test/implementation.ts b/src/backends/test/implementation.ts index 42512688..87f6fa0e 100644 --- a/src/backends/test/implementation.ts +++ b/src/backends/test/implementation.ts @@ -1,19 +1,23 @@ -import { attempt, isError, take, unset } from 'lodash'; +import attempt from 'lodash/attempt'; +import isError from 'lodash/isError'; +import take from 'lodash/take'; +import unset from 'lodash/unset'; import { extname } from 'path'; import uuid from 'uuid/v4'; import { basename, Cursor, CURSOR_COMPATIBILITY_SYMBOL } from '../../lib/util'; import AuthenticationPage from './AuthenticationPage'; -import type { ImplementationEntry } from '../../interface'; import type { - AssetProxy, + BackendEntry, + BackendClass, Config, - Entry, - Implementation, + DisplayURL, + ImplementationEntry, ImplementationFile, User, -} from '../../lib/util'; +} from '../../interface'; +import type AssetProxy from '../../valueObjects/AssetProxy'; type RepoFile = { path: string; content: string | AssetProxy }; type RepoTree = { [key: string]: RepoFile | RepoTree }; @@ -98,8 +102,8 @@ export function getFolderFiles( return files; } -export default class TestBackend implements Implementation { - mediaFolder: string; +export default class TestBackend implements BackendClass { + mediaFolder?: string; options: {}; constructor(config: Config, options = {}) { @@ -136,7 +140,7 @@ export default class TestBackend implements Implementation { } traverseCursor(cursor: Cursor, action: string) { - const { folder, extension, index, pageCount, depth } = cursor.data!.toObject() as { + const { folder, extension, index, pageCount, depth } = cursor.data as { folder: string; extension: string; index: number; @@ -177,6 +181,7 @@ export default class TestBackend implements Implementation { })); const cursor = getCursor(folder, extension, entries, 0, depth); const ret = take(entries, pageSize); + // TODO Remove // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore ret[CURSOR_COMPATIBILITY_SYMBOL] = cursor; @@ -199,7 +204,7 @@ export default class TestBackend implements Implementation { }); } - async persistEntry(entry: Entry) { + async persistEntry(entry: BackendEntry) { entry.dataFiles.forEach(dataFile => { const { path, raw } = dataFile; writeFile(path, raw, window.repoFiles); @@ -210,12 +215,14 @@ export default class TestBackend implements Implementation { return Promise.resolve(); } - getMedia(mediaFolder = this.mediaFolder) { + async getMedia(mediaFolder = this.mediaFolder) { + if (!mediaFolder) { + return []; + } const files = getFolderFiles(window.repoFiles, mediaFolder.split('/')[0], '', 100).filter(f => f.path.startsWith(mediaFolder), ); - const assets = files.map(f => this.normalizeAsset(f.content as AssetProxy)); - return Promise.resolve(assets); + return files.map(f => this.normalizeAsset(f.content as AssetProxy)); } async getMediaFile(path: string) { @@ -270,4 +277,16 @@ export default class TestBackend implements Implementation { return Promise.resolve(); } + + async allEntriesByFolder( + folder: string, + extension: string, + depth: number, + ): Promise { + return this.entriesByFolder(folder, extension, depth); + } + + getMediaDisplayURL(_displayURL: DisplayURL): Promise { + throw new Error('Not supported'); + } } diff --git a/src/backends/test/index.ts b/src/backends/test/index.ts index fc4135f5..1e03e646 100644 --- a/src/backends/test/index.ts +++ b/src/backends/test/index.ts @@ -1,8 +1,2 @@ -import TestBackend from './implementation'; -import AuthenticationPage from './AuthenticationPage'; - -export const StaticCmsBackendTest = { - TestBackend, - AuthenticationPage, -}; -export { TestBackend, AuthenticationPage }; +export { default as TestBackend } from './implementation'; +export { default as AuthenticationPage } from './AuthenticationPage'; diff --git a/src/bootstrap.js b/src/bootstrap.tsx similarity index 52% rename from src/bootstrap.js rename to src/bootstrap.tsx index 6d5acd11..952ceb2b 100644 --- a/src/bootstrap.js +++ b/src/bootstrap.tsx @@ -1,46 +1,57 @@ -import React from 'react'; -import { render } from 'react-dom'; -import { Provider, connect } from 'react-redux'; -import { Router } from 'react-router-dom'; -import { I18n } from 'react-polyglot'; +import 'symbol-observable'; -import { GlobalStyles } from './ui'; -import { store } from './store'; -import { history } from './routing/history'; -import { loadConfig } from './actions/config'; +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { I18n } from 'react-polyglot'; +import { connect, Provider } from 'react-redux'; +import { HashRouter as Router } from 'react-router-dom'; + +import 'what-input'; import { authenticateUser } from './actions/auth'; -import { getPhrases } from './lib/phrases'; -import { selectLocale } from './reducers/config'; -import { ErrorBoundary } from './components/UI'; +import { loadConfig } from './actions/config'; import App from './components/App/App'; import './components/EditorWidgets'; +import { ErrorBoundary } from './components/UI'; +import { addExtensions } from './extensions'; +import { getPhrases } from './lib/phrases'; import './mediaLibrary'; -import 'what-input'; +import { selectLocale } from './reducers/config'; +import { store } from './store'; + +import type { AnyAction } from '@reduxjs/toolkit'; +import type { ConnectedProps } from 'react-redux'; +import type { Config } from './interface'; +import type { RootState } from './store'; const ROOT_ID = 'nc-root'; -function TranslatedApp({ locale, config }) { +const TranslatedApp = ({ locale, config }: AppRootProps) => { + if (!config) { + return null; + } + return ( -
- - - - - - - -
+ + + + + + + ); +}; + +function mapDispatchToProps(state: RootState) { + return { locale: selectLocale(state.config.config), config: state.config.config }; } -function mapDispatchToProps(state) { - return { locale: selectLocale(state.config), config: state.config }; -} +const connector = connect(mapDispatchToProps); +export type AppRootProps = ConnectedProps; -const ConnectedTranslatedApp = connect(mapDispatchToProps)(TranslatedApp); +const ConnectedTranslatedApp = connector(TranslatedApp); -function bootstrap(opts = {}) { - const { config } = opts; +function bootstrap(opts?: { config?: Config; autoInitialize?: boolean }) { + const { config, autoInitialize = true } = opts ?? {}; /** * Log the version number. @@ -70,15 +81,14 @@ function bootstrap(opts = {}) { return newRoot; } - /** - * Dispatch config to store if received. This config will be merged into - * config.yml if it exists, and any portion that produces a conflict will be - * overwritten. - */ + if (autoInitialize) { + addExtensions(); + } + store.dispatch( loadConfig(config, function onLoad() { - store.dispatch(authenticateUser()); - }), + store.dispatch(authenticateUser() as unknown as AnyAction); + }) as AnyAction, ); /** @@ -87,7 +97,6 @@ function bootstrap(opts = {}) { function Root() { return ( <> - @@ -98,7 +107,8 @@ function bootstrap(opts = {}) { /** * Render application root. */ - render(, getRoot()); + const root = createRoot(getRoot()); + root.render(); } export default bootstrap; diff --git a/src/components/App/App.js b/src/components/App/App.js deleted file mode 100644 index 4141bbd9..00000000 --- a/src/components/App/App.js +++ /dev/null @@ -1,344 +0,0 @@ -import styled from '@emotion/styled'; -import PropTypes from 'prop-types'; -import React from 'react'; -import { hot } from 'react-hot-loader'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { translate } from 'react-polyglot'; -import { connect } from 'react-redux'; -import { Redirect, Route, Switch } from 'react-router-dom'; -import { ScrollSync } from 'react-scroll-sync'; -import TopBarProgress from 'react-topbar-progress-indicator'; - -import { loginUser, logoutUser } from '../../actions/auth'; -import { createNewEntry } from '../../actions/collections'; -import { openMediaLibrary } from '../../actions/mediaLibrary'; -import { currentBackend } from '../../backend'; -import { history } from '../../routing/history'; -import { colors, Loader } from '../../ui'; -import Collection from '../Collection/Collection'; -import Editor from '../Editor/Editor'; -import MediaLibrary from '../MediaLibrary/MediaLibrary'; -import Page from '../page/Page'; -import Snackbars from '../snackbar/Snackbars'; -import { Alert } from '../UI/Alert'; -import { Confirm } from '../UI/Confirm'; -import Header from './Header'; -import NotFoundPage from './NotFoundPage'; - -TopBarProgress.config({ - barColors: { - 0: colors.active, - '1.0': colors.active, - }, - shadowBlur: 0, - barThickness: 2, -}); - -const AppRoot = styled.div` - width: 100%; - min-width: 1200px; - height: 100vh; - position: relative; - overflow-y: auto; -`; - -const AppWrapper = styled.div` - width: 100%; - min-width: 1200px; - min-height: 100vh; -`; - -const AppMainContainer = styled.div` - min-width: 1200px; - max-width: 1440px; - margin: 0 auto; -`; - -const ErrorContainer = styled.div` - margin: 20px; -`; - -const ErrorCodeBlock = styled.pre` - margin-left: 20px; - font-size: 15px; - line-height: 1.5; -`; - -function getDefaultPath(collections) { - const first = collections - .filter( - collection => - collection.get('hide') !== true && - (!collection.has('files') || collection.get('files').size > 1), - ) - .first(); - if (first) { - return `/collections/${first.get('name')}`; - } else { - throw new Error('Could not find a non hidden collection'); - } -} - -/** - * Returns default collection name if only one collection - * - * @param {Collection} collection - * @returns {string} - */ -function getDefaultCollectionPath(collection) { - if (collection.has('files') && collection.get('files').size === 1) { - return `/collections/${collection.get('name')}/entries/${collection - .get('files') - .first() - .get('name')}`; - } - - return null; -} - -function RouteInCollectionDefault({ collections, render, ...props }) { - const defaultPath = getDefaultPath(collections); - return ( - { - const collectionExists = collections.get(routeProps.match.params.name); - if (!collectionExists) { - return ; - } - - const defaultCollectionPath = getDefaultCollectionPath(collectionExists); - if (defaultCollectionPath !== null) { - return ; - } - - return render(routeProps); - }} - /> - ); -} - -function RouteInCollection({ collections, render, ...props }) { - const defaultPath = getDefaultPath(collections); - return ( - { - const collectionExists = collections.get(routeProps.match.params.name); - return collectionExists ? render(routeProps) : ; - }} - /> - ); -} - -class App extends React.Component { - static propTypes = { - auth: PropTypes.object.isRequired, - config: PropTypes.object.isRequired, - collections: ImmutablePropTypes.map.isRequired, - loginUser: PropTypes.func.isRequired, - logoutUser: PropTypes.func.isRequired, - user: PropTypes.object, - isFetching: PropTypes.bool.isRequired, - siteId: PropTypes.string, - useMediaLibrary: PropTypes.bool, - openMediaLibrary: PropTypes.func.isRequired, - showMediaButton: PropTypes.bool, - scrollSyncEnabled: PropTypes.bool.isRequired, - t: PropTypes.func.isRequired, - }; - - configError(config) { - const t = this.props.t; - return ( - -

{t('app.app.errorHeader')}

-
- {t('app.app.configErrors')}: - {config.error} - {t('app.app.checkConfigYml')} -
-
- ); - } - - handleLogin(credentials) { - this.props.loginUser(credentials); - } - - authenticating() { - const { auth, t } = this.props; - const backend = currentBackend(this.props.config); - - if (backend == null) { - return ( -
-

{t('app.app.waitingBackend')}

-
- ); - } - - return ( -
- {React.createElement(backend.authComponent(), { - onLogin: this.handleLogin.bind(this), - error: auth.error, - inProgress: auth.isFetching, - siteId: this.props.config.backend.site_domain, - base_url: this.props.config.backend.base_url, - authEndpoint: this.props.config.backend.auth_endpoint, - config: this.props.config, - clearHash: () => history.replace('/'), - t, - })} -
- ); - } - - handleLinkClick(event, handler, ...args) { - event.preventDefault(); - handler(...args); - } - - render() { - const { - user, - config, - collections, - logoutUser, - isFetching, - useMediaLibrary, - openMediaLibrary, - t, - showMediaButton, - scrollSyncEnabled, - } = this.props; - - if (config === null) { - return null; - } - - if (config.error) { - return this.configError(config); - } - - if (config.isFetching) { - return {t('app.app.loadingConfig')}; - } - - if (user == null) { - return this.authenticating(t); - } - - const defaultPath = getDefaultPath(collections); - - return ( - - - - -
- - {isFetching && } - - - - } - /> - - } - /> - } - /> - } - /> - } - /> - } - /> - } - /> - { - const { name, entryName } = match.params; - return ; - }} - /> - } /> - - - {useMediaLibrary ? : null} - - - - - - - ); - } -} - -function mapStateToProps(state) { - const { auth, config, collections, globalUI, mediaLibrary, scroll } = state; - const user = auth.user; - const isFetching = globalUI.isFetching; - const useMediaLibrary = !mediaLibrary.get('externalLibrary'); - const showMediaButton = mediaLibrary.get('showMediaButton'); - const scrollSyncEnabled = scroll.isScrolling; - return { - auth, - config, - collections, - user, - isFetching, - showMediaButton, - useMediaLibrary, - scrollSyncEnabled, - }; -} - -const mapDispatchToProps = { - openMediaLibrary, - loginUser, - logoutUser, -}; - -export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(translate()(App))); diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx new file mode 100644 index 00000000..05a4a98d --- /dev/null +++ b/src/components/App/App.tsx @@ -0,0 +1,264 @@ +import { styled } from '@mui/material/styles'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import Fab from '@mui/material/Fab'; +import React, { useCallback, useMemo } from 'react'; +import { translate } from 'react-polyglot'; +import { connect } from 'react-redux'; +import { Navigate, Route, Routes, useParams } from 'react-router-dom'; +import { ScrollSync } from 'react-scroll-sync'; +import TopBarProgress from 'react-topbar-progress-indicator'; + +import { loginUser as loginUserAction } from '../../actions/auth'; +import { currentBackend } from '../../backend'; +import { colors, GlobalStyles } from '../../components/UI/styles'; +import { history } from '../../routing/history'; +import CollectionRoute from '../Collection/CollectionRoute'; +import EditorRoute from '../Editor/EditorRoute'; +import MediaLibrary from '../MediaLibrary/MediaLibrary'; +import Snackbars from '../snackbar/Snackbars'; +import { Alert } from '../UI/Alert'; +import { Confirm } from '../UI/Confirm'; +import Loader from '../UI/Loader'; +import ScrollTop from '../UI/ScrollTop'; +import NotFoundPage from './NotFoundPage'; + +import type { ComponentType } from 'react'; +import type { ConnectedProps } from 'react-redux'; +import type { Collections, Credentials, TranslatedProps } from '../../interface'; +import type { RootState } from '../../store'; +TopBarProgress.config({ + barColors: { + 0: colors.active, + '1.0': colors.active, + }, + shadowBlur: 0, + barThickness: 2, +}); + +const AppRoot = styled('div')` + width: 100%; + min-width: 1200px; + height: 100vh; + position: relative; +`; + +const AppWrapper = styled('div')` + width: 100%; + min-width: 1200px; + min-height: 100vh; +`; + +const ErrorContainer = styled('div')` + margin: 20px; +`; + +const ErrorCodeBlock = styled('pre')` + margin-left: 20px; + font-size: 15px; + line-height: 1.5; +`; + +function getDefaultPath(collections: Collections) { + const options = Object.values(collections).filter( + collection => + collection.hide !== true && (!('files' in collection) || (collection.files?.length ?? 0) > 1), + ); + + if (options.length > 0) { + return `/collections/${options[0].name}`; + } else { + throw new Error('Could not find a non hidden collection'); + } +} + +function CollectionSearchRedirect() { + const { name } = useParams(); + return ; +} + +function EditEntityRedirect() { + const { name, entryName } = useParams(); + return ; +} + +history.listen(e => { + console.log(e); +}); + +const App = ({ + auth, + user, + config, + collections, + loginUser, + isFetching, + useMediaLibrary, + t, + scrollSyncEnabled, +}: TranslatedProps) => { + const configError = useCallback(() => { + return ( + +

{t('app.app.errorHeader')}

+
+ {t('app.app.configErrors')}: + {config.error} + {t('app.app.checkConfigYml')} +
+
+ ); + }, [config.error, t]); + + const handleLogin = useCallback( + (credentials: Credentials) => { + loginUser(credentials); + }, + [loginUser], + ); + + const authenticating = useCallback(() => { + if (!config.config) { + return null; + } + + const backend = currentBackend(config.config); + + if (backend == null) { + return ( +
+

{t('app.app.waitingBackend')}

+
+ ); + } + + return ( +
+ {React.createElement(backend.authComponent(), { + onLogin: handleLogin, + error: auth.error, + inProgress: auth.isFetching, + siteId: config.config.backend.site_domain, + base_url: config.config.backend.base_url, + authEndpoint: config.config.backend.auth_endpoint, + config: config.config, + clearHash: () => history.replace('/'), + t, + })} +
+ ); + }, [auth.error, auth.isFetching, config.config, handleLogin, t]); + + const defaultPath = useMemo(() => getDefaultPath(collections), [collections]); + + if (!config.config) { + return null; + } + + if (config.error) { + return configError(); + } + + if (config.isFetching) { + return {t('app.app.loadingConfig')}; + } + + if (!user) { + return authenticating(); + } + + return ( + <> + + + <> +
+ + + + {isFetching && } + + } /> + } /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + + } + /> + } + /> + } + /> + } /> + } /> + + {useMediaLibrary ? : null} + + + + + + + + + + + + + ); +}; + +function mapStateToProps(state: RootState) { + const { auth, config, collections, globalUI, mediaLibrary, scroll } = state; + const user = auth.user; + const isFetching = globalUI.isFetching; + const useMediaLibrary = !mediaLibrary.externalLibrary; + const scrollSyncEnabled = scroll.isScrolling; + return { + auth, + config, + collections, + user, + isFetching, + useMediaLibrary, + scrollSyncEnabled, + }; +} + +const mapDispatchToProps = { + loginUser: loginUserAction, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); +export type AppProps = ConnectedProps; + +export default connector(translate()(App) as ComponentType); diff --git a/src/components/App/Header.js b/src/components/App/Header.js deleted file mode 100644 index 09147604..00000000 --- a/src/components/App/Header.js +++ /dev/null @@ -1,226 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import styled from '@emotion/styled'; -import { css } from '@emotion/react'; -import { translate } from 'react-polyglot'; -import { NavLink } from 'react-router-dom'; -import { connect } from 'react-redux'; - -import { - Icon, - Dropdown, - DropdownItem, - StyledDropdownButton, - colors, - lengths, - shadows, - buttons, - zIndex, -} from '../../ui'; -import { SettingsDropdown } from '../UI'; -import { checkBackendStatus } from '../../actions/status'; - -const styles = { - buttonActive: css` - color: ${colors.active}; - `, -}; - -function AppHeader(props) { - return ( -
- ); -} - -const AppHeaderContent = styled.div` - display: flex; - justify-content: space-between; - min-width: 1200px; - max-width: 1440px; - padding: 0 12px; - margin: 0 auto; -`; - -const AppHeaderButton = styled.button` - ${buttons.button}; - background: none; - color: #7b8290; - font-family: inherit; - font-size: 16px; - font-weight: 500; - display: inline-flex; - padding: 16px 20px; - align-items: center; - - ${Icon} { - margin-right: 4px; - color: #b3b9c4; - } - - &:hover, - &:active, - &:focus { - ${styles.buttonActive}; - - ${Icon} { - ${styles.buttonActive}; - } - } - - ${props => css` - &.${props.activeClassName} { - ${styles.buttonActive}; - - ${Icon} { - ${styles.buttonActive}; - } - } - `}; -`; - -const AppHeaderNavLink = AppHeaderButton.withComponent(NavLink); - -const AppHeaderActions = styled.div` - display: inline-flex; - align-items: center; -`; - -const AppHeaderQuickNewButton = styled(StyledDropdownButton)` - ${buttons.button}; - ${buttons.medium}; - ${buttons.gray}; - margin-right: 8px; - - &:after { - top: 11px; - } -`; - -const AppHeaderNavList = styled.ul` - display: flex; - margin: 0; - list-style: none; -`; - -class Header extends React.Component { - static propTypes = { - user: PropTypes.object.isRequired, - collections: ImmutablePropTypes.map.isRequired, - onCreateEntryClick: PropTypes.func.isRequired, - onLogoutClick: PropTypes.func.isRequired, - openMediaLibrary: PropTypes.func.isRequired, - displayUrl: PropTypes.string, - isTestRepo: PropTypes.bool, - t: PropTypes.func.isRequired, - checkBackendStatus: PropTypes.func.isRequired, - }; - - intervalId; - - componentDidMount() { - this.intervalId = setInterval(() => { - this.props.checkBackendStatus(); - }, 5 * 60 * 1000); - } - - componentWillUnmount() { - clearInterval(this.intervalId); - } - - handleCreatePostClick = collectionName => { - const { onCreateEntryClick } = this.props; - if (onCreateEntryClick) { - onCreateEntryClick(collectionName); - } - }; - - render() { - const { - user, - collections, - onLogoutClick, - openMediaLibrary, - displayUrl, - isTestRepo, - t, - showMediaButton, - } = this.props; - - const createableCollections = collections - .filter(collection => collection.get('create')) - .toList(); - - return ( - - - - - {createableCollections.size > 0 && ( - ( - {t('app.header.quickAdd')} - )} - dropdownTopOverlap="30px" - dropdownWidth="160px" - dropdownPosition="left" - > - {createableCollections.map(collection => ( - this.handleCreatePostClick(collection.get('name'))} - /> - ))} - - )} - - - - - ); - } -} - -const mapDispatchToProps = { - checkBackendStatus, -}; - -export default connect(null, mapDispatchToProps)(translate()(Header)); diff --git a/src/components/App/Header.tsx b/src/components/App/Header.tsx new file mode 100644 index 00000000..0e1f552e --- /dev/null +++ b/src/components/App/Header.tsx @@ -0,0 +1,212 @@ +import { styled } from '@mui/material/styles'; +import DescriptionIcon from '@mui/icons-material/Description'; +import ImageIcon from '@mui/icons-material/Image'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import OpenInNewIcon from '@mui/icons-material/OpenInNew'; +import AppBar from '@mui/material/AppBar'; +import Button from '@mui/material/Button'; +import Link from '@mui/material/Link'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Toolbar from '@mui/material/Toolbar'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { translate } from 'react-polyglot'; +import { connect } from 'react-redux'; + +import { logoutUser as logoutUserAction } from '../../actions/auth'; +import { createNewEntry } from '../../actions/collections'; +import { openMediaLibrary as openMediaLibraryAction } from '../../actions/mediaLibrary'; +import { checkBackendStatus as checkBackendStatusAction } from '../../actions/status'; +import { buttons, colors } from '../../components/UI/styles'; +import { stripProtocol } from '../../lib/urlHelper'; +import NavLink from '../UI/NavLink'; +import SettingsDropdown from '../UI/SettingsDropdown'; + +import type { ComponentType } from 'react'; +import type { ConnectedProps } from 'react-redux'; +import type { TranslatedProps } from '../../interface'; +import type { RootState } from '../../store'; + +const StyledAppBar = styled(AppBar)` + background-color: ${colors.foreground}; +`; + +const StyledToolbar = styled(Toolbar)` + gap: 12px; +`; + +const StyledButton = styled(Button)` + ${buttons.button}; + background: none; + color: #7b8290; + font-family: inherit; + font-size: 16px; + font-weight: 500; + text-transform: none; + gap: 2px; + + &:hover, + &:active, + &:focus { + color: ${colors.active}; + } +`; + +const StyledSpacer = styled('div')` + flex-grow: 1; +`; + +const StyledAppHeaderActions = styled('div')` + display: inline-flex; + align-items: center; + gap: 8px; +`; + +const Header = ({ + user, + collections, + logoutUser, + openMediaLibrary, + displayUrl, + isTestRepo, + t, + showMediaButton, + checkBackendStatus, +}: TranslatedProps) => { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + const handleClose = useCallback(() => { + setAnchorEl(null); + }, []); + + const handleCreatePostClick = useCallback((collectionName: string) => { + createNewEntry(collectionName); + }, []); + + const createableCollections = useMemo( + () => Object.values(collections).filter(collection => collection.create), + [collections], + ); + + useEffect(() => { + const intervalId = setInterval(() => { + checkBackendStatus(); + }, 5 * 60 * 1000); + + return () => { + clearInterval(intervalId); + }; + }, [checkBackendStatus]); + + const handleMediaClick = useCallback(() => { + openMediaLibrary(); + }, [openMediaLibrary]); + + return ( + + + + + {t('app.header.content')} + + {showMediaButton ? ( + + + {t('app.header.media')} + + ) : null} + + + {createableCollections.length > 0 && ( +
+ + + {createableCollections.map(collection => ( + handleCreatePostClick(collection.name)} + > + {collection.label_singular || collection.label} + + ))} + +
+ )} + {isTestRepo && ( + + )} + {displayUrl ? ( + + ) : null} + +
+
+
+ ); +}; + +function mapStateToProps(state: RootState) { + const { auth, config, collections, mediaLibrary } = state; + const user = auth.user; + const showMediaButton = mediaLibrary.showMediaButton; + return { + user, + collections, + displayUrl: config.config?.display_url, + isTestRepo: config.config?.backend.name === 'test-repo', + showMediaButton, + }; +} + +const mapDispatchToProps = { + checkBackendStatus: checkBackendStatusAction, + openMediaLibrary: openMediaLibraryAction, + logoutUser: logoutUserAction, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); +export type HeaderProps = ConnectedProps; + +export default connector(translate()(Header) as ComponentType); diff --git a/src/components/App/MainView.tsx b/src/components/App/MainView.tsx new file mode 100644 index 00000000..9fb97571 --- /dev/null +++ b/src/components/App/MainView.tsx @@ -0,0 +1,49 @@ +import { styled } from '@mui/material/styles'; +import React from 'react'; +import TopBarProgress from 'react-topbar-progress-indicator'; + +import { colors } from '../../components/UI/styles'; +import Header from './Header'; + +import type { ReactNode } from 'react'; + +TopBarProgress.config({ + barColors: { + 0: colors.active, + '1.0': colors.active, + }, + shadowBlur: 0, + barThickness: 2, +}); + +const StyledMainContainerWrapper = styled('div')` + position: relative; + padding: 24px; + gap: 24px; +`; + +const StyledMainContainer = styled('div')` + min-width: 1200px; + max-width: 1440px; + margin: 0 auto; + display: flex; + gap: 24px; + position: relative; +`; + +interface MainViewProps { + children: ReactNode; +} + +const MainView = ({ children }: MainViewProps) => { + return ( + <> +
+ + {children} + + + ); +}; + +export default MainView; diff --git a/src/components/App/NotFoundPage.js b/src/components/App/NotFoundPage.js deleted file mode 100644 index 92c571b6..00000000 --- a/src/components/App/NotFoundPage.js +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import styled from '@emotion/styled'; -import { translate } from 'react-polyglot'; -import PropTypes from 'prop-types'; - -import { lengths } from '../../ui'; - -const NotFoundContainer = styled.div` - margin: ${lengths.pageMargin}; -`; - -function NotFoundPage({ t }) { - return ( - -

{t('app.notFoundPage.header')}

-
- ); -} - -NotFoundPage.propTypes = { - t: PropTypes.func.isRequired, -}; - -export default translate()(NotFoundPage); diff --git a/src/components/App/NotFoundPage.tsx b/src/components/App/NotFoundPage.tsx new file mode 100644 index 00000000..5ac7afb8 --- /dev/null +++ b/src/components/App/NotFoundPage.tsx @@ -0,0 +1,22 @@ +import { styled } from '@mui/material/styles'; +import React from 'react'; +import { translate } from 'react-polyglot'; + +import { lengths } from '../../components/UI/styles'; + +import type { ComponentType } from 'react'; +import type { TranslateProps } from 'react-polyglot'; + +const NotFoundContainer = styled('div')` + margin: ${lengths.pageMargin}; +`; + +const NotFoundPage = ({ t }: TranslateProps) => { + return ( + +

{t('app.notFoundPage.header')}

+
+ ); +}; + +export default translate()(NotFoundPage) as ComponentType<{}>; diff --git a/src/components/Collection/Collection.tsx b/src/components/Collection/Collection.tsx index e2bad7b8..c5da7cf2 100644 --- a/src/components/Collection/Collection.tsx +++ b/src/components/Collection/Collection.tsx @@ -1,67 +1,52 @@ -import styled from '@emotion/styled'; +import { styled } from '@mui/material/styles'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { translate } from 'react-polyglot'; import { connect } from 'react-redux'; -import { changeViewStyle, filterByField, groupByField, sortByField } from '../../actions/entries'; +import { + changeViewStyle as changeViewStyleAction, + filterByField as filterByFieldAction, + groupByField as groupByFieldAction, + sortByField as sortByFieldAction, +} from '../../actions/entries'; +import { components } from '../../components/UI/styles'; import { SortDirection } from '../../interface'; import { getNewEntryUrl } from '../../lib/urlHelper'; import { selectSortableFields, selectViewFilters, selectViewGroups, -} from '../../reducers/collections'; +} from '../../lib/util/collection.util'; import { selectEntriesFilter, selectEntriesGroup, selectEntriesSort, selectViewStyle, } from '../../reducers/entries'; -import { components, lengths } from '../../ui'; import CollectionControls from './CollectionControls'; import CollectionTop from './CollectionTop'; import EntriesCollection from './Entries/EntriesCollection'; import EntriesSearch from './Entries/EntriesSearch'; import Sidebar from './Sidebar'; -import type { RouteComponentProps } from 'react-router-dom'; -import type { - CmsSortableFieldsDefault, - TranslatedProps, - ViewFilter, - ViewGroup, -} from '../../interface'; -import type { Collection, State } from '../../types/redux'; -import type { StaticallyTypedRecord } from '../../types/immutable'; +import type { ComponentType } from 'react'; +import type { ConnectedProps } from 'react-redux'; +import type { Collection, TranslatedProps, ViewFilter, ViewGroup } from '../../interface'; +import type { RootState } from '../../store'; -const CollectionContainer = styled.div` - margin: ${lengths.pageMargin}; +const CollectionMain = styled('main')` + width: 100%; `; -const CollectionMain = styled.main` - padding-left: 280px; -`; - -const SearchResultContainer = styled.div` +const SearchResultContainer = styled('div')` ${components.cardTop}; margin-bottom: 22px; `; -const SearchResultHeading = styled.h1` +const SearchResultHeading = styled('h1')` ${components.cardTopHeading}; `; -interface CollectionRouterParams { - name: string; - searchTerm?: string; - filterTerm?: string; -} - -interface CollectionViewProps extends RouteComponentProps { - isSearchResults?: boolean; - isSingleSearchResult?: boolean; -} - const CollectionView = ({ collection, collections, @@ -71,27 +56,28 @@ const CollectionView = ({ isSingleSearchResult, searchTerm, sortableFields, - onSortClick, + sortByField, sort, viewFilters, viewGroups, filterTerm, t, - onFilterClick, - onGroupClick, + filterByField, + groupByField, filter, group, - onChangeViewStyle, + changeViewStyle, viewStyle, -}: ReturnType) => { +}: TranslatedProps) => { const [readyToLoad, setReadyToLoad] = useState(false); - const [preCollection, setPreCollection] = useState(collection); + const [prevCollection, setPrevCollection] = useState(); + useEffect(() => { - setPreCollection(collection); + setPrevCollection(collection); }, [collection]); const newEntryUrl = useMemo(() => { - let url = collection.get('create') ? getNewEntryUrl(collectionName) : ''; + let url = collection.create ? getNewEntryUrl(collectionName) : ''; if (url && filterTerm) { url = getNewEntryUrl(collectionName); if (filterTerm) { @@ -106,50 +92,92 @@ const CollectionView = ({ [isSingleSearchResult], ); - const renderEntriesCollection = useCallback(() => { + const entries = useMemo(() => { + if (isSearchResults) { + let searchCollections = collections; + if (isSingleSearchResult) { + const searchCollection = Object.values(collections).filter(c => c === collection); + if (searchCollection.length === 1) { + searchCollections = { + [searchCollection[0].name]: searchCollection[0], + }; + } + } + + return ; + } + return ( ); - }, [collection, filterTerm, viewStyle, readyToLoad]); + }, [ + collection, + collections, + filterTerm, + isSearchResults, + isSingleSearchResult, + prevCollection, + readyToLoad, + searchTerm, + viewStyle, + ]); - const renderEntriesSearch = useCallback(() => { - return ( - c === collection) : collections} - searchTerm={searchTerm} - /> - ); - }, [searchTerm, collections, collection, isSingleSearchResult]); + const onSortClick = useCallback( + async (key: string, direction?: SortDirection) => { + await sortByField(collection, key, direction); + }, + [collection, sortByField], + ); + + const onFilterClick = useCallback( + async (filter: ViewFilter) => { + await filterByField(collection, filter); + }, + [collection, filterByField], + ); + + const onGroupClick = useCallback( + async (group: ViewGroup) => { + await groupByField(collection, group); + }, + [collection, groupByField], + ); useEffect(() => { + if (prevCollection === collection) { + if (!readyToLoad) { + setReadyToLoad(true); + } + return; + } + + if (sort?.[0]?.key) { + if (!readyToLoad) { + setReadyToLoad(true); + } + return; + } + + const defaultSort = collection.sortable_fields.default; + if (!defaultSort || !defaultSort.field) { + if (!readyToLoad) { + setReadyToLoad(true); + } + return; + } + setReadyToLoad(false); + let alive = true; const sortEntries = () => { setTimeout(async () => { - if (sort?.first()?.get('key')) { - setReadyToLoad(true); - return; - } - - const defaultSort = collection.getIn(['sortable_fields', 'default']) as - | StaticallyTypedRecord - | undefined; - - if (!defaultSort || !defaultSort.get('field')) { - setReadyToLoad(true); - return; - } - - await onSortClick( - defaultSort.get('field'), - defaultSort.get('direction') ?? SortDirection.Ascending, - ); + await onSortClick(defaultSort.field, defaultSort.direction ?? SortDirection.Ascending); if (alive) { setReadyToLoad(true); @@ -162,10 +190,10 @@ const CollectionView = ({ return () => { alive = false; }; - }, [collection]); + }, [collection, onSortClick, prevCollection, readyToLoad, sort]); return ( - + <> - {isSearchResults ? ( - - - {t(searchResultKey, { searchTerm, collection: collection.get('label') })} - - - ) : ( - <> - - - - )} - {isSearchResults ? renderEntriesSearch() : renderEntriesCollection()} + <> + {isSearchResults ? ( + + + {t(searchResultKey, { searchTerm, collection: collection.label })} + + + ) : ( + <> + + + + )} + {entries} + - + ); }; -function mapStateToProps(state: State, ownProps: TranslatedProps) { +interface CollectionViewOwnProps { + isSearchResults?: boolean; + isSingleSearchResult?: boolean; + name: string; + searchTerm?: string; + filterTerm?: string; +} + +function mapStateToProps(state: RootState, ownProps: TranslatedProps) { const { collections } = state; - const isSearchEnabled = state.config && state.config.search != false; - const { isSearchResults, match, t } = ownProps; - const { name, searchTerm = '', filterTerm = '' } = match.params; - const collection: Collection = name ? collections.get(name) : collections.first(); - const sort = selectEntriesSort(state.entries, collection.get('name')); + const isSearchEnabled = state.config.config && state.config.config.search != false; + const { + isSearchResults, + isSingleSearchResult, + name, + searchTerm = '', + filterTerm = '', + t, + } = ownProps; + const collection: Collection = name ? collections[name] : collections[0]; + const sort = selectEntriesSort(state.entries, collection.name); const sortableFields = selectSortableFields(collection, t); const viewFilters = selectViewFilters(collection); const viewGroups = selectViewGroups(collection); - const filter = selectEntriesFilter(state.entries, collection.get('name')); - const group = selectEntriesGroup(state.entries, collection.get('name')); + const filter = selectEntriesFilter(state.entries, collection.name); + const group = selectEntriesGroup(state.entries, collection.name); const viewStyle = selectViewStyle(state.entries); return { + isSearchResults, + isSingleSearchResult, + name, + searchTerm, + filterTerm, collection, collections, collectionName: name, isSearchEnabled, - isSearchResults, - searchTerm, - filterTerm, sort, sortableFields, viewFilters, @@ -238,33 +284,13 @@ function mapStateToProps(state: State, ownProps: TranslatedProps, - dispatchProps: typeof mapDispatchToProps, - ownProps: TranslatedProps, -) { - return { - ...stateProps, - ...ownProps, - onSortClick: (key: string, direction: SortDirection) => - dispatchProps.sortByField(stateProps.collection, key, direction), - onFilterClick: (filter: ViewFilter) => - dispatchProps.filterByField(stateProps.collection, filter), - onGroupClick: (group: ViewGroup) => dispatchProps.groupByField(stateProps.collection, group), - onChangeViewStyle: (viewStyle: string) => dispatchProps.changeViewStyle(viewStyle), - }; -} +const connector = connect(mapStateToProps, mapDispatchToProps); +export type CollectionViewProps = ConnectedProps; -const ConnectedCollection = connect( - mapStateToProps, - mapDispatchToProps, - mergeProps, -)(CollectionView); - -export default translate()(ConnectedCollection); +export default translate()(connector(CollectionView)) as ComponentType; diff --git a/src/components/Collection/CollectionControls.js b/src/components/Collection/CollectionControls.tsx similarity index 57% rename from src/components/Collection/CollectionControls.js rename to src/components/Collection/CollectionControls.tsx index f21deeb7..76e5580a 100644 --- a/src/components/Collection/CollectionControls.js +++ b/src/components/Collection/CollectionControls.tsx @@ -1,18 +1,28 @@ +import { styled } from '@mui/material/styles'; import React from 'react'; -import styled from '@emotion/styled'; -import { lengths } from '../../ui'; -import ViewStyleControl from './ViewStyleControl'; -import SortControl from './SortControl'; import FilterControl from './FilterControl'; import GroupControl from './GroupControl'; +import SortControl from './SortControl'; +import ViewStyleControl from './ViewStyleControl'; -const CollectionControlsContainer = styled.div` +import type { CollectionViewStyle } from '../../constants/collectionViews'; +import type { + FilterMap, + GroupMap, + SortableField, + SortDirection, + SortMap, + TranslatedProps, + ViewFilter, + ViewGroup, +} from '../../interface'; + +const CollectionControlsContainer = styled('div')` display: flex; align-items: center; flex-direction: row-reverse; margin-top: 22px; - width: ${lengths.topCardWidth}; max-width: 100%; & > div { @@ -20,7 +30,21 @@ const CollectionControlsContainer = styled.div` } `; -function CollectionControls({ +interface CollectionControlsProps { + viewStyle: CollectionViewStyle; + onChangeViewStyle: (viewStyle: CollectionViewStyle) => void; + sortableFields: SortableField[]; + onSortClick: (key: string, direction?: SortDirection) => Promise; + sort: SortMap | undefined; + filter: Record; + viewFilters: ViewFilter[]; + onFilterClick: (filter: ViewFilter) => void; + group: Record; + viewGroups: ViewGroup[]; + onGroupClick: (filter: ViewGroup) => void; +} + +const CollectionControls = ({ viewStyle, onChangeViewStyle, sortableFields, @@ -33,7 +57,7 @@ function CollectionControls({ t, filter, group, -}) { +}: TranslatedProps) => { return ( @@ -53,6 +77,6 @@ function CollectionControls({ )} ); -} +}; export default CollectionControls; diff --git a/src/components/Collection/CollectionRoute.tsx b/src/components/Collection/CollectionRoute.tsx new file mode 100644 index 00000000..5fb29930 --- /dev/null +++ b/src/components/Collection/CollectionRoute.tsx @@ -0,0 +1,60 @@ +import React, { useMemo } from 'react'; +import { Navigate, useParams } from 'react-router-dom'; + +import MainView from '../App/MainView'; +import Collection from './Collection'; + +import type { Collections } from '../../interface'; + +function getDefaultPath(collections: Collections) { + const first = Object.values(collections).filter(collection => collection.hide !== true)[0]; + if (first) { + return `/collections/${first.name}`; + } else { + throw new Error('Could not find a non hidden collection'); + } +} + +interface CollectionRouteProps { + isSearchResults?: boolean; + isSingleSearchResult?: boolean; + collections: Collections; +} + +const CollectionRoute = ({ + isSearchResults, + isSingleSearchResult, + collections, +}: CollectionRouteProps) => { + const { name, searchTerm, filterTerm } = useParams(); + const collection = useMemo(() => { + if (!name) { + return false; + } + return collections[name]; + }, [collections, name]); + + const defaultPath = useMemo(() => getDefaultPath(collections), [collections]); + + if (!name || !collection) { + return ; + } + + if ('files' in collection && collection.files?.length === 1) { + return ; + } + + return ( + + + + ); +}; + +export default CollectionRoute; diff --git a/src/components/Collection/CollectionSearch.js b/src/components/Collection/CollectionSearch.js deleted file mode 100644 index 383fe0ca..00000000 --- a/src/components/Collection/CollectionSearch.js +++ /dev/null @@ -1,239 +0,0 @@ -import React from 'react'; -import styled from '@emotion/styled'; -import { translate } from 'react-polyglot'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; - -import { colorsRaw, colors, Icon, lengths, zIndex } from '../../ui'; - -const SearchContainer = styled.div` - margin: 0 12px; - position: relative; - - ${Icon} { - position: absolute; - top: 0; - left: 6px; - z-index: ${zIndex.zIndex2}; - height: 100%; - display: flex; - align-items: center; - pointer-events: none; - } -`; - -const InputContainer = styled.div` - display: flex; - align-items: center; - position: relative; -`; - -const SearchInput = styled.input` - background-color: #eff0f4; - border-radius: ${lengths.borderRadius}; - font-size: 14px; - padding: 10px 6px 10px 32px; - width: 100%; - position: relative; - z-index: ${zIndex.zIndex1}; - - &:focus { - outline: none; - box-shadow: inset 0 0 0 2px ${colorsRaw.blue}; - } -`; - -const SuggestionsContainer = styled.div` - position: relative; - width: 100%; -`; - -const Suggestions = styled.ul` - position: absolute; - top: 6px; - left: 0; - right: 0; - padding: 10px 0; - margin: 0; - list-style: none; - background-color: #fff; - border-radius: ${lengths.borderRadius}; - border: 1px solid ${colors.textFieldBorder}; - z-index: ${zIndex.zIndex1}; -`; - -const SuggestionHeader = styled.li` - padding: 0 6px 6px 32px; - font-size: 12px; - color: ${colors.text}; -`; - -const SuggestionItem = styled.li( - ({ isActive }) => ` - color: ${isActive ? colors.active : colorsRaw.grayDark}; - background-color: ${isActive ? colors.activeBackground : 'inherit'}; - padding: 6px 6px 6px 32px; - cursor: pointer; - position: relative; - - &:hover { - color: ${colors.active}; - background-color: ${colors.activeBackground}; - } -`, -); - -const SuggestionDivider = styled.div` - width: 100%; -`; - -class CollectionSearch extends React.Component { - static propTypes = { - collections: ImmutablePropTypes.map.isRequired, - collection: ImmutablePropTypes.map, - searchTerm: PropTypes.string.isRequired, - onSubmit: PropTypes.func.isRequired, - t: PropTypes.func.isRequired, - }; - - state = { - query: this.props.searchTerm, - suggestionsVisible: false, - // default to the currently selected - selectedCollectionIdx: this.getSelectedSelectionBasedOnProps(), - }; - - componentDidUpdate(prevProps) { - if (prevProps.collection !== this.props.collection) { - const selectedCollectionIdx = this.getSelectedSelectionBasedOnProps(); - this.setState({ selectedCollectionIdx }); - } - } - - getSelectedSelectionBasedOnProps() { - const { collection, collections } = this.props; - return collection ? collections.keySeq().indexOf(collection.get('name')) : -1; - } - - toggleSuggestions(visible) { - this.setState({ suggestionsVisible: visible }); - } - - selectNextSuggestion() { - const { collections } = this.props; - const { selectedCollectionIdx } = this.state; - this.setState({ - selectedCollectionIdx: Math.min(selectedCollectionIdx + 1, collections.size - 1), - }); - } - - selectPreviousSuggestion() { - const { selectedCollectionIdx } = this.state; - this.setState({ - selectedCollectionIdx: Math.max(selectedCollectionIdx - 1, -1), - }); - } - - resetSelectedSuggestion() { - this.setState({ - selectedCollectionIdx: -1, - }); - } - - submitSearch = () => { - const { onSubmit, collections } = this.props; - const { selectedCollectionIdx, query } = this.state; - - this.toggleSuggestions(false); - if (selectedCollectionIdx !== -1) { - onSubmit(query, collections.toIndexedSeq().getIn([selectedCollectionIdx, 'name'])); - } else { - onSubmit(query); - } - }; - - handleKeyDown = event => { - const { suggestionsVisible } = this.state; - - if (event.key === 'Enter') { - this.submitSearch(); - } - - if (suggestionsVisible) { - // allow closing of suggestions with escape key - if (event.key === 'Escape') { - this.toggleSuggestions(false); - } - - if (event.key === 'ArrowDown') { - this.selectNextSuggestion(); - event.preventDefault(); - } else if (event.key === 'ArrowUp') { - this.selectPreviousSuggestion(); - event.preventDefault(); - } - } - }; - - handleQueryChange = query => { - this.setState({ query }); - this.toggleSuggestions(query !== ''); - if (query === '') { - this.resetSelectedSuggestion(); - } - }; - - handleSuggestionClick = (event, idx) => { - this.setState({ selectedCollectionIdx: idx }, this.submitSearch); - event.preventDefault(); - }; - - render() { - const { collections, t } = this.props; - const { suggestionsVisible, selectedCollectionIdx, query } = this.state; - return ( - this.toggleSuggestions(false)} - onFocus={() => this.toggleSuggestions(query !== '')} - > - - - this.handleQueryChange(e.target.value)} - onKeyDown={this.handleKeyDown} - onClick={() => this.toggleSuggestions(true)} - placeholder={t('collection.sidebar.searchAll')} - value={query} - /> - - {suggestionsVisible && ( - - - {t('collection.sidebar.searchIn')} - this.handleSuggestionClick(e, -1)} - onMouseDown={e => e.preventDefault()} - > - {t('collection.sidebar.allCollections')} - - - {collections.toIndexedSeq().map((collection, idx) => ( - this.handleSuggestionClick(e, idx)} - onMouseDown={e => e.preventDefault()} - > - {collection.get('label')} - - ))} - - - )} - - ); - } -} - -export default translate()(CollectionSearch); diff --git a/src/components/Collection/CollectionSearch.tsx b/src/components/Collection/CollectionSearch.tsx new file mode 100644 index 00000000..148b25c0 --- /dev/null +++ b/src/components/Collection/CollectionSearch.tsx @@ -0,0 +1,229 @@ +import { styled } from '@mui/material/styles'; +import SearchIcon from '@mui/icons-material/Search'; +import InputAdornment from '@mui/material/InputAdornment'; +import TextField from '@mui/material/TextField'; +import React, { useCallback, useEffect, useState } from 'react'; +import { translate } from 'react-polyglot'; + +import { transientOptions } from '../../lib'; +import { colors, colorsRaw, lengths, zIndex } from '../../components/UI/styles'; + +import type { KeyboardEvent, MouseEvent } from 'react'; +import type { Collection, Collections, TranslatedProps } from '../../interface'; + +const SearchContainer = styled('div')` + position: relative; +`; + +const SuggestionsContainer = styled('div')` + position: relative; + width: 100%; +`; + +const Suggestions = styled('ul')` + position: absolute; + top: 0px; + left: 0; + right: 0; + padding: 10px 0; + margin: 0; + list-style: none; + background-color: #fff; + border-radius: ${lengths.borderRadius}; + border: 1px solid ${colors.textFieldBorder}; + z-index: ${zIndex.zIndex1}; +`; + +const SuggestionHeader = styled('li')` + padding: 0 6px 6px 32px; + font-size: 12px; + color: ${colors.text}; +`; + +interface SuggestionItemProps { + $isActive: boolean; +} + +const SuggestionItem = styled( + 'li', + transientOptions, +)( + ({ $isActive }) => ` + color: ${$isActive ? colors.active : colorsRaw.grayDark}; + background-color: ${$isActive ? colors.activeBackground : 'inherit'}; + padding: 6px 6px 6px 32px; + cursor: pointer; + position: relative; + + &:hover { + color: ${colors.active}; + background-color: ${colors.activeBackground}; + } + `, +); + +const SuggestionDivider = styled('div')` + width: 100%; +`; + +interface CollectionSearchProps { + collections: Collections; + collection?: Collection; + searchTerm: string; + onSubmit: (query: string, collection?: string) => void; +} + +const CollectionSearch = ({ + collections, + collection, + searchTerm, + onSubmit, + t, +}: TranslatedProps) => { + const [query, setQuery] = useState(searchTerm); + const [suggestionsVisible, setSuggestionsVisible] = useState(false); + + const getSelectedSelectionBasedOnProps = useCallback(() => { + return collection ? Object.keys(collections).indexOf(collection.name) : -1; + }, [collection, collections]); + + const [selectedCollectionIdx, setSelectedCollectionIdx] = useState( + getSelectedSelectionBasedOnProps(), + ); + const [prevCollection, setPrevCollection] = useState(collection); + + useEffect(() => { + if (prevCollection !== collection) { + setSelectedCollectionIdx(getSelectedSelectionBasedOnProps()); + } + setPrevCollection(collection); + }, [collection, getSelectedSelectionBasedOnProps, prevCollection]); + + const toggleSuggestions = useCallback((visible: boolean) => { + setSuggestionsVisible(visible); + }, []); + + const selectNextSuggestion = useCallback(() => { + setSelectedCollectionIdx( + Math.min(selectedCollectionIdx + 1, Object.keys(collections).length - 1), + ); + }, [collections, selectedCollectionIdx]); + + const selectPreviousSuggestion = useCallback(() => { + setSelectedCollectionIdx(Math.max(selectedCollectionIdx - 1, -1)); + }, [selectedCollectionIdx]); + + const resetSelectedSuggestion = useCallback(() => { + setSelectedCollectionIdx(-1); + }, []); + + const submitSearch = useCallback(() => { + toggleSuggestions(false); + if (selectedCollectionIdx !== -1) { + onSubmit(query, Object.values(collections)[selectedCollectionIdx]?.name); + } else { + onSubmit(query); + } + }, [collections, onSubmit, query, selectedCollectionIdx, toggleSuggestions]); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'Enter') { + submitSearch(); + } + + if (suggestionsVisible) { + // allow closing of suggestions with escape key + if (event.key === 'Escape') { + toggleSuggestions(false); + } + + if (event.key === 'ArrowDown') { + selectNextSuggestion(); + event.preventDefault(); + } else if (event.key === 'ArrowUp') { + selectPreviousSuggestion(); + event.preventDefault(); + } + } + }, + [ + selectNextSuggestion, + selectPreviousSuggestion, + submitSearch, + suggestionsVisible, + toggleSuggestions, + ], + ); + + const handleQueryChange = useCallback( + (newQuery: string) => { + setQuery(newQuery); + toggleSuggestions(newQuery !== ''); + if (newQuery === '') { + resetSelectedSuggestion(); + } + }, + [resetSelectedSuggestion, toggleSuggestions], + ); + + const handleSuggestionClick = useCallback( + (event: MouseEvent, idx: number) => { + setSelectedCollectionIdx(idx); + submitSearch(); + event.preventDefault(); + }, + [submitSearch], + ); + + return ( + + toggleSuggestions(true)} + placeholder={t('collection.sidebar.searchAll')} + onBlur={() => toggleSuggestions(false)} + onFocus={() => toggleSuggestions(query !== '')} + value={query} + onChange={e => handleQueryChange(e.target.value)} + variant="outlined" + size="small" + fullWidth + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + {suggestionsVisible && ( + + + {t('collection.sidebar.searchIn')} + handleSuggestionClick(e, -1)} + onMouseDown={e => e.preventDefault()} + > + {t('collection.sidebar.allCollections')} + + + {Object.values(collections).map((collection, idx) => ( + handleSuggestionClick(e, idx)} + onMouseDown={e => e.preventDefault()} + > + {collection.label} + + ))} + + + )} + + ); +}; + +export default translate()(CollectionSearch); diff --git a/src/components/Collection/CollectionTop.js b/src/components/Collection/CollectionTop.js deleted file mode 100644 index 1028d7c0..00000000 --- a/src/components/Collection/CollectionTop.js +++ /dev/null @@ -1,82 +0,0 @@ -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import React from 'react'; -import styled from '@emotion/styled'; -import { translate } from 'react-polyglot'; -import { Link } from 'react-router-dom'; - -import { components, buttons, shadows } from '../../ui'; - -const CollectionTopContainer = styled.div` - ${components.cardTop}; - margin-bottom: 22px; -`; - -const CollectionTopRow = styled.div` - display: flex; - align-items: center; - justify-content: space-between; -`; - -const CollectionTopHeading = styled.h1` - ${components.cardTopHeading}; -`; - -const CollectionTopNewButton = styled(Link)` - ${buttons.button}; - ${shadows.dropDeep}; - ${buttons.default}; - ${buttons.gray}; - - padding: 0 30px; -`; - -const CollectionTopDescription = styled.p` - ${components.cardTopDescription}; - margin-bottom: 0; -`; - -function getCollectionProps(collection) { - const collectionLabel = collection.get('label'); - const collectionLabelSingular = collection.get('label_singular'); - const collectionDescription = collection.get('description'); - - return { - collectionLabel, - collectionLabelSingular, - collectionDescription, - }; -} - -function CollectionTop({ collection, newEntryUrl, t }) { - const { collectionLabel, collectionLabelSingular, collectionDescription } = getCollectionProps( - collection, - t, - ); - - return ( - - - {collectionLabel} - {newEntryUrl ? ( - - {t('collection.collectionTop.newButton', { - collectionLabel: collectionLabelSingular || collectionLabel, - })} - - ) : null} - - {collectionDescription ? ( - {collectionDescription} - ) : null} - - ); -} - -CollectionTop.propTypes = { - collection: ImmutablePropTypes.map.isRequired, - newEntryUrl: PropTypes.string, - t: PropTypes.func.isRequired, -}; - -export default translate()(CollectionTop); diff --git a/src/components/Collection/CollectionTop.tsx b/src/components/Collection/CollectionTop.tsx new file mode 100644 index 00000000..3cbd1953 --- /dev/null +++ b/src/components/Collection/CollectionTop.tsx @@ -0,0 +1,78 @@ +import { styled } from '@mui/material/styles'; +import Button from '@mui/material/Button'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import React, { useCallback } from 'react'; +import { translate } from 'react-polyglot'; +import { useNavigate } from 'react-router-dom'; + +import { components } from '../../components/UI/styles'; + +import type { Collection, TranslatedProps } from '../../interface'; + +const CollectionTopRow = styled('div')` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const CollectionTopHeading = styled('h1')` + ${components.cardTopHeading}; +`; + +const CollectionTopDescription = styled('p')` + ${components.cardTopDescription}; + margin-bottom: 0; +`; + +function getCollectionProps(collection: Collection) { + const collectionLabel = collection.label; + const collectionLabelSingular = collection.label_singular; + const collectionDescription = collection.description; + + return { + collectionLabel, + collectionLabelSingular, + collectionDescription, + }; +} + +interface CollectionTopProps { + collection: Collection; + newEntryUrl?: string; +} + +const CollectionTop = ({ collection, newEntryUrl, t }: TranslatedProps) => { + const navigate = useNavigate(); + + const { collectionLabel, collectionLabelSingular, collectionDescription } = + getCollectionProps(collection); + + const onNewClick = useCallback(() => { + if (newEntryUrl) { + navigate(newEntryUrl); + } + }, [navigate, newEntryUrl]); + + return ( + + + + {collectionLabel} + {newEntryUrl ? ( + + ) : null} + + {collectionDescription ? ( + {collectionDescription} + ) : null} + + + ); +}; + +export default translate()(CollectionTop); diff --git a/src/components/Collection/ControlButton.js b/src/components/Collection/ControlButton.js deleted file mode 100644 index 7c19a2fe..00000000 --- a/src/components/Collection/ControlButton.js +++ /dev/null @@ -1,28 +0,0 @@ -import React from 'react'; -import { css } from '@emotion/react'; -import styled from '@emotion/styled'; - -import { buttons, StyledDropdownButton, colors } from '../../ui'; - -const Button = styled(StyledDropdownButton)` - ${buttons.button}; - ${buttons.medium}; - ${buttons.grayText}; - font-size: 14px; - - &:after { - top: 11px; - } -`; - -export function ControlButton({ active, title }) { - return ( - - ); -} diff --git a/src/components/Collection/Entries/Entries.js b/src/components/Collection/Entries/Entries.js deleted file mode 100644 index 8f7e6224..00000000 --- a/src/components/Collection/Entries/Entries.js +++ /dev/null @@ -1,73 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import styled from '@emotion/styled'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { translate } from 'react-polyglot'; - -import { Loader, lengths } from '../../../ui'; -import EntryListing from './EntryListing'; - -const PaginationMessage = styled.div` - width: ${lengths.topCardWidth}; - padding: 16px; - text-align: center; -`; - -const NoEntriesMessage = styled(PaginationMessage)` - margin-top: 16px; -`; - -function Entries({ - collections, - entries, - isFetching, - viewStyle, - cursor, - handleCursorActions, - t, - page, -}) { - const loadingMessages = [ - t('collection.entries.loadingEntries'), - t('collection.entries.cachingEntries'), - t('collection.entries.longerLoading'), - ]; - - if (isFetching && page === undefined) { - return {loadingMessages}; - } - - const hasEntries = (entries && entries.size > 0) || cursor?.actions?.has('append_next'); - if (hasEntries) { - return ( - <> - - {isFetching && page !== undefined && entries.size > 0 ? ( - {t('collection.entries.loadingEntries')} - ) : null} - - ); - } - - return {t('collection.entries.noEntries')}; -} - -Entries.propTypes = { - collections: ImmutablePropTypes.iterable.isRequired, - entries: ImmutablePropTypes.list, - page: PropTypes.number, - isFetching: PropTypes.bool, - viewStyle: PropTypes.string, - cursor: PropTypes.any.isRequired, - handleCursorActions: PropTypes.func.isRequired, - t: PropTypes.func.isRequired, -}; - -export default translate()(Entries); diff --git a/src/components/Collection/Entries/Entries.tsx b/src/components/Collection/Entries/Entries.tsx new file mode 100644 index 00000000..de66720d --- /dev/null +++ b/src/components/Collection/Entries/Entries.tsx @@ -0,0 +1,93 @@ +import { styled } from '@mui/material/styles'; +import React from 'react'; +import { translate } from 'react-polyglot'; + +import Loader from '../../UI/Loader'; +import EntryListing from './EntryListing'; + +import type { CollectionViewStyle } from '../../../constants/collectionViews'; +import type { Collection, Collections, Entry, TranslatedProps } from '../../../interface'; +import type Cursor from '../../../lib/util/Cursor'; + +const PaginationMessage = styled('div')` + padding: 16px; + text-align: center; +`; + +const NoEntriesMessage = styled(PaginationMessage)` + margin-top: 16px; +`; + +export interface BaseEntriesProps { + entries: Entry[]; + page?: number; + isFetching: boolean; + viewStyle: CollectionViewStyle; + cursor: Cursor; + handleCursorActions: (action: string) => void; +} + +export interface SingleCollectionEntriesProps extends BaseEntriesProps { + collection: Collection; +} + +export interface MultipleCollectionEntriesProps extends BaseEntriesProps { + collections: Collections; +} + +export type EntriesProps = SingleCollectionEntriesProps | MultipleCollectionEntriesProps; + +const Entries = ({ + entries, + isFetching, + viewStyle, + cursor, + handleCursorActions, + t, + page, + ...otherProps +}: TranslatedProps) => { + const loadingMessages = [ + t('collection.entries.loadingEntries'), + t('collection.entries.cachingEntries'), + t('collection.entries.longerLoading'), + ]; + + if (isFetching && page === undefined) { + return {loadingMessages}; + } + + const hasEntries = (entries && entries.length > 0) || cursor?.actions?.has('append_next'); + if (hasEntries) { + return ( + <> + {'collection' in otherProps ? ( + + ) : ( + + )} + {isFetching && page !== undefined && entries.length > 0 ? ( + {t('collection.entries.loadingEntries')} + ) : null} + + ); + } + + return {t('collection.entries.noEntries')}; +}; + +export default translate()(Entries); diff --git a/src/components/Collection/Entries/EntriesCollection.js b/src/components/Collection/Entries/EntriesCollection.js deleted file mode 100644 index 0df30d4c..00000000 --- a/src/components/Collection/Entries/EntriesCollection.js +++ /dev/null @@ -1,166 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { connect } from 'react-redux'; -import styled from '@emotion/styled'; -import { translate } from 'react-polyglot'; -import { partial } from 'lodash'; - -import { colors } from '../../../ui'; -import { Cursor } from '../../../lib/util'; -import { - loadEntries as actionLoadEntries, - traverseCollectionCursor as actionTraverseCollectionCursor, -} from '../../../actions/entries'; -import { - selectEntries, - selectEntriesLoaded, - selectIsFetching, - selectGroups, -} from '../../../reducers/entries'; -import { selectCollectionEntriesCursor } from '../../../reducers/cursors'; -import Entries from './Entries'; - -const GroupHeading = styled.h2` - font-size: 23px; - font-weight: 600; - color: ${colors.textLead}; -`; - -const GroupContainer = styled.div``; - -function getGroupEntries(entries, paths) { - return entries.filter(entry => paths.has(entry.get('path'))); -} - -function getGroupTitle(group, t) { - const { label, value } = group; - if (value === undefined) { - return t('collection.groups.other'); - } - if (typeof value === 'boolean') { - return value ? label : t('collection.groups.negateLabel', { label }); - } - return `${label} ${value}`.trim(); -} - -function withGroups(groups, entries, EntriesToRender, t) { - return groups.map(group => { - const title = getGroupTitle(group, t); - return ( - - {title} - - - ); - }); -} - -class EntriesCollection extends React.Component { - static propTypes = { - collection: ImmutablePropTypes.map.isRequired, - page: PropTypes.number, - entries: ImmutablePropTypes.list, - groups: PropTypes.array, - isFetching: PropTypes.bool.isRequired, - viewStyle: PropTypes.string, - cursor: PropTypes.object.isRequired, - loadEntries: PropTypes.func.isRequired, - traverseCollectionCursor: PropTypes.func.isRequired, - entriesLoaded: PropTypes.bool, - readyToLoad: PropTypes.bool, - }; - - componentDidMount() { - const { collection, entriesLoaded, loadEntries, readyToLoad } = this.props; - if (collection && !entriesLoaded && readyToLoad) { - loadEntries(collection); - } - } - - componentDidUpdate(prevProps) { - const { collection, entriesLoaded, loadEntries, readyToLoad } = this.props; - if (!entriesLoaded && readyToLoad && (!prevProps.readyToLoad || prevProps.collection !== collection)) { - loadEntries(collection); - } - } - - handleCursorActions = (cursor, action) => { - const { collection, traverseCollectionCursor } = this.props; - traverseCollectionCursor(collection, action); - }; - - render() { - const { collection, entries, groups, isFetching, viewStyle, cursor, page, t } = this.props; - - const EntriesToRender = ({ entries }) => { - return ( - - ); - }; - - if (groups && groups.length > 0) { - return withGroups(groups, entries, EntriesToRender, t); - } - - return ; - } -} - -export function filterNestedEntries(path, collectionFolder, entries) { - const filtered = entries.filter(e => { - const entryPath = e.get('path').slice(collectionFolder.length + 1); - if (!entryPath.startsWith(path)) { - return false; - } - - // only show immediate children - if (path) { - // non root path - const trimmed = entryPath.slice(path.length + 1); - return trimmed.split('/').length === 2; - } else { - // root path - return entryPath.split('/').length <= 2; - } - }); - return filtered; -} - -function mapStateToProps(state, ownProps) { - const { collection, viewStyle, filterTerm } = ownProps; - const page = state.entries.getIn(['pages', collection.get('name'), 'page']); - - let entries = selectEntries(state.entries, collection); - const groups = selectGroups(state.entries, collection); - - if (collection.has('nested')) { - const collectionFolder = collection.get('folder'); - entries = filterNestedEntries(filterTerm || '', collectionFolder, entries); - } - const entriesLoaded = selectEntriesLoaded(state.entries, collection.get('name')); - const isFetching = selectIsFetching(state.entries, collection.get('name')); - - const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.get('name')); - const cursor = Cursor.create(rawCursor).clearData(); - - return { collection, page, entries, groups, entriesLoaded, isFetching, viewStyle, cursor }; -} - -const mapDispatchToProps = { - loadEntries: actionLoadEntries, - traverseCollectionCursor: actionTraverseCollectionCursor, -}; - -const ConnectedEntriesCollection = connect(mapStateToProps, mapDispatchToProps)(EntriesCollection); - -export default translate()(ConnectedEntriesCollection); diff --git a/src/components/Collection/Entries/EntriesCollection.tsx b/src/components/Collection/Entries/EntriesCollection.tsx new file mode 100644 index 00000000..21a8149d --- /dev/null +++ b/src/components/Collection/Entries/EntriesCollection.tsx @@ -0,0 +1,191 @@ +import { styled } from '@mui/material/styles'; +import React, { useCallback, useEffect, useState } from 'react'; +import { translate } from 'react-polyglot'; +import { connect } from 'react-redux'; + +import { + loadEntries as loadEntriesAction, + traverseCollectionCursor as traverseCollectionCursorAction, +} from '../../../actions/entries'; +import { colors } from '../../../components/UI/styles'; +import { Cursor } from '../../../lib/util'; +import { selectCollectionEntriesCursor } from '../../../reducers/cursors'; +import { + selectEntries, + selectEntriesLoaded, + selectGroups, + selectIsFetching, +} from '../../../reducers/entries'; +import Entries from './Entries'; + +import type { ComponentType } from 'react'; +import type { t } from 'react-polyglot'; +import type { ConnectedProps } from 'react-redux'; +import type { CollectionViewStyle } from '../../../constants/collectionViews'; +import type { Collection, Entry, GroupOfEntries, TranslatedProps } from '../../../interface'; +import type { RootState } from '../../../store'; + +const GroupHeading = styled('h2')` + font-size: 23px; + font-weight: 600; + color: ${colors.textLead}; +`; + +const GroupContainer = styled('div')``; + +function getGroupEntries(entries: Entry[], paths: Set) { + return entries.filter(entry => paths.has(entry.path)); +} + +function getGroupTitle(group: GroupOfEntries, t: t) { + const { label, value } = group; + if (value === undefined) { + return t('collection.groups.other'); + } + if (typeof value === 'boolean') { + return value ? label : t('collection.groups.negateLabel', { label }); + } + return `${label} ${value}`.trim(); +} + +function withGroups( + groups: GroupOfEntries[], + entries: Entry[], + EntriesToRender: ComponentType, + t: t, +) { + return groups.map(group => { + const title = getGroupTitle(group, t); + return ( + + {title} + + + ); + }); +} + +interface EntriesToRenderProps { + entries: Entry[]; +} + +const EntriesCollection = ({ + collection, + entries, + groups, + isFetching, + viewStyle, + cursor, + page, + traverseCollectionCursor, + t, + entriesLoaded, + readyToLoad, + loadEntries, +}: TranslatedProps) => { + const [prevReadyToLoad, setPrevReadyToLoad] = useState(false); + const [prevCollection, setPrevCollection] = useState(collection); + + useEffect(() => { + if ( + collection && + !entriesLoaded && + readyToLoad && + (!prevReadyToLoad || prevCollection !== collection) + ) { + loadEntries(collection); + } + + setPrevReadyToLoad(readyToLoad); + setPrevCollection(collection); + }, [collection, entriesLoaded, loadEntries, prevCollection, prevReadyToLoad, readyToLoad]); + + const handleCursorActions = useCallback( + (action: string) => { + traverseCollectionCursor(collection, action); + }, + [collection, traverseCollectionCursor], + ); + + const EntriesToRender = useCallback( + ({ entries }: EntriesToRenderProps) => { + return ( + + ); + }, + [collection, cursor, handleCursorActions, isFetching, page, viewStyle], + ); + + if (groups && groups.length > 0) { + return <>{withGroups(groups, entries, EntriesToRender, t)}; + } + + return ; +}; + +export function filterNestedEntries(path: string, collectionFolder: string, entries: Entry[]) { + const filtered = entries.filter(e => { + const entryPath = e.path.slice(collectionFolder.length + 1); + if (!entryPath.startsWith(path)) { + return false; + } + + // only show immediate children + if (path) { + // non root path + const trimmed = entryPath.slice(path.length + 1); + return trimmed.split('/').length === 2; + } else { + // root path + return entryPath.split('/').length <= 2; + } + }); + return filtered; +} + +interface EntriesCollectionOwnProps { + collection: Collection; + viewStyle: CollectionViewStyle; + readyToLoad: boolean; + filterTerm: string; +} + +function mapStateToProps(state: RootState, ownProps: EntriesCollectionOwnProps) { + const { collection, viewStyle, filterTerm } = ownProps; + const page = state.entries.pages[collection.name]?.page; + + let entries = selectEntries(state.entries, collection); + const groups = selectGroups(state.entries, collection); + + if ('nested' in collection) { + const collectionFolder = collection.folder ?? ''; + entries = filterNestedEntries(filterTerm || '', collectionFolder, entries); + } + + const entriesLoaded = selectEntriesLoaded(state.entries, collection.name); + const isFetching = selectIsFetching(state.entries, collection.name); + + const rawCursor = selectCollectionEntriesCursor(state.cursors, collection.name); + const cursor = Cursor.create(rawCursor).clearData(); + + return { ...ownProps, page, entries, groups, entriesLoaded, isFetching, viewStyle, cursor }; +} + +const mapDispatchToProps = { + loadEntries: loadEntriesAction, + traverseCollectionCursor: traverseCollectionCursorAction, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); +export type EntriesCollectionProps = ConnectedProps; + +export default connector(translate()(EntriesCollection) as ComponentType); diff --git a/src/components/Collection/Entries/EntriesSearch.js b/src/components/Collection/Entries/EntriesSearch.js deleted file mode 100644 index f72ab31f..00000000 --- a/src/components/Collection/Entries/EntriesSearch.js +++ /dev/null @@ -1,91 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { connect } from 'react-redux'; -import { isEqual } from 'lodash'; - -import { Cursor } from '../../../lib/util'; -import { selectSearchedEntries } from '../../../reducers'; -import { - searchEntries as actionSearchEntries, - clearSearch as actionClearSearch, -} from '../../../actions/search'; -import Entries from './Entries'; - -class EntriesSearch extends React.Component { - static propTypes = { - isFetching: PropTypes.bool, - searchEntries: PropTypes.func.isRequired, - clearSearch: PropTypes.func.isRequired, - searchTerm: PropTypes.string.isRequired, - collections: ImmutablePropTypes.seq, - collectionNames: PropTypes.array, - entries: ImmutablePropTypes.list, - page: PropTypes.number, - }; - - componentDidMount() { - const { searchTerm, searchEntries, collectionNames } = this.props; - searchEntries(searchTerm, collectionNames); - } - - componentDidUpdate(prevProps) { - const { searchTerm, collectionNames } = this.props; - - // check if the search parameters are the same - if (prevProps.searchTerm === searchTerm && isEqual(prevProps.collectionNames, collectionNames)) - return; - - const { searchEntries } = prevProps; - searchEntries(searchTerm, collectionNames); - } - - componentWillUnmount() { - this.props.clearSearch(); - } - - getCursor = () => { - const { page } = this.props; - return Cursor.create({ - actions: isNaN(page) ? [] : ['append_next'], - }); - }; - - handleCursorActions = action => { - const { page, searchTerm, searchEntries, collectionNames } = this.props; - if (action === 'append_next') { - const nextPage = page + 1; - searchEntries(searchTerm, collectionNames, nextPage); - } - }; - - render() { - const { collections, entries, isFetching } = this.props; - return ( - - ); - } -} - -function mapStateToProps(state, ownProps) { - const { searchTerm } = ownProps; - const collections = ownProps.collections.toIndexedSeq(); - const collectionNames = ownProps.collections.keySeq().toArray(); - const isFetching = state.search.isFetching; - const page = state.search.page; - const entries = selectSearchedEntries(state, collectionNames); - return { isFetching, page, collections, collectionNames, entries, searchTerm }; -} - -const mapDispatchToProps = { - searchEntries: actionSearchEntries, - clearSearch: actionClearSearch, -}; - -export default connect(mapStateToProps, mapDispatchToProps)(EntriesSearch); diff --git a/src/components/Collection/Entries/EntriesSearch.tsx b/src/components/Collection/Entries/EntriesSearch.tsx new file mode 100644 index 00000000..e1e99e02 --- /dev/null +++ b/src/components/Collection/Entries/EntriesSearch.tsx @@ -0,0 +1,101 @@ +import isEqual from 'lodash/isEqual'; +import React, { useCallback, useEffect, useState } from 'react'; +import { connect } from 'react-redux'; + +import { + clearSearch as clearSearchAction, + searchEntries as searchEntriesAction, +} from '../../../actions/search'; +import { Cursor } from '../../../lib/util'; +import { selectSearchedEntries } from '../../../reducers'; +import Entries from './Entries'; + +import type { ConnectedProps } from 'react-redux'; +import type { Collections } from '../../../interface'; +import type { RootState } from '../../../store'; + +const EntriesSearch = ({ + collections, + entries, + isFetching, + page, + searchTerm, + searchEntries, + collectionNames, + clearSearch, +}: EntriesSearchProps) => { + const getCursor = useCallback(() => { + return Cursor.create({ + actions: Number.isNaN(page) ? [] : ['append_next'], + }); + }, [page]); + + const handleCursorActions = useCallback( + (action: string) => { + if (action === 'append_next') { + const nextPage = page + 1; + searchEntries(searchTerm, collectionNames, nextPage); + } + }, + [collectionNames, page, searchEntries, searchTerm], + ); + + useEffect(() => { + searchEntries(searchTerm, collectionNames); + }, [collectionNames, searchEntries, searchTerm]); + + useEffect(() => { + return () => { + clearSearch(); + }; + }, [clearSearch]); + + const [prevSearch, setPrevSearch] = useState(''); + const [prevCollectionNames, setPrevCollectionNames] = useState([]); + useEffect(() => { + // check if the search parameters are the same + if (prevSearch === searchTerm && isEqual(prevCollectionNames, collectionNames)) { + return; + } + + setPrevSearch(searchTerm); + setPrevCollectionNames(collectionNames); + + searchEntries(searchTerm, collectionNames); + }, [collectionNames, prevCollectionNames, prevSearch, searchEntries, searchTerm]); + + return ( + + ); +}; + +interface EntriesSearchOwnProps { + searchTerm: string; + collections: Collections; +} + +function mapStateToProps(state: RootState, ownProps: EntriesSearchOwnProps) { + const { searchTerm } = ownProps; + const collections = Object.values(ownProps.collections); + const collectionNames = Object.keys(ownProps.collections); + const isFetching = state.search.isFetching; + const page = state.search.page; + const entries = selectSearchedEntries(state, collectionNames); + return { isFetching, page, collections, collectionNames, entries, searchTerm }; +} + +const mapDispatchToProps = { + searchEntries: searchEntriesAction, + clearSearch: clearSearchAction, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); +export type EntriesSearchProps = ConnectedProps; + +export default connector(EntriesSearch); diff --git a/src/components/Collection/Entries/EntryCard.js b/src/components/Collection/Entries/EntryCard.js deleted file mode 100644 index 0a809eb5..00000000 --- a/src/components/Collection/Entries/EntryCard.js +++ /dev/null @@ -1,167 +0,0 @@ -import React from 'react'; -import styled from '@emotion/styled'; -import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; - -import { colors, colorsRaw, components, lengths, zIndex } from '../../../ui'; -import { boundGetAsset } from '../../../actions/media'; -import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from '../../../constants/collectionViews'; -import { selectIsLoadingAsset } from '../../../reducers/medias'; -import { selectEntryCollectionTitle } from '../../../reducers/collections'; - -const ListCard = styled.li` - ${components.card}; - width: ${lengths.topCardWidth}; - margin-left: 12px; - margin-bottom: 10px; - overflow: hidden; -`; - -const ListCardLink = styled(Link)` - display: block; - max-width: 100%; - padding: 16px 22px; - - &:hover { - background-color: ${colors.foreground}; - } -`; - -const GridCard = styled.li` - ${components.card}; - flex: 0 0 335px; - height: 240px; - overflow: hidden; - margin-left: 12px; - margin-bottom: 16px; -`; - -const GridCardLink = styled(Link)` - display: block; - height: 100%; - outline-offset: -2px; - - &, - &:hover { - background-color: ${colors.foreground}; - color: ${colors.text}; - } -`; - -const CollectionLabel = styled.h2` - font-size: 12px; - color: ${colors.textLead}; - text-transform: uppercase; -`; - -const ListCardTitle = styled.h2` - margin-bottom: 0; -`; - -const CardHeading = styled.h2` - margin: 0 0 2px; -`; - -const CardBody = styled.div` - padding: 16px 22px; - height: 90px; - position: relative; - margin-bottom: ${props => props.hasImage && 0}; - - &:after { - content: ''; - position: absolute; - display: block; - z-index: ${zIndex.zIndex1}; - bottom: 0; - left: -20%; - height: 140%; - width: 140%; - box-shadow: inset 0 -15px 24px ${colorsRaw.white}; - } -`; - -const CardImage = styled.div` - background-image: url(${props => props.src}); - background-position: center center; - background-size: cover; - background-repeat: no-repeat; - height: 150px; -`; - -function EntryCard({ - path, - summary, - image, - imageField, - collectionLabel, - viewStyle = VIEW_STYLE_LIST, - getAsset, -}) { - if (viewStyle === VIEW_STYLE_LIST) { - return ( - - - {collectionLabel ? {collectionLabel} : null} - {summary} - - - ); - } - - if (viewStyle === VIEW_STYLE_GRID) { - return ( - - - - {collectionLabel ? {collectionLabel} : null} - {summary} - - {image ? : null} - - - ); - } -} - -function mapStateToProps(state, ownProps) { - const { entry, inferedFields, collection } = ownProps; - const entryData = entry.get('data'); - const summary = selectEntryCollectionTitle(collection, entry); - - let image = entryData.get(inferedFields.imageField); - if (image) { - image = encodeURI(image); - } - - const isLoadingAsset = selectIsLoadingAsset(state.medias); - - return { - summary, - path: `/collections/${collection.get('name')}/entries/${entry.get('slug')}`, - image, - imageFolder: collection - .get('fields') - ?.find(f => f.get('name') === inferedFields.imageField && f.get('widget') === 'image'), - isLoadingAsset, - }; -} - -function mapDispatchToProps(dispatch) { - return { - boundGetAsset: (collection, entry) => boundGetAsset(dispatch, collection, entry), - }; -} - -function mergeProps(stateProps, dispatchProps, ownProps) { - return { - ...stateProps, - ...dispatchProps, - ...ownProps, - getAsset: dispatchProps.boundGetAsset(ownProps.collection, ownProps.entry), - }; -} - -const ConnectedEntryCard = connect(mapStateToProps, mapDispatchToProps, mergeProps)(EntryCard); - -export default ConnectedEntryCard; diff --git a/src/components/Collection/Entries/EntryCard.tsx b/src/components/Collection/Entries/EntryCard.tsx new file mode 100644 index 00000000..ab0a7f57 --- /dev/null +++ b/src/components/Collection/Entries/EntryCard.tsx @@ -0,0 +1,102 @@ +import Card from '@mui/material/Card'; +import CardActionArea from '@mui/material/CardActionArea'; +import CardContent from '@mui/material/CardContent'; +import CardMedia from '@mui/material/CardMedia'; +import Typography from '@mui/material/Typography'; +import React, { useMemo } from 'react'; +import { connect } from 'react-redux'; +import { Link } from 'react-router-dom'; + +import { getAsset as getAssetAction } from '../../../actions/media'; +import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '../../../constants/collectionViews'; +import { selectEntryCollectionTitle } from '../../../lib/util/collection.util'; +import { selectIsLoadingAsset } from '../../../reducers/medias'; + +import type { ConnectedProps } from 'react-redux'; +import type { CollectionViewStyle } from '../../../constants/collectionViews'; +import type { Field, Collection, Entry } from '../../../interface'; +import type { RootState } from '../../../store'; + +const EntryCard = ({ + collection, + entry, + path, + image, + imageField, + collectionLabel, + viewStyle = VIEW_STYLE_LIST, + getAsset, +}: NestedCollectionProps) => { + const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]); + + return ( + + + {viewStyle === VIEW_STYLE_GRID && image && imageField ? ( + + ) : null} + + {collectionLabel ? ( + + {collectionLabel} + + ) : null} + + {summary} + + + + + ); +}; + +interface EntryCardOwnProps { + entry: Entry; + inferedFields: { + titleField?: string | null | undefined; + descriptionField?: string | null | undefined; + imageField?: string | null | undefined; + remainingFields?: Field[] | undefined; + }; + collection: Collection; + imageField?: Field; + collectionLabel?: string; + viewStyle?: CollectionViewStyle; +} + +function mapStateToProps(state: RootState, ownProps: EntryCardOwnProps) { + const { entry, inferedFields, collection } = ownProps; + const entryData = entry.data; + + let image = inferedFields.imageField + ? (entryData?.[inferedFields.imageField] as string | undefined) + : undefined; + if (image) { + image = encodeURI(image); + } + + const isLoadingAsset = selectIsLoadingAsset(state.medias); + + return { + ...ownProps, + path: `/collections/${collection.name}/entries/${entry.slug}`, + image, + imageField: collection.fields?.find( + f => f.name === inferedFields.imageField && f.widget === 'image', + ), + isLoadingAsset, + }; +} + +const mapDispatchToProps = { + getAsset: getAssetAction, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); +export type NestedCollectionProps = ConnectedProps; + +export default connector(EntryCard); diff --git a/src/components/Collection/Entries/EntryListing.js b/src/components/Collection/Entries/EntryListing.js deleted file mode 100644 index d991bde7..00000000 --- a/src/components/Collection/Entries/EntryListing.js +++ /dev/null @@ -1,87 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import styled from '@emotion/styled'; -import { Waypoint } from 'react-waypoint'; -import { Map } from 'immutable'; - -import { selectFields, selectInferedField } from '../../../reducers/collections'; -import EntryCard from './EntryCard'; - -const CardsGrid = styled.ul` - display: flex; - flex-flow: row wrap; - list-style-type: none; - margin-left: -12px; - margin-top: 16px; - margin-bottom: 16px; - padding-left: 0; -`; - -export default class EntryListing extends React.Component { - static propTypes = { - collections: ImmutablePropTypes.iterable.isRequired, - entries: ImmutablePropTypes.list, - viewStyle: PropTypes.string, - cursor: PropTypes.any.isRequired, - handleCursorActions: PropTypes.func.isRequired, - page: PropTypes.number, - }; - - hasMore = () => { - const hasMore = this.props.cursor?.actions?.has('append_next'); - return hasMore; - }; - - handleLoadMore = () => { - if (this.hasMore()) { - this.props.handleCursorActions('append_next'); - } - }; - - inferFields = collection => { - const titleField = selectInferedField(collection, 'title'); - const descriptionField = selectInferedField(collection, 'description'); - const imageField = selectInferedField(collection, 'image'); - const fields = selectFields(collection); - const inferedFields = [titleField, descriptionField, imageField]; - const remainingFields = - fields && fields.filter(f => inferedFields.indexOf(f.get('name')) === -1); - return { titleField, descriptionField, imageField, remainingFields }; - }; - - renderCardsForSingleCollection = () => { - const { collections, entries, viewStyle } = this.props; - const inferedFields = this.inferFields(collections); - const entryCardProps = { collection: collections, inferedFields, viewStyle }; - return entries.map((entry, idx) => ); - }; - - renderCardsForMultipleCollections = () => { - const { collections, entries } = this.props; - const isSingleCollectionInList = collections.size === 1; - return entries.map((entry, idx) => { - const collectionName = entry.get('collection'); - const collection = collections.find(coll => coll.get('name') === collectionName); - const collectionLabel = !isSingleCollectionInList && collection.get('label'); - const inferedFields = this.inferFields(collection); - const entryCardProps = { collection, entry, inferedFields, collectionLabel }; - return ; - }); - }; - - render() { - const { collections, page } = this.props; - - return ( -
- - {Map.isMap(collections) - ? this.renderCardsForSingleCollection() - : this.renderCardsForMultipleCollections()} - {this.hasMore() && } - -
- ); - } -} diff --git a/src/components/Collection/Entries/EntryListing.tsx b/src/components/Collection/Entries/EntryListing.tsx new file mode 100644 index 00000000..d53e6ca1 --- /dev/null +++ b/src/components/Collection/Entries/EntryListing.tsx @@ -0,0 +1,147 @@ +import { styled } from '@mui/material/styles'; +import React, { useCallback, useMemo } from 'react'; +import { Waypoint } from 'react-waypoint'; + +import { VIEW_STYLE_LIST } from '../../../constants/collectionViews'; +import { transientOptions } from '../../../lib'; +import { selectFields, selectInferedField } from '../../../lib/util/collection.util'; +import EntryCard from './EntryCard'; + +import type { CollectionViewStyle } from '../../../constants/collectionViews'; +import type { Field, Collection, Collections, Entry } from '../../../interface'; +import type Cursor from '../../../lib/util/Cursor'; + +interface CardsGridProps { + $layout: CollectionViewStyle; +} + +const CardsGrid = styled( + 'div', + transientOptions, +)( + ({ $layout }) => ` + ${ + $layout === VIEW_STYLE_LIST + ? ` + display: flex; + flex-direction: column; + ` + : ` + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + ` + } + width: 100%; + margin-top: 16px; + gap: 16px; + `, +); + +export interface BaseEntryListingProps { + entries: Entry[]; + viewStyle: CollectionViewStyle; + cursor?: Cursor; + handleCursorActions: (action: string) => void; + page?: number; +} + +export interface SingleCollectionEntryListingProps extends BaseEntryListingProps { + collection: Collection; +} + +export interface MultipleCollectionEntryListingProps extends BaseEntryListingProps { + collections: Collections; +} + +export type EntryListingProps = + | SingleCollectionEntryListingProps + | MultipleCollectionEntryListingProps; + +const EntryListing = ({ + entries, + page, + cursor, + viewStyle, + handleCursorActions, + ...otherProps +}: EntryListingProps) => { + const hasMore = useCallback(() => { + const hasMore = cursor?.actions?.has('append_next'); + return hasMore; + }, [cursor?.actions]); + + const handleLoadMore = useCallback(() => { + if (hasMore()) { + handleCursorActions('append_next'); + } + }, [handleCursorActions, hasMore]); + + const inferFields = useCallback( + ( + collection?: Collection, + ): { + titleField?: string | null; + descriptionField?: string | null; + imageField?: string | null; + remainingFields?: Field[]; + } => { + if (!collection) { + return {}; + } + + const titleField = selectInferedField(collection, 'title'); + const descriptionField = selectInferedField(collection, 'description'); + const imageField = selectInferedField(collection, 'image'); + const fields = selectFields(collection); + const inferedFields = [titleField, descriptionField, imageField]; + const remainingFields = fields && fields.filter(f => inferedFields.indexOf(f.name) === -1); + return { titleField, descriptionField, imageField, remainingFields }; + }, + [], + ); + + const renderedCards = useMemo(() => { + if ('collection' in otherProps) { + const inferedFields = inferFields(otherProps.collection); + return entries.map((entry, idx) => ( + + )); + } + + const isSingleCollectionInList = Object.keys(otherProps.collections).length === 1; + return entries.map((entry, idx) => { + const collectionName = entry.collection; + const collection = Object.values(otherProps.collections).find( + coll => coll.name === collectionName, + ); + const collectionLabel = !isSingleCollectionInList ? collection?.label : undefined; + const inferedFields = inferFields(collection); + return collection ? ( + + ) : null; + }); + }, [entries, inferFields, otherProps, viewStyle]); + + return ( +
+ + {renderedCards} + {hasMore() && } + +
+ ); +}; + +export default EntryListing; diff --git a/src/components/Collection/FilterControl.js b/src/components/Collection/FilterControl.js deleted file mode 100644 index 86a655ca..00000000 --- a/src/components/Collection/FilterControl.js +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import { translate } from 'react-polyglot'; - -import { Dropdown, DropdownCheckedItem } from '../../ui'; -import { ControlButton } from './ControlButton'; - -function FilterControl({ viewFilters, t, onFilterClick, filter }) { - const hasActiveFilter = filter - ?.valueSeq() - .toJS() - .some(f => f.active === true); - - return ( - { - return ( - - ); - }} - closeOnSelection={false} - dropdownTopOverlap="30px" - dropdownPosition="left" - > - {viewFilters.map(viewFilter => { - return ( - onFilterClick(viewFilter)} - /> - ); - })} - - ); -} - -export default translate()(FilterControl); diff --git a/src/components/Collection/FilterControl.tsx b/src/components/Collection/FilterControl.tsx new file mode 100644 index 00000000..da973c87 --- /dev/null +++ b/src/components/Collection/FilterControl.tsx @@ -0,0 +1,85 @@ +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import React, { useCallback, useMemo } from 'react'; +import { translate } from 'react-polyglot'; + +import type { FilterMap, TranslatedProps, ViewFilter } from '../../interface'; + +interface FilterControlProps { + filter: Record; + viewFilters: ViewFilter[]; + onFilterClick: (viewFilter: ViewFilter) => void; +} + +const FilterControl = ({ + viewFilters, + t, + onFilterClick, + filter, +}: TranslatedProps) => { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + const handleClose = useCallback(() => { + setAnchorEl(null); + }, []); + + const anyActive = useMemo(() => Object.keys(filter).some(key => filter[key]?.active), [filter]); + + return ( +
+ + + {viewFilters.map(viewFilter => { + const checked = filter[viewFilter.id]?.active ?? false; + const labelId = `filter-list-label-${viewFilter.label}`; + return ( + onFilterClick(viewFilter)} + sx={{ height: '36px' }} + > + + + + + + ); + })} + +
+ ); +}; + +export default translate()(FilterControl); diff --git a/src/components/Collection/GroupControl.js b/src/components/Collection/GroupControl.js deleted file mode 100644 index 1ebfc41b..00000000 --- a/src/components/Collection/GroupControl.js +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import { translate } from 'react-polyglot'; - -import { Dropdown, DropdownItem } from '../../ui'; -import { ControlButton } from './ControlButton'; - -function GroupControl({ viewGroups, t, onGroupClick, group }) { - const hasActiveGroup = group - ?.valueSeq() - .toJS() - .some(f => f.active === true); - - return ( - { - return ( - - ); - }} - closeOnSelection={false} - dropdownTopOverlap="30px" - dropdownWidth="160px" - dropdownPosition="left" - > - {viewGroups.map(viewGroup => { - return ( - onGroupClick(viewGroup)} - isActive={group.getIn([viewGroup.id, 'active'], false)} - /> - ); - })} - - ); -} - -export default translate()(GroupControl); diff --git a/src/components/Collection/GroupControl.tsx b/src/components/Collection/GroupControl.tsx new file mode 100644 index 00000000..928e70fe --- /dev/null +++ b/src/components/Collection/GroupControl.tsx @@ -0,0 +1,79 @@ +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import Button from '@mui/material/Button/Button'; +import ListItemText from '@mui/material/ListItemText'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import React, { useCallback, useMemo } from 'react'; +import { translate } from 'react-polyglot'; +import CheckIcon from '@mui/icons-material/Check'; +import { styled } from '@mui/material/styles'; + +import type { GroupMap, TranslatedProps, ViewGroup } from '../../interface'; + +const StyledMenuIconWrapper = styled('div')` + width: 32px; + height: 24px; + display: flex; + align-items: center; + justify-content: flex-end; +`; + +interface GroupControlProps { + group: Record; + viewGroups: ViewGroup[]; + onGroupClick: (viewGroup: ViewGroup) => void; +} + +const GroupControl = ({ + viewGroups, + group, + t, + onGroupClick, +}: TranslatedProps) => { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + const handleClose = useCallback(() => { + setAnchorEl(null); + }, []); + + const activeGroup = useMemo(() => Object.values(group).find(f => f.active === true), [group]); + + return ( +
+ + + {viewGroups.map(viewGroup => ( + onGroupClick(viewGroup)}> + {viewGroup.label} + + {viewGroup.id === activeGroup?.id ? : null} + + + ))} + +
+ ); +}; + +export default translate()(GroupControl); diff --git a/src/components/Collection/NestedCollection.js b/src/components/Collection/NestedCollection.js deleted file mode 100644 index ba0d94a0..00000000 --- a/src/components/Collection/NestedCollection.js +++ /dev/null @@ -1,309 +0,0 @@ -import React from 'react'; -import { List } from 'immutable'; -import { css } from '@emotion/react'; -import styled from '@emotion/styled'; -import { connect } from 'react-redux'; -import { NavLink } from 'react-router-dom'; -import { dirname, sep } from 'path'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { sortBy } from 'lodash'; - -import { Icon, colors, components } from '../../ui'; -import { stringTemplate } from '../../lib/widgets'; -import { selectEntries } from '../../reducers/entries'; -import { selectEntryCollectionTitle } from '../../reducers/collections'; - -const { addFileTemplateFields } = stringTemplate; - -const NodeTitleContainer = styled.div` - display: flex; - justify-content: center; - align-items: center; -`; - -const NodeTitle = styled.div` - margin-right: 4px; -`; - -const Caret = styled.div` - position: relative; - top: 2px; -`; - -const CaretDown = styled(Caret)` - ${components.caretDown}; - color: currentColor; -`; - -const CaretRight = styled(Caret)` - ${components.caretRight}; - color: currentColor; - left: 2px; -`; - -const TreeNavLink = styled(NavLink)` - display: flex; - font-size: 14px; - font-weight: 500; - align-items: center; - padding: 8px; - padding-left: ${props => props.depth * 20 + 12}px; - border-left: 2px solid #fff; - - ${Icon} { - margin-right: 8px; - flex-shrink: 0; - } - - ${props => css` - &:hover, - &:active, - &.${props.activeClassName} { - color: ${colors.active}; - background-color: ${colors.activeBackground}; - border-left-color: #4863c6; - } - `}; -`; - -function getNodeTitle(node) { - const title = node.isRoot - ? node.title - : node.children.find(c => !c.isDir && c.title)?.title || node.title; - return title; -} - -function TreeNode(props) { - const { collection, treeData, depth = 0, onToggle } = props; - const collectionName = collection.get('name'); - - const sortedData = sortBy(treeData, getNodeTitle); - return sortedData.map(node => { - const leaf = node.children.length <= 1 && !node.children[0]?.isDir && depth > 0; - if (leaf) { - return null; - } - let to = `/collections/${collectionName}`; - if (depth > 0) { - to = `${to}/filter${node.path}`; - } - const title = getNodeTitle(node); - - const hasChildren = depth === 0 || node.children.some(c => c.children.some(c => c.isDir)); - - return ( - - onToggle({ node, expanded: !node.expanded })} - depth={depth} - data-testid={node.path} - > - - - {title} - {hasChildren && (node.expanded ? : )} - - - {node.expanded && ( - - )} - - ); - }); -} - -TreeNode.propTypes = { - collection: ImmutablePropTypes.map.isRequired, - depth: PropTypes.number, - treeData: PropTypes.array.isRequired, - onToggle: PropTypes.func.isRequired, -}; - -export function walk(treeData, callback) { - function traverse(children) { - for (const child of children) { - callback(child); - traverse(child.children); - } - } - - return traverse(treeData); -} - -export function getTreeData(collection, entries) { - const collectionFolder = collection.get('folder'); - const rootFolder = '/'; - const entriesObj = entries - .toJS() - .map(e => ({ ...e, path: e.path.slice(collectionFolder.length) })); - - const dirs = entriesObj.reduce((acc, entry) => { - let dir = dirname(entry.path); - while (!acc[dir] && dir && dir !== rootFolder) { - const parts = dir.split(sep); - acc[dir] = parts.pop(); - dir = parts.length && parts.join(sep); - } - return acc; - }, {}); - - if (collection.getIn(['nested', 'summary'])) { - collection = collection.set('summary', collection.getIn(['nested', 'summary'])); - } else { - collection = collection.delete('summary'); - } - - const flatData = [ - { - title: collection.get('label'), - path: rootFolder, - isDir: true, - isRoot: true, - }, - ...Object.entries(dirs).map(([key, value]) => ({ - title: value, - path: key, - isDir: true, - isRoot: false, - })), - ...entriesObj.map((e, index) => { - let entryMap = entries.get(index); - entryMap = entryMap.set( - 'data', - addFileTemplateFields(entryMap.get('path'), entryMap.get('data')), - ); - const title = selectEntryCollectionTitle(collection, entryMap); - return { - ...e, - title, - isDir: false, - isRoot: false, - }; - }), - ]; - - const parentsToChildren = flatData.reduce((acc, node) => { - const parent = node.path === rootFolder ? '' : dirname(node.path); - if (acc[parent]) { - acc[parent].push(node); - } else { - acc[parent] = [node]; - } - return acc; - }, {}); - - function reducer(acc, value) { - const node = value; - let children = []; - if (parentsToChildren[node.path]) { - children = parentsToChildren[node.path].reduce(reducer, []); - } - - acc.push({ ...node, children }); - return acc; - } - - const treeData = parentsToChildren[''].reduce(reducer, []); - - return treeData; -} - -export function updateNode(treeData, node, callback) { - let stop = false; - - function updater(nodes) { - if (stop) { - return nodes; - } - for (let i = 0; i < nodes.length; i++) { - if (nodes[i].path === node.path) { - nodes[i] = callback(node); - stop = true; - return nodes; - } - } - nodes.forEach(node => updater(node.children)); - return nodes; - } - - return updater([...treeData]); -} - -export class NestedCollection extends React.Component { - static propTypes = { - collection: ImmutablePropTypes.map.isRequired, - entries: ImmutablePropTypes.list.isRequired, - filterTerm: PropTypes.string, - }; - - constructor(props) { - super(props); - this.state = { - treeData: getTreeData(this.props.collection, this.props.entries), - selected: null, - useFilter: true, - }; - } - - componentDidUpdate(prevProps) { - const { collection, entries, filterTerm } = this.props; - if ( - collection !== prevProps.collection || - entries !== prevProps.entries || - filterTerm !== prevProps.filterTerm - ) { - const expanded = {}; - walk(this.state.treeData, node => { - if (node.expanded) { - expanded[node.path] = true; - } - }); - const treeData = getTreeData(collection, entries); - - const path = `/${filterTerm}`; - walk(treeData, node => { - if (expanded[node.path] || (this.state.useFilter && path.startsWith(node.path))) { - node.expanded = true; - } - }); - this.setState({ treeData }); - } - } - - onToggle = ({ node, expanded }) => { - if (!this.state.selected || this.state.selected.path === node.path || expanded) { - const treeData = updateNode(this.state.treeData, node, node => ({ - ...node, - expanded, - })); - this.setState({ treeData, selected: node, useFilter: false }); - } else { - // don't collapse non selected nodes when clicked - this.setState({ selected: node, useFilter: false }); - } - }; - - render() { - const { treeData } = this.state; - const { collection } = this.props; - - return ; - } -} - -function mapStateToProps(state, ownProps) { - const { collection } = ownProps; - const entries = selectEntries(state.entries, collection) || List(); - return { entries }; -} - -export default connect(mapStateToProps, null)(NestedCollection); diff --git a/src/components/Collection/NestedCollection.tsx b/src/components/Collection/NestedCollection.tsx new file mode 100644 index 00000000..a1fa1c6e --- /dev/null +++ b/src/components/Collection/NestedCollection.tsx @@ -0,0 +1,354 @@ +import { styled } from '@mui/material/styles'; +import ArticleIcon from '@mui/icons-material/Article'; +import sortBy from 'lodash/sortBy'; +import { dirname, sep } from 'path'; +import React, { useCallback, useEffect, useState } from 'react'; +import { connect } from 'react-redux'; +import { NavLink } from 'react-router-dom'; + +import { colors, components } from '../../components/UI/styles'; +import { transientOptions } from '../../lib'; +import { selectEntryCollectionTitle } from '../../lib/util/collection.util'; +import { stringTemplate } from '../../lib/widgets'; +import { selectEntries } from '../../reducers/entries'; + +import type { ConnectedProps } from 'react-redux'; +import type { Collection, Entry } from '../../interface'; +import type { RootState } from '../../store'; + +const { addFileTemplateFields } = stringTemplate; + +const NodeTitleContainer = styled('div')` + display: flex; + justify-content: center; + align-items: center; +`; + +const NodeTitle = styled('div')` + margin-right: 4px; +`; + +const Caret = styled('div')` + position: relative; + top: 2px; +`; + +const CaretDown = styled(Caret)` + ${components.caretDown}; + color: currentColor; +`; + +const CaretRight = styled(Caret)` + ${components.caretRight}; + color: currentColor; + left: 2px; +`; + +interface TreeNavLinkProps { + $activeClassName: string; + $depth: number; +} + +const TreeNavLink = styled( + NavLink, + transientOptions, +)( + ({ $activeClassName, $depth }) => ` + display: flex; + font-size: 14px; + font-weight: 500; + align-items: center; + padding: 8px; + padding-left: ${$depth * 20 + 12}px; + border-left: 2px solid #fff; + + &:hover, + &:active, + &.${$activeClassName} { + color: ${colors.active}; + background-color: ${colors.activeBackground}; + border-left-color: #4863c6; + + .MuiListItemIcon-root { + color: ${colors.active}; + } + } + `, +); + +interface BaseTreeNodeData { + title: string | undefined; + path: string; + isDir: boolean; + isRoot: boolean; + expanded?: boolean; +} + +type SingleTreeNodeData = BaseTreeNodeData | (Entry & BaseTreeNodeData); + +type TreeNodeData = SingleTreeNodeData & { + children: TreeNodeData[]; +}; + +function getNodeTitle(node: TreeNodeData) { + const title = node.isRoot + ? node.title + : node.children.find(c => !c.isDir && c.title)?.title || node.title; + return title; +} + +interface TreeNodeProps { + collection: Collection; + treeData: TreeNodeData[]; + depth?: number; + onToggle: ({ node, expanded }: { node: TreeNodeData; expanded: boolean }) => void; +} + +const TreeNode = ({ collection, treeData, depth = 0, onToggle }: TreeNodeProps) => { + const collectionName = collection.name; + + const sortedData = sortBy(treeData, getNodeTitle); + return ( + <> + {sortedData.map(node => { + const leaf = node.children.length <= 1 && !node.children[0]?.isDir && depth > 0; + if (leaf) { + return null; + } + let to = `/collections/${collectionName}`; + if (depth > 0) { + to = `${to}/filter${node.path}`; + } + const title = getNodeTitle(node); + + const hasChildren = depth === 0 || node.children.some(c => c.children.some(c => c.isDir)); + + return ( + + onToggle({ node, expanded: !node.expanded })} + $depth={depth} + data-testid={node.path} + > + + + {title} + {hasChildren && (node.expanded ? : )} + + + {node.expanded && ( + + )} + + ); + })} + + ); +}; + +export function walk(treeData: TreeNodeData[], callback: (node: TreeNodeData) => void) { + function traverse(children: TreeNodeData[]) { + for (const child of children) { + callback(child); + traverse(child.children); + } + } + + return traverse(treeData); +} + +export function getTreeData(collection: Collection, entries: Entry[]): TreeNodeData[] { + const collectionFolder = collection.folder ?? ''; + const rootFolder = '/'; + const entriesObj = entries.map(e => ({ ...e, path: e.path.slice(collectionFolder.length) })); + + const dirs = entriesObj.reduce((acc, entry) => { + let dir: string | undefined = dirname(entry.path); + while (dir && !acc[dir] && dir !== rootFolder) { + const parts: string[] = dir.split(sep); + acc[dir] = parts.pop(); + dir = parts.length ? parts.join(sep) : undefined; + } + return acc; + }, {} as Record); + + if ('nested' in collection && collection.nested?.summary) { + collection = { + ...collection, + summary: collection.nested.summary, + }; + } else { + collection = { + ...collection, + }; + delete collection.summary; + } + + const flatData = [ + { + title: collection.label, + path: rootFolder, + isDir: true, + isRoot: true, + }, + ...Object.entries(dirs).map(([key, value]) => ({ + title: value, + path: key, + isDir: true, + isRoot: false, + })), + ...entriesObj.map((e, index) => { + let entryMap = entries[index]; + entryMap = { + ...entryMap, + data: addFileTemplateFields(entryMap.path, entryMap.data as Record), + }; + const title = selectEntryCollectionTitle(collection, entryMap); + return { + ...e, + title, + isDir: false, + isRoot: false, + }; + }), + ]; + + const parentsToChildren = flatData.reduce((acc, node) => { + const parent = node.path === rootFolder ? '' : dirname(node.path); + if (acc[parent]) { + acc[parent].push(node); + } else { + acc[parent] = [node]; + } + return acc; + }, {} as Record); + + function reducer(acc: TreeNodeData[], value: BaseTreeNodeData) { + const node = value; + let children: TreeNodeData[] = []; + if (parentsToChildren[node.path]) { + children = parentsToChildren[node.path].reduce(reducer, []); + } + + acc.push({ ...node, children }); + return acc; + } + + const treeData = parentsToChildren[''].reduce(reducer, []); + + return treeData; +} + +export function updateNode( + treeData: TreeNodeData[], + node: TreeNodeData, + callback: (node: TreeNodeData) => TreeNodeData, +) { + let stop = false; + + function updater(nodes: TreeNodeData[]) { + if (stop) { + return nodes; + } + for (let i = 0; i < nodes.length; i++) { + if (nodes[i].path === node.path) { + nodes[i] = callback(node); + stop = true; + return nodes; + } + } + nodes.forEach(node => updater(node.children)); + return nodes; + } + + return updater([...treeData]); +} + +const NestedCollection = ({ collection, entries, filterTerm }: NestedCollectionProps) => { + const [treeData, setTreeData] = useState(getTreeData(collection, entries)); + const [selected, setSelected] = useState(null); + const [useFilter, setUseFilter] = useState(true); + + const [prevCollection, setPrevCollection] = useState(collection); + const [prevEntries, setPrevEntries] = useState(entries); + const [prevFilterTerm, setPrevFilterTerm] = useState(filterTerm); + + useEffect(() => { + if (collection !== prevCollection || entries !== prevEntries || filterTerm !== prevFilterTerm) { + const expanded: Record = {}; + walk(treeData, node => { + if (node.expanded) { + expanded[node.path] = true; + } + }); + const newTreeData = getTreeData(collection, entries); + + const path = `/${filterTerm}`; + walk(newTreeData, node => { + if (expanded[node.path] || (useFilter && path.startsWith(node.path))) { + node.expanded = true; + } + }); + + setTreeData(newTreeData); + } + + setPrevCollection(collection); + setPrevEntries(entries); + setPrevFilterTerm(filterTerm); + }, [ + collection, + entries, + filterTerm, + prevCollection, + prevEntries, + prevFilterTerm, + treeData, + useFilter, + ]); + + const onToggle = useCallback( + ({ node, expanded }: { node: TreeNodeData; expanded: boolean }) => { + if (!selected || selected.path === node.path || expanded) { + setTreeData( + updateNode(treeData, node, node => ({ + ...node, + expanded, + })), + ); + setSelected(node); + setUseFilter(false); + } else { + // don't collapse non selected nodes when clicked + setSelected(node); + setUseFilter(false); + } + }, + [selected, treeData], + ); + + return ; +}; + +interface NestedCollectionOwnProps { + collection: Collection; + filterTerm: string; +} + +function mapStateToProps(state: RootState, ownProps: NestedCollectionOwnProps) { + const { collection } = ownProps; + const entries = selectEntries(state.entries, collection) ?? []; + return { ...ownProps, entries }; +} + +const connector = connect(mapStateToProps, {}); +export type NestedCollectionProps = ConnectedProps; + +export default connector(NestedCollection); diff --git a/src/components/Collection/Sidebar.js b/src/components/Collection/Sidebar.js deleted file mode 100644 index b1a6d897..00000000 --- a/src/components/Collection/Sidebar.js +++ /dev/null @@ -1,237 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import styled from '@emotion/styled'; -import { css } from '@emotion/react'; -import { translate } from 'react-polyglot'; -import { NavLink } from 'react-router-dom'; - -import { Icon, components, colors } from '../../ui'; -import { searchCollections } from '../../actions/collections'; -import CollectionSearch from './CollectionSearch'; -import NestedCollection from './NestedCollection'; -import { getAdditionalLinks, getIcon } from '../../lib/registry'; - -const styles = { - sidebarNavLinkActive: css` - color: ${colors.active}; - background-color: ${colors.activeBackground}; - border-left-color: #4863c6; - `, -}; - -const SidebarContainer = styled.aside` - ${components.card}; - width: 250px; - padding: 8px 0 12px; - position: fixed; - max-height: calc(100vh - 112px); - display: flex; - flex-direction: column; -`; - -const SidebarHeading = styled.h2` - font-size: 23px; - font-weight: 600; - padding: 0; - margin: 18px 12px 12px; - color: ${colors.textLead}; -`; - -const SidebarNavList = styled.ul` - margin: 16px 0 0; - padding-left: 0; - list-style: none; - overflow: auto; -`; - -const SidebarNavLink = styled(NavLink)` - display: flex; - font-size: 14px; - font-weight: 500; - align-items: center; - padding: 8px 12px; - border-left: 2px solid #fff; - z-index: -1; - - ${Icon} { - margin-right: 0; - flex-shrink: 0; - } - - ${props => css` - &:hover, - &:active, - &.${props.activeClassName} { - ${styles.sidebarNavLinkActive}; - } - `}; -`; - -const AdditionalLink = styled.a` - display: flex; - font-size: 14px; - font-weight: 500; - align-items: center; - padding: 8px 12px; - border-left: 2px solid #fff; - z-index: -1; - - ${Icon} { - margin-right: 0; - flex-shrink: 0; - } - - &:hover, - &:active { - ${styles.sidebarNavLinkActive}; - } -`; - -const IconWrapper = styled.div` - height: 24px; - width: 24px; - display: flex; - align-items: center; - justify-content: center; - margin-right: 8px; -`; - -class Sidebar extends React.Component { - static propTypes = { - collections: ImmutablePropTypes.map.isRequired, - collection: ImmutablePropTypes.map, - isSearchEnabled: PropTypes.bool, - searchTerm: PropTypes.string, - filterTerm: PropTypes.string, - t: PropTypes.func.isRequired, - }; - - renderLink = (collection, filterTerm) => { - const collectionName = collection.get('name'); - const iconName = collection.get('icon'); - let icon = ; - if (iconName) { - const storedIcon = getIcon(iconName); - if (storedIcon) { - icon = storedIcon; - } - } - if (collection.has('nested')) { - return ( -
  • - -
  • - ); - } - return ( -
  • - - {icon} - {collection.get('label')} - -
  • - ); - }; - - renderAdditionalLink = ({ id, title, data, iconName }) => { - let icon = ; - if (iconName) { - const storedIcon = getIcon(iconName); - if (storedIcon) { - icon = storedIcon; - } - } - - const content = ( - <> - {icon} - {title} - - ); - - return ( -
  • - {typeof data === 'string' ? ( - - {content} - - ) : ( - - {content} - - )} -
  • - ); - }; - - renderLink = (collection, filterTerm) => { - const collectionName = collection.get('name'); - const iconName = collection.get('icon'); - let icon = ; - if (iconName) { - const storedIcon = getIcon(iconName); - if (storedIcon) { - icon = storedIcon; - } - } - if (collection.has('nested')) { - return ( -
  • - -
  • - ); - } - return ( -
  • - - {icon} - {collection.get('label')} - -
  • - ); - }; - - render() { - const { collections, collection, isSearchEnabled, searchTerm, t, filterTerm } = this.props; - const additionalLinks = getAdditionalLinks(); - return ( - - {t('collection.sidebar.collections')} - {isSearchEnabled && ( - searchCollections(query, collection)} - /> - )} - - {collections - .toList() - .filter(collection => collection.get('hide') !== true) - .map(collection => this.renderLink(collection, filterTerm))} - {Object.values(additionalLinks).map(this.renderAdditionalLink)} - - - ); - } -} - -export default translate()(Sidebar); diff --git a/src/components/Collection/Sidebar.tsx b/src/components/Collection/Sidebar.tsx new file mode 100644 index 00000000..588304b8 --- /dev/null +++ b/src/components/Collection/Sidebar.tsx @@ -0,0 +1,177 @@ +import { styled } from '@mui/material/styles'; +import ArticleIcon from '@mui/icons-material/Article'; +import Card from '@mui/material/Card'; +import CardContent from '@mui/material/CardContent'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemButton from '@mui/material/ListItemButton'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import Typography from '@mui/material/Typography'; +import React, { useMemo } from 'react'; +import { translate } from 'react-polyglot'; + +import { searchCollections } from '../../actions/collections'; +import { colors } from '../../components/UI/styles'; +import { getAdditionalLinks, getIcon } from '../../lib/registry'; +import NavLink from '../UI/NavLink'; +import CollectionSearch from './CollectionSearch'; +import NestedCollection from './NestedCollection'; + +import type { ReactNode } from 'react'; +import type { Collection, Collections, TranslatedProps } from '../../interface'; + +const StyledSidebar = styled('div')` + position: sticky; + top: 88px; + align-self: flex-start; +`; + +const StyledListItemIcon = styled(ListItemIcon)` + min-width: 0; + margin-right: 12px; +`; + +interface SidebarProps { + collections: Collections; + collection: Collection; + isSearchEnabled: boolean; + searchTerm: string; + filterTerm: string; +} + +const Sidebar = ({ + collections, + collection, + isSearchEnabled, + searchTerm, + t, + filterTerm, +}: TranslatedProps) => { + const collectionLinks = useMemo( + () => + Object.values(collections) + .filter(collection => collection.hide !== true) + .map(collection => { + const collectionName = collection.name; + const iconName = collection.icon; + let icon: ReactNode = ; + if (iconName) { + const storedIcon = getIcon(iconName); + if (storedIcon) { + icon = storedIcon(); + } + } + + if ('nested' in collection) { + return ( +
  • + +
  • + ); + } + + return ( + + + {icon} + + + + ); + }), + [collections, filterTerm], + ); + + const additionalLinks = useMemo(() => getAdditionalLinks(), []); + const links = useMemo( + () => + Object.values(additionalLinks).map(({ id, title, data, options: { iconName } = {} }) => { + let icon: ReactNode = ; + if (iconName) { + const storedIcon = getIcon(iconName); + if (storedIcon) { + icon = storedIcon(); + } + } + + const content = ( + <> + {icon} + + + ); + + return typeof data === 'string' ? ( + + {content} + + ) : ( + + {content} + + ); + }), + [additionalLinks], + ); + + return ( + + + + + {t('collection.sidebar.collections')} + + {isSearchEnabled && ( + + searchCollections(query, collection) + } + /> + )} + + + {collectionLinks} + {links} + + + + ); +}; + +export default translate()(Sidebar); diff --git a/src/components/Collection/SortControl.js b/src/components/Collection/SortControl.js deleted file mode 100644 index 611d0d5f..00000000 --- a/src/components/Collection/SortControl.js +++ /dev/null @@ -1,68 +0,0 @@ -import React from 'react'; -import { translate } from 'react-polyglot'; - -import { SortDirection } from '../../interface'; -import { Dropdown, DropdownItem } from '../../ui'; -import { ControlButton } from './ControlButton'; - -function nextSortDirection(direction) { - switch (direction) { - case SortDirection.Ascending: - return SortDirection.Descending; - case SortDirection.Descending: - return SortDirection.None; - default: - return SortDirection.Ascending; - } -} - -function sortIconProps(sortDir) { - return { - icon: 'chevron', - iconDirection: sortIconDirections[sortDir], - iconSmall: true, - }; -} - -const sortIconDirections = { - [SortDirection.Ascending]: 'up', - [SortDirection.Descending]: 'down', -}; - -function SortControl({ t, fields, onSortClick, sort }) { - const hasActiveSort = sort - ?.valueSeq() - .toJS() - .some(s => s.direction !== SortDirection.None); - - return ( - { - return ( - - ); - }} - closeOnSelection={false} - dropdownTopOverlap="30px" - dropdownWidth="160px" - dropdownPosition="left" - > - {fields.map(field => { - const sortDir = sort?.getIn([field.key, 'direction']); - const isActive = sortDir && sortDir !== SortDirection.None; - const nextSortDir = nextSortDirection(sortDir); - return ( - onSortClick(field.key, nextSortDir)} - isActive={isActive} - {...(isActive && sortIconProps(sortDir))} - /> - ); - })} - - ); -} - -export default translate()(SortControl); diff --git a/src/components/Collection/SortControl.tsx b/src/components/Collection/SortControl.tsx new file mode 100644 index 00000000..55a7121b --- /dev/null +++ b/src/components/Collection/SortControl.tsx @@ -0,0 +1,112 @@ +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; +import { styled } from '@mui/material'; +import Button from '@mui/material/Button'; +import ListItemText from '@mui/material/ListItemText'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import React, { useCallback, useMemo } from 'react'; +import { translate } from 'react-polyglot'; + +import { SortDirection } from '../../interface'; + +import type { SortableField, SortMap, TranslatedProps } from '../../interface'; + +const StyledMenuIconWrapper = styled('div')` + width: 32px; + height: 24px; + display: flex; + align-items: center; + justify-content: flex-end; +`; + +function nextSortDirection(direction: SortDirection) { + switch (direction) { + case SortDirection.Ascending: + return SortDirection.Descending; + case SortDirection.Descending: + return SortDirection.None; + default: + return SortDirection.Ascending; + } +} + +interface SortControlProps { + fields: SortableField[]; + onSortClick: (key: string, direction?: SortDirection) => Promise; + sort: SortMap | undefined; +} + +const SortControl = ({ t, fields, onSortClick, sort }: TranslatedProps) => { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + const handleClose = useCallback(() => { + setAnchorEl(null); + }, []); + + const selectedSort = useMemo(() => { + if (!sort) { + return { key: undefined, direction: undefined }; + } + + const sortValues = Object.values(sort); + if (Object.values(sortValues).length < 1 || sortValues[0].direction === SortDirection.None) { + return { key: undefined, direction: undefined }; + } + + return sortValues[0]; + }, [sort]); + + return ( +
    + + + {fields.map(field => { + const sortDir = sort?.[field.name]?.direction ?? SortDirection.None; + const nextSortDir = nextSortDirection(sortDir); + return ( + onSortClick(field.name, nextSortDir)} + selected={field.name === selectedSort.key} + > + {field.label ?? field.name} + + {field.name === selectedSort.key ? ( + selectedSort.direction === SortDirection.Ascending ? ( + + ) : ( + + ) + ) : null} + + + ); + })} + +
    + ); +}; + +export default translate()(SortControl); diff --git a/src/components/Collection/ViewStyleControl.js b/src/components/Collection/ViewStyleControl.js deleted file mode 100644 index 01a4fa1b..00000000 --- a/src/components/Collection/ViewStyleControl.js +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react'; -import styled from '@emotion/styled'; - -import { Icon, buttons, colors } from '../../ui'; -import { VIEW_STYLE_LIST, VIEW_STYLE_GRID } from '../../constants/collectionViews'; - -const ViewControlsSection = styled.div` - display: flex; - align-items: center; - justify-content: flex-end; - max-width: 500px; -`; - -const ViewControlsButton = styled.button` - ${buttons.button}; - color: ${props => (props.isActive ? colors.active : '#b3b9c4')}; - background-color: transparent; - display: block; - padding: 0; - margin: 0 4px; - - &:last-child { - margin-right: 0; - } - - ${Icon} { - display: block; - } -`; - -function ViewStyleControl({ viewStyle, onChangeViewStyle }) { - return ( - - onChangeViewStyle(VIEW_STYLE_LIST)} - > - - - onChangeViewStyle(VIEW_STYLE_GRID)} - > - - - - ); -} - -export default ViewStyleControl; diff --git a/src/components/Collection/ViewStyleControl.tsx b/src/components/Collection/ViewStyleControl.tsx new file mode 100644 index 00000000..5bb8693b --- /dev/null +++ b/src/components/Collection/ViewStyleControl.tsx @@ -0,0 +1,44 @@ +import { styled } from '@mui/material/styles'; +import GridViewSharpIcon from '@mui/icons-material/GridViewSharp'; +import ReorderSharpIcon from '@mui/icons-material/ReorderSharp'; +import IconButton from '@mui/material/IconButton'; +import React from 'react'; + +import { VIEW_STYLE_GRID, VIEW_STYLE_LIST } from '../../constants/collectionViews'; + +import type { CollectionViewStyle } from '../../constants/collectionViews'; + +const ViewControlsSection = styled('div')` + margin-left: 24px; + display: flex; + align-items: center; + justify-content: flex-end; +`; + +interface ViewStyleControlPros { + viewStyle: CollectionViewStyle; + onChangeViewStyle: (viewStyle: CollectionViewStyle) => void; +} + +const ViewStyleControl = ({ viewStyle, onChangeViewStyle }: ViewStyleControlPros) => { + return ( + + onChangeViewStyle(VIEW_STYLE_LIST)} + > + + + onChangeViewStyle(VIEW_STYLE_GRID)} + > + + + + ); +}; + +export default ViewStyleControl; diff --git a/src/components/Editor/Editor.js b/src/components/Editor/Editor.js deleted file mode 100644 index 7092f484..00000000 --- a/src/components/Editor/Editor.js +++ /dev/null @@ -1,408 +0,0 @@ -import { debounce } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { translate } from 'react-polyglot'; -import { connect } from 'react-redux'; - -import { logoutUser } from '../../actions/auth'; -import { - changeDraftField, - changeDraftFieldValidation, - createDraftDuplicateFromEntry, - createEmptyDraft, - deleteEntry, - deleteLocalBackup, - discardDraft, - loadEntries, - loadEntry, - loadLocalBackup, - persistEntry, - persistLocalBackup, - retrieveLocalBackup, -} from '../../actions/entries'; -import { loadScroll, toggleScroll } from '../../actions/scroll'; -import { selectEntry } from '../../reducers'; -import { selectFields, selectAllowDeletion } from '../../reducers/collections'; -import { history, navigateToCollection, navigateToNewEntry } from '../../routing/history'; -import { Loader } from '../../ui'; -import confirm from '../UI/Confirm'; -import EditorInterface from './EditorInterface'; - -export class Editor extends React.Component { - static propTypes = { - changeDraftField: PropTypes.func.isRequired, - changeDraftFieldValidation: PropTypes.func.isRequired, - collection: ImmutablePropTypes.map.isRequired, - createDraftDuplicateFromEntry: PropTypes.func.isRequired, - createEmptyDraft: PropTypes.func.isRequired, - discardDraft: PropTypes.func.isRequired, - entry: ImmutablePropTypes.map, - entryDraft: ImmutablePropTypes.map.isRequired, - loadEntry: PropTypes.func.isRequired, - persistEntry: PropTypes.func.isRequired, - deleteEntry: PropTypes.func.isRequired, - showDelete: PropTypes.bool.isRequired, - fields: ImmutablePropTypes.list.isRequired, - slug: PropTypes.string, - newEntry: PropTypes.bool.isRequired, - displayUrl: PropTypes.string, - isModification: PropTypes.bool, - collectionEntriesLoaded: PropTypes.bool, - logoutUser: PropTypes.func.isRequired, - loadEntries: PropTypes.func.isRequired, - currentStatus: PropTypes.string, - user: PropTypes.object, - location: PropTypes.shape({ - pathname: PropTypes.string, - search: PropTypes.string, - }), - hasChanged: PropTypes.bool, - t: PropTypes.func.isRequired, - retrieveLocalBackup: PropTypes.func.isRequired, - localBackup: ImmutablePropTypes.map, - loadLocalBackup: PropTypes.func, - persistLocalBackup: PropTypes.func.isRequired, - deleteLocalBackup: PropTypes.func, - toggleScroll: PropTypes.func.isRequired, - scrollSyncEnabled: PropTypes.bool.isRequired, - loadScroll: PropTypes.func.isRequired, - }; - - componentDidMount() { - const { - newEntry, - collection, - slug, - loadEntry, - createEmptyDraft, - loadEntries, - retrieveLocalBackup, - collectionEntriesLoaded, - t, - } = this.props; - - retrieveLocalBackup(collection, slug); - - if (newEntry) { - createEmptyDraft(collection, this.props.location.search); - } else { - loadEntry(collection, slug); - } - - const leaveMessage = t('editor.editor.onLeavePage'); - - this.exitBlocker = event => { - if (this.props.entryDraft.get('hasChanged')) { - // This message is ignored in most browsers, but its presence - // triggers the confirmation dialog - event.returnValue = leaveMessage; - return leaveMessage; - } - }; - window.addEventListener('beforeunload', this.exitBlocker); - - const navigationBlocker = (location, action) => { - /** - * New entry being saved and redirected to it's new slug based url. - */ - const isPersisting = this.props.entryDraft.getIn(['entry', 'isPersisting']); - const newRecord = this.props.entryDraft.getIn(['entry', 'newRecord']); - const newEntryPath = `/collections/${collection.get('name')}/new`; - if ( - isPersisting && - newRecord && - this.props.location.pathname === newEntryPath && - action === 'PUSH' - ) { - return; - } - - if (this.props.hasChanged) { - return leaveMessage; - } - }; - - const unblock = history.block(navigationBlocker); - - /** - * This will run as soon as the location actually changes, unless creating - * a new post. The confirmation above will run first. - */ - this.unlisten = history.listen((location, action) => { - const newEntryPath = `/collections/${collection.get('name')}/new`; - const entriesPath = `/collections/${collection.get('name')}/entries/`; - const { pathname } = location; - if ( - pathname.startsWith(newEntryPath) || - (pathname.startsWith(entriesPath) && action === 'PUSH') - ) { - return; - } - - this.deleteBackup(); - - unblock(); - this.unlisten(); - }); - - if (!collectionEntriesLoaded) { - loadEntries(collection); - } - } - - async checkLocalBackup(prevProps) { - const { hasChanged, localBackup, loadLocalBackup, entryDraft, collection } = this.props; - - if (!prevProps.localBackup && localBackup) { - const confirmLoadBackup = await confirm({ - title: 'editor.editor.confirmLoadBackupTitle', - body: 'editor.editor.confirmLoadBackupBody', - }); - if (confirmLoadBackup) { - loadLocalBackup(); - } else { - this.deleteBackup(); - } - } - - if (hasChanged) { - this.createBackup(entryDraft.get('entry'), collection); - } - } - - componentDidUpdate(prevProps) { - this.checkLocalBackup(prevProps); - - if (prevProps.entry === this.props.entry) { - return; - } - - const { newEntry, collection } = this.props; - - if (newEntry) { - prevProps.createEmptyDraft(collection, this.props.location.search); - } - } - - componentWillUnmount() { - this.createBackup.flush(); - this.props.discardDraft(); - window.removeEventListener('beforeunload', this.exitBlocker); - } - - createBackup = debounce(function (entry, collection) { - this.props.persistLocalBackup(entry, collection); - }, 2000); - - handleChangeDraftField = (field, value, metadata, i18n) => { - const entries = [this.props.publishedEntry].filter(Boolean); - this.props.changeDraftField({ field, value, metadata, entries, i18n }); - }; - - deleteBackup() { - const { deleteLocalBackup, collection, slug, newEntry } = this.props; - this.createBackup.cancel(); - deleteLocalBackup(collection, !newEntry && slug); - } - - handlePersistEntry = async (opts = {}) => { - const { createNew = false, duplicate = false } = opts; - const { - persistEntry, - collection, - createDraftDuplicateFromEntry, - entryDraft, - } = this.props; - - await persistEntry(collection); - - this.deleteBackup(); - - if (createNew) { - navigateToNewEntry(collection.get('name')); - duplicate && createDraftDuplicateFromEntry(entryDraft.get('entry')); - } - }; - - handleDuplicateEntry = () => { - const { createDraftDuplicateFromEntry, collection, entryDraft } = this.props; - - navigateToNewEntry(collection.get('name')); - createDraftDuplicateFromEntry(entryDraft.get('entry')); - }; - - handleDeleteEntry = async () => { - const { entryDraft, newEntry, collection, deleteEntry, slug } = this.props; - if (entryDraft.get('hasChanged')) { - if ( - !(await confirm({ - title: 'editor.editor.onDeleteWithUnsavedChangesTitle', - body: 'editor.editor.onDeleteWithUnsavedChangesBody', - color: 'error', - })) - ) { - return; - } - } else if ( - !(await confirm({ - title: 'editor.editor.onDeletePublishedEntryTitle', - body: 'editor.editor.onDeletePublishedEntryBody', - color: 'error', - })) - ) { - return; - } - - if (newEntry) { - return navigateToCollection(collection.get('name')); - } - - setTimeout(async () => { - await deleteEntry(collection, slug); - this.deleteBackup(); - return navigateToCollection(collection.get('name')); - }, 0); - }; - - render() { - const { - entry, - entryDraft, - fields, - collection, - changeDraftFieldValidation, - user, - hasChanged, - displayUrl, - newEntry, - isModification, - currentStatus, - logoutUser, - draftKey, - t, - editorBackLink, - toggleScroll, - scrollSyncEnabled, - loadScroll, - } = this.props; - - if (entry && entry.get('error')) { - return ( -
    -

    {entry.get('error')}

    -
    - ); - } else if ( - entryDraft == null || - entryDraft.get('entry') === undefined || - (entry && entry.get('isFetching')) - ) { - return {t('editor.editor.loadingEntry')}; - } - - return ( - - ); - } -} - -function mapStateToProps(state, ownProps) { - const { collections, entryDraft, auth, config, entries, scroll } = state; - const slug = ownProps.match.params[0]; - const collection = collections.get(ownProps.match.params.name); - const collectionName = collection.get('name'); - const newEntry = ownProps.newRecord === true; - const fields = selectFields(collection, slug); - const entry = newEntry ? null : selectEntry(state, collectionName, slug); - const user = auth.user; - const hasChanged = entryDraft.get('hasChanged'); - const displayUrl = config.display_url; - const isModification = entryDraft.getIn(['entry', 'isModification']); - const collectionEntriesLoaded = !!entries.getIn(['pages', collectionName]); - const publishedEntry = selectEntry(state, collectionName, slug); - const localBackup = entryDraft.get('localBackup'); - const draftKey = entryDraft.get('key'); - let editorBackLink = `/collections/${collectionName}`; - if (collection.has('files') && collection.get('files').size === 1) { - editorBackLink = '/'; - } - - if (collection.has('nested') && slug) { - const pathParts = slug.split('/'); - if (pathParts.length > 2) { - editorBackLink = `${editorBackLink}/filter/${pathParts.slice(0, -2).join('/')}`; - } - } - - const scrollSyncEnabled = scroll.isScrolling; - - return { - showDelete: !newEntry && selectAllowDeletion(collection), - collection, - collections, - newEntry, - entryDraft, - fields, - slug, - entry, - user, - hasChanged, - displayUrl, - isModification, - collectionEntriesLoaded, - localBackup, - draftKey, - publishedEntry, - editorBackLink, - scrollSyncEnabled, - }; -} - -const mapDispatchToProps = { - changeDraftField, - changeDraftFieldValidation, - loadEntry, - loadEntries, - loadLocalBackup, - retrieveLocalBackup, - persistLocalBackup, - deleteLocalBackup, - createDraftDuplicateFromEntry, - createEmptyDraft, - discardDraft, - persistEntry, - deleteEntry, - logoutUser, - toggleScroll, - loadScroll, -}; - -export default connect(mapStateToProps, mapDispatchToProps)(translate()(Editor)); diff --git a/src/components/Editor/Editor.tsx b/src/components/Editor/Editor.tsx new file mode 100644 index 00000000..a9518482 --- /dev/null +++ b/src/components/Editor/Editor.tsx @@ -0,0 +1,400 @@ +import debounce from 'lodash/debounce'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { translate } from 'react-polyglot'; +import { connect } from 'react-redux'; + +import { logoutUser as logoutUserAction } from '../../actions/auth'; +import { + changeDraftFieldValidation as changeDraftFieldValidationAction, + createDraftDuplicateFromEntry as createDraftDuplicateFromEntryAction, + createEmptyDraft as createEmptyDraftAction, + deleteDraftLocalBackup as deleteDraftLocalBackupAction, + deleteEntry as deleteEntryAction, + deleteLocalBackup as deleteLocalBackupAction, + discardDraft as discardDraftAction, + loadEntries as loadEntriesAction, + loadEntry as loadEntryAction, + loadLocalBackup as loadLocalBackupAction, + persistEntry as persistEntryAction, + persistLocalBackup as persistLocalBackupAction, + retrieveLocalBackup as retrieveLocalBackupAction, +} from '../../actions/entries'; +import { + loadScroll as loadScrollAction, + toggleScroll as toggleScrollAction, +} from '../../actions/scroll'; +import { selectFields } from '../../lib/util/collection.util'; +import { useWindowEvent } from '../../lib/util/window.util'; +import { selectEntry } from '../../reducers'; +import { history, navigateToCollection, navigateToNewEntry } from '../../routing/history'; +import confirm from '../UI/Confirm'; +import Loader from '../UI/Loader'; +import EditorInterface from './EditorInterface'; + +import type { TransitionPromptHook } from 'history'; +import type { ComponentType } from 'react'; +import type { ConnectedProps } from 'react-redux'; +import type { Collection, EditorPersistOptions, Entry, TranslatedProps } from '../../interface'; +import type { RootState } from '../../store'; + +const Editor = ({ + entry, + entryDraft, + fields, + collection, + user, + hasChanged, + displayUrl, + isModification, + logoutUser, + draftKey, + t, + editorBackLink, + toggleScroll, + scrollSyncEnabled, + loadScroll, + showDelete, + slug, + localBackup, + persistLocalBackup, + loadEntry, + persistEntry, + deleteEntry, + loadLocalBackup, + retrieveLocalBackup, + deleteLocalBackup, + deleteDraftLocalBackup, + createDraftDuplicateFromEntry, + createEmptyDraft, + discardDraft, +}: TranslatedProps) => { + const [version, setVersion] = useState(0); + + const createBackup = useMemo( + () => + debounce(function (entry: Entry, collection: Collection) { + persistLocalBackup(entry, collection); + }, 2000), + [persistLocalBackup], + ); + + const deleteBackup = useCallback(() => { + createBackup.cancel(); + if (slug) { + deleteLocalBackup(collection, slug); + } + deleteDraftLocalBackup(); + }, [collection, createBackup, deleteDraftLocalBackup, deleteLocalBackup, slug]); + + const [submitted, setSubmitted] = useState(false); + const handlePersistEntry = useCallback( + async (opts: EditorPersistOptions = {}) => { + const { createNew = false, duplicate = false } = opts; + + if (!entryDraft.entry) { + return; + } + + try { + await persistEntry(collection); + setVersion(version + 1); + + deleteBackup(); + + if (createNew) { + navigateToNewEntry(collection.name); + if (duplicate && entryDraft.entry) { + createDraftDuplicateFromEntry(entryDraft.entry); + } + } + // eslint-disable-next-line no-empty + } catch (e) {} + + setSubmitted(true); + }, + [ + collection, + createDraftDuplicateFromEntry, + deleteBackup, + entryDraft.entry, + persistEntry, + version, + ], + ); + + const handleDuplicateEntry = useCallback(() => { + if (!entryDraft.entry) { + return; + } + + navigateToNewEntry(collection.name); + createDraftDuplicateFromEntry(entryDraft.entry); + }, [collection.name, createDraftDuplicateFromEntry, entryDraft.entry]); + + const handleDeleteEntry = useCallback(async () => { + if (entryDraft.hasChanged) { + if ( + !(await confirm({ + title: 'editor.editor.onDeleteWithUnsavedChangesTitle', + body: 'editor.editor.onDeleteWithUnsavedChangesBody', + color: 'error', + })) + ) { + return; + } + } else if ( + !(await confirm({ + title: 'editor.editor.onDeletePublishedEntryTitle', + body: 'editor.editor.onDeletePublishedEntryBody', + color: 'error', + })) + ) { + return; + } + + if (!slug) { + return navigateToCollection(collection.name); + } + + setTimeout(async () => { + await deleteEntry(collection, slug); + deleteBackup(); + return navigateToCollection(collection.name); + }, 0); + }, [collection, deleteBackup, deleteEntry, entryDraft.hasChanged, slug]); + + const [prevLocalBackup, setPrevLocalBackup] = useState< + | { + entry: Entry; + } + | undefined + >(); + + useEffect(() => { + if (!prevLocalBackup && localBackup) { + const updateLocalBackup = async () => { + const confirmLoadBackup = await confirm({ + title: 'editor.editor.confirmLoadBackupTitle', + body: 'editor.editor.confirmLoadBackupBody', + }); + + if (confirmLoadBackup) { + loadLocalBackup(); + setVersion(version + 1); + } else { + deleteBackup(); + } + }; + + updateLocalBackup(); + } + + setPrevLocalBackup(localBackup); + }, [deleteBackup, loadLocalBackup, localBackup, prevLocalBackup, version]); + + useEffect(() => { + if (hasChanged && entryDraft.entry) { + createBackup(entryDraft.entry, collection); + } + + return () => { + createBackup.flush(); + }; + }, [collection, createBackup, entryDraft.entry, hasChanged]); + + const [prevCollection, setPrevCollection] = useState(null); + const [preSlug, setPrevSlug] = useState(null); + useEffect(() => { + if (!slug && preSlug !== slug) { + setTimeout(() => { + createEmptyDraft(collection, location.search); + }); + } else if (slug && (prevCollection !== collection || preSlug !== slug)) { + setTimeout(() => { + retrieveLocalBackup(collection, slug); + loadEntry(collection, slug); + }); + } + + setPrevCollection(collection); + setPrevSlug(slug); + }, [ + collection, + createEmptyDraft, + discardDraft, + entryDraft.entry, + loadEntry, + preSlug, + prevCollection, + retrieveLocalBackup, + slug, + ]); + + const leaveMessage = useMemo(() => t('editor.editor.onLeavePage'), [t]); + + const exitBlocker = useCallback( + (event: BeforeUnloadEvent) => { + if (entryDraft.hasChanged) { + // This message is ignored in most browsers, but its presence triggers the confirmation dialog + event.returnValue = leaveMessage; + return leaveMessage; + } + }, + [entryDraft.hasChanged, leaveMessage], + ); + + useWindowEvent('beforeunload', exitBlocker); + + const navigationBlocker: TransitionPromptHook = useCallback( + (location, action) => { + /** + * New entry being saved and redirected to it's new slug based url. + */ + const isPersisting = entryDraft.entry?.isPersisting; + const newRecord = entryDraft.entry?.newRecord; + const newEntryPath = `/collections/${collection.name}/new`; + if (isPersisting && newRecord && location.pathname === newEntryPath && action === 'PUSH') { + return; + } + + if (hasChanged) { + return leaveMessage; + } + }, + [ + collection.name, + entryDraft.entry?.isPersisting, + entryDraft.entry?.newRecord, + hasChanged, + leaveMessage, + ], + ); + + useEffect(() => { + const unblock = history.block(navigationBlocker); + + return () => { + unblock(); + }; + }, [collection.name, deleteBackup, discardDraft, navigationBlocker]); + + // TODO Is this needed? + // if (!collectionEntriesLoaded) { + // loadEntries(collection); + // } + + if (entry && entry.error) { + return ( +
    +

    {entry.error}

    +
    + ); + } else if (entryDraft == null || entryDraft.entry === undefined || (entry && entry.isFetching)) { + return {t('editor.editor.loadingEntry')}; + } + + return ( + + ); +}; + +interface CollectionViewOwnProps { + name: string; + slug?: string; + newRecord: boolean; + showDelete?: boolean; +} + +function mapStateToProps(state: RootState, ownProps: CollectionViewOwnProps) { + const { collections, entryDraft, auth, config, entries, scroll } = state; + const { name, slug } = ownProps; + const collection = collections[name]; + const collectionName = collection.name; + const fields = selectFields(collection, slug); + const entry = !slug ? null : selectEntry(state, collectionName, slug); + const user = auth.user; + const hasChanged = entryDraft.hasChanged; + const displayUrl = config.config?.display_url; + const isModification = entryDraft.entry?.isModification ?? false; + const collectionEntriesLoaded = Boolean(entries.pages[collectionName]); + const localBackup = entryDraft.localBackup; + const draftKey = entryDraft.key; + let editorBackLink = `/collections/${collectionName}`; + if ('files' in collection && collection.files?.length === 1) { + editorBackLink = '/'; + } + + if ('nested' in collection && collection.nested && slug) { + const pathParts = slug.split('/'); + if (pathParts.length > 2) { + editorBackLink = `${editorBackLink}/filter/${pathParts.slice(0, -2).join('/')}`; + } + } + + const scrollSyncEnabled = scroll.isScrolling; + + return { + ...ownProps, + collection, + collections, + entryDraft, + fields, + entry, + user, + hasChanged, + displayUrl, + isModification, + collectionEntriesLoaded, + localBackup, + draftKey, + editorBackLink, + scrollSyncEnabled, + }; +} + +const mapDispatchToProps = { + loadEntry: loadEntryAction, + loadEntries: loadEntriesAction, + loadLocalBackup: loadLocalBackupAction, + deleteDraftLocalBackup: deleteDraftLocalBackupAction, + retrieveLocalBackup: retrieveLocalBackupAction, + persistLocalBackup: persistLocalBackupAction, + deleteLocalBackup: deleteLocalBackupAction, + changeDraftFieldValidation: changeDraftFieldValidationAction, + createDraftDuplicateFromEntry: createDraftDuplicateFromEntryAction, + createEmptyDraft: createEmptyDraftAction, + discardDraft: discardDraftAction, + persistEntry: persistEntryAction, + deleteEntry: deleteEntryAction, + logoutUser: logoutUserAction, + toggleScroll: toggleScrollAction, + loadScroll: loadScrollAction, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); +export type EditorProps = ConnectedProps; + +export default connector(translate()(Editor) as ComponentType); diff --git a/src/components/Editor/EditorControlPane/EditorControl.js b/src/components/Editor/EditorControlPane/EditorControl.js deleted file mode 100644 index d06d4648..00000000 --- a/src/components/Editor/EditorControlPane/EditorControl.js +++ /dev/null @@ -1,431 +0,0 @@ -import React from 'react'; -import { bindActionCreators } from 'redux'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { translate } from 'react-polyglot'; -import { ClassNames, Global, css as coreCss } from '@emotion/react'; -import styled from '@emotion/styled'; -import { partial, uniqueId } from 'lodash'; -import { connect } from 'react-redux'; -import ReactMarkdown from 'react-markdown'; -import gfm from 'remark-gfm'; - -import { - FieldLabel, - colors, - transitions, - lengths, - borders, -} from '../../../ui'; -import { resolveWidget, getEditorComponents } from '../../../lib/registry'; -import { clearFieldErrors, tryLoadEntry, validateMetaField } from '../../../actions/entries'; -import { addAsset, boundGetAsset } from '../../../actions/media'; -import { selectIsLoadingAsset } from '../../../reducers/medias'; -import { query, clearSearch } from '../../../actions/search'; -import { - openMediaLibrary, - removeInsertedMedia, - clearMediaControl, - removeMediaControl, - persistMedia, -} from '../../../actions/mediaLibrary'; -import Widget from './Widget'; - -/** - * This is a necessary bridge as we are still passing classnames to widgets - * for styling. Once that changes we can stop storing raw style strings like - * this. - */ -const styleStrings = { - widget: ` - display: block; - width: 100%; - padding: ${lengths.inputPadding}; - margin: 0; - border: ${borders.textField}; - border-radius: ${lengths.borderRadius}; - border-top-left-radius: 0; - outline: 0; - box-shadow: none; - background-color: ${colors.inputBackground}; - color: #444a57; - transition: border-color ${transitions.main}; - position: relative; - font-size: 15px; - line-height: 1.5; - - select& { - text-indent: 14px; - height: 58px; - } - `, - widgetActive: ` - border-color: ${colors.active}; - `, - widgetError: ` - border-color: ${colors.errorText}; - `, - disabled: ` - pointer-events: none; - opacity: 0.5; - background: #ccc; - `, - hidden: ` - visibility: hidden; - `, -}; - -const ControlContainer = styled.div` - margin-top: 16px; -`; - -const ControlErrorsList = styled.ul` - list-style-type: none; - font-size: 12px; - color: ${colors.errorText}; - margin-bottom: 5px; - text-align: right; - text-transform: uppercase; - position: relative; - font-weight: 600; - top: 20px; -`; - -export const ControlHint = styled.p` - margin-bottom: 0; - padding: 3px 0; - font-size: 12px; - color: ${props => - props.error ? colors.errorText : props.active ? colors.active : colors.controlLabel}; - transition: color ${transitions.main}; -`; - -function LabelComponent({ field, isActive, hasErrors, uniqueFieldId, isFieldOptional, t }) { - const label = `${field.get('label', field.get('name'))}`; - const labelComponent = ( - - {label} {`${isFieldOptional ? ` (${t('editor.editorControl.field.optional')})` : ''}`} - - ); - - return labelComponent; -} - -class EditorControl extends React.Component { - static propTypes = { - value: PropTypes.oneOfType([ - PropTypes.node, - PropTypes.object, - PropTypes.string, - PropTypes.bool, - ]), - field: ImmutablePropTypes.map.isRequired, - fieldsMetaData: ImmutablePropTypes.map, - fieldsErrors: ImmutablePropTypes.map, - mediaPaths: ImmutablePropTypes.map.isRequired, - boundGetAsset: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - openMediaLibrary: PropTypes.func.isRequired, - addAsset: PropTypes.func.isRequired, - removeInsertedMedia: PropTypes.func.isRequired, - persistMedia: PropTypes.func.isRequired, - onValidate: PropTypes.func, - processControlRef: PropTypes.func, - controlRef: PropTypes.func, - query: PropTypes.func.isRequired, - queryHits: PropTypes.object, - isFetching: PropTypes.bool, - clearSearch: PropTypes.func.isRequired, - clearFieldErrors: PropTypes.func.isRequired, - loadEntry: PropTypes.func.isRequired, - t: PropTypes.func.isRequired, - isEditorComponent: PropTypes.bool, - isNewEditorComponent: PropTypes.bool, - parentIds: PropTypes.arrayOf(PropTypes.string), - entry: ImmutablePropTypes.map.isRequired, - collection: ImmutablePropTypes.map.isRequired, - isDisabled: PropTypes.bool, - isHidden: PropTypes.bool, - isFieldDuplicate: PropTypes.func, - isFieldHidden: PropTypes.func, - locale: PropTypes.string, - }; - - static defaultProps = { - parentIds: [], - }; - - state = { - activeLabel: false, - }; - - uniqueFieldId = uniqueId(`${this.props.field.get('name')}-field-`); - - isAncestorOfFieldError = () => { - const { fieldsErrors } = this.props; - - if (fieldsErrors && fieldsErrors.size > 0) { - return Object.values(fieldsErrors.toJS()).some(arr => - arr.some(err => err.parentIds && err.parentIds.includes(this.uniqueFieldId)), - ); - } - return false; - }; - - render() { - const { - value, - entry, - collection, - config, - field, - fieldsMetaData, - fieldsErrors, - mediaPaths, - boundGetAsset, - onChange, - openMediaLibrary, - clearMediaControl, - removeMediaControl, - addAsset, - removeInsertedMedia, - persistMedia, - onValidate, - processControlRef, - controlRef, - query, - queryHits, - isFetching, - clearSearch, - clearFieldErrors, - loadEntry, - className, - isSelected, - isEditorComponent, - isNewEditorComponent, - parentIds, - t, - validateMetaField, - isDisabled, - isHidden, - isFieldDuplicate, - isFieldHidden, - locale, - } = this.props; - - const widgetName = field.get('widget'); - const widget = resolveWidget(widgetName); - const fieldName = field.get('name'); - const fieldHint = field.get('hint'); - const isFieldOptional = field.get('required') === false; - const onValidateObject = onValidate; - const metadata = fieldsMetaData && fieldsMetaData.get(fieldName); - const errors = fieldsErrors && fieldsErrors.get(this.uniqueFieldId); - const childErrors = this.isAncestorOfFieldError(); - const hasErrors = !!errors || childErrors; - - return ( - - {({ css, cx }) => ( - - {widget.globalStyles && } - {errors && ( - - {errors.map( - error => - error.message && - typeof error.message === 'string' && ( -
  • - {error.message} -
  • - ), - )} -
    - )} - - onChange(field, newValue, newMetadata)} - onValidate={onValidate && partial(onValidate, this.uniqueFieldId)} - onOpenMediaLibrary={openMediaLibrary} - onClearMediaControl={clearMediaControl} - onRemoveMediaControl={removeMediaControl} - onRemoveInsertedMedia={removeInsertedMedia} - onPersistMedia={persistMedia} - onAddAsset={addAsset} - getAsset={boundGetAsset} - hasActiveStyle={isSelected || this.state.styleActive} - setActiveStyle={() => this.setState({ styleActive: true })} - setInactiveStyle={() => this.setState({ styleActive: false })} - resolveWidget={resolveWidget} - widget={widget} - getEditorComponents={getEditorComponents} - ref={processControlRef && partial(processControlRef, field)} - controlRef={controlRef} - editorControl={ConnectedEditorControl} - query={query} - loadEntry={loadEntry} - queryHits={queryHits[this.uniqueFieldId] || []} - clearSearch={clearSearch} - clearFieldErrors={clearFieldErrors} - isFetching={isFetching} - fieldsErrors={fieldsErrors} - onValidateObject={onValidateObject} - isEditorComponent={isEditorComponent} - isNewEditorComponent={isNewEditorComponent} - parentIds={parentIds} - t={t} - validateMetaField={validateMetaField} - isDisabled={isDisabled} - isFieldDuplicate={isFieldDuplicate} - isFieldHidden={isFieldHidden} - locale={locale} - /> - {fieldHint && ( - - ( - - ), - }} - > - {fieldHint} - - - )} -
    - )} -
    - ); - } -} - -function mapStateToProps(state) { - const { collections, entryDraft } = state; - const entry = entryDraft.get('entry'); - const collection = collections.get(entryDraft.getIn(['entry', 'collection'])); - const isLoadingAsset = selectIsLoadingAsset(state.medias); - - async function loadEntry(collectionName, slug) { - const targetCollection = collections.get(collectionName); - if (targetCollection) { - const loadedEntry = await tryLoadEntry(state, targetCollection, slug); - return loadedEntry; - } else { - throw new Error(`Can't find collection '${collectionName}'`); - } - } - - return { - mediaPaths: state.mediaLibrary.get('controlMedia'), - isFetching: state.search.isFetching, - queryHits: state.search.queryHits, - config: state.config, - entry, - collection, - isLoadingAsset, - loadEntry, - validateMetaField: (field, value, t) => validateMetaField(state, collection, field, value, t), - }; -} - -function mapDispatchToProps(dispatch) { - const creators = bindActionCreators( - { - openMediaLibrary, - clearMediaControl, - removeMediaControl, - removeInsertedMedia, - persistMedia, - addAsset, - query, - clearSearch, - clearFieldErrors, - }, - dispatch, - ); - return { - ...creators, - boundGetAsset: (collection, entry) => boundGetAsset(dispatch, collection, entry), - }; -} - -function mergeProps(stateProps, dispatchProps, ownProps) { - return { - ...stateProps, - ...dispatchProps, - ...ownProps, - boundGetAsset: dispatchProps.boundGetAsset(stateProps.collection, stateProps.entry), - }; -} - -const ConnectedEditorControl = connect( - mapStateToProps, - mapDispatchToProps, - mergeProps, -)(translate()(EditorControl)); - -export default ConnectedEditorControl; diff --git a/src/components/Editor/EditorControlPane/EditorControl.tsx b/src/components/Editor/EditorControlPane/EditorControl.tsx new file mode 100644 index 00000000..55819b49 --- /dev/null +++ b/src/components/Editor/EditorControlPane/EditorControl.tsx @@ -0,0 +1,336 @@ +import { styled } from '@mui/material/styles'; +import isEmpty from 'lodash/isEmpty'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { translate } from 'react-polyglot'; +import { connect } from 'react-redux'; + +import { + addDraftEntryMediaFile as addDraftEntryMediaFileAction, + changeDraftField as changeDraftFieldAction, + changeDraftFieldValidation as changeDraftFieldValidationAction, + clearFieldErrors as clearFieldErrorsAction, + tryLoadEntry, +} from '../../../actions/entries'; +import { addAsset as addAssetAction, getAsset as getAssetAction } from '../../../actions/media'; +import { + clearMediaControl as clearMediaControlAction, + openMediaLibrary as openMediaLibraryAction, + removeInsertedMedia as removeInsertedMediaAction, + removeMediaControl as removeMediaControlAction, +} from '../../../actions/mediaLibrary'; +import { clearSearch as clearSearchAction, query as queryAction } from '../../../actions/search'; +import { borders, colors, lengths, transitions } from '../../../components/UI/styles'; +import { transientOptions } from '../../../lib'; +import { resolveWidget } from '../../../lib/registry'; +import { getFieldLabel } from '../../../lib/util/field.util'; +import { validate } from '../../../lib/util/validation.util'; +import { selectIsLoadingAsset } from '../../../reducers/medias'; + +import type { ComponentType } from 'react'; +import type { ConnectedProps } from 'react-redux'; +import type { + Collection, + Entry, + Field, + FieldsErrors, + GetAssetFunction, + I18nSettings, + TranslatedProps, + ValueOrNestedValue, + Widget, +} from '../../../interface'; +import type { RootState } from '../../../store'; +import type { EditorControlPaneProps } from './EditorControlPane'; + +/** + * This is a necessary bridge as we are still passing classnames to widgets + * for styling. Once that changes we can stop storing raw style strings like + * this. + */ +const styleStrings = { + widget: ` + display: block; + width: 100%; + padding: ${lengths.inputPadding}; + margin: 0; + border: ${borders.textField}; + border-radius: ${lengths.borderRadius}; + border-top-left-radius: 0; + outline: 0; + box-shadow: none; + background-color: ${colors.inputBackground}; + color: #444a57; + transition: border-color ${transitions.main}; + position: relative; + font-size: 15px; + line-height: 1.5; + + select& { + text-indent: 14px; + height: 58px; + } + `, + widgetActive: ` + border-color: ${colors.active}; + `, + widgetError: ` + border-color: ${colors.errorText}; + `, + disabled: ` + pointer-events: none; + opacity: 0.5; + background: #ccc; + `, + hidden: ` + visibility: hidden; + `, +}; + +interface ControlContainerProps { + $isHidden: boolean; +} + +const ControlContainer = styled( + 'div', + transientOptions, +)( + ({ $isHidden }) => ` + display: flex; + flex-direction: column; + gap: 4px; + align-items: flex-start; + width: 100%; + ${$isHidden ? styleStrings.hidden : ''}; + `, +); + +const ControlErrorsList = styled('ul')` + list-style-type: none; + font-size: 12px; + color: ${colors.errorText}; + position: relative; + font-weight: 600; + display: flex; + flex-direction: column; + margin: 0; + padding: 4px 8px; +`; + +interface ControlHintProps { + $error: boolean; +} + +export const ControlHint = styled( + 'p', + transientOptions, +)( + ({ $error }) => ` + margin: 0; + margin-left: 8px; + padding: 0; + font-size: 12px; + color: ${$error ? colors.errorText : colors.controlLabel}; + transition: color ${transitions.main}; + `, +); + +const EditorControl = ({ + className, + clearFieldErrors, + clearMediaControl, + clearSearch, + collection, + config: configState, + entry, + field, + fieldsErrors, + submitted, + getAsset, + isDisabled, + isEditorComponent, + isFetching, + isFieldDuplicate, + isFieldHidden, + isHidden = false, + isNewEditorComponent, + loadEntry, + locale, + mediaPaths, + changeDraftFieldValidation, + addAsset, + addDraftEntryMediaFile, + openMediaLibrary, + parentPath, + query, + removeInsertedMedia, + removeMediaControl, + t, + value, + forList = false, + changeDraftField, + i18n, +}: TranslatedProps) => { + const widgetName = field.widget; + const widget = resolveWidget(widgetName) as Widget; + const fieldHint = field.hint; + + const path = useMemo( + () => (parentPath.length > 0 ? `${parentPath}.${field.name}` : field.name), + [field.name, parentPath], + ); + + const [dirty, setDirty] = useState(!isEmpty(value)); + const errors = useMemo(() => fieldsErrors[path] ?? [], [fieldsErrors, path]); + const hasErrors = (submitted || dirty) && Boolean(errors.length); + + const handleGetAsset = useCallback( + (collection: Collection, entry: Entry): GetAssetFunction => + (path: string, field?: Field) => { + return getAsset(collection, entry, path, field); + }, + [getAsset], + ); + + useEffect(() => { + const validateValue = async () => { + await validate(path, field, value, widget, changeDraftFieldValidation, t); + }; + + validateValue(); + }, [field, value, changeDraftFieldValidation, path, t, widget, dirty]); + + const handleChangeDraftField = useCallback( + (value: ValueOrNestedValue) => { + setDirty(true); + changeDraftField({ path, field, value, entry, i18n }); + }, + [changeDraftField, entry, field, i18n, path], + ); + + const config = useMemo(() => configState.config, [configState.config]); + if (!collection || !entry || !config) { + return null; + } + + return ( + + <> + {React.createElement(widget.control, { + clearFieldErrors, + clearSearch, + collection, + config, + entry, + field, + fieldsErrors, + submitted, + getAsset: handleGetAsset(collection, entry), + isDisabled: isDisabled ?? false, + isEditorComponent: isEditorComponent ?? false, + isFetching, + isFieldDuplicate, + isFieldHidden, + isNewEditorComponent: isNewEditorComponent ?? false, + label: getFieldLabel(field, t), + loadEntry, + locale, + mediaPaths, + onChange: handleChangeDraftField, + clearMediaControl, + addAsset, + addDraftEntryMediaFile, + openMediaLibrary, + removeInsertedMedia, + removeMediaControl, + path, + query, + t, + value, + forList, + i18n, + hasErrors, + })} + {fieldHint && {fieldHint}} + {hasErrors ? ( + + {errors.map(error => { + return ( + error.message && + typeof error.message === 'string' && ( +
  • {error.message}
  • + ) + ); + })} +
    + ) : null} + +
    + ); +}; + +interface EditorControlOwnProps { + className?: string; + clearFieldErrors: EditorControlPaneProps['clearFieldErrors']; + field: Field; + fieldsErrors: FieldsErrors; + submitted: boolean; + isDisabled?: boolean; + isEditorComponent?: boolean; + isFieldDuplicate?: (field: Field) => boolean; + isFieldHidden?: (field: Field) => boolean; + isHidden?: boolean; + isNewEditorComponent?: boolean; + locale?: string; + parentPath: string; + value: ValueOrNestedValue; + forList?: boolean; + i18n: I18nSettings | undefined; +} + +function mapStateToProps(state: RootState, ownProps: EditorControlOwnProps) { + const { collections, entryDraft } = state; + const entry = entryDraft.entry; + const collection = entryDraft.entry ? collections[entryDraft.entry.collection] : null; + const isLoadingAsset = selectIsLoadingAsset(state.medias); + + async function loadEntry(collectionName: string, slug: string) { + const targetCollection = collections[collectionName]; + if (targetCollection) { + const loadedEntry = await tryLoadEntry(state, targetCollection, slug); + return loadedEntry; + } else { + throw new Error(`Can't find collection '${collectionName}'`); + } + } + + return { + ...ownProps, + mediaPaths: state.mediaLibrary.controlMedia, + isFetching: state.search.isFetching, + config: state.config, + entry, + collection, + isLoadingAsset, + loadEntry, + }; +} + +const mapDispatchToProps = { + changeDraftField: changeDraftFieldAction, + changeDraftFieldValidation: changeDraftFieldValidationAction, + addAsset: addAssetAction, + addDraftEntryMediaFile: addDraftEntryMediaFileAction, + openMediaLibrary: openMediaLibraryAction, + clearMediaControl: clearMediaControlAction, + removeMediaControl: removeMediaControlAction, + removeInsertedMedia: removeInsertedMediaAction, + query: queryAction, + clearSearch: clearSearchAction, + clearFieldErrors: clearFieldErrorsAction, + getAsset: getAssetAction, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); +export type EditorControlProps = ConnectedProps; + +export default connector(translate()(EditorControl) as ComponentType); diff --git a/src/components/Editor/EditorControlPane/EditorControlPane.js b/src/components/Editor/EditorControlPane/EditorControlPane.js deleted file mode 100644 index a71cefbd..00000000 --- a/src/components/Editor/EditorControlPane/EditorControlPane.js +++ /dev/null @@ -1,251 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { css } from '@emotion/react'; -import styled from '@emotion/styled'; - -import { buttons, colors, Dropdown, DropdownItem, StyledDropdownButton, text } from '../../../ui'; -import EditorControl from './EditorControl'; -import { - getI18nInfo, - getLocaleDataPath, - hasI18n, - isFieldDuplicate, - isFieldHidden, - isFieldTranslatable, -} from '../../../lib/i18n'; - -const ControlPaneContainer = styled.div` - max-width: 1200px; - margin: 0 auto; - padding-bottom: 16px; - font-size: 16px; -`; - -const LocaleButton = styled(StyledDropdownButton)` - ${buttons.button}; - ${buttons.medium}; - color: ${colors.controlLabel}; - background: ${colors.textFieldBorder}; - height: 100%; - - &:after { - top: 11px; - } -`; - -const LocaleButtonWrapper = styled.div` - display: flex; -`; - -const LocaleRowWrapper = styled.div` - display: flex; -`; - -const StyledDropdown = styled(Dropdown)` - width: max-content; - margin-top: 20px; - margin-bottom: 20px; - margin-right: 20px; -`; - -function LocaleDropdown({ locales, dropdownText, onLocaleChange }) { - return ( - { - return ( - - {dropdownText} - - ); - }} - > - {locales.map(l => ( - onLocaleChange(l)} - /> - ))} - - ); -} - -function getFieldValue({ field, entry, isTranslatable, locale }) { - if (field.get('meta')) { - return entry.getIn(['meta', field.get('name')]); - } - - if (isTranslatable) { - const dataPath = getLocaleDataPath(locale); - return entry.getIn([...dataPath, field.get('name')]); - } - - return entry.getIn(['data', field.get('name')]); -} - -export default class ControlPane extends React.Component { - state = { - selectedLocale: this.props.locale, - }; - - componentValidate = {}; - - controlRef(field, wrappedControl) { - if (!wrappedControl) return; - const name = field.get('name'); - - this.componentValidate[name] = - wrappedControl.innerWrappedControl?.validate || wrappedControl.validate; - } - - handleLocaleChange = val => { - this.setState({ selectedLocale: val }); - this.props.onLocaleChange(val); - }; - - copyFromOtherLocale = - ({ targetLocale }) => - async sourceLocale => { - if ( - !(await confirm({ - title: 'editor.editorControlPane.i18n.copyFromLocaleConfirmTitle', - body: { - key: 'editor.editorControlPane.i18n.copyFromLocaleConfirmBody', - options: { locale: sourceLocale.toUpperCase() }, - }, - })) - ) { - return; - } - const { entry, collection } = this.props; - const { locales, defaultLocale } = getI18nInfo(collection); - - const locale = this.state.selectedLocale; - const i18n = locales && { - currentLocale: locale, - locales, - defaultLocale, - }; - - this.props.fields.forEach(field => { - if (isFieldTranslatable(field, targetLocale, sourceLocale)) { - const copyValue = getFieldValue({ - field, - entry, - locale: sourceLocale, - isTranslatable: sourceLocale !== defaultLocale, - }); - this.props.onChange(field, copyValue, undefined, i18n); - } - }); - }; - - validate = async () => { - this.props.fields.forEach(field => { - if (field.get('widget') === 'hidden') return; - this.componentValidate[field.get('name')](); - }); - }; - - switchToDefaultLocale = () => { - if (hasI18n(this.props.collection)) { - const { defaultLocale } = getI18nInfo(this.props.collection); - return new Promise(resolve => this.setState({ selectedLocale: defaultLocale }, resolve)); - } else { - return Promise.resolve(); - } - }; - - render() { - const { collection, entry, fields, fieldsMetaData, fieldsErrors, onChange, onValidate, t } = - this.props; - - if (!collection || !fields) { - return null; - } - - if (entry.size === 0 || entry.get('partial') === true) { - return null; - } - - const { locales, defaultLocale } = getI18nInfo(collection); - const locale = this.state.selectedLocale; - const i18n = locales && { - currentLocale: locale, - locales, - defaultLocale, - }; - - return ( - - {locales && ( - - - l !== locale)} - dropdownText={t('editor.editorControlPane.i18n.copyFromLocale')} - onLocaleChange={this.copyFromOtherLocale({ targetLocale: locale, t })} - /> - - )} - {fields - .filter(f => f.get('widget') !== 'hidden') - .map((field, i) => { - const isTranslatable = isFieldTranslatable(field, locale, defaultLocale); - const isDuplicate = isFieldDuplicate(field, locale, defaultLocale); - const isHidden = isFieldHidden(field, locale, defaultLocale); - const key = i18n ? `${locale}_${i}` : i; - - return ( - { - onChange(field, newValue, newMetadata, i18n); - }} - onValidate={onValidate} - processControlRef={this.controlRef.bind(this)} - controlRef={this.controlRef} - entry={entry} - collection={collection} - isDisabled={isDuplicate} - isHidden={isHidden} - isFieldDuplicate={field => isFieldDuplicate(field, locale, defaultLocale)} - isFieldHidden={field => isFieldHidden(field, locale, defaultLocale)} - locale={locale} - /> - ); - })} - - ); - } -} - -ControlPane.propTypes = { - collection: ImmutablePropTypes.map.isRequired, - entry: ImmutablePropTypes.map.isRequired, - fields: ImmutablePropTypes.list.isRequired, - fieldsMetaData: ImmutablePropTypes.map.isRequired, - fieldsErrors: ImmutablePropTypes.map.isRequired, - onChange: PropTypes.func.isRequired, - onValidate: PropTypes.func.isRequired, - locale: PropTypes.string, -}; diff --git a/src/components/Editor/EditorControlPane/EditorControlPane.tsx b/src/components/Editor/EditorControlPane/EditorControlPane.tsx new file mode 100644 index 00000000..c97c1e78 --- /dev/null +++ b/src/components/Editor/EditorControlPane/EditorControlPane.tsx @@ -0,0 +1,248 @@ +import Button from '@mui/material/Button'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import { styled } from '@mui/material/styles'; +import get from 'lodash/get'; +import React, { useCallback, useMemo } from 'react'; +import { connect } from 'react-redux'; + +import { + changeDraftField as changeDraftFieldAction, + clearFieldErrors as clearFieldErrorsAction, +} from '../../../actions/entries'; +import confirm from '../../../components/UI/Confirm'; +import { + getI18nInfo, + getLocaleDataPath, + hasI18n, + isFieldDuplicate, + isFieldHidden, + isFieldTranslatable, +} from '../../../lib/i18n'; +import EditorControl from './EditorControl'; + +import type { ConnectedProps } from 'react-redux'; +import type { + Collection, + Entry, + Field, + FieldsErrors, + I18nSettings, + TranslatedProps, + ValueOrNestedValue, +} from '../../../interface'; +import type { RootState } from '../../../store'; + +const ControlPaneContainer = styled('div')` + max-width: 1000px; + width: 100%; + font-size: 16px; + display: flex; + flex-direction: column; + gap: 16px; +`; + +const LocaleRowWrapper = styled('div')` + display: flex; +`; + +interface LocaleDropdownProps { + locales: string[]; + dropdownText: string; + onLocaleChange: (locale: string) => void; +} + +const LocaleDropdown = ({ locales, dropdownText, onLocaleChange }: LocaleDropdownProps) => { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + const handleClose = useCallback(() => { + setAnchorEl(null); + }, []); + + return ( +
    + + + {locales.map(locale => ( + onLocaleChange(locale)}> + {locale} + + ))} + +
    + ); +}; + +function getFieldValue( + field: Field, + entry: Entry, + isTranslatable: boolean, + locale: string | undefined, +): ValueOrNestedValue { + if (isTranslatable && locale) { + const dataPath = getLocaleDataPath(locale); + return get(entry, [...dataPath, field.name]); + } + + return entry.data?.[field.name]; +} + +const EditorControlPane = ({ + collection, + entry, + fields, + fieldsErrors, + submitted, + changeDraftField, + locale, + onLocaleChange, + clearFieldErrors, + t, +}: TranslatedProps) => { + const i18n = useMemo(() => { + if (hasI18n(collection)) { + const { locales, defaultLocale } = getI18nInfo(collection); + return { + currentLocale: locale ?? locales[0], + locales, + defaultLocale, + } as I18nSettings; + } + + return undefined; + }, [collection, locale]); + + const copyFromOtherLocale = useCallback( + ({ targetLocale }: { targetLocale?: string }) => + async (sourceLocale: string) => { + if (!targetLocale) { + return; + } + + if ( + !(await confirm({ + title: 'editor.editorControlPane.i18n.copyFromLocaleConfirmTitle', + body: { + key: 'editor.editorControlPane.i18n.copyFromLocaleConfirmBody', + options: { locale: sourceLocale.toUpperCase() }, + }, + })) + ) { + return; + } + + fields.forEach(field => { + if (isFieldTranslatable(field, targetLocale, sourceLocale)) { + const copyValue = getFieldValue( + field, + entry, + sourceLocale !== i18n?.defaultLocale, + sourceLocale, + ); + changeDraftField({ path: field.name, field, value: copyValue, entry, i18n }); + } + }); + }, + [fields, entry, i18n, changeDraftField], + ); + + if (!collection || !fields) { + return null; + } + + if (!entry || entry.partial === true) { + return null; + } + + return ( + + {i18n?.locales && locale ? ( + + + l !== locale)} + dropdownText={t('editor.editorControlPane.i18n.copyFromLocale')} + onLocaleChange={copyFromOtherLocale({ targetLocale: locale })} + /> + + ) : null} + {fields + .filter(f => f.widget !== 'hidden') + .map((field, i) => { + const isTranslatable = isFieldTranslatable(field, locale, i18n?.defaultLocale); + const isDuplicate = isFieldDuplicate(field, locale, i18n?.defaultLocale); + const isHidden = isFieldHidden(field, locale, i18n?.defaultLocale); + const key = i18n ? `${locale}_${i}` : i; + + return ( + isFieldDuplicate(field, locale, i18n?.defaultLocale)} + isFieldHidden={field => isFieldHidden(field, locale, i18n?.defaultLocale)} + locale={locale} + clearFieldErrors={clearFieldErrors} + parentPath="" + i18n={i18n} + /> + ); + })} + + ); +}; + +export interface EditorControlPaneOwnProps { + collection: Collection; + entry: Entry; + fields: Field[]; + fieldsErrors: FieldsErrors; + submitted: boolean; + locale?: string; + onLocaleChange: (locale: string) => void; +} + +function mapStateToProps(_state: RootState, ownProps: EditorControlPaneOwnProps) { + return { + ...ownProps, + }; +} + +const mapDispatchToProps = { + changeDraftField: changeDraftFieldAction, + clearFieldErrors: clearFieldErrorsAction, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); +export type EditorControlPaneProps = ConnectedProps; + +export default connector(EditorControlPane); diff --git a/src/components/Editor/EditorControlPane/Widget.js b/src/components/Editor/EditorControlPane/Widget.js deleted file mode 100644 index 3bbaebc7..00000000 --- a/src/components/Editor/EditorControlPane/Widget.js +++ /dev/null @@ -1,339 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { Map, List } from 'immutable'; - -import { getRemarkPlugins } from '../../../lib/registry'; -import ValidationErrorTypes from '../../../constants/validationErrorTypes'; - -function isEmpty(value) { - return ( - value === null || - value === undefined || - (Object.prototype.hasOwnProperty.call(value, 'length') && value.length === 0) || - (value.constructor === Object && Object.keys(value).length === 0) || - (List.isList(value) && value.size === 0) - ); -} - -export default class Widget extends Component { - static propTypes = { - controlComponent: PropTypes.func.isRequired, - validator: PropTypes.func, - field: ImmutablePropTypes.map.isRequired, - hasActiveStyle: PropTypes.bool, - setActiveStyle: PropTypes.func.isRequired, - setInactiveStyle: PropTypes.func.isRequired, - classNameWrapper: PropTypes.string.isRequired, - classNameWidget: PropTypes.string.isRequired, - classNameWidgetActive: PropTypes.string.isRequired, - classNameLabel: PropTypes.string.isRequired, - classNameLabelActive: PropTypes.string.isRequired, - value: PropTypes.oneOfType([ - PropTypes.node, - PropTypes.object, - PropTypes.string, - PropTypes.bool, - ]), - mediaPaths: ImmutablePropTypes.map.isRequired, - metadata: ImmutablePropTypes.map, - fieldsErrors: ImmutablePropTypes.map, - onChange: PropTypes.func.isRequired, - onValidate: PropTypes.func, - onOpenMediaLibrary: PropTypes.func.isRequired, - onClearMediaControl: PropTypes.func.isRequired, - onRemoveMediaControl: PropTypes.func.isRequired, - onPersistMedia: PropTypes.func.isRequired, - onAddAsset: PropTypes.func.isRequired, - onRemoveInsertedMedia: PropTypes.func.isRequired, - getAsset: PropTypes.func.isRequired, - resolveWidget: PropTypes.func.isRequired, - widget: PropTypes.object.isRequired, - getEditorComponents: PropTypes.func.isRequired, - isFetching: PropTypes.bool, - controlRef: PropTypes.func, - query: PropTypes.func.isRequired, - clearSearch: PropTypes.func.isRequired, - clearFieldErrors: PropTypes.func.isRequired, - queryHits: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), - editorControl: PropTypes.elementType.isRequired, - uniqueFieldId: PropTypes.string.isRequired, - loadEntry: PropTypes.func.isRequired, - t: PropTypes.func.isRequired, - onValidateObject: PropTypes.func, - isEditorComponent: PropTypes.bool, - isNewEditorComponent: PropTypes.bool, - entry: ImmutablePropTypes.map.isRequired, - isDisabled: PropTypes.bool, - isFieldDuplicate: PropTypes.func, - isFieldHidden: PropTypes.func, - locale: PropTypes.string, - }; - - shouldComponentUpdate(nextProps) { - /** - * Allow widgets to provide their own `shouldComponentUpdate` method. - */ - if (this.wrappedControlShouldComponentUpdate) { - return this.wrappedControlShouldComponentUpdate(nextProps); - } - return ( - this.props.value !== nextProps.value || - this.props.classNameWrapper !== nextProps.classNameWrapper || - this.props.hasActiveStyle !== nextProps.hasActiveStyle - ); - } - - processInnerControlRef = ref => { - if (!ref) return; - - /** - * If the widget is a container that receives state updates from the store, - * we'll need to get the ref of the actual control via the `react-redux` - * `getWrappedInstance` method. Note that connected widgets must pass - * `withRef: true` to `connect` in the options object. - */ - this.innerWrappedControl = ref.getWrappedInstance ? ref.getWrappedInstance() : ref; - - /** - * Get the `shouldComponentUpdate` method from the wrapped control, and - * provide the control instance is the `this` binding. - */ - const { shouldComponentUpdate: scu } = this.innerWrappedControl; - this.wrappedControlShouldComponentUpdate = scu && scu.bind(this.innerWrappedControl); - }; - - getValidateValue = () => { - let value = this.innerWrappedControl?.getValidateValue?.() || this.props.value; - // Convert list input widget value to string for validation test - List.isList(value) && (value = value.join(',')); - return value; - }; - - validate = (skipWrapped = false) => { - const value = this.getValidateValue(); - const field = this.props.field; - const errors = []; - const validations = [this.validatePresence, this.validatePattern]; - if (field.get('meta')) { - validations.push(this.props.validateMetaField); - } - validations.forEach(func => { - const response = func(field, value, this.props.t); - if (response.error) errors.push(response.error); - }); - if (skipWrapped) { - if (skipWrapped.error) errors.push(skipWrapped.error); - } else { - const wrappedError = this.validateWrappedControl(field); - if (wrappedError.error) errors.push(wrappedError.error); - } - - this.props.onValidate(errors); - }; - - validatePresence = (field, value) => { - const { t, parentIds } = this.props; - const isRequired = field.get('required', true); - if (isRequired && isEmpty(value)) { - const error = { - type: ValidationErrorTypes.PRESENCE, - parentIds, - message: t('editor.editorControlPane.widget.required', { - fieldLabel: field.get('label', field.get('name')), - }), - }; - - return { error }; - } - return { error: false }; - }; - - validatePattern = (field, value) => { - const { t, parentIds } = this.props; - const pattern = field.get('pattern', false); - - if (isEmpty(value)) { - return { error: false }; - } - - if (pattern && !RegExp(pattern.first()).test(value)) { - const error = { - type: ValidationErrorTypes.PATTERN, - parentIds, - message: t('editor.editorControlPane.widget.regexPattern', { - fieldLabel: field.get('label', field.get('name')), - pattern: pattern.last(), - }), - }; - - return { error }; - } - - return { error: false }; - }; - - validateWrappedControl = field => { - const { t, parentIds, validator, value } = this.props; - const response = validator?.({ value, field, t }); - if (response !== undefined) { - if (typeof response === 'boolean') { - return { error: !response }; - } else if (Object.prototype.hasOwnProperty.call(response, 'error')) { - return response; - } else if (response instanceof Promise) { - response.then( - () => { - this.validate({ error: false }); - }, - err => { - const error = { - type: ValidationErrorTypes.CUSTOM, - message: `${field.get('label', field.get('name'))} - ${err}.`, - }; - - this.validate({ error }); - }, - ); - - const error = { - type: ValidationErrorTypes.CUSTOM, - parentIds, - message: t('editor.editorControlPane.widget.processing', { - fieldLabel: field.get('label', field.get('name')), - }), - }; - - return { error }; - } - } - return { error: false }; - }; - - /** - * In case the `onChangeObject` function is frozen by a child widget implementation, - * e.g. when debounced, always get the latest object value instead of using - * `this.props.value` directly. - */ - getObjectValue = () => this.props.value || Map(); - - /** - * Change handler for fields that are nested within another field. - */ - onChangeObject = (field, newValue, newMetadata) => { - const newObjectValue = this.getObjectValue().set(field.get('name'), newValue); - return this.props.onChange( - newObjectValue, - newMetadata && { [this.props.field.get('name')]: newMetadata }, - ); - }; - - setInactiveStyle = () => { - this.props.setInactiveStyle(); - if (this.props.field.has('pattern') && !isEmpty(this.getValidateValue())) { - this.validate(); - } - }; - - render() { - const { - controlComponent, - entry, - collection, - config, - field, - value, - mediaPaths, - metadata, - onChange, - onValidateObject, - onOpenMediaLibrary, - onRemoveMediaControl, - onPersistMedia, - onClearMediaControl, - onAddAsset, - onRemoveInsertedMedia, - getAsset, - classNameWrapper, - classNameWidget, - classNameWidgetActive, - classNameLabel, - classNameLabelActive, - setActiveStyle, - hasActiveStyle, - editorControl, - uniqueFieldId, - resolveWidget, - widget, - getEditorComponents, - query, - queryHits, - clearSearch, - clearFieldErrors, - isFetching, - loadEntry, - fieldsErrors, - controlRef, - isEditorComponent, - isNewEditorComponent, - parentIds, - t, - isDisabled, - isFieldDuplicate, - isFieldHidden, - locale, - } = this.props; - - return React.createElement(controlComponent, { - entry, - collection, - config, - field, - value, - mediaPaths, - metadata, - onChange, - onChangeObject: this.onChangeObject, - onValidateObject, - onOpenMediaLibrary, - onClearMediaControl, - onRemoveMediaControl, - onPersistMedia, - onAddAsset, - onRemoveInsertedMedia, - getAsset, - forID: uniqueFieldId, - ref: this.processInnerControlRef, - validate: this.validate, - classNameWrapper, - classNameWidget, - classNameWidgetActive, - classNameLabel, - classNameLabelActive, - setActiveStyle, - setInactiveStyle: () => this.setInactiveStyle(), - hasActiveStyle, - editorControl, - resolveWidget, - widget, - getEditorComponents, - getRemarkPlugins, - query, - queryHits, - clearSearch, - clearFieldErrors, - isFetching, - loadEntry, - isEditorComponent, - isNewEditorComponent, - fieldsErrors, - controlRef, - parentIds, - t, - isDisabled, - isFieldDuplicate, - isFieldHidden, - locale, - }); - } -} diff --git a/src/components/Editor/EditorInterface.js b/src/components/Editor/EditorInterface.js deleted file mode 100644 index 58b2bb1e..00000000 --- a/src/components/Editor/EditorInterface.js +++ /dev/null @@ -1,395 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { css, Global } from '@emotion/react'; -import styled from '@emotion/styled'; -import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync'; - -import { - colors, - colorsRaw, - components, - transitions, - IconButton, - zIndex, -} from '../../ui'; -import EditorControlPane from './EditorControlPane/EditorControlPane'; -import EditorPreviewPane from './EditorPreviewPane/EditorPreviewPane'; -import EditorToolbar from './EditorToolbar'; -import { hasI18n, getI18nInfo, getPreviewEntry } from '../../lib/i18n'; -import { FILES } from '../../constants/collectionTypes'; -import { getFileFromSlug } from '../../reducers/collections'; - -const PREVIEW_VISIBLE = 'cms.preview-visible'; -const I18N_VISIBLE = 'cms.i18n-visible'; - -const styles = { - splitPane: css` - ${components.card}; - border-radius: 0; - height: 100%; - `, - pane: css` - height: 100%; - `, -}; - -const EditorToggle = styled(IconButton)` - margin-bottom: 12px; -`; - -function ReactSplitPaneGlobalStyles() { - return ( - - ); -} - -const StyledSplitPane = styled.div` - display: grid; - grid-template-columns: min(864px, 50%) auto;; - height: 100%; - - > div:nth-child(2)::before { - content: ''; - width: 2px; - height: 100%; - position: relative; - background-color: rgb(223, 223, 227); - display: block; - } -`; - -const NoPreviewContainer = styled.div` - ${styles.splitPane}; -`; - -const EditorContainer = styled.div` - width: 100%; - min-width: 1200px; - height: 100%; - position: absolute; - top: 0; - left: 0; - overflow: hidden; - padding-top: 66px; -`; - -const Editor = styled.div` - height: 100%; - margin: 0 auto; - position: relative; - background-color: ${colorsRaw.white}; -`; - -const PreviewPaneContainer = styled.div` - height: 100%; - pointer-events: ${props => (props.blockEntry ? 'none' : 'auto')}; - overflow-y: ${props => (props.overFlow ? 'auto' : 'hidden')}; -`; - -const ControlPaneContainer = styled(PreviewPaneContainer)` - padding: 0 16px; - position: relative; - overflow-x: hidden; -`; - -const ViewControls = styled.div` - position: fixed; - bottom: 3px; - right: 12px; - z-index: ${zIndex.zIndex299}; -`; - -function EditorContent({ - i18nVisible, - previewVisible, - editor, - editorWithEditor, - editorWithPreview, -}) { - if (i18nVisible) { - return editorWithEditor; - } else if (previewVisible) { - return editorWithPreview; - } else { - return {editor}; - } -} - -function isPreviewEnabled(collection, entry) { - if (collection.get('type') === FILES) { - const file = getFileFromSlug(collection, entry.get('slug')); - const previewEnabled = file?.getIn(['editor', 'preview']); - if (previewEnabled != null) return previewEnabled; - } - return collection.getIn(['editor', 'preview'], true); -} - -class EditorInterface extends Component { - state = { - previewVisible: localStorage.getItem(PREVIEW_VISIBLE) !== 'false', - i18nVisible: localStorage.getItem(I18N_VISIBLE) !== 'false', - }; - - constructor(props) { - super(props); - - this.props.loadScroll(); - } - - handleOnPersist = async (opts = {}) => { - const { createNew = false, duplicate = false } = opts; - await this.controlPaneRef.switchToDefaultLocale(); - this.controlPaneRef.validate(); - this.props.onPersist({ createNew, duplicate }); - }; - - handleOnPublish = async (opts = {}) => { - const { createNew = false, duplicate = false } = opts; - await this.controlPaneRef.switchToDefaultLocale(); - this.controlPaneRef.validate(); - this.props.onPublish({ createNew, duplicate }); - }; - - handleTogglePreview = () => { - const newPreviewVisible = !this.state.previewVisible; - this.setState({ previewVisible: newPreviewVisible }); - localStorage.setItem(PREVIEW_VISIBLE, newPreviewVisible); - }; - - handleToggleScrollSync = () => { - const { toggleScroll } = this.props; - toggleScroll(); - }; - - handleToggleI18n = () => { - const newI18nVisible = !this.state.i18nVisible; - this.setState({ i18nVisible: newI18nVisible }); - localStorage.setItem(I18N_VISIBLE, newI18nVisible); - }; - - handleLeftPanelLocaleChange = locale => { - this.setState({ leftPanelLocale: locale }); - }; - - render() { - const { - collection, - entry, - fields, - fieldsMetaData, - fieldsErrors, - onChange, - showDelete, - onDelete, - onChangeStatus, - onPublish, - onDuplicate, - onValidate, - user, - hasChanged, - displayUrl, - isNewEntry, - isModification, - currentStatus, - onLogoutClick, - draftKey, - editorBackLink, - scrollSyncEnabled, - t, - } = this.props; - - const previewEnabled = isPreviewEnabled(collection, entry); - - const collectionI18nEnabled = hasI18n(collection); - const { locales, defaultLocale } = getI18nInfo(this.props.collection); - const editorProps = { - collection, - entry, - fields, - fieldsMetaData, - fieldsErrors, - onChange, - onValidate, - }; - - const leftPanelLocale = this.state.leftPanelLocale || locales?.[0]; - const editor = ( - - (this.controlPaneRef = c)} - locale={leftPanelLocale} - t={t} - onLocaleChange={this.handleLeftPanelLocaleChange} - /> - - ); - - const editor2 = ( - - - - ); - - const previewEntry = collectionI18nEnabled - ? getPreviewEntry(entry, leftPanelLocale, defaultLocale) - : entry; - - const editorWithPreview = ( - <> - - - {editor} - - - - - - ); - - const editorWithEditor = ( - -
    - - {editor} - {editor2} - -
    -
    - ); - - const i18nVisible = collectionI18nEnabled && this.state.i18nVisible; - const previewVisible = previewEnabled && this.state.previewVisible; - const scrollSyncVisible = i18nVisible || previewVisible; - - return ( - - this.handleOnPersist({ createNew: true })} - onPersistAndDuplicate={() => this.handleOnPersist({ createNew: true, duplicate: true })} - onDelete={onDelete} - onChangeStatus={onChangeStatus} - showDelete={showDelete} - onPublish={onPublish} - onDuplicate={onDuplicate} - onPublishAndNew={() => this.handleOnPublish({ createNew: true })} - onPublishAndDuplicate={() => this.handleOnPublish({ createNew: true, duplicate: true })} - user={user} - hasChanged={hasChanged} - displayUrl={displayUrl} - collection={collection} - isNewEntry={isNewEntry} - isModification={isModification} - currentStatus={currentStatus} - onLogoutClick={onLogoutClick} - editorBackLink={editorBackLink} - /> - - - {collectionI18nEnabled && ( - - )} - {previewEnabled && ( - - )} - {scrollSyncVisible && ( - - )} - - - - - ); - } -} - -EditorInterface.propTypes = { - collection: ImmutablePropTypes.map.isRequired, - entry: ImmutablePropTypes.map.isRequired, - fields: ImmutablePropTypes.list.isRequired, - fieldsMetaData: ImmutablePropTypes.map.isRequired, - fieldsErrors: ImmutablePropTypes.map.isRequired, - onChange: PropTypes.func.isRequired, - onValidate: PropTypes.func.isRequired, - onPersist: PropTypes.func.isRequired, - showDelete: PropTypes.bool.isRequired, - onDelete: PropTypes.func.isRequired, - onPublish: PropTypes.func.isRequired, - onDuplicate: PropTypes.func.isRequired, - onChangeStatus: PropTypes.func.isRequired, - user: PropTypes.object, - hasChanged: PropTypes.bool, - displayUrl: PropTypes.string, - isNewEntry: PropTypes.bool, - isModification: PropTypes.bool, - currentStatus: PropTypes.string, - onLogoutClick: PropTypes.func.isRequired, - draftKey: PropTypes.string.isRequired, - toggleScroll: PropTypes.func.isRequired, - scrollSyncEnabled: PropTypes.bool.isRequired, - loadScroll: PropTypes.func.isRequired, - t: PropTypes.func.isRequired, -}; - -export default EditorInterface; diff --git a/src/components/Editor/EditorInterface.tsx b/src/components/Editor/EditorInterface.tsx new file mode 100644 index 00000000..bc8d50ef --- /dev/null +++ b/src/components/Editor/EditorInterface.tsx @@ -0,0 +1,363 @@ +import HeightIcon from '@mui/icons-material/Height'; +import LanguageIcon from '@mui/icons-material/Language'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import Fab from '@mui/material/Fab'; +import { styled } from '@mui/material/styles'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { ScrollSync, ScrollSyncPane } from 'react-scroll-sync'; + +import { colorsRaw, components, zIndex } from '../../components/UI/styles'; +import { FILES } from '../../constants/collectionTypes'; +import { transientOptions } from '../../lib'; +import { getI18nInfo, getPreviewEntry, hasI18n } from '../../lib/i18n'; +import { getFileFromSlug } from '../../lib/util/collection.util'; +import EditorControlPane from './EditorControlPane/EditorControlPane'; +import EditorPreviewPane from './EditorPreviewPane/EditorPreviewPane'; +import EditorToolbar from './EditorToolbar'; + +import type { + Collection, + EditorPersistOptions, + Entry, + Field, + FieldsErrors, + TranslatedProps, + User, +} from '../../interface'; + +const PREVIEW_VISIBLE = 'cms.preview-visible'; +const I18N_VISIBLE = 'cms.i18n-visible'; + +const StyledSplitPane = styled('div')` + display: grid; + grid-template-columns: min(864px, 50%) auto; + height: calc(100vh - 64px); + + > div:nth-of-type(2)::before { + content: ''; + width: 2px; + height: calc(100vh - 64px); + position: relative; + background-color: rgb(223, 223, 227); + display: block; + } +`; + +const NoPreviewContainer = styled('div')` + ${components.card}; + border-radius: 0; + height: 100%; +`; + +const EditorContainer = styled('div')` + width: 100%; + min-width: 1200px; + height: 100vh; + overflow: hidden; +`; + +const Editor = styled('div')` + height: calc(100vh - 64px); + position: relative; + background-color: ${colorsRaw.white}; + overflow-y: auto; +`; + +interface PreviewPaneContainerProps { + $blockEntry?: boolean; + $overFlow?: boolean; +} + +const PreviewPaneContainer = styled( + 'div', + transientOptions, +)( + ({ $blockEntry, $overFlow }) => ` + height: 100%; + pointer-events: ${$blockEntry ? 'none' : 'auto'}; + overflow-y: ${$overFlow ? 'auto' : 'hidden'}; + `, +); + +const ControlPaneContainer = styled(PreviewPaneContainer)` + padding: 24px 16px 16px; + position: relative; + overflow-x: hidden; + display: flex; + align-items: flex-start; + justify-content: center; +`; + +const StyledViewControls = styled('div')` + position: fixed; + bottom: 4px; + right: 8px; + z-index: ${zIndex.zIndex299}; + display: flex; + flex-direction: column; + gap: 4px; +`; + +interface EditorContentProps { + i18nVisible: boolean; + previewVisible: boolean; + editor: JSX.Element; + editorSideBySideLocale: JSX.Element; + editorWithPreview: JSX.Element; +} + +const EditorContent = ({ + i18nVisible, + previewVisible, + editor, + editorSideBySideLocale, + editorWithPreview, +}: EditorContentProps) => { + if (i18nVisible) { + return editorSideBySideLocale; + } else if (previewVisible) { + return editorWithPreview; + } else { + return {editor}; + } +}; + +function isPreviewEnabled(collection: Collection, entry: Entry) { + if (collection.type === FILES) { + const file = getFileFromSlug(collection, entry.slug); + const previewEnabled = file?.editor?.preview ?? false; + if (previewEnabled) { + return previewEnabled; + } + } + return collection.editor?.preview ?? true; +} + +interface EditorInterfaceProps { + draftKey: string; + entry: Entry; + collection: Collection; + fields: Field[] | undefined; + fieldsErrors: FieldsErrors; + onPersist: (opts?: EditorPersistOptions) => Promise; + onDelete: () => Promise; + onDuplicate: () => void; + showDelete: boolean; + user: User | undefined; + hasChanged: boolean; + displayUrl: string | undefined; + isNewEntry: boolean; + isModification: boolean; + onLogoutClick: () => void; + editorBackLink: string; + toggleScroll: () => Promise<{ readonly type: 'TOGGLE_SCROLL' }>; + scrollSyncEnabled: boolean; + loadScroll: () => void; + submitted: boolean; +} + +const EditorInterface = ({ + collection, + entry, + fields = [], + fieldsErrors, + showDelete, + onDelete, + onDuplicate, + onPersist, + user, + hasChanged, + displayUrl, + isNewEntry, + isModification, + onLogoutClick, + draftKey, + editorBackLink, + scrollSyncEnabled, + t, + loadScroll, + toggleScroll, + submitted, +}: TranslatedProps) => { + const [previewVisible, setPreviewVisible] = useState( + localStorage.getItem(PREVIEW_VISIBLE) !== 'false', + ); + const [i18nVisible, setI18nVisible] = useState(localStorage.getItem(I18N_VISIBLE) !== 'false'); + + useEffect(() => { + loadScroll(); + }, [loadScroll]); + + const { locales, defaultLocale } = useMemo(() => getI18nInfo(collection), [collection]) ?? {}; + const [selectedLocale, setSelectedLocale] = useState(locales?.[0]); + const switchToDefaultLocale = useCallback(() => { + if (hasI18n(collection)) { + const { defaultLocale } = getI18nInfo(collection); + setSelectedLocale(defaultLocale); + } + }, [collection]); + + const handleOnPersist = useCallback( + async (opts: EditorPersistOptions = {}) => { + const { createNew = false, duplicate = false } = opts; + await switchToDefaultLocale(); + // TODO Trigger field validation on persist + // this.controlPaneRef.validate(); + onPersist({ createNew, duplicate }); + }, + [onPersist, switchToDefaultLocale], + ); + + const handleTogglePreview = useCallback(() => { + const newPreviewVisible = !previewVisible; + setPreviewVisible(newPreviewVisible); + localStorage.setItem(PREVIEW_VISIBLE, `${newPreviewVisible}`); + }, [previewVisible]); + + const handleToggleScrollSync = useCallback(() => { + toggleScroll(); + }, [toggleScroll]); + + const handleToggleI18n = useCallback(() => { + const newI18nVisible = !i18nVisible; + setI18nVisible(newI18nVisible); + localStorage.setItem(I18N_VISIBLE, `${newI18nVisible}`); + }, [i18nVisible]); + + const handleLocaleChange = useCallback((locale: string) => { + setSelectedLocale(locale); + }, []); + + const previewEnabled = isPreviewEnabled(collection, entry); + + const collectionI18nEnabled = hasI18n(collection); + + const editor = ( + + + + ); + + const editorLocale = ( + + + + ); + + const previewEntry = collectionI18nEnabled + ? getPreviewEntry(entry, selectedLocale, defaultLocale) + : entry; + + const editorWithPreview = ( + <> + + {editor} + + + + + + ); + + const editorSideBySideLocale = ( + +
    + + {editor} + {editorLocale} + +
    +
    + ); + + const finalI18nVisible = collectionI18nEnabled && i18nVisible; + const finalPreviewVisible = previewEnabled && previewVisible; + const scrollSyncVisible = finalI18nVisible || finalPreviewVisible; + + return ( + + handleOnPersist({ createNew: true })} + onPersistAndDuplicate={() => handleOnPersist({ createNew: true, duplicate: true })} + onDelete={onDelete} + showDelete={showDelete} + onDuplicate={onDuplicate} + user={user} + hasChanged={hasChanged} + displayUrl={displayUrl} + collection={collection} + isNewEntry={isNewEntry} + isModification={isModification} + onLogoutClick={onLogoutClick} + editorBackLink={editorBackLink} + /> + + + {collectionI18nEnabled && ( + + + + )} + {previewEnabled && ( + + + + )} + {scrollSyncVisible && ( + + + + )} + + + + + ); +}; + +export default EditorInterface; diff --git a/src/components/Editor/EditorPreviewPane/EditorPreview.js b/src/components/Editor/EditorPreviewPane/EditorPreview.js deleted file mode 100644 index ffced1cc..00000000 --- a/src/components/Editor/EditorPreviewPane/EditorPreview.js +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import styled from '@emotion/styled'; - -function isVisible(field) { - return field.get('widget') !== 'hidden'; -} - -const PreviewContainer = styled.div` - overflow-y: auto; - height: 100%; - padding: 24px; - box-sizing: border-box; - font-family: Roboto, 'Helvetica Neue', HelveticaNeue, Helvetica, Arial, sans-serif; -`; - -/** - * Use a stateful component so that child components can effectively utilize - * `shouldComponentUpdate`. - */ -export default class Preview extends React.Component { - render() { - const { collection, fields, widgetFor } = this.props; - if (!collection || !fields) { - return null; - } - return ( - - {fields.filter(isVisible).map(field => ( -
    {widgetFor(field.get('name'))}
    - ))} -
    - ); - } -} - -Preview.propTypes = { - collection: ImmutablePropTypes.map.isRequired, - entry: ImmutablePropTypes.map.isRequired, - fields: ImmutablePropTypes.list.isRequired, - getAsset: PropTypes.func.isRequired, - widgetFor: PropTypes.func.isRequired, -}; diff --git a/src/components/Editor/EditorPreviewPane/EditorPreview.tsx b/src/components/Editor/EditorPreviewPane/EditorPreview.tsx new file mode 100644 index 00000000..13a08a99 --- /dev/null +++ b/src/components/Editor/EditorPreviewPane/EditorPreview.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { styled } from '@mui/material/styles'; + +import type { Field, TemplatePreviewProps } from '../../../interface'; + +function isVisible(field: Field) { + return field.widget !== 'hidden'; +} + +const PreviewContainer = styled('div')` + overflow-y: auto; + height: 100%; + padding: 24px; + font-family: Roboto, 'Helvetica Neue', HelveticaNeue, Helvetica, Arial, sans-serif; +`; + +const Preview = ({ collection, fields, widgetFor }: TemplatePreviewProps) => { + if (!collection || !fields) { + return null; + } + + return ( + + {fields.filter(isVisible).map(field => ( +
    {widgetFor(field.name)}
    + ))} +
    + ); +}; + +export default Preview; diff --git a/src/components/Editor/EditorPreviewPane/EditorPreviewContent.tsx b/src/components/Editor/EditorPreviewPane/EditorPreviewContent.tsx index 8fe533d2..e73265a4 100644 --- a/src/components/Editor/EditorPreviewPane/EditorPreviewContent.tsx +++ b/src/components/Editor/EditorPreviewPane/EditorPreviewContent.tsx @@ -1,26 +1,24 @@ -/* eslint-disable @typescript-eslint/consistent-type-imports */ -/* eslint-disable func-style */ -import styled from '@emotion/styled'; -import React, { ComponentType, ReactNode, useMemo } from 'react'; +import { styled } from '@mui/material/styles'; +import React, { useMemo } from 'react'; import ReactDOM from 'react-dom'; import { ScrollSyncPane } from 'react-scroll-sync'; -import type { CmsWidgetPreviewProps } from '../../../interface'; +import type { TemplatePreviewComponent, TemplatePreviewProps } from '../../../interface'; +import type { ReactNode } from 'react'; interface PreviewContentProps { - previewComponent?: - | React.ReactElement> - | ComponentType; - previewProps: CmsWidgetPreviewProps; + previewComponent?: TemplatePreviewComponent; + previewProps: TemplatePreviewProps; } -const StyledPreviewContent = styled.div` +const StyledPreviewContent = styled('div')` width: calc(100% - min(864px, 50%)); - top: 66px; + top: 64px; right: 0; position: absolute; - height: calc(100% - 66px); + height: calc(100vh - 64px); overflow-y: auto; + padding: 16px; `; const PreviewContent = ({ previewComponent, previewProps }: PreviewContentProps) => { @@ -45,7 +43,7 @@ const PreviewContent = ({ previewComponent, previewProps }: PreviewContentProps) {children} , element, - 'preview-content' + 'preview-content', ); }, [previewComponent, previewProps, element]); }; diff --git a/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js b/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js deleted file mode 100644 index dc768e6e..00000000 --- a/src/components/Editor/EditorPreviewPane/EditorPreviewPane.js +++ /dev/null @@ -1,272 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import styled from '@emotion/styled'; -import { List, Map } from 'immutable'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { connect } from 'react-redux'; - -import { lengths } from '../../../ui'; -import { - resolveWidget, - getPreviewTemplate, - getPreviewStyles, - getRemarkPlugins, -} from '../../../lib/registry'; -import { ErrorBoundary } from '../../UI'; -import { selectTemplateName, selectInferedField, selectField } from '../../../reducers/collections'; -import { boundGetAsset } from '../../../actions/media'; -import { selectIsLoadingAsset } from '../../../reducers/medias'; -import { INFERABLE_FIELDS } from '../../../constants/fieldInference'; -import EditorPreviewContent from './EditorPreviewContent'; -import PreviewHOC from './PreviewHOC'; -import EditorPreview from './EditorPreview'; - -const PreviewPaneFrame = styled.div` - width: 100%; - height: 100%; - border: none; - background: #fff; - border-radius: ${lengths.borderRadius}; - overflow: auto; -`; - -export class PreviewPane extends React.Component { - getWidget = (field, value, metadata, props, idx = null) => { - const { getAsset, entry } = props; - const widget = resolveWidget(field.get('widget')); - const key = idx ? field.get('name') + '_' + idx : field.get('name'); - const valueIsInMap = value && !widget.allowMapValue && Map.isMap(value); - - /** - * Use an HOC to provide conditional updates for all previews. - */ - return !widget.preview ? null : ( - - ); - }; - - inferedFields = {}; - - inferFields() { - const titleField = selectInferedField(this.props.collection, 'title'); - const shortTitleField = selectInferedField(this.props.collection, 'shortTitle'); - const authorField = selectInferedField(this.props.collection, 'author'); - - this.inferedFields = {}; - if (titleField) this.inferedFields[titleField] = INFERABLE_FIELDS.title; - if (shortTitleField) this.inferedFields[shortTitleField] = INFERABLE_FIELDS.shortTitle; - if (authorField) this.inferedFields[authorField] = INFERABLE_FIELDS.author; - } - - /** - * Returns the widget component for a named field, and makes recursive calls - * to retrieve components for nested and deeply nested fields, which occur in - * object and list type fields. Used internally to retrieve widgets, and also - * exposed for use in custom preview templates. - */ - widgetFor = ( - name, - fields = this.props.fields, - values = this.props.entry.get('data'), - fieldsMetaData = this.props.fieldsMetaData, - ) => { - // We retrieve the field by name so that this function can also be used in - // custom preview templates, where the field object can't be passed in. - let field = fields && fields.find(f => f.get('name') === name); - let value = Map.isMap(values) && values.get(field.get('name')); - if (field.get('meta')) { - value = this.props.entry.getIn(['meta', field.get('name')]); - } - const nestedFields = field.get('fields'); - const singleField = field.get('field'); - const metadata = fieldsMetaData && fieldsMetaData.get(field.get('name'), Map()); - - if (nestedFields) { - field = field.set('fields', this.getNestedWidgets(nestedFields, value, metadata)); - } - - if (singleField) { - field = field.set('field', this.getSingleNested(singleField, value, metadata)); - } - - const labelledWidgets = ['string', 'text', 'number']; - const inferedField = Object.entries(this.inferedFields) - .filter(([key]) => { - const fieldToMatch = selectField(this.props.collection, key); - return fieldToMatch === field; - }) - .map(([, value]) => value)[0]; - - if (inferedField) { - value = inferedField.defaultPreview(value); - } else if ( - value && - labelledWidgets.indexOf(field.get('widget')) !== -1 && - value.toString().length < 50 - ) { - value = ( -
    - {field.get('label', field.get('name'))}: {value} -
    - ); - } - - return value ? this.getWidget(field, value, metadata, this.props) : null; - }; - - /** - * Retrieves widgets for nested fields (children of object/list fields) - */ - getNestedWidgets = (fields, values, fieldsMetaData) => { - // Fields nested within a list field will be paired with a List of value Maps. - if (List.isList(values)) { - return values.map(value => this.widgetsForNestedFields(fields, value, fieldsMetaData)); - } - // Fields nested within an object field will be paired with a single Map of values. - return this.widgetsForNestedFields(fields, values, fieldsMetaData); - }; - - getSingleNested = (field, values, fieldsMetaData) => { - if (List.isList(values)) { - return values.map((value, idx) => - this.getWidget(field, value, fieldsMetaData.get(field.get('name')), this.props, idx), - ); - } - return this.getWidget(field, values, fieldsMetaData.get(field.get('name')), this.props); - }; - - /** - * Use widgetFor as a mapping function for recursive widget retrieval - */ - widgetsForNestedFields = (fields, values, fieldsMetaData) => { - return fields.map(field => this.widgetFor(field.get('name'), fields, values, fieldsMetaData)); - }; - - /** - * This function exists entirely to expose nested widgets for object and list - * fields to custom preview templates. - * - * TODO: see if widgetFor can now provide this functionality for preview templates - */ - widgetsFor = name => { - const { fields, entry, fieldsMetaData } = this.props; - const field = fields.find(f => f.get('name') === name); - const nestedFields = field && field.get('fields'); - const value = entry.getIn(['data', field.get('name')]); - const metadata = fieldsMetaData.get(field.get('name'), Map()); - - if (List.isList(value)) { - return value.map(val => { - const widgets = - nestedFields && - Map( - nestedFields.map((f, i) => [ - f.get('name'), -
    {this.getWidget(f, val, metadata.get(f.get('name')), this.props)}
    , - ]), - ); - return Map({ data: val, widgets }); - }); - } - - return Map({ - data: value, - widgets: - nestedFields && - Map( - nestedFields.map(f => [ - f.get('name'), - this.getWidget(f, value, metadata.get(f.get('name')), this.props), - ]), - ), - }); - }; - - render() { - const { entry, collection, config } = this.props; - - if (!entry || !entry.get('data')) { - return null; - } - - const previewComponent = - getPreviewTemplate(selectTemplateName(collection, entry.get('slug'))) || EditorPreview; - - this.inferFields(); - - const previewProps = { - ...this.props, - widgetFor: this.widgetFor, - widgetsFor: this.widgetsFor, - }; - - const styleEls = getPreviewStyles().map((style, i) => { - if (style.raw) { - return ; - } - return ; - }); - - if (!collection) { - ; - } - - const initialContent = ` - - - -
    - -`; - - return ( - - - - - - ); - } -} - -PreviewPane.propTypes = { - collection: ImmutablePropTypes.map.isRequired, - fields: ImmutablePropTypes.list.isRequired, - entry: ImmutablePropTypes.map.isRequired, - fieldsMetaData: ImmutablePropTypes.map.isRequired, - getAsset: PropTypes.func.isRequired, -}; - -function mapStateToProps(state) { - const isLoadingAsset = selectIsLoadingAsset(state.medias); - return { isLoadingAsset, config: state.config }; -} - -function mapDispatchToProps(dispatch) { - return { - boundGetAsset: (collection, entry) => boundGetAsset(dispatch, collection, entry), - }; -} - -function mergeProps(stateProps, dispatchProps, ownProps) { - return { - ...stateProps, - ...dispatchProps, - ...ownProps, - getAsset: dispatchProps.boundGetAsset(ownProps.collection, ownProps.entry), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps, mergeProps)(PreviewPane); diff --git a/src/components/Editor/EditorPreviewPane/EditorPreviewPane.tsx b/src/components/Editor/EditorPreviewPane/EditorPreviewPane.tsx new file mode 100644 index 00000000..7d95f71b --- /dev/null +++ b/src/components/Editor/EditorPreviewPane/EditorPreviewPane.tsx @@ -0,0 +1,357 @@ +import { styled } from '@mui/material/styles'; +import React, { isValidElement, useCallback, useMemo } from 'react'; +import { connect } from 'react-redux'; + +import { getAsset as getAssetAction } from '../../../actions/media'; +import { lengths } from '../../../components/UI/styles'; +import { INFERABLE_FIELDS } from '../../../constants/fieldInference'; +import { getPreviewTemplate, getRemarkPlugins, resolveWidget } from '../../../lib/registry'; +import { selectInferedField, selectTemplateName } from '../../../lib/util/collection.util'; +import { selectField } from '../../../lib/util/field.util'; +import { selectIsLoadingAsset } from '../../../reducers/medias'; +import { ErrorBoundary } from '../../UI'; +import EditorPreview from './EditorPreview'; +import EditorPreviewContent from './EditorPreviewContent'; +import PreviewHOC from './PreviewHOC'; + +import type { ReactFragment, ReactNode } from 'react'; +import type { ConnectedProps } from 'react-redux'; +import type { InferredField } from '../../../constants/fieldInference'; +import type { + Field, + TemplatePreviewProps, + Collection, + Entry, + EntryData, + GetAssetFunction, + ValueOrNestedValue, +} from '../../../interface'; +import type { RootState } from '../../../store'; + +const PreviewPaneFrame = styled('div')` + width: 100%; + height: 100%; + border: none; + background: #fff; + border-radius: ${lengths.borderRadius}; + overflow: auto; +`; + +/** + * Returns the widget component for a named field, and makes recursive calls + * to retrieve components for nested and deeply nested fields, which occur in + * object and list type fields. Used internally to retrieve widgets, and also + * exposed for use in custom preview templates. + */ +function getWidgetFor( + collection: Collection, + name: string, + fields: Field[], + entry: Entry, + inferedFields: Record, + getAsset: GetAssetFunction, + widgetFields: Field[] = fields, + values: EntryData = entry.data, +): ReactNode { + // We retrieve the field by name so that this function can also be used in + // custom preview templates, where the field object can't be passed in. + const field = widgetFields && widgetFields.find(f => f.name === name); + if (!field) { + return null; + } + + const value = values?.[field.name]; + let fieldWithWidgets: Omit & { + fields?: ReactNode[]; + field?: ReactNode; + } = Object.entries(field).reduce((acc, [key, fieldValue]) => { + if (!['fields', 'fields'].includes(key)) { + acc[key] = fieldValue; + } + return acc; + }, {} as Record) as Omit; + + if ('fields' in field && field.fields) { + fieldWithWidgets = { + ...fieldWithWidgets, + fields: getNestedWidgets( + collection, + fields, + entry, + inferedFields, + getAsset, + field.fields, + value as EntryData | EntryData[], + ), + }; + } + + const labelledWidgets = ['string', 'text', 'number']; + const inferedField = Object.entries(inferedFields) + .filter(([key]) => { + const fieldToMatch = selectField(collection, key); + return fieldToMatch === fieldWithWidgets; + }) + .map(([, value]) => value)[0]; + + let renderedValue: ValueOrNestedValue | ReactNode = value; + if (inferedField) { + renderedValue = inferedField.defaultPreview(String(value)); + } else if ( + value && + fieldWithWidgets.widget && + labelledWidgets.indexOf(fieldWithWidgets.widget) !== -1 && + value.toString().length < 50 + ) { + renderedValue = ( +
    + <> + {field.label ?? field.name}: {value} + +
    + ); + } + return renderedValue ? getWidget(field, renderedValue, entry, getAsset) : null; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isJsxElement(value: any): value is JSX.Element { + return isValidElement(value); +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function isReactFragment(value: any): value is ReactFragment { + if (value.type) { + return value.type === React.Fragment; + } + + return value === React.Fragment; +} + +function getWidget( + field: Field, + value: ValueOrNestedValue | ReactNode, + entry: Entry, + getAsset: GetAssetFunction, + idx: number | null = null, +) { + if (!field.widget) { + return null; + } + + const widget = resolveWidget(field.widget); + const key = idx ? field.name + '_' + idx : field.name; + + /** + * Use an HOC to provide conditional updates for all previews. + */ + return !widget.preview ? null : ( + )[field.name] + : value + } + entry={entry} + resolveWidget={resolveWidget} + getRemarkPlugins={getRemarkPlugins} + /> + ); +} + +/** + * Use getWidgetFor as a mapping function for recursive widget retrieval + */ +function widgetsForNestedFields( + collection: Collection, + fields: Field[], + entry: Entry, + inferedFields: Record, + getAsset: GetAssetFunction, + widgetFields: Field[], + values: EntryData, +) { + return widgetFields + .map(field => + getWidgetFor( + collection, + field.name, + fields, + entry, + inferedFields, + getAsset, + widgetFields, + values, + ), + ) + .filter(widget => Boolean(widget)) as JSX.Element[]; +} + +/** + * Retrieves widgets for nested fields (children of object/list fields) + */ +function getNestedWidgets( + collection: Collection, + fields: Field[], + entry: Entry, + inferedFields: Record, + getAsset: GetAssetFunction, + widgetFields: Field[], + values: EntryData | EntryData[], +) { + // Fields nested within a list field will be paired with a List of value Maps. + if (Array.isArray(values)) { + return values.flatMap(value => + widgetsForNestedFields( + collection, + fields, + entry, + inferedFields, + getAsset, + widgetFields, + value, + ), + ); + } + + // Fields nested within an object field will be paired with a single Record of values. + return widgetsForNestedFields( + collection, + fields, + entry, + inferedFields, + getAsset, + widgetFields, + values, + ); +} + +const PreviewPane = (props: EditorPreviewPaneProps) => { + const { entry, collection, config, fields, getAsset } = props; + + const inferedFields = useMemo(() => { + const titleField = selectInferedField(collection, 'title'); + const shortTitleField = selectInferedField(collection, 'shortTitle'); + const authorField = selectInferedField(collection, 'author'); + + const iFields: Record = {}; + if (titleField) { + iFields[titleField] = INFERABLE_FIELDS.title; + } + if (shortTitleField) { + iFields[shortTitleField] = INFERABLE_FIELDS.shortTitle; + } + if (authorField) { + iFields[authorField] = INFERABLE_FIELDS.author; + } + return iFields; + }, [collection]); + + const handleGetAsset = useCallback( + (path: string, field?: Field) => { + return getAsset(collection, entry, path, field); + }, + [collection, entry, getAsset], + ); + + /** + * This function exists entirely to expose nested widgets for object and list + * fields to custom preview templates. + */ + const widgetsFor = useCallback( + (name: string) => { + const field = fields.find(f => f.name === name); + if (!field) { + return { + data: null, + widgets: {}, + }; + } + + const nestedFields = field && 'fields' in field ? field.fields ?? [] : []; + const value = entry.data?.[field.name] as EntryData | EntryData[]; + + if (Array.isArray(value)) { + return value.map(val => { + const widgets = nestedFields.reduce((acc, field, index) => { + acc[field.name] =
    {getWidget(field, val, entry, handleGetAsset)}
    ; + return acc; + }, {} as Record); + return { data: val, widgets }; + }); + } + + return { + data: value, + widgets: nestedFields.reduce((acc, field, index) => { + acc[field.name] =
    {getWidget(field, value, entry, handleGetAsset)}
    ; + return acc; + }, {} as Record), + }; + }, + [entry, fields, handleGetAsset], + ); + + const widgetFor = useCallback( + (name: string) => { + return getWidgetFor(collection, name, fields, entry, inferedFields, handleGetAsset); + }, + [collection, entry, fields, handleGetAsset, inferedFields], + ); + + if (!entry || !entry.data) { + return null; + } + + const previewComponent = + getPreviewTemplate(selectTemplateName(collection, entry.slug)) ?? EditorPreview; + + const previewProps: TemplatePreviewProps = { + ...props, + getAsset: handleGetAsset, + widgetFor, + widgetsFor, + }; + + if (!collection) { + ; + } + + return ( + + + + + + ); +}; + +export interface EditorPreviewPaneOwnProps { + collection: Collection; + fields: Field[]; + entry: Entry; +} + +function mapStateToProps(state: RootState, ownProps: EditorPreviewPaneOwnProps) { + const isLoadingAsset = selectIsLoadingAsset(state.medias); + return { ...ownProps, isLoadingAsset, config: state.config }; +} + +const mapDispatchToProps = { + getAsset: getAssetAction, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); +export type EditorPreviewPaneProps = ConnectedProps; + +export default connector(PreviewPane); diff --git a/src/components/Editor/EditorPreviewPane/PreviewHOC.js b/src/components/Editor/EditorPreviewPane/PreviewHOC.js deleted file mode 100644 index e00fb762..00000000 --- a/src/components/Editor/EditorPreviewPane/PreviewHOC.js +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; - -class PreviewHOC extends React.Component { - /** - * Only re-render on value change, but always re-render objects and lists. - * Their child widgets will each also be wrapped with this component, and - * will only be updated on value change. - */ - shouldComponentUpdate(nextProps) { - const isWidgetContainer = ['object', 'list'].includes(nextProps.field.get('widget')); - return ( - isWidgetContainer || - this.props.value !== nextProps.value || - this.props.fieldsMetaData !== nextProps.fieldsMetaData || - this.props.getAsset !== nextProps.getAsset - ); - } - - render() { - const { previewComponent, ...props } = this.props; - return React.createElement(previewComponent, props); - } -} - -PreviewHOC.propTypes = { - previewComponent: PropTypes.func.isRequired, - field: ImmutablePropTypes.map.isRequired, - value: PropTypes.oneOfType([PropTypes.node, PropTypes.object, PropTypes.string, PropTypes.bool]), -}; - -export default PreviewHOC; diff --git a/src/components/Editor/EditorPreviewPane/PreviewHOC.tsx b/src/components/Editor/EditorPreviewPane/PreviewHOC.tsx new file mode 100644 index 00000000..7f7803c1 --- /dev/null +++ b/src/components/Editor/EditorPreviewPane/PreviewHOC.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import type { WidgetPreviewComponent, WidgetPreviewProps } from '../../../interface'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +interface PreviewHOCProps extends WidgetPreviewProps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + previewComponent: WidgetPreviewComponent; +} + +const PreviewHOC = ({ previewComponent, ...props }: PreviewHOCProps) => { + if (!previewComponent) { + return null; + } else if (React.isValidElement(previewComponent)) { + return React.cloneElement(previewComponent, props); + } else { + return React.createElement(previewComponent, props); + } +}; + +export default PreviewHOC; diff --git a/src/components/Editor/EditorRoute.tsx b/src/components/Editor/EditorRoute.tsx new file mode 100644 index 00000000..407c4d0a --- /dev/null +++ b/src/components/Editor/EditorRoute.tsx @@ -0,0 +1,40 @@ +import React, { useMemo } from 'react'; +import { Navigate, useParams } from 'react-router-dom'; + +import Editor from './Editor'; + +import type { Collections } from '../../interface'; + +function getDefaultPath(collections: Collections) { + const first = Object.values(collections).filter(collection => collection.hide !== true)[0]; + if (first) { + return `/collections/${first.name}`; + } else { + throw new Error('Could not find a non hidden collection'); + } +} + +interface EditorRouteProps { + newRecord?: boolean; + collections: Collections; +} + +const EditorRoute = ({ newRecord = false, collections }: EditorRouteProps) => { + const { name, slug } = useParams(); + const shouldRedirect = useMemo(() => { + if (!name) { + return false; + } + return !collections[name]; + }, [collections, name]); + + const defaultPath = useMemo(() => getDefaultPath(collections), [collections]); + + if (shouldRedirect || !name || (!newRecord && !slug)) { + return ; + } + + return ; +}; + +export default EditorRoute; diff --git a/src/components/Editor/EditorToolbar.js b/src/components/Editor/EditorToolbar.js deleted file mode 100644 index d5711a7e..00000000 --- a/src/components/Editor/EditorToolbar.js +++ /dev/null @@ -1,372 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { css } from '@emotion/react'; -import styled from '@emotion/styled'; -import { translate } from 'react-polyglot'; -import { Link } from 'react-router-dom'; - -import { - Icon, - Dropdown, - DropdownItem, - StyledDropdownButton, - colorsRaw, - colors, - components, - buttons, - zIndex, -} from '../../ui'; -import { SettingsDropdown } from '../UI'; - -const styles = { - noOverflow: css` - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - `, - buttonMargin: css` - margin: 0 10px; - `, - toolbarSection: css` - height: 100%; - display: flex; - align-items: center; - border: 0 solid ${colors.textFieldBorder}; - `, - publishedButton: css` - background-color: ${colorsRaw.tealLight}; - color: ${colorsRaw.teal}; - `, -}; - -const TooltipText = styled.div` - visibility: hidden; - width: 321px; - background-color: #555; - color: #fff; - text-align: unset; - border-radius: 6px; - padding: 5px; - - /* Position the tooltip text */ - position: absolute; - z-index: 1; - top: 145%; - left: 50%; - margin-left: -320px; - - /* Fade in tooltip */ - opacity: 0; - transition: opacity 0.3s; -`; - -const Tooltip = styled.div` - position: relative; - display: inline-block; - &:hover + ${TooltipText} { - visibility: visible; - opacity: 0.9; - } -`; - -const TooltipContainer = styled.div` - position: relative; -`; - -const DropdownButton = styled(StyledDropdownButton)` - ${styles.noOverflow} - @media (max-width: 1200px) { - padding-left: 10px; - } -`; - -const ToolbarContainer = styled.div` - box-shadow: 0 2px 6px 0 rgba(68, 74, 87, 0.05), 0 1px 3px 0 rgba(68, 74, 87, 0.1), - 0 2px 54px rgba(0, 0, 0, 0.1); - position: fixed; - top: 0; - left: 0; - width: 100%; - min-width: 1200px; - z-index: ${zIndex.zIndex300}; - background-color: #fff; - height: 66px; - display: flex; - justify-content: space-between; -`; - -const ToolbarSectionMain = styled.div` - ${styles.toolbarSection}; - flex: 10; - display: flex; - justify-content: space-between; - padding: 0 10px; -`; - -const ToolbarSubSectionFirst = styled.div` - display: flex; - align-items: center; -`; - -const ToolbarSectionBackLink = styled(Link)` - ${styles.toolbarSection}; - border-right-width: 1px; - font-weight: normal; - padding: 0 20px; - - &:hover, - &:focus { - background-color: #f1f2f4; - } -`; - -const ToolbarSectionMeta = styled.div` - ${styles.toolbarSection}; - border-left-width: 1px; - padding: 0 7px; -`; - -const ToolbarDropdown = styled(Dropdown)` - ${styles.buttonMargin}; - - ${Icon} { - color: ${colorsRaw.teal}; - } -`; - -const BackArrow = styled.div` - color: ${colors.textLead}; - font-size: 21px; - font-weight: 600; - margin-right: 16px; -`; - -const BackCollection = styled.div` - color: ${colors.textLead}; - font-size: 14px; -`; - -const BackStatus = styled.div` - margin-top: 6px; -`; - -const BackStatusUnchanged = styled(BackStatus)` - ${components.textBadgeSuccess}; -`; - -const BackStatusChanged = styled(BackStatus)` - ${components.textBadgeDanger}; -`; - -const ToolbarButton = styled.button` - ${buttons.button}; - ${buttons.default}; - ${styles.buttonMargin}; - ${styles.noOverflow}; - display: block; - - @media (max-width: 1200px) { - padding: 0 10px; - } -`; - -const DeleteButton = styled(ToolbarButton)` - ${buttons.lightRed}; -`; - -const PublishedToolbarButton = styled(DropdownButton)` - ${styles.publishedButton} -`; - -const PublishedButton = styled(ToolbarButton)` - ${styles.publishedButton} -`; - -const PublishButton = styled(DropdownButton)` - background-color: ${colorsRaw.teal}; -`; - -export class EditorToolbar extends React.Component { - static propTypes = { - isPersisting: PropTypes.bool, - isPublishing: PropTypes.bool, - isUpdatingStatus: PropTypes.bool, - isDeleting: PropTypes.bool, - onPersist: PropTypes.func.isRequired, - onPersistAndNew: PropTypes.func.isRequired, - onPersistAndDuplicate: PropTypes.func.isRequired, - showDelete: PropTypes.bool.isRequired, - onDelete: PropTypes.func.isRequired, - onChangeStatus: PropTypes.func.isRequired, - onPublish: PropTypes.func.isRequired, - onDuplicate: PropTypes.func.isRequired, - onPublishAndNew: PropTypes.func.isRequired, - onPublishAndDuplicate: PropTypes.func.isRequired, - user: PropTypes.object, - hasChanged: PropTypes.bool, - displayUrl: PropTypes.string, - collection: ImmutablePropTypes.map.isRequired, - isNewEntry: PropTypes.bool, - isModification: PropTypes.bool, - currentStatus: PropTypes.string, - onLogoutClick: PropTypes.func.isRequired, - t: PropTypes.func.isRequired, - editorBackLink: PropTypes.string.isRequired, - }; - - renderSimpleControls = () => { - const { collection, hasChanged, isNewEntry, showDelete, onDelete, t } = this.props; - const canCreate = collection.get('create'); - - return ( - <> - {!isNewEntry && !hasChanged - ? this.renderExistingEntrySimplePublishControls({ canCreate }) - : this.renderNewEntrySimplePublishControls({ canCreate })} -
    - {showDelete ? ( - - {t('editor.editorToolbar.deleteEntry')} - - ) : null} -
    - - ); - }; - - renderStatusInfoTooltip = () => { - const { t, currentStatus } = this.props; - - const statusToLocaleKey = { - [status.get('DRAFT')]: 'statusInfoTooltipDraft', - [status.get('PENDING_REVIEW')]: 'statusInfoTooltipInReview', - }; - - const statusKey = Object.keys(statusToLocaleKey).find(key => key === currentStatus); - return ( - - - - - {statusKey && ( - - {t(`editor.editorToolbar.${statusToLocaleKey[statusKey]}`)} - - )} - - ); - }; - - renderExistingEntrySimplePublishControls = ({ canCreate }) => { - const { onDuplicate, t } = this.props; - return canCreate ? ( - ( - {t('editor.editorToolbar.published')} - )} - > - { - - } - - ) : ( - {t('editor.editorToolbar.published')} - ); - }; - - renderNewEntrySimplePublishControls = ({ canCreate }) => { - const { onPersist, onPersistAndNew, onPersistAndDuplicate, isPersisting, t } = this.props; - - return ( -
    - ( - - {isPersisting - ? t('editor.editorToolbar.publishing') - : t('editor.editorToolbar.publish')} - - )} - > - - {canCreate ? ( - <> - - - - ) : null} - -
    - ); - }; - - render() { - const { - user, - hasChanged, - displayUrl, - collection, - onLogoutClick, - t, - editorBackLink, - isNewEntry - } = this.props; - - return ( - - - -
    - - {t('editor.editorToolbar.backCollection', { - collectionLabel: collection.get('label'), - })} - - {isNewEntry || hasChanged ? ( - {t('editor.editorToolbar.unsavedChanges')} - ) : ( - {t('editor.editorToolbar.changesSaved')} - )} -
    -
    - - - {this.renderSimpleControls()} - - - - - -
    - ); - } -} - -export default translate()(EditorToolbar); diff --git a/src/components/Editor/EditorToolbar.tsx b/src/components/Editor/EditorToolbar.tsx new file mode 100644 index 00000000..400fd7a3 --- /dev/null +++ b/src/components/Editor/EditorToolbar.tsx @@ -0,0 +1,245 @@ +import { styled } from '@mui/material/styles'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import AppBar from '@mui/material/AppBar'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import green from '@mui/material/colors/green'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Toolbar from '@mui/material/Toolbar'; +import React, { useCallback, useMemo, useState } from 'react'; +import { translate } from 'react-polyglot'; + +import { colors, components, zIndex } from '../../components/UI/styles'; +import { SettingsDropdown } from '../UI'; +import NavLink from '../UI/NavLink'; + +import type { MouseEvent } from 'react'; +import type { Collection, EditorPersistOptions, TranslatedProps, User } from '../../interface'; + +const StyledAppBar = styled(AppBar)` + background-color: ${colors.foreground}; + z-index: ${zIndex.zIndex100}; +`; + +const StyledToolbar = styled(Toolbar)` + gap: 12px; +`; + +const StyledToolbarSectionBackLink = styled('div')` + display: flex; + margin: -32px -24px; + height: 64px; + + a { + display: flex; + height: 100%; + padding: 16px; + align-items: center; + } +`; + +const StyledToolbarSectionMain = styled('div')` + flex-grow: 1; + display: flex; + gap: 8px; + padding: 0 16px; + margin-left: 24px; +`; + +const StyledBackCollection = styled('div')` + color: ${colors.textLead}; + font-size: 14px; +`; + +const StyledBackStatus = styled('div')` + margin-top: 6px; +`; + +const StyledBackStatusUnchanged = styled(StyledBackStatus)` + ${components.textBadgeSuccess}; +`; + +const StyledBackStatusChanged = styled(StyledBackStatus)` + ${components.textBadgeDanger}; +`; + +const StyledButtonWrapper = styled('div')` + position: relative; +`; + +export interface EditorToolbarProps { + isPersisting?: boolean; + isDeleting?: boolean; + onPersist: (opts?: EditorPersistOptions) => Promise; + onPersistAndNew: () => Promise; + onPersistAndDuplicate: () => Promise; + onDelete: () => Promise; + showDelete: boolean; + onDuplicate: () => void; + user: User; + hasChanged: boolean; + displayUrl: string | undefined; + collection: Collection; + isNewEntry: boolean; + isModification?: boolean; + onLogoutClick: () => void; + editorBackLink: string; +} + +const EditorToolbar = ({ + user, + hasChanged, + displayUrl, + collection, + onLogoutClick, + onDuplicate, + isPersisting, + onPersist, + onPersistAndDuplicate, + onPersistAndNew, + isNewEntry, + showDelete, + onDelete, + t, + editorBackLink, +}: TranslatedProps) => { + const canCreate = useMemo(() => collection.create ?? false, [collection.create]); + const isPublished = useMemo(() => !isNewEntry && !hasChanged, [hasChanged, isNewEntry]); + + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + const handleClick = useCallback((event: MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + const handleClose = useCallback(() => { + setAnchorEl(null); + }, []); + + const controls = useMemo( + () => ( + +
    + + + {isPersisting ? ( + + ) : null} + + + {!isPublished ? ( + [ + onPersist()}> + {t('editor.editorToolbar.publishNow')} + , + ...(canCreate + ? [ + + {t('editor.editorToolbar.publishAndCreateNew')} + , + + {t('editor.editorToolbar.publishAndDuplicate')} + , + ] + : []), + ] + ) : ( + {t('editor.editorToolbar.duplicate')} + )} + +
    + {showDelete ? ( + + ) : null} +
    + ), + [ + anchorEl, + canCreate, + handleClick, + handleClose, + isPersisting, + isPublished, + onDelete, + onDuplicate, + onPersist, + onPersistAndDuplicate, + onPersistAndNew, + open, + showDelete, + t, + ], + ); + + return ( + + + + + + {controls} + + + + ); +}; + +export default translate()(EditorToolbar); diff --git a/src/components/EditorWidgets/Unknown/UnknownControl.js b/src/components/EditorWidgets/Unknown/UnknownControl.js deleted file mode 100644 index 638b9ea6..00000000 --- a/src/components/EditorWidgets/Unknown/UnknownControl.js +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import { translate } from 'react-polyglot'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import PropTypes from 'prop-types'; - -function UnknownControl({ field, t }) { - return ( -
    {t('editor.editorWidgets.unknownControl.noControl', { widget: field.get('widget') })}
    - ); -} - -UnknownControl.propTypes = { - field: ImmutablePropTypes.map, - t: PropTypes.func.isRequired, -}; - -export default translate()(UnknownControl); diff --git a/src/components/EditorWidgets/Unknown/UnknownControl.tsx b/src/components/EditorWidgets/Unknown/UnknownControl.tsx new file mode 100644 index 00000000..11bd44f1 --- /dev/null +++ b/src/components/EditorWidgets/Unknown/UnknownControl.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { translate } from 'react-polyglot'; + +import type { WidgetControlProps, TranslatedProps } from '../../../interface'; + +const UnknownControl = ({ field, t }: TranslatedProps>) => { + return
    {t('editor.editorWidgets.unknownControl.noControl', { widget: field.widget })}
    ; +}; + +export default translate()(UnknownControl); diff --git a/src/components/EditorWidgets/Unknown/UnknownPreview.js b/src/components/EditorWidgets/Unknown/UnknownPreview.js deleted file mode 100644 index 44a8ec0b..00000000 --- a/src/components/EditorWidgets/Unknown/UnknownPreview.js +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import { translate } from 'react-polyglot'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import PropTypes from 'prop-types'; - -function UnknownPreview({ field, t }) { - return ( -
    - {t('editor.editorWidgets.unknownPreview.noPreview', { widget: field.get('widget') })} -
    - ); -} - -UnknownPreview.propTypes = { - field: ImmutablePropTypes.map, - t: PropTypes.func.isRequired, -}; - -export default translate()(UnknownPreview); diff --git a/src/components/EditorWidgets/Unknown/UnknownPreview.tsx b/src/components/EditorWidgets/Unknown/UnknownPreview.tsx new file mode 100644 index 00000000..0fa8c6b5 --- /dev/null +++ b/src/components/EditorWidgets/Unknown/UnknownPreview.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { translate } from 'react-polyglot'; + +import type { WidgetPreviewProps, TranslatedProps } from '../../../interface'; + +const UnknownPreview = ({ field, t }: TranslatedProps) => { + return ( +
    + {t('editor.editorWidgets.unknownPreview.noPreview', { widget: field.widget })} +
    + ); +}; + +export default translate()(UnknownPreview); diff --git a/src/components/EditorWidgets/index.js b/src/components/EditorWidgets/index.ts similarity index 100% rename from src/components/EditorWidgets/index.js rename to src/components/EditorWidgets/index.ts diff --git a/src/components/MediaLibrary/EmptyMessage.js b/src/components/MediaLibrary/EmptyMessage.js deleted file mode 100644 index bb4825fb..00000000 --- a/src/components/MediaLibrary/EmptyMessage.js +++ /dev/null @@ -1,29 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; - -import { colors } from '../../ui'; - -const EmptyMessageContainer = styled.div` - height: 100%; - width: 100%; - display: flex; - justify-content: center; - align-items: center; - color: ${props => props.isPrivate && colors.textFieldBorder}; -`; - -function EmptyMessage({ content, isPrivate }) { - return ( - -

    {content}

    -
    - ); -} - -EmptyMessage.propTypes = { - content: PropTypes.string.isRequired, - isPrivate: PropTypes.bool, -}; - -export default EmptyMessage; diff --git a/src/components/MediaLibrary/EmptyMessage.tsx b/src/components/MediaLibrary/EmptyMessage.tsx new file mode 100644 index 00000000..56c76e87 --- /dev/null +++ b/src/components/MediaLibrary/EmptyMessage.tsx @@ -0,0 +1,38 @@ +import { styled } from '@mui/material/styles'; +import React from 'react'; + +import { colors } from '../../components/UI/styles'; +import { transientOptions } from '../../lib'; + +interface EmptyMessageContainerProps { + $isPrivate: boolean; +} + +const EmptyMessageContainer = styled( + 'div', + transientOptions, +)( + ({ $isPrivate }) => ` + height: 100%; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + ${$isPrivate ? `color: ${colors.textFieldBorder};` : ''} + `, +); + +interface EmptyMessageProps { + content: string; + isPrivate?: boolean; +} + +const EmptyMessage = ({ content, isPrivate = false }: EmptyMessageProps) => { + return ( + +

    {content}

    +
    + ); +}; + +export default EmptyMessage; diff --git a/src/components/MediaLibrary/MediaLibrary.js b/src/components/MediaLibrary/MediaLibrary.js deleted file mode 100644 index dbbace16..00000000 --- a/src/components/MediaLibrary/MediaLibrary.js +++ /dev/null @@ -1,404 +0,0 @@ -import fuzzy from 'fuzzy'; -import { map, orderBy } from 'lodash'; -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { translate } from 'react-polyglot'; -import { connect } from 'react-redux'; - -import { - closeMediaLibrary as closeMediaLibraryAction, - deleteMedia as deleteMediaAction, - insertMedia as insertMediaAction, - loadMedia as loadMediaAction, - loadMediaDisplayURL as loadMediaDisplayURLAction, - persistMedia as persistMediaAction, -} from '../../actions/mediaLibrary'; -import { fileExtension } from '../../lib/util'; -import { selectMediaFiles } from '../../reducers/mediaLibrary'; -import alert from '../UI/Alert'; -import confirm from '../UI/Confirm'; -import MediaLibraryModal, { fileShape } from './MediaLibraryModal'; - -/** - * Extensions used to determine which files to show when the media library is - * accessed from an image insertion field. - */ -const IMAGE_EXTENSIONS_VIEWABLE = [ - 'jpg', - 'jpeg', - 'webp', - 'gif', - 'png', - 'bmp', - 'tiff', - 'svg', - 'avif', -]; -const IMAGE_EXTENSIONS = [...IMAGE_EXTENSIONS_VIEWABLE]; - -class MediaLibrary extends React.Component { - static propTypes = { - isVisible: PropTypes.bool, - loadMediaDisplayURL: PropTypes.func, - displayURLs: ImmutablePropTypes.map, - canInsert: PropTypes.bool, - files: PropTypes.arrayOf(PropTypes.shape(fileShape)).isRequired, - dynamicSearch: PropTypes.bool, - dynamicSearchActive: PropTypes.bool, - forImage: PropTypes.bool, - isLoading: PropTypes.bool, - isPersisting: PropTypes.bool, - isDeleting: PropTypes.bool, - hasNextPage: PropTypes.bool, - isPaginating: PropTypes.bool, - privateUpload: PropTypes.bool, - config: ImmutablePropTypes.map, - loadMedia: PropTypes.func.isRequired, - dynamicSearchQuery: PropTypes.string, - page: PropTypes.number, - persistMedia: PropTypes.func.isRequired, - deleteMedia: PropTypes.func.isRequired, - insertMedia: PropTypes.func.isRequired, - closeMediaLibrary: PropTypes.func.isRequired, - t: PropTypes.func.isRequired, - }; - - static defaultProps = { - files: [], - }; - - /** - * The currently selected file and query are tracked in component state as - * they do not impact the rest of the application. - */ - state = { - selectedFile: {}, - query: '', - }; - - componentDidMount() { - this.props.loadMedia(); - } - - UNSAFE_componentWillReceiveProps(nextProps) { - /** - * We clear old state from the media library when it's being re-opened - * because, when doing so on close, the state is cleared while the media - * library is still fading away. - */ - const isOpening = !this.props.isVisible && nextProps.isVisible; - if (isOpening) { - this.setState({ selectedFile: {}, query: '' }); - } - } - - componentDidUpdate(prevProps) { - const isOpening = !prevProps.isVisible && this.props.isVisible; - - if (isOpening && prevProps.privateUpload !== this.props.privateUpload) { - this.props.loadMedia({ privateUpload: this.props.privateUpload }); - } - } - - loadDisplayURL = file => { - const { loadMediaDisplayURL } = this.props; - loadMediaDisplayURL(file); - }; - - /** - * Filter an array of file data to include only images. - */ - filterImages = files => { - return files.filter(file => { - const ext = fileExtension(file.name).toLowerCase(); - return IMAGE_EXTENSIONS.includes(ext); - }); - }; - - /** - * Transform file data for table display. - */ - toTableData = files => { - const tableData = - files && - files.map(({ key, name, id, size, path, queryOrder, displayURL, draft }) => { - const ext = fileExtension(name).toLowerCase(); - return { - key, - id, - name, - path, - type: ext.toUpperCase(), - size, - queryOrder, - displayURL, - draft, - isImage: IMAGE_EXTENSIONS.includes(ext), - isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext), - }; - }); - - /** - * Get the sort order for use with `lodash.orderBy`, and always add the - * `queryOrder` sort as the lowest priority sort order. - */ - const { sortFields } = this.state; - const fieldNames = map(sortFields, 'fieldName').concat('queryOrder'); - const directions = map(sortFields, 'direction').concat('asc'); - return orderBy(tableData, fieldNames, directions); - }; - - handleClose = () => { - this.props.closeMediaLibrary(); - }; - - /** - * Toggle asset selection on click. - */ - handleAssetClick = asset => { - const selectedFile = this.state.selectedFile.key === asset.key ? {} : asset; - this.setState({ selectedFile }); - }; - - /** - * Upload a file. - */ - handlePersist = async event => { - /** - * Stop the browser from automatically handling the file input click, and - * get the file for upload, and retain the synthetic event for access after - * the asynchronous persist operation. - */ - event.persist(); - event.stopPropagation(); - event.preventDefault(); - const { persistMedia, privateUpload, config, field } = this.props; - const { files: fileList } = event.dataTransfer || event.target; - const files = [...fileList]; - const file = files[0]; - const maxFileSize = config.get('max_file_size'); - - if (maxFileSize && file.size > maxFileSize) { - alert({ - title: 'mediaLibrary.mediaLibrary.fileTooLargeTitle', - body: { - key: 'mediaLibrary.mediaLibrary.fileTooLargeBody', - options: { - size: Math.floor(maxFileSize / 1000), - }, - }, - }); - } else { - await persistMedia(file, { privateUpload, field }); - - this.setState({ selectedFile: this.props.files[0] }); - - this.scrollToTop(); - } - - event.target.value = null; - }; - - /** - * Stores the public path of the file in the application store, where the - * editor field that launched the media library can retrieve it. - */ - handleInsert = () => { - const { selectedFile } = this.state; - const { path } = selectedFile; - const { insertMedia, field } = this.props; - insertMedia(path, field); - this.handleClose(); - }; - - /** - * Removes the selected file from the backend. - */ - handleDelete = async () => { - const { selectedFile } = this.state; - const { files, deleteMedia, privateUpload } = this.props; - if ( - !(await confirm({ - title: 'mediaLibrary.mediaLibrary.onDeleteTitle', - body: 'mediaLibrary.mediaLibrary.onDeleteBody', - color: 'error', - })) - ) { - return; - } - const file = files.find(file => selectedFile.key === file.key); - deleteMedia(file, { privateUpload }).then(() => { - this.setState({ selectedFile: {} }); - }); - }; - - /** - * Downloads the selected file. - */ - handleDownload = () => { - const { selectedFile } = this.state; - const { displayURLs } = this.props; - const url = displayURLs.getIn([selectedFile.id, 'url']) || selectedFile.url; - if (!url) { - return; - } - - const filename = selectedFile.name; - - const element = document.createElement('a'); - element.setAttribute('href', url); - element.setAttribute('download', filename); - - element.style.display = 'none'; - document.body.appendChild(element); - - element.click(); - - document.body.removeChild(element); - this.setState({ selectedFile: {} }); - }; - - /** - * - */ - - handleLoadMore = () => { - const { loadMedia, dynamicSearchQuery, page, privateUpload } = this.props; - loadMedia({ query: dynamicSearchQuery, page: page + 1, privateUpload }); - }; - - /** - * Executes media library search for implementations that support dynamic - * search via request. For these implementations, the Enter key must be - * pressed to execute search. If assets are being stored directly through - * the GitHub backend, search is in-memory and occurs as the query is typed, - * so this handler has no impact. - */ - handleSearchKeyDown = async event => { - const { dynamicSearch, loadMedia, privateUpload } = this.props; - if (event.key === 'Enter' && dynamicSearch) { - await loadMedia({ query: this.state.query, privateUpload }); - this.scrollToTop(); - } - }; - - scrollToTop = () => { - this.scrollContainerRef.scrollTop = 0; - }; - - /** - * Updates query state as the user types in the search field. - */ - handleSearchChange = event => { - this.setState({ query: event.target.value }); - }; - - /** - * Filters files that do not match the query. Not used for dynamic search. - */ - queryFilter = (query, files) => { - /** - * Because file names don't have spaces, typing a space eliminates all - * potential matches, so we strip them all out internally before running the - * query. - */ - const strippedQuery = query.replace(/ /g, ''); - const matches = fuzzy.filter(strippedQuery, files, { extract: file => file.name }); - const matchFiles = matches.map((match, queryIndex) => { - const file = files[match.index]; - return { ...file, queryIndex }; - }); - return matchFiles; - }; - - render() { - const { - isVisible, - canInsert, - files, - dynamicSearch, - dynamicSearchActive, - forImage, - isLoading, - isPersisting, - isDeleting, - hasNextPage, - isPaginating, - privateUpload, - displayURLs, - t, - } = this.props; - - return ( - (this.scrollContainerRef = ref)} - handleAssetClick={this.handleAssetClick} - handleLoadMore={this.handleLoadMore} - displayURLs={displayURLs} - loadDisplayURL={this.loadDisplayURL} - t={t} - /> - ); - } -} - -function mapStateToProps(state) { - const { mediaLibrary } = state; - const field = mediaLibrary.get('field'); - const mediaLibraryProps = { - isVisible: mediaLibrary.get('isVisible'), - canInsert: mediaLibrary.get('canInsert'), - files: selectMediaFiles(state, field), - displayURLs: mediaLibrary.get('displayURLs'), - dynamicSearch: mediaLibrary.get('dynamicSearch'), - dynamicSearchActive: mediaLibrary.get('dynamicSearchActive'), - dynamicSearchQuery: mediaLibrary.get('dynamicSearchQuery'), - forImage: mediaLibrary.get('forImage'), - isLoading: mediaLibrary.get('isLoading'), - isPersisting: mediaLibrary.get('isPersisting'), - isDeleting: mediaLibrary.get('isDeleting'), - privateUpload: mediaLibrary.get('privateUpload'), - config: mediaLibrary.get('config'), - page: mediaLibrary.get('page'), - hasNextPage: mediaLibrary.get('hasNextPage'), - isPaginating: mediaLibrary.get('isPaginating'), - field, - }; - return { ...mediaLibraryProps }; -} - -const mapDispatchToProps = { - loadMedia: loadMediaAction, - persistMedia: persistMediaAction, - deleteMedia: deleteMediaAction, - insertMedia: insertMediaAction, - loadMediaDisplayURL: loadMediaDisplayURLAction, - closeMediaLibrary: closeMediaLibraryAction, -}; - -export default connect(mapStateToProps, mapDispatchToProps)(translate()(MediaLibrary)); diff --git a/src/components/MediaLibrary/MediaLibrary.tsx b/src/components/MediaLibrary/MediaLibrary.tsx new file mode 100644 index 00000000..37f868a0 --- /dev/null +++ b/src/components/MediaLibrary/MediaLibrary.tsx @@ -0,0 +1,407 @@ +import fuzzy from 'fuzzy'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { translate } from 'react-polyglot'; +import { connect } from 'react-redux'; + +import { + closeMediaLibrary as closeMediaLibraryAction, + deleteMedia as deleteMediaAction, + insertMedia as insertMediaAction, + loadMedia as loadMediaAction, + loadMediaDisplayURL as loadMediaDisplayURLAction, + persistMedia as persistMediaAction, +} from '../../actions/mediaLibrary'; +import { fileExtension } from '../../lib/util'; +import { selectMediaFiles } from '../../reducers/mediaLibrary'; +import alert from '../UI/Alert'; +import confirm from '../UI/Confirm'; +import MediaLibraryModal from './MediaLibraryModal'; + +import type { ChangeEvent, KeyboardEvent } from 'react'; +import type { ConnectedProps } from 'react-redux'; +import type { MediaFile, TranslatedProps } from '../../interface'; +import type { RootState } from '../../store'; + +/** + * Extensions used to determine which files to show when the media library is + * accessed from an image insertion field. + */ +const IMAGE_EXTENSIONS_VIEWABLE = [ + 'jpg', + 'jpeg', + 'webp', + 'gif', + 'png', + 'bmp', + 'tiff', + 'svg', + 'avif', +]; +const IMAGE_EXTENSIONS = [...IMAGE_EXTENSIONS_VIEWABLE]; + +const MediaLibrary = ({ + isVisible, + loadMediaDisplayURL, + displayURLs, + canInsert, + files = [], + dynamicSearch, + dynamicSearchActive, + forImage, + isLoading, + isPersisting, + isDeleting, + hasNextPage, + isPaginating, + privateUpload = false, + config, + loadMedia, + dynamicSearchQuery, + page, + persistMedia, + deleteMedia, + insertMedia, + closeMediaLibrary, + field, + t, +}: TranslatedProps) => { + const [selectedFile, setSelectedFile] = useState(null); + const [query, setQuery] = useState(undefined); + + const [prevIsVisible, setPrevIsVisible] = useState(false); + const [prevPrivateUpload, setPrevPrivateUpload] = useState(false); + + useEffect(() => { + loadMedia(); + }, [loadMedia]); + + useEffect(() => { + if (!prevIsVisible && isVisible) { + setSelectedFile(null); + setQuery(''); + } + + setPrevIsVisible(isVisible); + }, [isVisible, prevIsVisible]); + + useEffect(() => { + setPrevPrivateUpload(privateUpload); + }, [privateUpload]); + + useEffect(() => { + if (!prevIsVisible && isVisible && !prevPrivateUpload && privateUpload) { + loadMedia({ privateUpload }); + } + }, [isVisible, loadMedia, prevIsVisible, prevPrivateUpload, privateUpload]); + + const loadDisplayURL = useCallback( + (file: MediaFile) => { + loadMediaDisplayURL(file); + }, + [loadMediaDisplayURL], + ); + + /** + * Filter an array of file data to include only images. + */ + const filterImages = useCallback((files: MediaFile[]) => { + return files.filter(file => { + const ext = fileExtension(file.name).toLowerCase(); + return IMAGE_EXTENSIONS.includes(ext); + }); + }, []); + + /** + * Transform file data for table display. + */ + const toTableData = useCallback((files: MediaFile[]) => { + const tableData = + files && + files.map(({ key, name, id, size, path, queryOrder, displayURL, draft }) => { + const ext = fileExtension(name).toLowerCase(); + return { + key, + id, + name, + path, + type: ext.toUpperCase(), + size, + queryOrder, + displayURL, + draft, + isImage: IMAGE_EXTENSIONS.includes(ext), + isViewableImage: IMAGE_EXTENSIONS_VIEWABLE.includes(ext), + }; + }); + + /** + * Get the sort order for use with `lodash.orderBy`, and always add the + * `queryOrder` sort as the lowest priority sort order. + */ + // TODO Sorting? + // const fieldNames = map(sortFields, 'fieldName').concat('queryOrder'); + // const directions = map(sortFields, 'direction').concat('asc'); + // return orderBy(tableData, fieldNames, directions); + return tableData; + }, []); + + const handleClose = useCallback(() => { + closeMediaLibrary(); + }, [closeMediaLibrary]); + + /** + * Toggle asset selection on click. + */ + const handleAssetClick = useCallback( + (asset: MediaFile) => { + if (selectedFile?.key !== asset.key) { + setSelectedFile(asset); + } + }, + [selectedFile?.key], + ); + + const scrollContainerRef = useRef(); + const scrollToTop = () => { + if (scrollContainerRef.current) { + scrollContainerRef.current.scrollTop = 0; + } + }; + + /** + * Upload a file. + */ + const handlePersist = useCallback( + async (event: ChangeEvent | DragEvent) => { + /** + * Stop the browser from automatically handling the file input click, and + * get the file for upload, and retain the synthetic event for access after + * the asynchronous persist operation. + */ + + let fileList: FileList | null; + if ('dataTransfer' in event) { + fileList = event.dataTransfer?.files ?? null; + } else { + event.persist(); + fileList = event.target.files; + } + + if (!fileList) { + return; + } + + event.stopPropagation(); + event.preventDefault(); + const files = [...Array.from(fileList)]; + const file = files[0]; + const maxFileSize = typeof config.max_file_size === 'number' ? config.max_file_size : 512000; + + if (maxFileSize && file.size > maxFileSize) { + alert({ + title: 'mediaLibrary.mediaLibrary.fileTooLargeTitle', + body: { + key: 'mediaLibrary.mediaLibrary.fileTooLargeBody', + options: { + size: Math.floor(maxFileSize / 1000), + }, + }, + }); + } else { + await persistMedia(file, { privateUpload, field }); + + setSelectedFile(files[0] as unknown as MediaFile); + + scrollToTop(); + } + + if (!('dataTransfer' in event)) { + event.target.value = ''; + } + }, + [config.max_file_size, field, persistMedia, privateUpload], + ); + + /** + * Stores the public path of the file in the application store, where the + * editor field that launched the media library can retrieve it. + */ + const handleInsert = useCallback(() => { + if (!selectedFile) { + return; + } + + const { path } = selectedFile; + insertMedia(path, field); + handleClose(); + }, [field, handleClose, insertMedia, selectedFile]); + + /** + * Removes the selected file from the backend. + */ + const handleDelete = useCallback(async () => { + if ( + !(await confirm({ + title: 'mediaLibrary.mediaLibrary.onDeleteTitle', + body: 'mediaLibrary.mediaLibrary.onDeleteBody', + color: 'error', + })) + ) { + return; + } + const file = files.find(file => selectedFile?.key === file.key); + if (file) { + deleteMedia(file, { privateUpload }).then(() => { + setSelectedFile(null); + }); + } + }, [deleteMedia, files, privateUpload, selectedFile?.key]); + + /** + * Downloads the selected file. + */ + const handleDownload = useCallback(() => { + if (!selectedFile) { + return; + } + + const url = displayURLs[selectedFile.id]?.url ?? selectedFile.url; + if (!url) { + return; + } + + const filename = selectedFile.name; + + const element = document.createElement('a'); + element.setAttribute('href', url); + element.setAttribute('download', filename); + + element.style.display = 'none'; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); + setSelectedFile(null); + }, [displayURLs, selectedFile]); + + const handleLoadMore = useCallback(() => { + loadMedia({ query: dynamicSearchQuery, page: (page ?? 0) + 1, privateUpload }); + }, [dynamicSearchQuery, loadMedia, page, privateUpload]); + + /** + * Executes media library search for implementations that support dynamic + * search via request. For these implementations, the Enter key must be + * pressed to execute search. If assets are being stored directly through + * the GitHub backend, search is in-memory and occurs as the query is typed, + * so this handler has no impact. + */ + const handleSearchKeyDown = useCallback( + async (event: KeyboardEvent) => { + if (event.key === 'Enter' && dynamicSearch) { + await loadMedia({ query, privateUpload }); + scrollToTop(); + } + }, + [dynamicSearch, loadMedia, privateUpload, query], + ); + + /** + * Updates query state as the user types in the search field. + */ + const handleSearchChange = useCallback((event: ChangeEvent) => { + setQuery(event.target.value); + }, []); + + /** + * Filters files that do not match the query. Not used for dynamic search. + */ + const queryFilter = useCallback((query: string, files: { name: string }[]) => { + /** + * Because file names don't have spaces, typing a space eliminates all + * potential matches, so we strip them all out internally before running the + * query. + */ + const strippedQuery = query.replace(/ /g, ''); + const matches = fuzzy.filter(strippedQuery, files, { extract: file => file.name }); + const matchFiles = matches.map((match, queryIndex) => { + const file = files[match.index]; + return { ...file, queryIndex }; + }); + return matchFiles; + }, []); + + return ( + + ); +}; + +function mapStateToProps(state: RootState) { + const { mediaLibrary } = state; + const field = mediaLibrary.field; + const mediaLibraryProps = { + isVisible: mediaLibrary.isVisible, + canInsert: mediaLibrary.canInsert, + files: selectMediaFiles(state, field), + displayURLs: mediaLibrary.displayURLs, + dynamicSearch: mediaLibrary.dynamicSearch, + dynamicSearchActive: mediaLibrary.dynamicSearchActive, + dynamicSearchQuery: mediaLibrary.dynamicSearchQuery, + forImage: mediaLibrary.forImage, + isLoading: mediaLibrary.isLoading, + isPersisting: mediaLibrary.isPersisting, + isDeleting: mediaLibrary.isDeleting, + privateUpload: mediaLibrary.privateUpload, + config: mediaLibrary.config, + page: mediaLibrary.page, + hasNextPage: mediaLibrary.hasNextPage, + isPaginating: mediaLibrary.isPaginating, + field, + }; + return { ...mediaLibraryProps }; +} + +const mapDispatchToProps = { + loadMedia: loadMediaAction, + persistMedia: persistMediaAction, + deleteMedia: deleteMediaAction, + insertMedia: insertMediaAction, + loadMediaDisplayURL: loadMediaDisplayURLAction, + closeMediaLibrary: closeMediaLibraryAction, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); +export type MediaLibraryProps = ConnectedProps; + +export default connector(translate()(MediaLibrary)); diff --git a/src/components/MediaLibrary/MediaLibraryButtons.js b/src/components/MediaLibrary/MediaLibraryButtons.tsx similarity index 51% rename from src/components/MediaLibrary/MediaLibraryButtons.js rename to src/components/MediaLibrary/MediaLibraryButtons.tsx index ea25657a..18c5a8cc 100644 --- a/src/components/MediaLibrary/MediaLibraryButtons.js +++ b/src/components/MediaLibrary/MediaLibraryButtons.tsx @@ -1,13 +1,15 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { useCallback, useEffect, useState } from 'react'; import { css } from '@emotion/react'; -import styled from '@emotion/styled'; +import { styled } from '@mui/material/styles'; import copyToClipboard from 'copy-text-to-clipboard'; +import Button from '@mui/material/Button'; -import { buttons, shadows, zIndex } from '../../ui'; +import { buttons, shadows, zIndex } from '../../components/UI/styles'; import { isAbsolutePath } from '../../lib/util'; import { FileUploadButton } from '../UI'; +import type { TranslatedProps } from '../../interface'; + const styles = { button: css` ${buttons.button}; @@ -50,54 +52,58 @@ export const UploadButton = styled(FileUploadButton)` } `; -export const DeleteButton = styled.button` +export const DeleteButton = styled('button')` ${styles.button}; ${buttons.lightRed}; `; -export const InsertButton = styled.button` +export const InsertButton = styled('button')` ${styles.button}; ${buttons.green}; `; -const ActionButton = styled.button` - ${styles.button}; - ${props => - !props.disabled && - css` - ${buttons.gray} - `} -`; +export interface CopyToClipBoardButtonProps { + disabled: boolean; + draft?: boolean; + path?: string; + name?: string; +} -export const DownloadButton = ActionButton; +export const CopyToClipBoardButton = ({ + disabled, + draft, + path, + name, + t, +}: TranslatedProps) => { + const [copied, setCopied] = useState(false); -export class CopyToClipBoardButton extends React.Component { - mounted = false; - timeout; + useEffect(() => { + let alive = true; - state = { - copied: false, - }; + const timer = setTimeout(() => { + if (alive) { + setCopied(false); + } + }, 1500); - componentDidMount() { - this.mounted = true; - } + return () => { + alive = false; + clearTimeout(timer); + }; + }, []); - componentWillUnmount() { - this.mounted = false; - } + const handleCopy = useCallback(() => { + if (!path || !name) { + return; + } - handleCopy = () => { - clearTimeout(this.timeout); - const { path, draft, name } = this.props; copyToClipboard(isAbsolutePath(path) || !draft ? path : name); - this.setState({ copied: true }); - this.timeout = setTimeout(() => this.mounted && this.setState({ copied: false }), 1500); - }; + setCopied(true); + }, [draft, name, path]); - getTitle = () => { - const { t, path, draft } = this.props; - if (this.state.copied) { + const getTitle = useCallback(() => { + if (copied) { return t('mediaLibrary.mediaLibraryCard.copied'); } @@ -114,23 +120,11 @@ export class CopyToClipBoardButton extends React.Component { } return t('mediaLibrary.mediaLibraryCard.copyPath'); - }; + }, [copied, draft, path, t]); - render() { - const { disabled } = this.props; - - return ( - - {this.getTitle()} - - ); - } -} - -CopyToClipBoardButton.propTypes = { - disabled: PropTypes.bool.isRequired, - draft: PropTypes.bool, - path: PropTypes.string, - name: PropTypes.string, - t: PropTypes.func.isRequired, + return ( + + ); }; diff --git a/src/components/MediaLibrary/MediaLibraryCard.js b/src/components/MediaLibrary/MediaLibraryCard.js deleted file mode 100644 index 6dc652c0..00000000 --- a/src/components/MediaLibrary/MediaLibraryCard.js +++ /dev/null @@ -1,129 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import styled from '@emotion/styled'; - -import { colors, borders, lengths, shadows, effects } from '../../ui'; - -const IMAGE_HEIGHT = 160; - -const Card = styled.div` - width: ${props => props.width}; - height: ${props => props.height}; - margin: ${props => props.margin}; - border: ${borders.textField}; - border-color: ${props => props.isSelected && colors.active}; - border-radius: ${lengths.borderRadius}; - cursor: pointer; - overflow: hidden; - background-color: ${props => props.isPrivate && colors.textFieldBorder}; - - &:focus { - outline: none; - } -`; - -const CardImageWrapper = styled.div` - height: ${IMAGE_HEIGHT + 2}px; - ${effects.checkerboard}; - ${shadows.inset}; - border-bottom: solid ${lengths.borderWidth} ${colors.textFieldBorder}; - position: relative; -`; - -const CardImage = styled.img` - width: 100%; - height: ${IMAGE_HEIGHT}px; - object-fit: contain; - border-radius: 2px 2px 0 0; -`; - -const CardFileIcon = styled.div` - width: 100%; - height: 160px; - object-fit: cover; - border-radius: 2px 2px 0 0; - padding: 1em; - font-size: 3em; -`; - -const CardText = styled.p` - color: ${colors.text}; - padding: 8px; - margin-top: 20px; - overflow-wrap: break-word; - line-height: 1.3 !important; -`; - -const DraftText = styled.p` - color: ${colors.mediaDraftText}; - background-color: ${colors.mediaDraftBackground}; - position: absolute; - padding: 8px; - border-radius: ${lengths.borderRadius} 0 ${lengths.borderRadius} 0; -`; - -class MediaLibraryCard extends React.Component { - render() { - const { - isSelected, - displayURL, - text, - onClick, - draftText, - width, - height, - margin, - isPrivate, - type, - isViewableImage, - isDraft, - } = this.props; - const url = displayURL.get('url'); - return ( - - - {isDraft ? {draftText} : null} - {url && isViewableImage ? ( - - ) : ( - {type} - )} - - {text} - - ); - } - componentDidMount() { - const { displayURL, loadDisplayURL } = this.props; - if (!displayURL.get('url')) { - loadDisplayURL(); - } - } -} - -MediaLibraryCard.propTypes = { - isSelected: PropTypes.bool, - displayURL: ImmutablePropTypes.map.isRequired, - text: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, - draftText: PropTypes.string.isRequired, - width: PropTypes.string.isRequired, - height: PropTypes.string.isRequired, - margin: PropTypes.string.isRequired, - isPrivate: PropTypes.bool, - type: PropTypes.string, - isViewableImage: PropTypes.bool.isRequired, - loadDisplayURL: PropTypes.func.isRequired, - isDraft: PropTypes.bool, -}; - -export default MediaLibraryCard; diff --git a/src/components/MediaLibrary/MediaLibraryCard.tsx b/src/components/MediaLibrary/MediaLibraryCard.tsx new file mode 100644 index 00000000..662ba188 --- /dev/null +++ b/src/components/MediaLibrary/MediaLibraryCard.tsx @@ -0,0 +1,142 @@ +import { styled } from '@mui/material/styles'; +import React, { useEffect, useMemo } from 'react'; + +import { transientOptions } from '../../lib'; +import { borders, colors, effects, lengths, shadows } from '../../components/UI/styles'; + +import type { MediaLibraryDisplayURL } from '../../reducers/mediaLibrary'; + +const IMAGE_HEIGHT = 160; + +interface CardProps { + $width: string; + $height: string; + $margin: string; + $isSelected: boolean; + $isPrivate: boolean; +} + +const Card = styled( + 'div', + transientOptions, +)( + ({ $width, $height, $margin, $isSelected, $isPrivate }) => ` + width: ${$width}; + height: ${$height}; + margin: ${$margin}; + border: ${borders.textField}; + ${$isSelected ? `border-color: ${colors.active};` : ''} + border-radius: ${lengths.borderRadius}; + cursor: pointer; + overflow: hidden; + ${$isPrivate ? `background-color: ${colors.textFieldBorder};` : ''} + + &:focus { + outline: none; + } + `, +); + +const CardImageWrapper = styled('div')` + height: ${IMAGE_HEIGHT + 2}px; + ${effects.checkerboard}; + ${shadows.inset}; + border-bottom: solid ${lengths.borderWidth} ${colors.textFieldBorder}; + position: relative; +`; + +const CardImage = styled('img')` + width: 100%; + height: ${IMAGE_HEIGHT}px; + object-fit: contain; + border-radius: 2px 2px 0 0; +`; + +const CardFileIcon = styled('div')` + width: 100%; + height: 160px; + object-fit: cover; + border-radius: 2px 2px 0 0; + padding: 1em; + font-size: 3em; +`; + +const CardText = styled('p')` + color: ${colors.text}; + padding: 8px; + margin-top: 20px; + overflow-wrap: break-word; + line-height: 1.3; +`; + +const DraftText = styled('p')` + color: ${colors.mediaDraftText}; + background-color: ${colors.mediaDraftBackground}; + position: absolute; + padding: 8px; + border-radius: ${lengths.borderRadius} 0 ${lengths.borderRadius} 0; +`; + +interface MediaLibraryCardProps { + isSelected?: boolean; + displayURL: MediaLibraryDisplayURL; + text: string; + onClick: () => void; + draftText: string; + width: string; + height: string; + margin: string; + isPrivate?: boolean; + type?: string; + isViewableImage: boolean; + loadDisplayURL: () => void; + isDraft?: boolean; +} + +const MediaLibraryCard = ({ + isSelected = false, + displayURL, + text, + onClick, + draftText, + width, + height, + margin, + isPrivate = false, + type, + isViewableImage, + isDraft, + loadDisplayURL, +}: MediaLibraryCardProps) => { + const url = useMemo(() => displayURL.url, [displayURL.url]); + + useEffect(() => { + if (!displayURL.url) { + loadDisplayURL(); + } + }, [displayURL.url, loadDisplayURL]); + + return ( + + + {isDraft ? {draftText} : null} + {url && isViewableImage ? ( + + ) : ( + {type} + )} + + {text} + + ); +}; + +export default MediaLibraryCard; diff --git a/src/components/MediaLibrary/MediaLibraryCardGrid.js b/src/components/MediaLibrary/MediaLibraryCardGrid.js deleted file mode 100644 index d2d627c3..00000000 --- a/src/components/MediaLibrary/MediaLibraryCardGrid.js +++ /dev/null @@ -1,198 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; -import { Waypoint } from 'react-waypoint'; -import { Map } from 'immutable'; -import { FixedSizeGrid as Grid } from 'react-window'; -import AutoSizer from 'react-virtualized-auto-sizer'; - -import { colors } from '../../ui'; -import MediaLibraryCard from './MediaLibraryCard'; - -function CardWrapper(props) { - const { - rowIndex, - columnIndex, - style, - data: { - mediaItems, - isSelectedFile, - onAssetClick, - cardDraftText, - cardWidth, - cardHeight, - isPrivate, - displayURLs, - loadDisplayURL, - columnCount, - gutter, - }, - } = props; - const index = rowIndex * columnCount + columnIndex; - if (index >= mediaItems.length) { - return null; - } - const file = mediaItems[index]; - - return ( -
    - onAssetClick(file)} - isDraft={file.draft} - draftText={cardDraftText} - width={cardWidth} - height={cardHeight} - margin={'0px'} - isPrivate={isPrivate} - displayURL={displayURLs.get(file.id, file.url ? Map({ url: file.url }) : Map())} - loadDisplayURL={() => loadDisplayURL(file)} - type={file.type} - isViewableImage={file.isViewableImage} - /> -
    - ); -} - -function VirtualizedGrid(props) { - const { mediaItems, setScrollContainerRef } = props; - - return ( - - - {({ height, width }) => { - const cardWidth = parseInt(props.cardWidth, 10); - const cardHeight = parseInt(props.cardHeight, 10); - const gutter = parseInt(props.cardMargin, 10); - const columnWidth = cardWidth + gutter; - const rowHeight = cardHeight + gutter; - const columnCount = Math.floor(width / columnWidth); - const rowCount = Math.ceil(mediaItems.length / columnCount); - return ( - - {CardWrapper} - - ); - }} - - - ); -} - -function PaginatedGrid({ - setScrollContainerRef, - mediaItems, - isSelectedFile, - onAssetClick, - cardDraftText, - cardWidth, - cardHeight, - cardMargin, - isPrivate, - displayURLs, - loadDisplayURL, - canLoadMore, - onLoadMore, - isPaginating, - paginatingMessage, -}) { - return ( - - - {mediaItems.map(file => ( - onAssetClick(file)} - isDraft={file.draft} - draftText={cardDraftText} - width={cardWidth} - height={cardHeight} - margin={cardMargin} - isPrivate={isPrivate} - displayURL={displayURLs.get(file.id, file.url ? Map({ url: file.url }) : Map())} - loadDisplayURL={() => loadDisplayURL(file)} - type={file.type} - isViewableImage={file.isViewableImage} - /> - ))} - {!canLoadMore ? null : } - - {!isPaginating ? null : ( - {paginatingMessage} - )} - - ); -} - -const CardGridContainer = styled.div` - overflow-y: auto; - overflow-x: hidden; -`; - -const CardGrid = styled.div` - display: flex; - flex-wrap: wrap; - - margin-left: -10px; - margin-right: -10px; -`; - -const PaginatingMessage = styled.h1` - color: ${props => props.isPrivate && colors.textFieldBorder}; -`; - -function MediaLibraryCardGrid(props) { - const { canLoadMore, isPaginating } = props; - if (canLoadMore || isPaginating) { - return ; - } - return ; -} - -MediaLibraryCardGrid.propTypes = { - setScrollContainerRef: PropTypes.func.isRequired, - mediaItems: PropTypes.arrayOf( - PropTypes.shape({ - displayURL: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), - id: PropTypes.string.isRequired, - key: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, - draft: PropTypes.bool, - }), - ).isRequired, - isSelectedFile: PropTypes.func.isRequired, - onAssetClick: PropTypes.func.isRequired, - canLoadMore: PropTypes.bool, - onLoadMore: PropTypes.func.isRequired, - isPaginating: PropTypes.bool, - paginatingMessage: PropTypes.string, - cardDraftText: PropTypes.string.isRequired, - cardWidth: PropTypes.string.isRequired, - cardMargin: PropTypes.string.isRequired, - loadDisplayURL: PropTypes.func.isRequired, - isPrivate: PropTypes.bool, - displayURLs: PropTypes.instanceOf(Map).isRequired, -}; - -export default MediaLibraryCardGrid; diff --git a/src/components/MediaLibrary/MediaLibraryCardGrid.tsx b/src/components/MediaLibrary/MediaLibraryCardGrid.tsx new file mode 100644 index 00000000..f35e91db --- /dev/null +++ b/src/components/MediaLibrary/MediaLibraryCardGrid.tsx @@ -0,0 +1,239 @@ +import { styled } from '@mui/material/styles'; +import React from 'react'; +import AutoSizer from 'react-virtualized-auto-sizer'; +import { Waypoint } from 'react-waypoint'; +import { FixedSizeGrid as Grid } from 'react-window'; + +import { transientOptions } from '../../lib'; +import { colors } from '../../components/UI/styles'; +import MediaLibraryCard from './MediaLibraryCard'; + +import type { GridChildComponentProps } from 'react-window'; +import type { MediaLibraryDisplayURL, MediaLibraryState } from '../../reducers/mediaLibrary'; +import type { MediaFile } from '../../interface'; + +export interface MediaLibraryCardItem { + displayURL?: MediaLibraryDisplayURL; + id: string; + key: string; + name: string; + type: string; + draft: boolean; + isViewableImage?: boolean; + url?: string; +} + +export interface MediaLibraryCardGridProps { + setScrollContainerRef: () => void; + mediaItems: MediaFile[]; + isSelectedFile: (file: MediaFile) => boolean; + onAssetClick: (asset: MediaFile) => void; + canLoadMore?: boolean; + onLoadMore: () => void; + isPaginating?: boolean; + paginatingMessage?: string; + cardDraftText: string; + cardWidth: string; + cardHeight: string; + cardMargin: string; + loadDisplayURL: (asset: MediaFile) => void; + isPrivate?: boolean; + displayURLs: MediaLibraryState['displayURLs']; +} + +export type CardGridItemData = MediaLibraryCardGridProps & { + columnCount: number; + gutter: number; +}; + +const CardWrapper = ({ + rowIndex, + columnIndex, + style, + data: { + mediaItems, + isSelectedFile, + onAssetClick, + cardDraftText, + cardWidth, + cardHeight, + isPrivate, + displayURLs, + loadDisplayURL, + columnCount, + gutter, + }, +}: GridChildComponentProps) => { + const index = rowIndex * columnCount + columnIndex; + if (index >= mediaItems.length) { + return null; + } + const file = mediaItems[index]; + + return ( +
    + onAssetClick(file)} + isDraft={file.draft} + draftText={cardDraftText} + width={cardWidth} + height={cardHeight} + margin={'0px'} + isPrivate={isPrivate} + displayURL={displayURLs[file.id] ?? (file.url ? { url: file.url } : {})} + loadDisplayURL={() => loadDisplayURL(file)} + type={file.type} + isViewableImage={file.isViewableImage ?? false} + /> +
    + ); +}; + +interface StyledCardGridContainerProps { + $width?: number; + $height?: number; +} + +const StyledCardGridContainer = styled('div')( + ({ $width, $height }) => ` + overflow-y: auto; + overflow-x: hidden; + width: ${$width ? `${$width}px` : '100%'}; + height: ${$height ? `${$height}px` : '100%'};ƒ + `, +); + +const CardGrid = styled('div')` + display: flex; + flex-wrap: wrap; + + margin-left: -10px; + margin-right: -10px; +`; + +const VirtualizedGrid = (props: MediaLibraryCardGridProps) => { + const { + cardWidth: inputCardWidth, + cardHeight: inputCardHeight, + cardMargin, + mediaItems, + setScrollContainerRef, + } = props; + + return ( + + {({ height, width }) => { + const cardWidth = parseInt(inputCardWidth, 10); + const cardHeight = parseInt(inputCardHeight, 10); + const gutter = parseInt(cardMargin, 10); + const columnWidth = cardWidth + gutter; + const rowHeight = cardHeight + gutter; + const columnCount = Math.floor(width / columnWidth); + const rowCount = Math.ceil(mediaItems.length / columnCount); + + return ( + + + {CardWrapper} + + + ); + }} + + ); +}; + +interface PaginatingMessageProps { + $isPrivate: boolean; +} + +const PaginatingMessage = styled( + 'h1', + transientOptions, +)( + ({ $isPrivate }) => ` + ${$isPrivate ? `color: ${colors.textFieldBorder};` : ''} + `, +); + +const PaginatedGrid = ({ + setScrollContainerRef, + mediaItems, + isSelectedFile, + onAssetClick, + cardDraftText, + cardWidth, + cardHeight, + cardMargin, + isPrivate = false, + displayURLs, + loadDisplayURL, + canLoadMore, + onLoadMore, + isPaginating, + paginatingMessage, +}: MediaLibraryCardGridProps) => { + return ( + + + {mediaItems.map(file => ( + onAssetClick(file)} + isDraft={file.draft} + draftText={cardDraftText} + width={cardWidth} + height={cardHeight} + margin={cardMargin} + isPrivate={isPrivate} + displayURL={displayURLs[file.id] ?? (file.url ? { url: file.url } : {})} + loadDisplayURL={() => loadDisplayURL(file)} + type={file.type} + isViewableImage={file.isViewableImage ?? false} + /> + ))} + {!canLoadMore ? null : } + + {!isPaginating ? null : ( + {paginatingMessage} + )} + + ); +}; + +function MediaLibraryCardGrid(props: MediaLibraryCardGridProps) { + const { canLoadMore, isPaginating } = props; + if (canLoadMore || isPaginating) { + return ; + } + return ; +} + +export default MediaLibraryCardGrid; diff --git a/src/components/MediaLibrary/MediaLibraryHeader.js b/src/components/MediaLibrary/MediaLibraryHeader.js deleted file mode 100644 index 5bbffeb5..00000000 --- a/src/components/MediaLibrary/MediaLibraryHeader.js +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; - -import { Icon, shadows, colors, buttons } from '../../ui'; - -const CloseButton = styled.button` - ${buttons.button}; - ${shadows.dropMiddle}; - position: absolute; - margin-right: -40px; - left: -40px; - top: -40px; - width: 40px; - height: 40px; - border-radius: 50%; - background-color: white; - padding: 0; - display: flex; - justify-content: center; - align-items: center; -`; - -const LibraryTitle = styled.h1` - line-height: 36px; - font-size: 22px; - text-align: left; - margin-bottom: 25px; - color: ${props => props.isPrivate && colors.textFieldBorder}; -`; - -function MediaLibraryHeader({ onClose, title, isPrivate }) { - return ( -
    - - - - {title} -
    - ); -} - -MediaLibraryHeader.propTypes = { - onClose: PropTypes.func.isRequired, - title: PropTypes.string.isRequired, - isPrivate: PropTypes.bool, -}; - -export default MediaLibraryHeader; diff --git a/src/components/MediaLibrary/MediaLibraryModal.js b/src/components/MediaLibrary/MediaLibraryModal.js deleted file mode 100644 index 52bfd5b0..00000000 --- a/src/components/MediaLibrary/MediaLibraryModal.js +++ /dev/null @@ -1,200 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; -import { Map } from 'immutable'; -import { isEmpty } from 'lodash'; -import { translate } from 'react-polyglot'; - -import { colors } from '../../ui'; -import { Modal } from '../UI'; -import MediaLibraryTop from './MediaLibraryTop'; -import MediaLibraryCardGrid from './MediaLibraryCardGrid'; -import EmptyMessage from './EmptyMessage'; - -/** - * Responsive styling needs to be overhauled. Current setup requires specifying - * widths per breakpoint. - */ -const cardWidth = `280px`; -const cardHeight = `240px`; -const cardMargin = `10px`; - -/** - * cardWidth + cardMargin * 2 = cardOutsideWidth - * (not using calc because this will be nested in other calcs) - */ -const cardOutsideWidth = `300px`; - -const StyledModal = styled(Modal)` - display: grid; - grid-template-rows: 120px auto; - width: calc(${cardOutsideWidth} + 20px); - background-color: ${props => props.isPrivate && colors.grayDark}; - - @media (min-width: 800px) { - width: calc(${cardOutsideWidth} * 2 + 20px); - } - - @media (min-width: 1120px) { - width: calc(${cardOutsideWidth} * 3 + 20px); - } - - @media (min-width: 1440px) { - width: calc(${cardOutsideWidth} * 4 + 20px); - } - - @media (min-width: 1760px) { - width: calc(${cardOutsideWidth} * 5 + 20px); - } - - @media (min-width: 2080px) { - width: calc(${cardOutsideWidth} * 6 + 20px); - } - - h1 { - color: ${props => props.isPrivate && colors.textFieldBorder}; - } - - button:disabled, - label[disabled] { - background-color: ${props => props.isPrivate && `rgba(217, 217, 217, 0.15)`}; - } -`; - -function MediaLibraryModal({ - isVisible, - canInsert, - files, - dynamicSearch, - dynamicSearchActive, - forImage, - isLoading, - isPersisting, - isDeleting, - hasNextPage, - isPaginating, - privateUpload, - query, - selectedFile, - handleFilter, - handleQuery, - toTableData, - handleClose, - handleSearchChange, - handleSearchKeyDown, - handlePersist, - handleDelete, - handleInsert, - handleDownload, - setScrollContainerRef, - handleAssetClick, - handleLoadMore, - loadDisplayURL, - displayURLs, - t, -}) { - const filteredFiles = forImage ? handleFilter(files) : files; - const queriedFiles = !dynamicSearch && query ? handleQuery(query, filteredFiles) : filteredFiles; - const tableData = toTableData(queriedFiles); - const hasFiles = files && !!files.length; - const hasFilteredFiles = filteredFiles && !!filteredFiles.length; - const hasSearchResults = queriedFiles && !!queriedFiles.length; - const hasMedia = hasSearchResults; - const shouldShowEmptyMessage = !hasMedia; - const emptyMessage = - (isLoading && !hasMedia && t('mediaLibrary.mediaLibraryModal.loading')) || - (dynamicSearchActive && t('mediaLibrary.mediaLibraryModal.noResults')) || - (!hasFiles && t('mediaLibrary.mediaLibraryModal.noAssetsFound')) || - (!hasFilteredFiles && t('mediaLibrary.mediaLibraryModal.noImagesFound')) || - (!hasSearchResults && t('mediaLibrary.mediaLibraryModal.noResults')); - - const hasSelection = hasMedia && !isEmpty(selectedFile); - - return ( - - - {!shouldShowEmptyMessage ? null : ( - - )} - selectedFile.key === file.key} - onAssetClick={handleAssetClick} - canLoadMore={hasNextPage} - onLoadMore={handleLoadMore} - isPaginating={isPaginating} - paginatingMessage={t('mediaLibrary.mediaLibraryModal.loading')} - cardDraftText={t('mediaLibrary.mediaLibraryCard.draft')} - cardWidth={cardWidth} - cardHeight={cardHeight} - cardMargin={cardMargin} - isPrivate={privateUpload} - loadDisplayURL={loadDisplayURL} - displayURLs={displayURLs} - /> - - ); -} - -export const fileShape = { - displayURL: PropTypes.oneOfType([PropTypes.string, PropTypes.object]).isRequired, - id: PropTypes.string.isRequired, - key: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - queryOrder: PropTypes.number, - size: PropTypes.number, - path: PropTypes.string.isRequired, -}; - -MediaLibraryModal.propTypes = { - isVisible: PropTypes.bool, - canInsert: PropTypes.bool, - files: PropTypes.arrayOf(PropTypes.shape(fileShape)).isRequired, - dynamicSearch: PropTypes.bool, - dynamicSearchActive: PropTypes.bool, - forImage: PropTypes.bool, - isLoading: PropTypes.bool, - isPersisting: PropTypes.bool, - isDeleting: PropTypes.bool, - hasNextPage: PropTypes.bool, - isPaginating: PropTypes.bool, - privateUpload: PropTypes.bool, - query: PropTypes.string, - selectedFile: PropTypes.oneOfType([PropTypes.shape(fileShape), PropTypes.shape({})]), - handleFilter: PropTypes.func.isRequired, - handleQuery: PropTypes.func.isRequired, - toTableData: PropTypes.func.isRequired, - handleClose: PropTypes.func.isRequired, - handleSearchChange: PropTypes.func.isRequired, - handleSearchKeyDown: PropTypes.func.isRequired, - handlePersist: PropTypes.func.isRequired, - handleDelete: PropTypes.func.isRequired, - handleInsert: PropTypes.func.isRequired, - setScrollContainerRef: PropTypes.func.isRequired, - handleAssetClick: PropTypes.func.isRequired, - handleLoadMore: PropTypes.func.isRequired, - loadDisplayURL: PropTypes.func.isRequired, - t: PropTypes.func.isRequired, - displayURLs: PropTypes.instanceOf(Map).isRequired, -}; - -export default translate()(MediaLibraryModal); diff --git a/src/components/MediaLibrary/MediaLibraryModal.tsx b/src/components/MediaLibrary/MediaLibraryModal.tsx new file mode 100644 index 00000000..a0001de1 --- /dev/null +++ b/src/components/MediaLibrary/MediaLibraryModal.tsx @@ -0,0 +1,227 @@ +import { styled } from '@mui/material/styles'; +import CloseIcon from '@mui/icons-material/Close'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; +import Fab from '@mui/material/Fab'; +import isEmpty from 'lodash/isEmpty'; +import React from 'react'; +import { translate } from 'react-polyglot'; + +import { transientOptions } from '../../lib'; +import { colors, colorsRaw } from '../../components/UI/styles'; +import EmptyMessage from './EmptyMessage'; +import MediaLibraryCardGrid from './MediaLibraryCardGrid'; +import MediaLibraryTop from './MediaLibraryTop'; + +import type { ChangeEvent, ChangeEventHandler, KeyboardEventHandler } from 'react'; +import type { MediaFile, TranslatedProps } from '../../interface'; +import type { MediaLibraryState } from '../../reducers/mediaLibrary'; + +const StyledFab = styled(Fab)` + position: absolute; + top: -20px; + left: -20px; +`; + +/** + * TODO Responsive styling needs to be overhauled. Current setup requires specifying + * widths per breakpoint. + */ +const cardWidth = `280px`; +const cardHeight = `240px`; +const cardMargin = `10px`; + +/** + * cardWidth + cardMargin * 2 = cardOutsideWidth + * (not using calc because this will be nested in other calcs) + */ +const cardOutsideWidth = `300px`; + +interface StyledModalProps { + $isPrivate: boolean; +} + +const StyledModal = styled( + Dialog, + transientOptions, +)( + ({ $isPrivate }) => ` + .MuiDialog-paper { + display: flex; + flex-direction: column; + overflow: visible; + height: 80%; + width: calc(${cardOutsideWidth} + 20px); + max-width: calc(${cardOutsideWidth} + 20px); + ${$isPrivate ? `background-color: ${colorsRaw.grayDark};` : ''} + + @media (min-width: 800px) { + width: calc(${cardOutsideWidth} * 2 + 20px); + max-width: calc(${cardOutsideWidth} * 2 + 20px); + } + + @media (min-width: 1120px) { + width: calc(${cardOutsideWidth} * 3 + 20px); + max-width: calc(${cardOutsideWidth} * 3 + 20px); + } + + @media (min-width: 1440px) { + width: calc(${cardOutsideWidth} * 4 + 20px); + max-width: calc(${cardOutsideWidth} * 4 + 20px); + } + + @media (min-width: 1760px) { + width: calc(${cardOutsideWidth} * 5 + 20px); + max-width: calc(${cardOutsideWidth} * 5 + 20px); + } + + @media (min-width: 2080px) { + width: calc(${cardOutsideWidth} * 6 + 20px); + max-width: calc(${cardOutsideWidth} * 6 + 20px); + } + + h1 { + ${$isPrivate && `color: ${colors.textFieldBorder};`} + } + + button:disabled, + label[disabled] { + ${$isPrivate ? 'background-color: rgba(217, 217, 217, 0.15);' : ''} + } + } + `, +); + +interface MediaLibraryModalProps { + isVisible?: boolean; + canInsert?: boolean; + files: MediaFile[]; + dynamicSearch?: boolean; + dynamicSearchActive?: boolean; + forImage?: boolean; + isLoading?: boolean; + isPersisting?: boolean; + isDeleting?: boolean; + hasNextPage?: boolean; + isPaginating?: boolean; + privateUpload?: boolean; + query?: string; + selectedFile?: MediaFile; + handleFilter: (files: MediaFile[]) => MediaFile[]; + handleQuery: (query: string, files: MediaFile[]) => MediaFile[]; + toTableData: (files: MediaFile[]) => MediaFile[]; + handleClose: () => void; + handleSearchChange: ChangeEventHandler; + handleSearchKeyDown: KeyboardEventHandler; + handlePersist: (event: ChangeEvent | DragEvent) => void; + handleDelete: () => void; + handleInsert: () => void; + handleDownload: () => void; + setScrollContainerRef: () => void; + handleAssetClick: (asset: MediaFile) => void; + handleLoadMore: () => void; + loadDisplayURL: (file: MediaFile) => void; + displayURLs: MediaLibraryState['displayURLs']; +} + +const MediaLibraryModal = ({ + isVisible = false, + canInsert, + files, + dynamicSearch, + dynamicSearchActive, + forImage, + isLoading, + isPersisting, + isDeleting, + hasNextPage, + isPaginating, + privateUpload = false, + query, + selectedFile, + handleFilter, + handleQuery, + toTableData, + handleClose, + handleSearchChange, + handleSearchKeyDown, + handlePersist, + handleDelete, + handleInsert, + handleDownload, + setScrollContainerRef, + handleAssetClick, + handleLoadMore, + loadDisplayURL, + displayURLs, + t, +}: TranslatedProps) => { + const filteredFiles = forImage ? handleFilter(files) : files; + const queriedFiles = !dynamicSearch && query ? handleQuery(query, filteredFiles) : filteredFiles; + const tableData = toTableData(queriedFiles); + const hasFiles = files && !!files.length; + const hasFilteredFiles = filteredFiles && !!filteredFiles.length; + const hasSearchResults = queriedFiles && !!queriedFiles.length; + const hasMedia = hasSearchResults; + const shouldShowEmptyMessage = !hasMedia; + const emptyMessage = + (isLoading && !hasMedia && t('mediaLibrary.mediaLibraryModal.loading')) || + (dynamicSearchActive && t('mediaLibrary.mediaLibraryModal.noResults')) || + (!hasFiles && t('mediaLibrary.mediaLibraryModal.noAssetsFound')) || + (!hasFilteredFiles && t('mediaLibrary.mediaLibraryModal.noImagesFound')) || + (!hasSearchResults && t('mediaLibrary.mediaLibraryModal.noResults')) || + ''; + + const hasSelection = hasMedia && !isEmpty(selectedFile); + + return ( + + + + + + + {!shouldShowEmptyMessage ? null : ( + + )} + selectedFile?.key === file.key} + onAssetClick={handleAssetClick} + canLoadMore={hasNextPage} + onLoadMore={handleLoadMore} + isPaginating={isPaginating} + paginatingMessage={t('mediaLibrary.mediaLibraryModal.loading')} + cardDraftText={t('mediaLibrary.mediaLibraryCard.draft')} + cardWidth={cardWidth} + cardHeight={cardHeight} + cardMargin={cardMargin} + isPrivate={privateUpload} + loadDisplayURL={loadDisplayURL} + displayURLs={displayURLs} + /> + + + ); +}; + +export default translate()(MediaLibraryModal); diff --git a/src/components/MediaLibrary/MediaLibrarySearch.js b/src/components/MediaLibrary/MediaLibrarySearch.js deleted file mode 100644 index e63aca19..00000000 --- a/src/components/MediaLibrary/MediaLibrarySearch.js +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; - -import { Icon, lengths, colors, zIndex } from '../../ui'; - -const SearchContainer = styled.div` - height: 37px; - display: flex; - align-items: center; - position: relative; - width: 400px; -`; - -const SearchInput = styled.input` - background-color: #eff0f4; - border-radius: ${lengths.borderRadius}; - - font-size: 14px; - padding: 10px 6px 10px 32px; - width: 100%; - position: relative; - z-index: ${zIndex.zIndex1}; - - &:focus { - outline: none; - box-shadow: inset 0 0 0 2px ${colors.active}; - } -`; - -const SearchIcon = styled(Icon)` - position: absolute; - top: 50%; - left: 6px; - z-index: ${zIndex.zIndex2}; - transform: translate(0, -50%); -`; - -function MediaLibrarySearch({ value, onChange, onKeyDown, placeholder, disabled }) { - return ( - - - - - ); -} - -MediaLibrarySearch.propTypes = { - value: PropTypes.string, - onChange: PropTypes.func.isRequired, - onKeyDown: PropTypes.func.isRequired, - placeholder: PropTypes.string.isRequired, - disabled: PropTypes.bool, -}; - -export default MediaLibrarySearch; diff --git a/src/components/MediaLibrary/MediaLibrarySearch.tsx b/src/components/MediaLibrary/MediaLibrarySearch.tsx new file mode 100644 index 00000000..8651c922 --- /dev/null +++ b/src/components/MediaLibrary/MediaLibrarySearch.tsx @@ -0,0 +1,43 @@ +import SearchIcon from '@mui/icons-material/Search'; +import InputAdornment from '@mui/material/InputAdornment'; +import TextField from '@mui/material/TextField'; +import React from 'react'; + +import type { ChangeEventHandler, KeyboardEventHandler } from 'react'; + +export interface MediaLibrarySearchProps { + value?: string; + onChange: ChangeEventHandler; + onKeyDown: KeyboardEventHandler; + placeholder: string; + disabled?: boolean; +} + +const MediaLibrarySearch = ({ + value = '', + onChange, + onKeyDown, + placeholder, + disabled, +}: MediaLibrarySearchProps) => { + return ( + + + + ), + }} + /> + ); +}; + +export default MediaLibrarySearch; diff --git a/src/components/MediaLibrary/MediaLibraryTop.js b/src/components/MediaLibrary/MediaLibraryTop.js deleted file mode 100644 index 00b3ed06..00000000 --- a/src/components/MediaLibrary/MediaLibraryTop.js +++ /dev/null @@ -1,143 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; - -import MediaLibrarySearch from './MediaLibrarySearch'; -import MediaLibraryHeader from './MediaLibraryHeader'; -import { - UploadButton, - DeleteButton, - DownloadButton, - CopyToClipBoardButton, - InsertButton, -} from './MediaLibraryButtons'; - -const LibraryTop = styled.div` - position: relative; - display: flex; - flex-direction: column; -`; - -const RowContainer = styled.div` - display: flex; - justify-content: space-between; -`; - -const ButtonsContainer = styled.div` - flex-shrink: 0; -`; - -function MediaLibraryTop({ - t, - onClose, - privateUpload, - forImage, - onDownload, - onUpload, - query, - onSearchChange, - onSearchKeyDown, - searchDisabled, - onDelete, - canInsert, - onInsert, - hasSelection, - isPersisting, - isDeleting, - selectedFile, -}) { - const shouldShowButtonLoader = isPersisting || isDeleting; - const uploadEnabled = !shouldShowButtonLoader; - const deleteEnabled = !shouldShowButtonLoader && hasSelection; - - const uploadButtonLabel = isPersisting - ? t('mediaLibrary.mediaLibraryModal.uploading') - : t('mediaLibrary.mediaLibraryModal.upload'); - const deleteButtonLabel = isDeleting - ? t('mediaLibrary.mediaLibraryModal.deleting') - : t('mediaLibrary.mediaLibraryModal.deleteSelected'); - const downloadButtonLabel = t('mediaLibrary.mediaLibraryModal.download'); - const insertButtonLabel = t('mediaLibrary.mediaLibraryModal.chooseSelected'); - - return ( - - - - - - - {downloadButtonLabel} - - - - - - - - - {deleteButtonLabel} - - {!canInsert ? null : ( - - {insertButtonLabel} - - )} - - - - ); -} - -MediaLibraryTop.propTypes = { - t: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, - privateUpload: PropTypes.bool, - forImage: PropTypes.bool, - onDownload: PropTypes.func.isRequired, - onUpload: PropTypes.func.isRequired, - query: PropTypes.string, - onSearchChange: PropTypes.func.isRequired, - onSearchKeyDown: PropTypes.func.isRequired, - searchDisabled: PropTypes.bool.isRequired, - onDelete: PropTypes.func.isRequired, - canInsert: PropTypes.bool, - onInsert: PropTypes.func.isRequired, - hasSelection: PropTypes.bool.isRequired, - isPersisting: PropTypes.bool, - isDeleting: PropTypes.bool, - selectedFile: PropTypes.oneOfType([ - PropTypes.shape({ - path: PropTypes.string.isRequired, - draft: PropTypes.bool.isRequired, - name: PropTypes.string.isRequired, - }), - PropTypes.shape({}), - ]), -}; - -export default MediaLibraryTop; diff --git a/src/components/MediaLibrary/MediaLibraryTop.tsx b/src/components/MediaLibrary/MediaLibraryTop.tsx new file mode 100644 index 00000000..a4c8e722 --- /dev/null +++ b/src/components/MediaLibrary/MediaLibraryTop.tsx @@ -0,0 +1,130 @@ +import { styled } from '@mui/material/styles'; +import Button from '@mui/material/Button'; +import DialogTitle from '@mui/material/DialogTitle'; +import React from 'react'; + +import { CopyToClipBoardButton, UploadButton } from './MediaLibraryButtons'; +import MediaLibrarySearch from './MediaLibrarySearch'; + +import type { ChangeEvent, ChangeEventHandler, KeyboardEventHandler } from 'react'; +import type { MediaFile, TranslatedProps } from '../../interface'; + +const LibraryTop = styled('div')` + position: relative; + display: flex; + flex-direction: column; +`; + +const StyledButtonsContainer = styled('div')` + flex-shrink: 0; + display: flex; + gap: 8px; +`; + +const StyledDialogTitle = styled(DialogTitle)` + display: flex; + justify-content: space-between; + align-items: center; +`; + +export interface MediaLibraryTopProps { + onClose: () => void; + privateUpload?: boolean; + forImage?: boolean; + onDownload: () => void; + onUpload: (event: ChangeEvent | DragEvent) => void; + query?: string; + onSearchChange: ChangeEventHandler; + onSearchKeyDown: KeyboardEventHandler; + searchDisabled: boolean; + onDelete: () => void; + canInsert?: boolean; + onInsert: () => void; + hasSelection: boolean; + isPersisting?: boolean; + isDeleting?: boolean; + selectedFile?: MediaFile; +} + +const MediaLibraryTop = ({ + t, + forImage, + onDownload, + onUpload, + query, + onSearchChange, + onSearchKeyDown, + searchDisabled, + onDelete, + canInsert, + onInsert, + hasSelection, + isPersisting, + isDeleting, + selectedFile, + privateUpload, +}: TranslatedProps) => { + const shouldShowButtonLoader = isPersisting || isDeleting; + const uploadEnabled = !shouldShowButtonLoader; + const deleteEnabled = !shouldShowButtonLoader && hasSelection; + + const uploadButtonLabel = isPersisting + ? t('mediaLibrary.mediaLibraryModal.uploading') + : t('mediaLibrary.mediaLibraryModal.upload'); + const deleteButtonLabel = isDeleting + ? t('mediaLibrary.mediaLibraryModal.deleting') + : t('mediaLibrary.mediaLibraryModal.deleteSelected'); + const downloadButtonLabel = t('mediaLibrary.mediaLibraryModal.download'); + const insertButtonLabel = t('mediaLibrary.mediaLibraryModal.chooseSelected'); + + return ( + + + {`${privateUpload ? t('mediaLibrary.mediaLibraryModal.private') : ''}${ + forImage + ? t('mediaLibrary.mediaLibraryModal.images') + : t('mediaLibrary.mediaLibraryModal.mediaAssets') + }`} + + + + + + + + + + + {!canInsert ? null : ( + + )} + + + + ); +}; + +export default MediaLibraryTop; diff --git a/src/components/UI/Alert.tsx b/src/components/UI/Alert.tsx index d054c107..f77edbc0 100644 --- a/src/components/UI/Alert.tsx +++ b/src/components/UI/Alert.tsx @@ -13,9 +13,9 @@ import { useWindowEvent } from '../../lib/util/window.util'; import type { TranslateProps } from 'react-polyglot'; interface AlertProps { - title: string | { key: string; options?: any }; - body: string | { key: string; options?: any }; - okay?: string | { key: string; options?: any }; + title: string | { key: string; options?: Record }; + body: string | { key: string; options?: Record }; + okay?: string | { key: string; options?: Record }; color?: 'success' | 'error' | 'primary'; } @@ -49,18 +49,18 @@ const AlertDialog = ({ t }: TranslateProps) => { return ''; } return typeof rawTitle === 'string' ? t(rawTitle) : t(rawTitle.key, rawTitle.options); - }, [rawTitle]); + }, [rawTitle, t]); const body = useMemo(() => { if (!rawBody) { return ''; } return typeof rawBody === 'string' ? t(rawBody) : t(rawBody.key, rawBody.options); - }, [rawBody]); + }, [rawBody, t]); const okay = useMemo( () => (typeof rawOkay === 'string' ? t(rawOkay) : t(rawOkay.key, rawOkay.options)), - [rawOkay], + [rawOkay, t], ); if (!detail) { @@ -75,7 +75,7 @@ const AlertDialog = ({ t }: TranslateProps) => { aria-labelledby="alert-dialog-title" aria-describedby="alert-dialog-description" > - {t(title)} + {title} {body} diff --git a/src/components/UI/AuthenticationPage.tsx b/src/components/UI/AuthenticationPage.tsx new file mode 100644 index 00000000..62478fee --- /dev/null +++ b/src/components/UI/AuthenticationPage.tsx @@ -0,0 +1,86 @@ +import Button from '@mui/material/Button'; +import { styled } from '@mui/material/styles'; +import React from 'react'; + +import GoBackButton from './GoBackButton'; +import Icon from './Icon'; + +import type { MouseEventHandler, ReactNode } from 'react'; +import type { TranslatedProps } from '../../interface'; + +const StyledAuthenticationPage = styled('section')` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; +`; + +const CustomIconWrapper = styled('span')` + width: 300px; + height: 15∏0px; + margin-top: -150px; +`; + +const SimpleLogoIcon = styled(Icon)` + color: #c4c6d2; +`; + +const StaticCustomIcon = styled(Icon)` + color: #c4c6d2; +`; + +const CustomLogoIcon = ({ url }: { url: string }) => { + return ( + + Logo + + ); +}; + +const renderPageLogo = (logoUrl?: string) => { + if (logoUrl) { + return ; + } + return ; +}; + +export interface AuthenticationPageProps { + onLogin?: MouseEventHandler; + logoUrl?: string; + siteUrl?: string; + loginDisabled?: boolean; + loginErrorMessage?: ReactNode; + icon?: ReactNode; + buttonContent?: ReactNode; + pageContent?: ReactNode; +} + +const AuthenticationPage = ({ + onLogin, + loginDisabled, + loginErrorMessage, + icon, + buttonContent, + pageContent, + logoUrl, + siteUrl, + t, +}: TranslatedProps) => { + return ( + + {renderPageLogo(logoUrl)} + {loginErrorMessage ?

    {loginErrorMessage}

    : null} + {pageContent ?? null} + {buttonContent ? ( + + ) : null} + {siteUrl ? : null} + {logoUrl ? : null} +
    + ); +}; + +export default AuthenticationPage; diff --git a/src/components/UI/Confirm.tsx b/src/components/UI/Confirm.tsx index 84185a97..b3dce11e 100644 --- a/src/components/UI/Confirm.tsx +++ b/src/components/UI/Confirm.tsx @@ -13,10 +13,10 @@ import { useWindowEvent } from '../../lib/util/window.util'; import type { TranslateProps } from 'react-polyglot'; interface ConfirmProps { - title: string | { key: string; options?: any }; - body: string | { key: string; options?: any }; - cancel?: string | { key: string; options?: any }; - confirm?: string | { key: string; options?: any }; + title: string | { key: string; options?: Record }; + body: string | { key: string; options?: Record }; + cancel?: string | { key: string; options?: Record }; + confirm?: string | { key: string; options?: Record }; color?: 'success' | 'error' | 'primary'; } @@ -60,23 +60,23 @@ const ConfirmDialog = ({ t }: TranslateProps) => { return ''; } return typeof rawTitle === 'string' ? t(rawTitle) : t(rawTitle.key, rawTitle.options); - }, [rawTitle]); + }, [rawTitle, t]); const body = useMemo(() => { if (!rawBody) { return ''; } return typeof rawBody === 'string' ? t(rawBody) : t(rawBody.key, rawBody.options); - }, [rawBody]); + }, [rawBody, t]); const cancel = useMemo( () => (typeof rawCancel === 'string' ? t(rawCancel) : t(rawCancel.key, rawCancel.options)), - [rawCancel], + [rawCancel, t], ); const confirm = useMemo( () => (typeof rawConfirm === 'string' ? t(rawConfirm) : t(rawConfirm.key, rawConfirm.options)), - [rawConfirm], + [rawConfirm, t], ); if (!detail) { diff --git a/src/components/UI/DragDrop.js b/src/components/UI/DragDrop.js deleted file mode 100644 index 9e08eb61..00000000 --- a/src/components/UI/DragDrop.js +++ /dev/null @@ -1,66 +0,0 @@ -import { HTML5Backend as ReactDNDHTML5Backend } from 'react-dnd-html5-backend'; -import { - DndProvider as ReactDNDProvider, - DragSource as ReactDNDDragSource, - DropTarget as ReactDNDDropTarget, -} from 'react-dnd'; -import React from 'react'; -import PropTypes from 'prop-types'; - -export function DragSource({ namespace, ...props }) { - const DragComponent = ReactDNDDragSource( - namespace, - { - // eslint-disable-next-line no-unused-vars - beginDrag({ children, isDragging, connectDragComponent, ...ownProps }) { - // We return the rest of the props as the ID of the element being dragged. - return ownProps; - }, - }, - connect => ({ - connectDragComponent: connect.dragSource(), - }), - )(({ children, connectDragComponent }) => children(connectDragComponent)); - - return React.createElement(DragComponent, props, props.children); -} - -DragSource.propTypes = { - namespace: PropTypes.any.isRequired, - children: PropTypes.func.isRequired, -}; - -export function DropTarget({ onDrop, namespace, ...props }) { - const DropComponent = ReactDNDDropTarget( - namespace, - { - drop(ownProps, monitor) { - onDrop(monitor.getItem()); - }, - }, - (connect, monitor) => ({ - connectDropTarget: connect.dropTarget(), - isHovered: monitor.isOver(), - }), - )(({ children, connectDropTarget, isHovered }) => children(connectDropTarget, { isHovered })); - - return React.createElement(DropComponent, props, props.children); -} - -DropTarget.propTypes = { - onDrop: PropTypes.func.isRequired, - namespace: PropTypes.any.isRequired, - children: PropTypes.func.isRequired, -}; - -export function HTML5DragDrop(WrappedComponent) { - return class HTML5DragDrop extends React.Component { - render() { - return ( - - - - ); - } - }; -} diff --git a/src/components/UI/ErrorBoundary.js b/src/components/UI/ErrorBoundary.tsx similarity index 69% rename from src/components/UI/ErrorBoundary.js rename to src/components/UI/ErrorBoundary.tsx index e5d2fc0c..53a517bc 100644 --- a/src/components/UI/ErrorBoundary.js +++ b/src/components/UI/ErrorBoundary.tsx @@ -1,18 +1,20 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { translate } from 'react-polyglot'; -import styled from '@emotion/styled'; -import yaml from 'yaml'; -import { truncate } from 'lodash'; -import copyToClipboard from 'copy-text-to-clipboard'; +import { styled } from '@mui/material/styles'; import cleanStack from 'clean-stack'; +import copyToClipboard from 'copy-text-to-clipboard'; +import truncate from 'lodash/truncate'; +import React from 'react'; +import { translate } from 'react-polyglot'; +import yaml from 'yaml'; -import { buttons, colors } from '../../ui'; import { localForage } from '../../lib/util'; +import { buttons, colors } from '../../components/UI/styles'; + +import type { ReactNode } from 'react'; +import type { Config, TranslatedProps } from '../../interface'; const ISSUE_URL = 'https://github.com/StaticJsCMS/static-cms/issues/new?'; -function getIssueTemplate({ version, provider, browser, config }) { +function getIssueTemplate(version: string, provider: string, browser: string, config: string) { return ` **Describe the bug** @@ -36,24 +38,24 @@ ${config} `; } -function buildIssueTemplate({ config }) { +function buildIssueTemplate(config: Config) { let version = ''; if (typeof STATIC_CMS_CORE_VERSION === 'string') { version = `static-cms@${STATIC_CMS_CORE_VERSION}`; } - const template = getIssueTemplate({ + const template = getIssueTemplate( version, - provider: config.backend.name, - browser: navigator.userAgent, - config: yaml.stringify(config), - }); + config.backend.name, + navigator.userAgent, + yaml.stringify(config), + ); return template; } -function buildIssueUrl({ title, config }) { +function buildIssueUrl(title: string, config: Config) { try { - const body = buildIssueTemplate({ config }); + const body = buildIssueTemplate(config); const params = new URLSearchParams(); params.append('title', truncate(title, { length: 100 })); @@ -67,7 +69,7 @@ function buildIssueUrl({ title, config }) { } } -const ErrorBoundaryContainer = styled.div` +const ErrorBoundaryContainer = styled('div')` padding: 40px; h1 { @@ -97,11 +99,11 @@ const ErrorBoundaryContainer = styled.div` } `; -const PrivacyWarning = styled.span` +const PrivacyWarning = styled('span')` color: ${colors.text}; `; -const CopyButton = styled.button` +const CopyButton = styled('button')` ${buttons.button}; ${buttons.default}; ${buttons.gray}; @@ -109,7 +111,11 @@ const CopyButton = styled.button` margin: 12px 0; `; -function RecoveredEntry({ entry, t }) { +interface RecoveredEntryProps { + entry: string; +} + +const RecoveredEntry = ({ entry, t }: TranslatedProps) => { console.info(entry); return ( <> @@ -124,23 +130,33 @@ function RecoveredEntry({ entry, t }) { ); +}; + +interface ErrorBoundaryProps { + children: ReactNode; + config: Config; + showBackup?: boolean; } -export class ErrorBoundary extends React.Component { - static propTypes = { - children: PropTypes.node, - t: PropTypes.func.isRequired, - config: PropTypes.object.isRequired, - }; +interface ErrorBoundaryState { + hasError: boolean; + errorMessage: string; + errorTitle: string; + backup: string; +} - state = { +export class ErrorBoundary extends React.Component< + TranslatedProps, + ErrorBoundaryState +> { + state: ErrorBoundaryState = { hasError: false, errorMessage: '', errorTitle: '', backup: '', }; - static getDerivedStateFromError(error) { + static getDerivedStateFromError(error: Error) { console.error(error); return { hasError: true, @@ -149,7 +165,10 @@ export class ErrorBoundary extends React.Component { }; } - shouldComponentUpdate(nextProps, nextState) { + shouldComponentUpdate( + _nextProps: TranslatedProps, + nextState: ErrorBoundaryState, + ) { if (this.props.showBackup) { return ( this.state.errorMessage !== nextState.errorMessage || this.state.backup !== nextState.backup @@ -160,9 +179,11 @@ export class ErrorBoundary extends React.Component { async componentDidUpdate() { if (this.props.showBackup) { - const backup = await localForage.getItem('backup'); - backup && console.info(backup); - this.setState({ backup }); + const backup = await localForage.getItem('backup'); + if (backup) { + console.info(backup); + this.setState({ backup }); + } } } @@ -178,7 +199,7 @@ export class ErrorBoundary extends React.Component {

    {t('ui.errorBoundary.details')} ; +} + +const FieldLabel = ({ children, htmlFor, onClick, hasErrors = false }: FieldLabelProps) => { + return ( + + {children} + + ); +}; + +export default FieldLabel; diff --git a/src/components/UI/FileUploadButton.js b/src/components/UI/FileUploadButton.js deleted file mode 100644 index 61bf36c7..00000000 --- a/src/components/UI/FileUploadButton.js +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -export function FileUploadButton({ label, imagesOnly, onChange, disabled, className }) { - return ( - - ); -} - -FileUploadButton.propTypes = { - className: PropTypes.string, - label: PropTypes.string.isRequired, - imagesOnly: PropTypes.bool, - onChange: PropTypes.func.isRequired, - disabled: PropTypes.bool, -}; diff --git a/src/components/UI/FileUploadButton.tsx b/src/components/UI/FileUploadButton.tsx new file mode 100644 index 00000000..1ea33b51 --- /dev/null +++ b/src/components/UI/FileUploadButton.tsx @@ -0,0 +1,27 @@ +import Button from '@mui/material/Button'; +import React from 'react'; + +export interface FileUploadButtonProps { + label: string; + imagesOnly?: boolean; + onChange: React.ChangeEventHandler; + disabled?: boolean; +} + +const FileUploadButton = ({ label, imagesOnly, onChange, disabled }: FileUploadButtonProps) => { + return ( + + ); +}; + +export default FileUploadButton; diff --git a/src/components/UI/GoBackButton.tsx b/src/components/UI/GoBackButton.tsx new file mode 100644 index 00000000..e27249ac --- /dev/null +++ b/src/components/UI/GoBackButton.tsx @@ -0,0 +1,19 @@ +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import Button from '@mui/material/Button'; +import React from 'react'; + +import type { TranslatedProps } from '../../interface'; + +interface GoBackButtonProps { + href: string; +} + +const GoBackButton = ({ href, t }: TranslatedProps) => { + return ( + + ); +}; + +export default GoBackButton; diff --git a/src/components/UI/Icon.tsx b/src/components/UI/Icon.tsx new file mode 100644 index 00000000..4c8ff334 --- /dev/null +++ b/src/components/UI/Icon.tsx @@ -0,0 +1,97 @@ +import { styled } from '@mui/material/styles'; +import React from 'react'; + +import icons from './Icon/icons'; +import transientOptions from '../../lib/util/transientOptions'; + +import type { IconType } from './Icon/icons'; + +interface IconWrapperProps { + $width: number; + $height: number; + $rotation: string; +} + +const IconWrapper = styled( + 'span', + transientOptions, +)( + ({ $width, $height, $rotation }) => ` + display: inline-block; + line-height: 0; + width: ${$width}px; + height: ${$height}px; + transform: rotate(${$rotation}); + + & path:not(.no-fill), + & circle:not(.no-fill), + & polygon:not(.no-fill), + & rect:not(.no-fill) { + fill: currentColor; + } + + & path.clipped { + fill: transparent; + } + + svg { + width: 100%; + height: 100%; + } + `, +); + +const rotations = { right: 90, down: 180, left: 270, up: 360 }; + +export type Direction = keyof typeof rotations; + +/** + * Calculates rotation for icons that have a `direction` property configured + * in the imported icon definition object. If no direction is configured, a + * neutral rotation value is returned. + * + * Returned value is a string of shape `${degrees}deg`, for use in a CSS + * transform. + */ +function getRotation(iconDirection?: Direction, newDirection?: Direction) { + if (!iconDirection || !newDirection) { + return '0deg'; + } + const degrees = rotations[newDirection] - rotations[iconDirection]; + return `${degrees}deg`; +} + +const sizes = { + xsmall: 12, + small: 18, + medium: 24, + large: 32, +}; + +export interface IconProps { + type: IconType; + direction?: Direction; + width?: number; + height?: number; + size?: keyof typeof sizes; + className?: string; +} + +const Icon = ({ type, direction, width, height, size = 'medium', className }: IconProps) => { + const IconSvg = icons[type].image; + + return ( + + + + ); +}; + +export default styled(Icon)``; diff --git a/src/ui/Icon/icons.js b/src/components/UI/Icon/icons.tsx similarity index 54% rename from src/ui/Icon/icons.js rename to src/components/UI/Icon/icons.tsx index 7704ff54..3276b216 100644 --- a/src/ui/Icon/icons.js +++ b/src/components/UI/Icon/icons.tsx @@ -1,7 +1,9 @@ -import mapValues from 'lodash/mapValues'; - import images from './images/_index'; +import type { Direction } from '../Icon'; + +export type IconType = keyof typeof images; + /** * This module outputs icon objects with the following shape: * @@ -16,29 +18,25 @@ import images from './images/_index'; * defining the default direction here. */ -/** - * Configuration for individual icons. - */ -const config = { - arrow: { - direction: 'left', - }, - chevron: { - direction: 'down', - }, - 'chevron-double': { - direction: 'down', - }, -}; +interface IconTypeConfig { + direction: Direction; +} + +export interface IconTypeProps extends Partial { + image: () => JSX.Element; +} /** - * Map icon definition objects - imported object of images simply maps the icon + * Record icon definition objects - imported object of images simply maps the icon * name to the raw svg, so we move that to the `image` property of the * definition object and set any additional configured properties for each icon. */ -const icons = mapValues(images, (image, name) => { - const props = config[name] || {}; - return { image, ...props }; -}); +const icons = (Object.keys(images) as IconType[]).reduce((acc, name) => { + const image = images[name]; + acc[name] = { + image, + }; + return acc; +}, {} as Record); export default icons; diff --git a/src/components/UI/Icon/images/_index.tsx b/src/components/UI/Icon/images/_index.tsx new file mode 100644 index 00000000..1737108c --- /dev/null +++ b/src/components/UI/Icon/images/_index.tsx @@ -0,0 +1,15 @@ +import azure from './azure.svg'; +import bitbucket from './bitbucket.svg'; +import github from './github.svg'; +import gitlab from './gitlab.svg'; +import staticCms from './static-cms-logo.svg'; + +const images = { + azure, + bitbucket, + github, + gitlab, + 'static-cms': staticCms, +}; + +export default images; diff --git a/src/ui/Icon/images/azure.svg b/src/components/UI/Icon/images/azure.svg similarity index 98% rename from src/ui/Icon/images/azure.svg rename to src/components/UI/Icon/images/azure.svg index 0e8f1242..28bb019a 100644 --- a/src/ui/Icon/images/azure.svg +++ b/src/components/UI/Icon/images/azure.svg @@ -6,4 +6,4 @@ width="26px"> - + \ No newline at end of file diff --git a/src/ui/Icon/images/bitbucket.svg b/src/components/UI/Icon/images/bitbucket.svg similarity index 94% rename from src/ui/Icon/images/bitbucket.svg rename to src/components/UI/Icon/images/bitbucket.svg index cd8a5d76..b56be078 100644 --- a/src/ui/Icon/images/bitbucket.svg +++ b/src/components/UI/Icon/images/bitbucket.svg @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/src/ui/Icon/images/github.svg b/src/components/UI/Icon/images/github.svg similarity index 100% rename from src/ui/Icon/images/github.svg rename to src/components/UI/Icon/images/github.svg diff --git a/src/ui/Icon/images/gitlab.svg b/src/components/UI/Icon/images/gitlab.svg similarity index 100% rename from src/ui/Icon/images/gitlab.svg rename to src/components/UI/Icon/images/gitlab.svg diff --git a/src/ui/Icon/images/static-cms-logo.svg b/src/components/UI/Icon/images/static-cms-logo.svg similarity index 100% rename from src/ui/Icon/images/static-cms-logo.svg rename to src/components/UI/Icon/images/static-cms-logo.svg diff --git a/src/components/UI/ListItemTopBar.tsx b/src/components/UI/ListItemTopBar.tsx new file mode 100644 index 00000000..86a0421b --- /dev/null +++ b/src/components/UI/ListItemTopBar.tsx @@ -0,0 +1,131 @@ +import { styled } from '@mui/material/styles'; +import CloseIcon from '@mui/icons-material/Close'; +import DragHandleIcon from '@mui/icons-material/DragHandle'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import IconButton from '@mui/material/IconButton'; +import React from 'react'; + +import { transientOptions } from '../../lib/util'; +import { buttons, colors, lengths, transitions } from './styles'; + +import type { ComponentClass, MouseEvent, ReactNode } from 'react'; + +interface TopBarProps { + $isVariableTypesList: boolean; + $collapsed: boolean; +} + +const TopBar = styled( + 'div', + transientOptions, +)( + ({ $isVariableTypesList, $collapsed }) => ` + display: flex; + align-items: center; + justify-content: space-between; + height: 44px; + padding: 2px 8px; + border-radius: ${ + !$isVariableTypesList + ? $collapsed + ? lengths.borderRadius + : `${lengths.borderRadius} ${lengths.borderRadius} 0 0` + : $collapsed + ? `0 ${lengths.borderRadius} ${lengths.borderRadius} ${lengths.borderRadius}` + : `0 ${lengths.borderRadius} 0 0` + }; + position: relative; + `, +); + +const TopBarButton = styled('button')` + ${buttons.button}; + color: ${colors.controlLabel}; + background: transparent; + font-size: 16px; + line-height: 1; + padding: 0; + width: 32px; + text-align: center; + cursor: pointer; + display: flex; + justify-content: center; + align-items: center; + position: relative; +`; + +const StyledTitle = styled('div')` + position: absolute; + top: 0; + left: 48px; + line-height: 40px; + white-space: nowrap; + cursor: pointer; + display: flex; + align-items: center; + z-index: 1; +`; + +const TopBarButtonSpan = TopBarButton.withComponent('span'); + +const DragIconContainer = styled(TopBarButtonSpan)` + width: 100%; + cursor: move; +`; + +export interface DragHandleProps { + dragHandleHOC: (render: () => ReactNode) => ComponentClass; +} + +const DragHandle = ({ dragHandleHOC }: DragHandleProps) => { + const Handle = dragHandleHOC(() => ( + + + + )); + return ; +}; + +export interface ListItemTopBarProps { + className?: string; + title: ReactNode; + collapsed?: boolean; + onCollapseToggle?: (event: MouseEvent) => void; + onRemove: (event: MouseEvent) => void; + dragHandleHOC: (render: () => ReactNode) => ComponentClass; + isVariableTypesList?: boolean; +} + +const ListItemTopBar = ({ + className, + title, + collapsed = false, + onCollapseToggle, + onRemove, + dragHandleHOC, + isVariableTypesList = false, +}: ListItemTopBarProps) => { + return ( + + {onCollapseToggle ? ( + + + + ) : null} + {title} + {dragHandleHOC ? : null} + {onRemove ? ( + + + + ) : null} + + ); +}; + +export default ListItemTopBar; diff --git a/src/components/UI/Loader.tsx b/src/components/UI/Loader.tsx new file mode 100644 index 00000000..e0597c45 --- /dev/null +++ b/src/components/UI/Loader.tsx @@ -0,0 +1,55 @@ +import CircularProgress from '@mui/material/CircularProgress'; +import { styled } from '@mui/material/styles'; +import Typography from '@mui/material/Typography'; +import React, { useEffect, useMemo, useState } from 'react'; + +const StyledLoader = styled('div')` + display: flex; + width: 100%; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; +`; + +export interface LoaderProps { + children: string | string[] | undefined; +} + +const Loader = ({ children }: LoaderProps) => { + const [currentItem, setCurrentItem] = useState(0); + + const text = useMemo(() => { + if (!children) { + return undefined; + } else if (typeof children == 'string') { + return children; + } else if (Array.isArray(children)) { + return currentItem < children.length ? children[currentItem] : undefined; + } + }, [children, currentItem]); + + useEffect(() => { + if (!Array.isArray(children)) { + return; + } + + const timer = setInterval(() => { + const nextItem = currentItem === children?.length - 1 ? 0 : currentItem + 1; + setCurrentItem(nextItem); + }, 5000); + + return () => { + clearInterval(timer); + }; + }, [children, currentItem]); + + return ( + + + {text} + + ); +}; + +export default Loader; diff --git a/src/components/UI/Modal.js b/src/components/UI/Modal.js deleted file mode 100644 index 67725b8a..00000000 --- a/src/components/UI/Modal.js +++ /dev/null @@ -1,110 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { css, Global, ClassNames } from '@emotion/react'; -import ReactModal from 'react-modal'; - -import { transitions, shadows, lengths, zIndex } from '../../ui'; - -function ReactModalGlobalStyles() { - return ( - - ); -} - -const styleStrings = { - modalBody: ` - ${shadows.dropDeep}; - background-color: #fff; - border-radius: ${lengths.borderRadius}; - height: 80%; - text-align: center; - max-width: 2200px; - padding: 20px; - - &:focus { - outline: none; - } - `, - overlay: ` - z-index: ${zIndex.zIndex1002}; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - display: flex; - justify-content: center; - align-items: center; - opacity: 0; - background-color: rgba(0, 0, 0, 0); - transition: background-color ${transitions.main}, opacity ${transitions.main}; - `, - overlayAfterOpen: ` - background-color: rgba(0, 0, 0, 0.6); - opacity: 1; - `, - overlayBeforeClose: ` - background-color: rgba(0, 0, 0, 0); - opacity: 0; - `, -}; - -export class Modal extends React.Component { - static propTypes = { - children: PropTypes.node.isRequired, - isOpen: PropTypes.bool.isRequired, - className: PropTypes.string, - onClose: PropTypes.func.isRequired, - }; - - componentDidMount() { - ReactModal.setAppElement('#nc-root'); - } - - render() { - const { isOpen, children, className, onClose } = this.props; - return ( - <> - - - {({ css, cx }) => ( - - {children} - - )} - - - ); - } -} diff --git a/src/components/UI/NavLink.tsx b/src/components/UI/NavLink.tsx new file mode 100644 index 00000000..8ae92c02 --- /dev/null +++ b/src/components/UI/NavLink.tsx @@ -0,0 +1,74 @@ +import React, { forwardRef } from 'react'; +import { NavLink as NavLinkBase } from 'react-router-dom'; +import { styled } from '@mui/material/styles'; + +import { colors } from '../../components/UI/styles'; +import { transientOptions } from '../../lib'; + +import type { RefAttributes } from 'react'; +import type { NavLinkProps as RouterNavLinkProps } from 'react-router-dom'; + +export type NavLinkBaseProps = RouterNavLinkProps & RefAttributes; + +export interface NavLinkProps extends RouterNavLinkProps { + activeClassName?: string; +} + +interface StyledNavLinkProps { + $activeClassName?: string; +} + +const StyledNavLinkWrapper = styled( + 'div', + transientOptions, +)( + ({ $activeClassName }) => ` + position: relative; + + a { + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: ${colors.inactive}; + + :hover { + color: ${colors.active}; + + .MuiListItemIcon-root { + color: ${colors.active}; + } + } + } + + ${ + $activeClassName + ? ` + & > .${$activeClassName} { + color: ${colors.active}; + + .MuiListItemIcon-root { + color: ${colors.active}; + } + } + ` + : '' + } + `, +); + +const NavLink = forwardRef( + ({ activeClassName, ...props }, ref) => ( + + (isActive ? activeClassName : '')} + /> + + ), +); + +NavLink.displayName = 'NavLink'; + +export default NavLink; diff --git a/src/components/UI/ObjectWidgetTopBar.tsx b/src/components/UI/ObjectWidgetTopBar.tsx new file mode 100644 index 00000000..b9433d77 --- /dev/null +++ b/src/components/UI/ObjectWidgetTopBar.tsx @@ -0,0 +1,168 @@ +import AddIcon from '@mui/icons-material/Add'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import { styled } from '@mui/material/styles'; +import React, { useCallback } from 'react'; + +import { transientOptions } from '../../lib'; +import { colors, colorsRaw, transitions } from './styles'; + +import type { MouseEvent, ReactNode } from 'react'; +import type { ObjectField, TranslatedProps } from '../../interface'; + +const TopBarContainer = styled('div')` + position: relative; + align-items: center; + background-color: ${colors.textFieldBorder}; + display: flex; + justify-content: space-between; + padding: 2px 8px; +`; + +interface ExpandButtonContainerProps { + $hasError: boolean; +} + +const ExpandButtonContainer = styled( + 'div', + transientOptions, +)( + ({ $hasError }) => ` + display: flex; + align-items: center; + color: rgba(0, 0, 0, 0.6); + font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif; + font-weight: 400; + font-size: 1rem; + line-height: 1.4375em; + letter-spacing: 0.00938em; + ${$hasError ? `color: ${colorsRaw.red}` : ''} + `, +); + +export interface ObjectWidgetTopBarProps { + allowAdd?: boolean; + types?: ObjectField[]; + onAdd?: (event: MouseEvent) => void; + onAddType?: (name: string) => void; + onCollapseToggle: (event: MouseEvent) => void; + collapsed: boolean; + heading: ReactNode; + label?: string; + hasError?: boolean; +} + +const ObjectWidgetTopBar = ({ + allowAdd, + types, + onAdd, + onAddType, + onCollapseToggle, + collapsed, + heading, + label, + hasError = false, + t, +}: TranslatedProps) => { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + const handleClose = useCallback(() => { + setAnchorEl(null); + }, []); + const handleAddType = useCallback( + (type: ObjectField) => () => { + handleClose(); + onAddType?.(type.name); + }, + [handleClose, onAddType], + ); + + const renderTypesDropdown = useCallback( + (types: ObjectField[]) => { + return ( +

    + ); + }, + [open, handleClick, t, label, anchorEl, handleClose, handleAddType], + ); + + const renderAddButton = useCallback(() => { + return ( + + ); + }, [t, label, onAdd]); + + const renderAddUI = useCallback(() => { + if (!allowAdd) { + return null; + } + if (types && types.length > 0) { + return renderTypesDropdown(types); + } else { + return renderAddButton(); + } + }, [allowAdd, types, renderTypesDropdown, renderAddButton]); + + return ( + + + + + + {heading} + + {renderAddUI()} + + ); +}; + +export default ObjectWidgetTopBar; diff --git a/src/components/UI/Outline.tsx b/src/components/UI/Outline.tsx new file mode 100644 index 00000000..6e0f14d7 --- /dev/null +++ b/src/components/UI/Outline.tsx @@ -0,0 +1,60 @@ +import { styled } from '@mui/material/styles'; +import React from 'react'; + +import transientOptions from '../../lib/util/transientOptions'; + +interface StyledOutlineProps { + $active: boolean; + $hasError: boolean; + $hasLabel: boolean; +} + +const StyledOutline = styled( + 'div', + transientOptions, +)( + ({ $active, $hasError, $hasLabel }) => ` + position: absolute; + bottom: 0; + right: 0; + top: ${$hasLabel ? 22 : 0}px; + left: 0; + margin: 0; + padding: 0 8px; + pointer-events: none; + border-radius: 4px; + border-style: solid; + border-width: 1px; + overflow: hidden; + min-width: 0%; + border-color: rgba(0, 0, 0, 0.23); + ${ + $active + ? ` + border-color: #1976d2; + border-width: 2px; + ` + : '' + } + ${ + $hasError + ? ` + border-color: #d32f2f; + border-width: 2px; + ` + : '' + } + `, +); + +interface OutlineProps { + active?: boolean; + hasError?: boolean; + hasLabel?: boolean; +} + +const Outline = ({ active = false, hasError = false, hasLabel = false }: OutlineProps) => { + return ; +}; + +export default Outline; diff --git a/src/components/UI/ScrollTop.tsx b/src/components/UI/ScrollTop.tsx new file mode 100644 index 00000000..952f01d1 --- /dev/null +++ b/src/components/UI/ScrollTop.tsx @@ -0,0 +1,43 @@ +import Fade from '@mui/material/Fade'; +import { styled } from '@mui/material/styles'; +import useScrollTrigger from '@mui/material/useScrollTrigger'; +import React, { useCallback } from 'react'; + +const StyledScrollTop = styled('div')` + position: fixed; + bottom: 16px; + right: 16px; +`; + +interface ScrollTopProps { + children: React.ReactNode; +} + +const ScrollTop = ({ children }: ScrollTopProps) => { + const trigger = useScrollTrigger({ + disableHysteresis: true, + threshold: 100, + }); + + const handleClick = useCallback((event: React.MouseEvent) => { + const anchor = ((event.target as HTMLDivElement).ownerDocument || document).querySelector( + '#back-to-top-anchor', + ); + + if (anchor) { + anchor.scrollIntoView({ + block: 'center', + }); + } + }, []); + + return ( + + + {children} + + + ); +}; + +export default ScrollTop; diff --git a/src/components/UI/SettingsDropdown.js b/src/components/UI/SettingsDropdown.js deleted file mode 100644 index 184605f8..00000000 --- a/src/components/UI/SettingsDropdown.js +++ /dev/null @@ -1,103 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { css } from '@emotion/react'; -import styled from '@emotion/styled'; -import { translate } from 'react-polyglot'; - -import { Icon, Dropdown, DropdownItem, DropdownButton, colors } from '../../ui'; -import { stripProtocol } from '../../lib/urlHelper'; - -const styles = { - avatarImage: css` - width: 32px; - border-radius: 32px; - `, -}; - -const AvatarDropdownButton = styled(DropdownButton)` - display: inline-block; - padding: 8px; - cursor: pointer; - color: #1e2532; - background-color: transparent; -`; - -const AvatarImage = styled.img` - ${styles.avatarImage}; -`; - -const AvatarPlaceholderIcon = styled(Icon)` - ${styles.avatarImage}; - height: 32px; - color: #1e2532; - background-color: ${colors.textFieldBorder}; -`; - -const AppHeaderSiteLink = styled.a` - font-size: 14px; - font-weight: 400; - color: #7b8290; - padding: 10px 16px; -`; - -const AppHeaderTestRepoIndicator = styled.a` - font-size: 14px; - font-weight: 400; - color: #7b8290; - padding: 10px 16px; -`; - -function Avatar({ imageUrl }) { - return imageUrl ? ( - - ) : ( - - ); -} - -Avatar.propTypes = { - imageUrl: PropTypes.string, -}; - -function SettingsDropdown({ displayUrl, isTestRepo, imageUrl, onLogoutClick, t }) { - return ( - - {isTestRepo && ( - - Test Backend ↗ - - )} - {displayUrl ? ( - - {stripProtocol(displayUrl)} - - ) : null} - ( - - - - )} - > - - - - ); -} - -SettingsDropdown.propTypes = { - displayUrl: PropTypes.string, - isTestRepo: PropTypes.bool, - imageUrl: PropTypes.string, - onLogoutClick: PropTypes.func.isRequired, - t: PropTypes.func.isRequired, -}; - -export default translate()(SettingsDropdown); diff --git a/src/components/UI/SettingsDropdown.tsx b/src/components/UI/SettingsDropdown.tsx new file mode 100644 index 00000000..d8b02215 --- /dev/null +++ b/src/components/UI/SettingsDropdown.tsx @@ -0,0 +1,74 @@ +import Avatar from '@mui/material/Avatar'; +import IconButton from '@mui/material/IconButton'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Tooltip from '@mui/material/Tooltip'; +import React, { useCallback } from 'react'; +import { translate } from 'react-polyglot'; +import PersonIcon from '@mui/icons-material/Person'; + +import type { TranslatedProps } from '../../interface'; + +interface AvatarImageProps { + imageUrl: string | undefined; +} + +const AvatarImage = ({ imageUrl }: AvatarImageProps) => { + return imageUrl ? ( + + ) : ( + + + + ); +}; + +interface SettingsDropdownProps { + imageUrl?: string; + onLogoutClick: () => void; +} + +const SettingsDropdown = ({ + imageUrl, + onLogoutClick, + t, +}: TranslatedProps) => { + const [anchorEl, setAnchorEl] = React.useState(null); + const open = Boolean(anchorEl); + const handleClick = useCallback((event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }, []); + const handleClose = useCallback(() => { + setAnchorEl(null); + }, []); + + return ( +
    + + + + + + + {t('ui.settingsDropdown.logOut')} + +
    + ); +}; + +export default translate()(SettingsDropdown); diff --git a/src/components/UI/WidgetPreviewContainer.tsx b/src/components/UI/WidgetPreviewContainer.tsx new file mode 100644 index 00000000..c3857522 --- /dev/null +++ b/src/components/UI/WidgetPreviewContainer.tsx @@ -0,0 +1,7 @@ +import { styled } from '@mui/material/styles'; + +const WidgetPreviewContainer = styled('div')` + margin: 15px 2px; +`; + +export default WidgetPreviewContainer; diff --git a/src/components/UI/index.js b/src/components/UI/index.js deleted file mode 100644 index 8f58d1cd..00000000 --- a/src/components/UI/index.js +++ /dev/null @@ -1,5 +0,0 @@ -export { DragSource, DropTarget, HTML5DragDrop } from './DragDrop'; -export { default as ErrorBoundary } from './ErrorBoundary'; -export { FileUploadButton } from './FileUploadButton'; -export { Modal } from './Modal'; -export { default as SettingsDropdown } from './SettingsDropdown'; diff --git a/src/components/UI/index.ts b/src/components/UI/index.ts new file mode 100644 index 00000000..ff23e204 --- /dev/null +++ b/src/components/UI/index.ts @@ -0,0 +1,3 @@ +export { default as ErrorBoundary } from './ErrorBoundary'; +export { default as FileUploadButton } from './FileUploadButton'; +export { default as SettingsDropdown } from './SettingsDropdown'; diff --git a/src/ui/styles.js b/src/components/UI/styles.tsx similarity index 85% rename from src/ui/styles.js rename to src/components/UI/styles.tsx index 71140558..39e57e16 100644 --- a/src/ui/styles.js +++ b/src/components/UI/styles.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { css, Global } from '@emotion/react'; +import type { CSSProperties } from 'react'; + export const quantifier = '.cms-wrapper'; /** @@ -77,7 +79,7 @@ const colors = { warnBackground: colorsRaw.yellow, errorText: colorsRaw.red, errorBackground: colorsRaw.redLight, - textFieldBorder: '#dfdfe3', + textFieldBorder: '#f7f9fc', controlLabel: '#7a8291', checkerboardLight: '#f2f2f2', checkerboardDark: '#e6e6e6', @@ -91,8 +93,7 @@ const lengths = { borderRadius: '5px', richTextEditorMinHeight: '300px', borderWidth: '2px', - topCardWidth: '682px', - pageMargin: '28px 18px', + pageMargin: '24px', objectWidgetTopBarContainerPadding: '0 14px 0', }; @@ -359,7 +360,6 @@ const components = { cardTop: css` && { ${card}; - width: ${lengths.topCardWidth}; max-width: 100%; padding: 18px 20px; margin-bottom: 28px; @@ -376,10 +376,9 @@ const components = { `, cardTopDescription: css` && { - max-width: 480px; color: ${colors.text}; font-size: 14px; - margin-top: 8px; + margin-top: 16px; } `, objectWidgetTopBarContainer: css` @@ -432,48 +431,17 @@ const components = { `, }; -const reactSelectStyles = { - control: styles => ({ - ...styles, - border: 0, - boxShadow: 'none', - padding: '9px 0 9px 12px', - }), - option: (styles, state) => ({ - ...styles, - backgroundColor: state.isSelected - ? `${colors.active}` - : state.isFocused - ? `${colors.activeBackground}` - : 'transparent', - paddingLeft: '22px', - }), - menu: styles => ({ ...styles, right: 0, zIndex: zIndex.zIndex300 }), - container: styles => ({ ...styles, padding: '0 !important' }), - indicatorSeparator: (styles, state) => - state.hasValue && state.selectProps.isClearable - ? { ...styles, backgroundColor: `${colors.textFieldBorder}` } - : { display: 'none' }, - dropdownIndicator: styles => ({ ...styles, color: `${colors.controlLabel}` }), - clearIndicator: styles => ({ ...styles, color: `${colors.controlLabel}` }), - multiValue: styles => ({ - ...styles, - backgroundColor: colors.background, - }), - multiValueLabel: styles => ({ - ...styles, - color: colors.textLead, - fontWeight: 500, - }), - multiValueRemove: styles => ({ - ...styles, - color: colors.controlLabel, - ':hover': { - color: colors.errorText, - backgroundColor: colors.errorBackground, - }, - }), -}; +export interface OptionStyleState { + isSelected: boolean; + isFocused: boolean; +} + +export interface IndicatorSeparatorStyleState { + hasValue: boolean; + selectProps: { + isClearable: boolean; + }; +} const zIndex = { zIndex0: 0, @@ -489,6 +457,49 @@ const zIndex = { zIndex1002: 1002, }; +const reactSelectStyles = { + control: (styles: CSSProperties) => ({ + ...styles, + border: 0, + boxShadow: 'none', + padding: '9px 0 9px 12px', + }), + option: (styles: CSSProperties, state: OptionStyleState) => ({ + ...styles, + backgroundColor: state.isSelected + ? `${colors.active}` + : state.isFocused + ? `${colors.activeBackground}` + : 'transparent', + paddingLeft: '22px', + }), + menu: (styles: CSSProperties) => ({ ...styles, right: 0, zIndex: zIndex.zIndex300 }), + container: (styles: CSSProperties) => ({ ...styles, padding: '0' }), + indicatorSeparator: (styles: CSSProperties, state: IndicatorSeparatorStyleState) => + state.hasValue && state.selectProps.isClearable + ? { ...styles, backgroundColor: `${colors.textFieldBorder}` } + : { display: 'none' }, + dropdownIndicator: (styles: CSSProperties) => ({ ...styles, color: `${colors.controlLabel}` }), + clearIndicator: (styles: CSSProperties) => ({ ...styles, color: `${colors.controlLabel}` }), + multiValue: (styles: CSSProperties) => ({ + ...styles, + backgroundColor: colors.background, + }), + multiValueLabel: (styles: CSSProperties) => ({ + ...styles, + color: colors.textLead, + fontWeight: 500, + }), + multiValueRemove: (styles: CSSProperties) => ({ + ...styles, + color: colors.controlLabel, + ':hover': { + color: colors.errorText, + backgroundColor: colors.errorBackground, + }, + }), +}; + function GlobalStyles() { return ( { - const { id } = match.params; +const Page = ({ collections, isSearchEnabled, searchTerm, filterTerm }: PageProps) => { + const { id } = useParams(); const Content = useMemo(() => { + if (!id) { + return ''; + } + const page = getAdditionalLink(id); if (!page) { return ''; } return page.data; - }, []); + }, [id]); const pageContent = useMemo(() => { if (!Content) { @@ -74,34 +64,21 @@ const Page = ({ ); }; -function mapStateToProps(state: State, ownProps: PageProps) { +function mapStateToProps(state: RootState) { const { collections } = state; - const isSearchEnabled = state.config && state.config.search != false; - const { match } = ownProps; - const { searchTerm = '', filterTerm = '' } = match.params; + const isSearchEnabled = state.config.config && state.config.config.search != false; return { collections, isSearchEnabled, - searchTerm, - filterTerm, + searchTerm: '', + filterTerm: '', }; } const mapDispatchToProps = {}; -function mergeProps( - stateProps: ReturnType, - dispatchProps: typeof mapDispatchToProps, - ownProps: PageProps, -) { - return { - ...stateProps, - ...dispatchProps, - ...ownProps, - }; -} +const connector = connect(mapStateToProps, mapDispatchToProps); +export type PageProps = ConnectedProps; -const ConnectedPage = connect(mapStateToProps, mapDispatchToProps, mergeProps)(Page); - -export default translate()(ConnectedPage); +export default connector(translate()(Page) as ComponentType); diff --git a/src/components/snackbar/Snackbars.tsx b/src/components/snackbar/Snackbars.tsx index d83ee9f7..a3fe093e 100644 --- a/src/components/snackbar/Snackbars.tsx +++ b/src/components/snackbar/Snackbars.tsx @@ -32,7 +32,7 @@ const Snackbars = ({ t }: TranslatedProps) => { // Close an active snack when a new one is added setOpen(false); } - }, [snackbars, messageInfo, open]); + }, [snackbars, messageInfo, open, dispatch]); const handleClose = useCallback((_event?: React.SyntheticEvent | Event, reason?: string) => { if (reason === 'clickaway') { @@ -48,14 +48,19 @@ const Snackbars = ({ t }: TranslatedProps) => { const renderAlert = useCallback( (data: SnackbarMessage) => { - const { - type, - message: { key, ...options }, - } = data; + const { type, message } = data; + + let renderedMessage: string; + if (typeof message === 'string') { + renderedMessage = message; + } else { + const { key, options } = message; + renderedMessage = t(key, options); + } return ( - {t(key, options)} + {renderedMessage} ); }, diff --git a/src/constants/collectionViews.js b/src/constants/collectionViews.ts similarity index 54% rename from src/constants/collectionViews.js rename to src/constants/collectionViews.ts index 103a0454..7835d9ef 100644 --- a/src/constants/collectionViews.js +++ b/src/constants/collectionViews.ts @@ -1,2 +1,4 @@ export const VIEW_STYLE_LIST = 'VIEW_STYLE_LIST'; export const VIEW_STYLE_GRID = 'VIEW_STYLE_GRID'; + +export type CollectionViewStyle = typeof VIEW_STYLE_LIST | typeof VIEW_STYLE_GRID; diff --git a/src/constants/commitProps.ts b/src/constants/commitProps.ts index df25f139..5a8f4bd1 100644 --- a/src/constants/commitProps.ts +++ b/src/constants/commitProps.ts @@ -1,2 +1,4 @@ export const COMMIT_AUTHOR = 'commit_author'; export const COMMIT_DATE = 'commit_date'; + +export type CollectionProps = typeof COMMIT_AUTHOR | typeof COMMIT_DATE; diff --git a/src/constants/configSchema.js b/src/constants/configSchema.tsx similarity index 89% rename from src/constants/configSchema.js rename to src/constants/configSchema.tsx index e4714a6c..bef62ea4 100644 --- a/src/constants/configSchema.js +++ b/src/constants/configSchema.tsx @@ -1,10 +1,8 @@ import AJV from 'ajv'; -import { - select, - uniqueItemProperties, - instanceof as instanceOf, - prohibited, -} from 'ajv-keywords/keywords'; +import select from 'ajv-keywords/dist/keywords/select'; +import uniqueItemProperties from 'ajv-keywords/dist/keywords/uniqueItemProperties'; +import instanceOf from 'ajv-keywords/dist/keywords/instanceof'; +import prohibited from 'ajv-keywords/dist/keywords/prohibited'; import ajvErrors from 'ajv-errors'; import uuid from 'uuid/v4'; @@ -12,6 +10,9 @@ import { formatExtensions, frontmatterFormats, extensionFormatters } from '../fo import { getWidgets } from '../lib/registry'; import { I18N_STRUCTURE, I18N_FIELD } from '../lib/i18n'; +import type { ErrorObject } from 'ajv'; +import type { Config } from '../interface'; + const localeType = { type: 'string', minLength: 2, maxLength: 10, pattern: '^[a-zA-Z-_]+$' }; const i18n = { @@ -24,7 +25,7 @@ const i18n = { items: localeType, uniqueItems: true, }, - default_locale: localeType, + defaultLocale: localeType, }, }; @@ -64,6 +65,7 @@ function fieldsConfig() { pattern: { type: 'array', minItems: 2, + maxItems: 2, items: [{ oneOf: [{ type: 'string' }, { instanceof: 'RegExp' }] }, { type: 'string' }], }, field: { $ref: `field_${id}` }, @@ -274,22 +276,6 @@ function getConfigSchema() { }, required: ['depth'], }, - meta: { - type: 'object', - properties: { - path: { - type: 'object', - properties: { - label: { type: 'string' }, - widget: { type: 'string' }, - index_file: { type: 'string' }, - }, - required: ['label', 'widget', 'index_file'], - }, - }, - additionalProperties: false, - minProperties: 1, - }, i18n: i18nCollection, }, required: ['name', 'label'], @@ -328,15 +314,18 @@ function getConfigSchema() { } function getWidgetSchemas() { - const schemas = getWidgets().map(widget => ({ [widget.name]: widget.schema })); - return Object.assign(...schemas); + const schemas = getWidgets().reduce((acc, widget) => { + acc[widget.name] = widget.schema ?? {}; + return acc; + }, {} as Record); + return { ...schemas }; } class ConfigError extends Error { - constructor(errors, ...args) { + constructor(errors: ErrorObject, unknown>[]) { const message = errors - .map(({ message, dataPath }) => { - const dotPath = dataPath + .map(({ message, schemaPath }) => { + const dotPath = schemaPath .slice(1) .split('/') .map(seg => (seg.match(/^\d+$/) ? `[${seg}]` : `.${seg}`)) @@ -345,10 +334,8 @@ class ConfigError extends Error { return `${dotPath ? `'${dotPath}'` : 'config'} ${message}`; }) .join('\n'); - super(message, ...args); - this.errors = errors; - this.message = message; + super(message); } toString() { @@ -360,8 +347,8 @@ class ConfigError extends Error { * `validateConfig` is a pure function. It does not mutate * the config that is passed in. */ -export function validateConfig(config) { - const ajv = new AJV({ allErrors: true, $data: true }); +export function validateConfig(config: Config) { + const ajv = new AJV({ allErrors: true, allowUnionTypes: true, $data: true }); uniqueItemProperties(ajv); select(ajv); instanceOf(ajv); @@ -370,11 +357,11 @@ export function validateConfig(config) { const valid = ajv.validate(getConfigSchema(), config); if (!valid) { - const errors = ajv.errors.map(e => { + const errors = ajv.errors?.map(e => { switch (e.keyword) { // TODO: remove after https://github.com/ajv-validator/ajv-keywords/pull/123 is merged case 'uniqueItemProperties': { - const path = e.dataPath || ''; + const path = e.schemaPath || ''; let newError = e; if (path.endsWith('/fields')) { newError = { ...e, message: 'fields names must be unique' }; @@ -386,7 +373,7 @@ export function validateConfig(config) { return newError; } case 'instanceof': { - const path = e.dataPath || ''; + const path = e.schemaPath || ''; let newError = e; if (/fields\/\d+\/pattern\/\d+/.test(path)) { newError = { @@ -401,6 +388,6 @@ export function validateConfig(config) { } }); console.error('Config Errors', errors); - throw new ConfigError(errors); + throw new ConfigError(errors ?? []); } } diff --git a/src/constants/fieldInference.tsx b/src/constants/fieldInference.tsx index 56c49aa7..728e0ce8 100644 --- a/src/constants/fieldInference.tsx +++ b/src/constants/fieldInference.tsx @@ -1,15 +1,26 @@ import React from 'react'; +import type { ReactNode } from 'react'; + export const IDENTIFIER_FIELDS = ['title', 'path'] as const; export const SORTABLE_FIELDS = ['title', 'date', 'author', 'description'] as const; -export const INFERABLE_FIELDS = { +export interface InferredField { + type: string; + secondaryTypes: string[]; + synonyms: string[]; + defaultPreview: (value: string | boolean | number) => JSX.Element | ReactNode; + fallbackToFirstField: boolean; + showError: boolean; +} + +export const INFERABLE_FIELDS: Record = { title: { type: 'string', secondaryTypes: [], synonyms: ['title', 'name', 'label', 'headline', 'header'], - defaultPreview: (value: React.ReactNode) =>

    {value}

    , // eslint-disable-line react/display-name + defaultPreview: value =>

    {value}

    , // eslint-disable-line react/display-name fallbackToFirstField: true, showError: true, }, @@ -17,7 +28,7 @@ export const INFERABLE_FIELDS = { type: 'string', secondaryTypes: [], synonyms: ['short_title', 'shortTitle', 'short'], - defaultPreview: (value: React.ReactNode) =>

    {value}

    , // eslint-disable-line react/display-name + defaultPreview: value =>

    {value}

    , // eslint-disable-line react/display-name fallbackToFirstField: false, showError: false, }, @@ -25,7 +36,7 @@ export const INFERABLE_FIELDS = { type: 'string', secondaryTypes: [], synonyms: ['author', 'name', 'by', 'byline', 'owner'], - defaultPreview: (value: React.ReactNode) => {value}, // eslint-disable-line react/display-name + defaultPreview: value => {value}, // eslint-disable-line react/display-name fallbackToFirstField: false, showError: false, }, @@ -33,7 +44,7 @@ export const INFERABLE_FIELDS = { type: 'datetime', secondaryTypes: ['date'], synonyms: ['date', 'publishDate', 'publish_date'], - defaultPreview: (value: React.ReactNode) => value, + defaultPreview: value => value, fallbackToFirstField: false, showError: false, }, @@ -53,7 +64,7 @@ export const INFERABLE_FIELDS = { 'bio', 'summary', ], - defaultPreview: (value: React.ReactNode) => value, + defaultPreview: value => value, fallbackToFirstField: false, showError: false, }, @@ -71,7 +82,7 @@ export const INFERABLE_FIELDS = { 'hero', 'logo', ], - defaultPreview: (value: React.ReactNode) => value, + defaultPreview: value => value, fallbackToFirstField: false, showError: false, }, diff --git a/src/constants/validationErrorTypes.js b/src/constants/validationErrorTypes.ts similarity index 50% rename from src/constants/validationErrorTypes.js rename to src/constants/validationErrorTypes.ts index b29adddb..c7f857a0 100644 --- a/src/constants/validationErrorTypes.js +++ b/src/constants/validationErrorTypes.ts @@ -1,6 +1,8 @@ -export default { +const ValidationErrorTypes = { PRESENCE: 'PRESENCE', PATTERN: 'PATTERN', RANGE: 'RANGE', CUSTOM: 'CUSTOM', -}; +} as const; + +export default ValidationErrorTypes; diff --git a/src/editor-components/editorPlugin.ts b/src/editor-components/editorPlugin.ts new file mode 100644 index 00000000..f75e1ff6 --- /dev/null +++ b/src/editor-components/editorPlugin.ts @@ -0,0 +1,20 @@ +import { useCallback } from 'react'; + +import type { EditorProps } from '@toast-ui/react-editor'; +import type { PluginContext } from '@toast-ui/editor/types/editor'; +import type { Field } from '../interface'; + +export interface ShortCodePluginProps { + fields: Field; +} + +const useShortCodePlugin = (_props: ShortCodePluginProps) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const plugin: Required['plugins'][number] = useCallback((_context: PluginContext, _options?: any) => { + return null; + }, []); + + return plugin; +}; + +export default useShortCodePlugin; diff --git a/src/editor-components/image/index.js b/src/editor-components/image/index.tsx similarity index 73% rename from src/editor-components/image/index.js rename to src/editor-components/image/index.tsx index f2c93c03..ea463911 100644 --- a/src/editor-components/image/index.js +++ b/src/editor-components/image/index.tsx @@ -1,6 +1,14 @@ import React from 'react'; -const image = { +import type { EditorComponentManualOptions } from '../../interface'; + +interface ImageData { + alt: string; + image: string; + title: string; +} + +const image: EditorComponentManualOptions = { label: 'Image', id: 'image', fromBlock: match => @@ -13,8 +21,8 @@ const image = { `![${alt || ''}](${image || ''}${title ? ` "${title.replace(/"/g, '\\"')}"` : ''})`, // eslint-disable-next-line react/display-name toPreview: ({ alt, image, title }, getAsset, fields) => { - const imageField = fields?.find(f => f.get('widget') === 'image'); - const src = getAsset(image, imageField); + const imageField = fields?.find(f => f.widget === 'image'); + const src = getAsset(image, imageField).toString(); return {alt; }, pattern: /^!\[(.*)\]\((.*?)(\s"(.*)")?\)$/, diff --git a/src/editor-components/index.tsx b/src/editor-components/index.tsx index 8b4cbe43..7d988707 100644 --- a/src/editor-components/index.tsx +++ b/src/editor-components/index.tsx @@ -1,3 +1 @@ -import imageEditorComponent from './image'; - -export { imageEditorComponent }; +export { default as imageEditorComponent } from './image'; diff --git a/src/extensions.ts b/src/extensions.ts new file mode 100644 index 00000000..2b75ca29 --- /dev/null +++ b/src/extensions.ts @@ -0,0 +1,70 @@ +import { + AzureBackend, + BitbucketBackend, + GitGatewayBackend, + GitHubBackend, + GitLabBackend, + ProxyBackend, + TestBackend, +} from './backends'; +import { imageEditorComponent } from './editor-components'; +import { + registerBackend, + registerEditorComponent, + registerLocale, + registerWidget, +} from './lib/registry'; +import { locales } from './locales'; +import { + BooleanWidget, + CodeWidget, + ColorStringWidget, + DateTimeWidget, + FileWidget, + ImageWidget, + ListWidget, + MapWidget, + MarkdownWidget, + NumberWidget, + ObjectWidget, + RelationWidget, + SelectWidget, + StringWidget, + TextWidget, +} from './widgets'; + +export function addExtensions() { + // Register all the things + registerBackend('git-gateway', GitGatewayBackend); + registerBackend('azure', AzureBackend); + registerBackend('github', GitHubBackend); + registerBackend('gitlab', GitLabBackend); + registerBackend('bitbucket', BitbucketBackend); + registerBackend('test-repo', TestBackend); + registerBackend('proxy', ProxyBackend); + registerWidget([ + StringWidget(), + NumberWidget(), + TextWidget(), + ImageWidget(), + FileWidget(), + SelectWidget(), + MarkdownWidget(), + ListWidget(), + ObjectWidget(), + RelationWidget(), + BooleanWidget(), + MapWidget(), + DateTimeWidget(), + CodeWidget(), + ColorStringWidget(), + ]); + registerEditorComponent(imageEditorComponent); + registerEditorComponent({ + id: 'code-block', + label: 'Code Block', + widget: 'code', + type: 'code-block', + }); + registerLocale('en', locales.en); +} diff --git a/src/formats/formats.ts b/src/formats/formats.ts index 3508986b..8202675b 100644 --- a/src/formats/formats.ts +++ b/src/formats/formats.ts @@ -1,5 +1,4 @@ -import { List } from 'immutable'; -import { get } from 'lodash'; +import get from 'lodash/get'; import yamlFormatter from './yaml'; import tomlFormatter from './toml'; @@ -7,8 +6,7 @@ import jsonFormatter from './json'; import { FrontmatterInfer, frontmatterJSON, frontmatterTOML, frontmatterYAML } from './frontmatter'; import type { Delimiter } from './frontmatter'; -import type { Collection, EntryObject, Format } from '../types/redux'; -import type { EntryValue } from '../valueObjects/Entry'; +import type { Collection, Entry, Format } from '../interface'; export const frontmatterFormats = ['yaml-frontmatter', 'toml-frontmatter', 'json-frontmatter']; @@ -46,23 +44,14 @@ function formatByName(name: Format, customDelimiter?: Delimiter) { }[name]; } -function frontmatterDelimiterIsList( - frontmatterDelimiter?: Delimiter | List, -): frontmatterDelimiter is List { - return List.isList(frontmatterDelimiter); -} - -export function resolveFormat(collection: Collection, entry: EntryObject | EntryValue) { +export function resolveFormat(collection: Collection, entry: Entry) { // Check for custom delimiter - const frontmatter_delimiter = collection.get('frontmatter_delimiter'); - const customDelimiter = frontmatterDelimiterIsList(frontmatter_delimiter) - ? (frontmatter_delimiter.toArray() as [string, string]) - : frontmatter_delimiter; + const frontmatter_delimiter = collection.frontmatter_delimiter; // If the format is specified in the collection, use that format. - const formatSpecification = collection.get('format'); + const formatSpecification = collection.format; if (formatSpecification) { - return formatByName(formatSpecification, customDelimiter); + return formatByName(formatSpecification, frontmatter_delimiter); } // If a file already exists, infer the format from its file extension. @@ -76,11 +65,11 @@ export function resolveFormat(collection: Collection, entry: EntryObject | Entry // If creating a new file, and an `extension` is specified in the // collection config, infer the format from that extension. - const extension = collection.get('extension'); + const extension = collection.extension; if (extension) { return get(extensionFormatters, extension); } // If no format is specified and it cannot be inferred, return the default. - return formatByName('frontmatter', customDelimiter); + return formatByName('frontmatter', frontmatter_delimiter); } diff --git a/src/formats/yaml.ts b/src/formats/yaml.ts index 923945b1..3d6004f3 100644 --- a/src/formats/yaml.ts +++ b/src/formats/yaml.ts @@ -2,7 +2,7 @@ import yaml from 'yaml'; import { sortKeys } from './helpers'; -import type { YAMLMap, YAMLSeq, Pair, Node } from 'yaml/types'; +import type { Pair, YAMLMap, YAMLSeq } from 'yaml/types'; function addComments(items: Array, comments: Record, prefix = '') { items.forEach(item => { @@ -20,28 +20,12 @@ function addComments(items: Array, comments: Record, prefi }); } -const timestampTag = { - identify: (value: unknown) => value instanceof Date, - default: true, - tag: '!timestamp', - test: RegExp( - '^' + - '([0-9]{4})-([0-9]{2})-([0-9]{2})' + // YYYY-MM-DD - 'T' + // T - '([0-9]{2}):([0-9]{2}):([0-9]{2}(\\.[0-9]+)?)' + // HH:MM:SS(.ss)? - 'Z' + // Z - '$', - ), - resolve: (str: string) => new Date(str), - stringify: (value: Node) => (value as Date).toISOString(), -} as const; - export default { fromFile(content: string) { if (content && content.trim().endsWith('---')) { content = content.trim().slice(0, -3); } - return yaml.parse(content, { customTags: [timestampTag] }); + return yaml.parse(content); }, toFile(data: object, sortedKeys: string[] = [], comments: Record = {}) { diff --git a/src/index.js b/src/index.ts similarity index 58% rename from src/index.js rename to src/index.ts index 9a9909e5..b97e9ddc 100644 --- a/src/index.js +++ b/src/index.ts @@ -3,13 +3,6 @@ import React from 'react'; import bootstrap from './bootstrap'; import Registry from './lib/registry'; -import * as backends from './backends'; -import * as widgets from './widgets'; -import * as mediaLibraries from './media-libraries'; -import * as editorComponents from './editor-components'; -import * as locales from './locales'; -import * as lib from './lib'; -import * as ui from './ui'; export * from './backends'; export * from './widgets'; @@ -17,17 +10,9 @@ export * from './media-libraries'; export * from './editor-components'; export * from './locales'; export * from './lib'; -export * from './ui'; export const CMS = { ...Registry, - ...backends, - ...widgets, - ...mediaLibraries, - ...editorComponents, - ...locales, - ...lib, - ...ui, init: bootstrap, }; diff --git a/src/integrations/index.js b/src/integrations/index.js deleted file mode 100644 index 9439d874..00000000 --- a/src/integrations/index.js +++ /dev/null @@ -1,35 +0,0 @@ -import { Map } from 'immutable'; - -import Algolia from './providers/algolia/implementation'; -import AssetStore from './providers/assetStore/implementation'; - -export function resolveIntegrations(interationsConfig, getToken) { - let integrationInstances = Map({}); - interationsConfig.get('providers').forEach((providerData, providerName) => { - switch (providerName) { - case 'algolia': - integrationInstances = integrationInstances.set('algolia', new Algolia(providerData)); - break; - case 'assetStore': - integrationInstances = integrationInstances.set( - 'assetStore', - new AssetStore(providerData, getToken), - ); - break; - } - }); - return integrationInstances; -} - -export const getIntegrationProvider = (function () { - let integrations = null; - - return (interationsConfig, getToken, provider) => { - if (integrations) { - return integrations.get(provider); - } else { - integrations = resolveIntegrations(interationsConfig, getToken); - return integrations.get(provider); - } - }; -})(); diff --git a/src/integrations/index.ts b/src/integrations/index.ts new file mode 100644 index 00000000..c1abebfd --- /dev/null +++ b/src/integrations/index.ts @@ -0,0 +1,74 @@ +import Algolia from './providers/algolia/implementation'; +import AssetStore from './providers/assetStore/implementation'; + +import type { + AlgoliaConfig, + AssetStoreConfig, + MediaIntegrationProvider, + SearchIntegrationProvider, +} from '../interface'; + +interface IntegrationsConfig { + providers?: { + algolia?: AlgoliaConfig; + assetStore?: AssetStoreConfig; + }; +} + +interface Integrations { + algolia?: Algolia; + assetStore?: AssetStore; +} + +export function resolveIntegrations( + config: IntegrationsConfig | undefined, + getToken: () => Promise, +) { + const integrationInstances: Integrations = {}; + + if (config?.providers?.algolia) { + integrationInstances.algolia = new Algolia(config.providers.algolia); + } + + if (config?.providers?.['assetStore']) { + integrationInstances['assetStore'] = new AssetStore(config.providers['assetStore'], getToken); + } + + return integrationInstances; +} + +export const getSearchIntegrationProvider = (function () { + let integrations: Integrations = {}; + + return ( + config: IntegrationsConfig | undefined, + getToken: () => Promise, + provider: SearchIntegrationProvider, + ) => { + if (provider in (config?.providers ?? {})) + if (integrations) { + return integrations[provider]; + } else { + integrations = resolveIntegrations(config, getToken); + return integrations[provider]; + } + }; +})(); + +export const getMediaIntegrationProvider = (function () { + let integrations: Integrations = {}; + + return ( + config: IntegrationsConfig | undefined, + getToken: () => Promise, + provider: MediaIntegrationProvider, + ) => { + if (provider in (config?.providers ?? {})) + if (integrations) { + return integrations[provider]; + } else { + integrations = resolveIntegrations(config, getToken); + return integrations[provider]; + } + }; +})(); diff --git a/src/integrations/providers/algolia/implementation.js b/src/integrations/providers/algolia/implementation.ts similarity index 56% rename from src/integrations/providers/algolia/implementation.js rename to src/integrations/providers/algolia/implementation.ts index 205beab7..f94b80f5 100644 --- a/src/integrations/providers/algolia/implementation.js +++ b/src/integrations/providers/algolia/implementation.ts @@ -1,29 +1,58 @@ -import _ from 'lodash'; +import flatten from 'lodash/flatten'; import { unsentRequest } from '../../../lib/util'; +import { selectEntrySlug } from '../../../lib/util/collection.util'; import { createEntry } from '../../../valueObjects/Entry'; -import { selectEntrySlug } from '../../../reducers/collections'; + +import type { AlgoliaConfig, Collection, Entry, SearchResponse } from '../../../interface'; const { fetchWithTimeout: fetch } = unsentRequest; -function getSlug(path) { - return path - .split('/') - .pop() - .replace(/\.[^.]+$/, ''); +function getSlug(path: string): string { + return ( + path + .split('/') + .pop() + ?.replace(/\.[^.]+$/, '') ?? path + ); +} + +interface EntriesCache { + collection: Collection | null; + page: number | null; + entries: Entry[]; +} + +interface AlgoliaHits { + nbPages?: number; + page: number; + hits: { + path: string; + slug: string; + data: unknown; + }[]; +} + +interface AlgoliaSearchResponse { + results: AlgoliaHits[]; } export default class Algolia { - constructor(config) { - this.config = config; - if (config.get('applicationID') == null || config.get('apiKey') == null) { + private applicationID: string; + private apiKey: string; + private indexPrefix: string; + private searchURL: string; + private entriesCache: EntriesCache; + + constructor(config: AlgoliaConfig) { + if (config.applicationID == null || config.apiKey == null) { throw 'The Algolia search integration needs the credentials (applicationID and apiKey) in the integration configuration.'; } - this.applicationID = config.get('applicationID'); - this.apiKey = config.get('apiKey'); + this.applicationID = config.applicationID; + this.apiKey = config.apiKey; - const prefix = config.get('indexPrefix'); + const prefix = config.indexPrefix; this.indexPrefix = prefix ? `${prefix}-` : ''; this.searchURL = `https://${this.applicationID}-dsn.algolia.net/1`; @@ -44,7 +73,7 @@ export default class Algolia { }; } - parseJsonResponse(response) { + parseJsonResponse(response: Response) { return response.json().then(json => { if (!response.ok) { return Promise.reject(json); @@ -54,12 +83,10 @@ export default class Algolia { }); } - urlFor(path, options) { + urlFor(path: string, optionParams: Record = {}) { const params = []; - if (options.params) { - for (const key in options.params) { - params.push(`${key}=${encodeURIComponent(options.params[key])}`); - } + for (const key in optionParams) { + params.push(`${key}=${encodeURIComponent(optionParams[key])}`); } if (params.length) { path += `?${params.join('&')}`; @@ -67,9 +94,14 @@ export default class Algolia { return path; } - request(path, options = {}) { + request( + path: string, + options: RequestInit & { + params?: Record; + }, + ) { const headers = this.requestHeaders(options.headers || {}); - const url = this.urlFor(path, options); + const url = this.urlFor(path, options.params); return fetch(url, { ...options, headers }).then(response => { const contentType = response.headers.get('Content-Type'); if (contentType && contentType.match(/json/)) { @@ -80,7 +112,7 @@ export default class Algolia { }); } - search(collections, searchTerm, page) { + search(collections: string[], searchTerm: string, page: number): Promise { const searchCollections = collections.map(collection => ({ indexName: `${this.indexPrefix}${collection}`, params: `query=${searchTerm}&page=${page}`, @@ -89,7 +121,7 @@ export default class Algolia { return this.request(`${this.searchURL}/indexes/*/queries`, { method: 'POST', body: JSON.stringify({ requests: searchCollections }), - }).then(response => { + }).then((response: AlgoliaSearchResponse) => { const entries = response.results.map((result, index) => result.hits.map(hit => { const slug = getSlug(hit.path); @@ -97,11 +129,11 @@ export default class Algolia { }), ); - return { entries: _.flatten(entries), pagination: page }; + return { entries: flatten(entries), pagination: page }; }); } - searchBy(field, collection, query) { + searchBy(field: string, collection: string, query: string) { return this.request(`${this.searchURL}/indexes/${this.indexPrefix}${collection}`, { params: { restrictSearchableAttributes: field, @@ -110,44 +142,41 @@ export default class Algolia { }); } - listEntries(collection, page) { + listEntries(collection: Collection, page: number) { if (this.entriesCache.collection === collection && this.entriesCache.page === page) { return Promise.resolve({ page: this.entriesCache.page, entries: this.entriesCache.entries }); } else { - return this.request( - `${this.searchURL}/indexes/${this.indexPrefix}${collection.get('name')}`, - { - params: { page }, - }, - ).then(response => { + return this.request(`${this.searchURL}/indexes/${this.indexPrefix}${collection.name}`, { + params: { page: `${page}` }, + }).then((response: AlgoliaHits) => { const entries = response.hits.map(hit => { const slug = selectEntrySlug(collection, hit.path); - return createEntry(collection.get('name'), slug, hit.path, { + return createEntry(collection.name, slug, hit.path, { data: hit.data, partial: true, }); }); - this.entriesCache = { collection, pagination: response.page, entries }; - return { entries, pagination: response.page }; + this.entriesCache = { collection, page: response.page, entries }; + return { entries, page: response.page }; }); } } - async listAllEntries(collection) { + async listAllEntries(collection: Collection) { const params = { - hitsPerPage: 1000, + hitsPerPage: '1000', }; - let response = await this.request( - `${this.searchURL}/indexes/${this.indexPrefix}${collection.get('name')}`, + let response: AlgoliaHits = await this.request( + `${this.searchURL}/indexes/${this.indexPrefix}${collection.name}`, { params }, ); let { nbPages = 0, hits, page } = response; page = page + 1; while (page < nbPages) { response = await this.request( - `${this.searchURL}/indexes/${this.indexPrefix}${collection.get('name')}`, + `${this.searchURL}/indexes/${this.indexPrefix}${collection.name}`, { - params: { ...params, page }, + params: { ...params, page: `${page}` }, }, ); hits = [...hits, ...response.hits]; @@ -155,7 +184,7 @@ export default class Algolia { } const entries = hits.map(hit => { const slug = selectEntrySlug(collection, hit.path); - return createEntry(collection.get('name'), slug, hit.path, { + return createEntry(collection.name, slug, hit.path, { data: hit.data, partial: true, }); @@ -164,10 +193,10 @@ export default class Algolia { return entries; } - getEntry(collection, slug) { - return this.searchBy('slug', collection.get('name'), slug).then(response => { + getEntry(collection: Collection, slug: string) { + return this.searchBy('slug', collection.name, slug).then((response: AlgoliaHits) => { const entry = response.hits.filter(hit => hit.slug === slug)[0]; - return createEntry(collection.get('name'), slug, entry.path, { + return createEntry(collection.name, slug, entry.path, { data: entry.data, partial: true, }); diff --git a/src/integrations/providers/assetStore/implementation.js b/src/integrations/providers/assetStore/implementation.ts similarity index 68% rename from src/integrations/providers/assetStore/implementation.js rename to src/integrations/providers/assetStore/implementation.ts index a53ad601..98ef8fa5 100644 --- a/src/integrations/providers/assetStore/implementation.js +++ b/src/integrations/providers/assetStore/implementation.ts @@ -1,23 +1,36 @@ -import { pickBy, trimEnd } from 'lodash'; +import pickBy from 'lodash/pickBy'; +import trimEnd from 'lodash/trimEnd'; import { unsentRequest } from '../../../lib/util'; import { addParams } from '../../../lib/urlHelper'; +import type { AssetStoreConfig } from '../../../interface'; + const { fetchWithTimeout: fetch } = unsentRequest; +interface AssetStoreResponse { + id: string; + name: string; + size: number; + url: string; +} + export default class AssetStore { - constructor(config, getToken) { - this.config = config; - if (config.get('getSignedFormURL') == null) { + private shouldConfirmUpload: boolean; + private getSignedFormURL: string; + private getToken: () => Promise; + + constructor(config: AssetStoreConfig, getToken: () => Promise) { + if (config.getSignedFormURL == null) { throw 'The AssetStore integration needs the getSignedFormURL in the integration configuration.'; } this.getToken = getToken; - this.shouldConfirmUpload = config.get('shouldConfirmUpload', false); - this.getSignedFormURL = trimEnd(config.get('getSignedFormURL'), '/'); + this.shouldConfirmUpload = config.shouldConfirmUpload ?? false; + this.getSignedFormURL = trimEnd(config.getSignedFormURL, '/'); } - parseJsonResponse(response) { + parseJsonResponse(response: Response) { return response.json().then(json => { if (!response.ok) { return Promise.reject(json); @@ -27,12 +40,10 @@ export default class AssetStore { }); } - urlFor(path, options) { + urlFor(path: string, optionParams: Record = {}) { const params = []; - if (options.params) { - for (const key in options.params) { - params.push(`${key}=${encodeURIComponent(options.params[key])}`); - } + for (const key in optionParams) { + params.push(`${key}=${encodeURIComponent(optionParams[key])}`); } if (params.length) { path += `?${params.join('&')}`; @@ -46,7 +57,7 @@ export default class AssetStore { }; } - confirmRequest(assetID) { + confirmRequest(assetID: string) { this.getToken().then(token => this.request(`${this.getSignedFormURL}/${assetID}`, { method: 'PUT', @@ -59,9 +70,14 @@ export default class AssetStore { ); } - async request(path, options = {}) { + async request( + path: string, + options: RequestInit & { + params?: Record; + }, + ) { const headers = this.requestHeaders(options.headers || {}); - const url = this.urlFor(path, options); + const url = this.urlFor(path, options.params); const response = await fetch(url, { ...options, headers }); const contentType = response.headers.get('Content-Type'); const isJson = contentType && contentType.match(/json/); @@ -69,9 +85,9 @@ export default class AssetStore { return content; } - async retrieve(query, page, privateUpload) { + async retrieve(query: string, page: number, privateUpload: boolean) { const params = pickBy( - { search: query, page, filter: privateUpload ? 'private' : 'public' }, + { search: query, page: `${page}`, filter: privateUpload ? 'private' : 'public' }, val => !!val, ); const url = addParams(this.getSignedFormURL, params); @@ -80,14 +96,14 @@ export default class AssetStore { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }; - const response = await this.request(url, { headers }); + const response: AssetStoreResponse[] = await this.request(url, { headers }); const files = response.map(({ id, name, size, url }) => { return { id, name, size, displayURL: url, url, path: url }; }); return files; } - delete(assetID) { + delete(assetID: string) { const url = `${this.getSignedFormURL}/${assetID}`; return this.getToken().then(token => this.request(url, { @@ -100,8 +116,13 @@ export default class AssetStore { ); } - async upload(file, privateUpload = false) { - const fileData = { + async upload(file: File, privateUpload = false) { + const fileData: { + name: string; + size: number; + content_type?: string; + visibility?: 'private'; + } = { name: file.name, size: file.size, }; diff --git a/src/interface.ts b/src/interface.ts index 1912d2b1..9a88bc32 100644 --- a/src/interface.ts +++ b/src/interface.ts @@ -1,120 +1,365 @@ -import type { List, Map } from 'immutable'; -import type { ComponentType, FocusEventHandler, ReactNode } from 'react'; +import type { PropertiesSchema } from 'ajv/dist/types/json-schema'; +import type { ComponentType, ReactNode } from 'react'; import type { t, TranslateProps as ReactPolyglotTranslateProps } from 'react-polyglot'; import type { Pluggable } from 'unified'; +import type { MediaFile as BackendMediaFile } from './backend'; +import type { EditorControlProps } from './components/Editor/EditorControlPane/EditorControl'; +import type { formatExtensions } from './formats/formats'; +import type { I18N_STRUCTURE } from './lib/i18n'; +import type { AllowedEvent } from './lib/registry'; import type Cursor from './lib/util/Cursor'; -import type { CollectionType } from './constants/collectionTypes'; +import type AssetProxy from './valueObjects/AssetProxy'; -export type TranslatedProps = T & ReactPolyglotTranslateProps; +export interface SlugConfig { + encoding: string; + clean_accents: boolean; + sanitize_replacement: string; +} -export type GetAssetFunction = (asset: string) => { - url: string; - path: string; - field?: any; - fileObj: File; +export interface Pages { + [collection: string]: { isFetching?: boolean; page?: number; ids: string[] }; +} + +export type SortableField = + | { + key: string; + name: string; + label: string; + } + | ({ + key: string; + } & Field); + +export interface SortObject { + key: string; + direction: SortDirection; +} + +export type SortMap = Record; + +export type Sort = Record; + +export type FilterMap = ViewFilter & { active?: boolean }; + +export type GroupMap = ViewGroup & { active?: boolean }; + +export type Filter = Record>; // collection.field.active + +export type Group = Record>; // collection.field.active + +export interface GroupOfEntries { + id: string; + label: string; + value: string | boolean | undefined; + paths: Set; +} + +export type ObjectValue = { + [key: string]: ValueOrNestedValue; }; -export interface CmsWidgetControlProps { - value: T; - field: Map; - onChange: (value: T) => void; - forID: string; - classNameWrapper: string; - setActiveStyle: FocusEventHandler; - setInactiveStyle: FocusEventHandler; +export type ValueOrNestedValue = + | string + | number + | boolean + | string[] + | null + | undefined + | ObjectValue + | ObjectValue[]; + +export type EntryData = ObjectValue | undefined | null; + +export interface Entry { + collection: string; + slug: string; + path: string; + partial: boolean; + raw: string; + data: EntryData; + label: string | null; + isModification: boolean | null; + mediaFiles: MediaFile[]; + author: string; + updatedOn: string; + status?: string; + newRecord?: boolean; + isFetching?: boolean; + isPersisting?: boolean; + isDeleting?: boolean; + error?: string; + i18n?: { + [locale: string]: { + data: EntryData; + }; + }; +} + +export type Entities = Record; + +export interface FieldError { + type: string; + message?: string; +} + +export interface FieldsErrors { + [field: string]: FieldError[]; +} + +export interface FieldValidationMethodProps { + field: F; + value: T | undefined | null; t: t; } -export interface CmsWidgetPreviewProps { - value: T; - field: Map; - metadata: Map; - getAsset: GetAssetFunction; - entry: Map; - fieldsMetaData: Map; +export type FieldValidationMethod = ( + props: FieldValidationMethodProps, +) => false | FieldError | Promise; + +export interface EntryDraft { + entry: Entry; + fieldsErrors: FieldsErrors; } -export interface CmsWidgetParam { +export interface FilterRule { + value: string; + field: string; +} + +export interface CollectionFile { name: string; - controlComponent: ComponentType>; - previewComponent?: ComponentType>; - validator?: (props: { - field: Map; - value: T | undefined | null; - t: t; - }) => boolean | { error: any } | Promise; - globalStyles?: any; + label: string; + file: string; + fields: Field[]; + label_singular?: string; + description?: string; + media_folder?: string; + public_folder?: string; + i18n?: boolean | I18nInfo; + editor?: { + preview?: boolean; + }; } -export interface CmsWidget { - control: ComponentType>; - preview?: ComponentType>; - globalStyles?: any; +interface Nested { + summary?: string; + depth: number; } -export type PreviewTemplateComponentProps = { - entry: Map; - collection: Map; - widgetFor: (name: any, fields?: any, values?: any, fieldsMetaData?: any) => JSX.Element | null; - widgetsFor: (name: any) => any; +export interface I18nSettings { + currentLocale: string; + defaultLocale: string; + locales: string[]; +} + +export type Format = keyof typeof formatExtensions; + +export interface i18nCollection extends Omit { + i18n: Required['i18n']; +} + +export interface Collection { + name: string; + description?: string; + icon?: string; + folder?: string; + files?: CollectionFile[]; + fields: Field[]; + isFetching?: boolean; + media_folder?: string; + public_folder?: string; + preview_path?: string; + preview_path_date_field?: string; + summary?: string; + filter?: FilterRule; + type: 'file_based_collection' | 'folder_based_collection'; + extension?: string; + format?: Format; + frontmatter_delimiter?: string | [string, string]; + create?: boolean; + delete?: boolean; + identifier_field?: string; + path?: string; + slug?: string; + label_singular?: string; + label: string; + sortable_fields: SortableFields; + view_filters: ViewFilter[]; + view_groups: ViewGroup[]; + nested?: Nested; + i18n?: boolean | I18nInfo; + hide?: boolean; + editor?: { + preview?: boolean; + }; +} + +export type Collections = Record; + +export interface MediaLibraryInstance { + show: (args: { + id?: string; + value?: string | string[]; + config: Record; + allowMultiple?: boolean; + imagesOnly?: boolean; + }) => void; + hide?: () => void; + onClearControl?: (args: { id: string }) => void; + onRemoveControl?: (args: { id: string }) => void; + enableStandalone: () => boolean; +} + +export type MediaFile = BackendMediaFile & { key?: string }; + +export interface DisplayURLState { + isFetching: boolean; + url?: string; + err?: Error; +} + +export type Hook = string | boolean; + +export type TranslatedProps = T & ReactPolyglotTranslateProps; + +export type GetAssetFunction = (path: string, field?: Field) => AssetProxy; + +export interface WidgetControlProps { + clearFieldErrors: EditorControlProps['clearFieldErrors']; + clearSearch: EditorControlProps['clearSearch']; + collection: Collection; + config: Config; + entry: Entry; + field: F; + fieldsErrors: FieldsErrors; + submitted: boolean; + forList: boolean; getAsset: GetAssetFunction; - boundGetAsset: (collection: any, path: any) => GetAssetFunction; - fieldsMetaData: Map; - config: Map; - fields: List>; + isDisabled: boolean; + isEditorComponent: boolean; + isFetching: boolean; + isFieldDuplicate: EditorControlProps['isFieldDuplicate']; + isFieldHidden: EditorControlProps['isFieldHidden']; + isNewEditorComponent: boolean; + label: string; + loadEntry: EditorControlProps['loadEntry']; + locale: string | undefined; + mediaPaths: Record; + onChange: (value: T | null | undefined) => void; + addAsset: EditorControlProps['addAsset']; + addDraftEntryMediaFile: EditorControlProps['addDraftEntryMediaFile']; + clearMediaControl: EditorControlProps['clearMediaControl']; + openMediaLibrary: EditorControlProps['openMediaLibrary']; + removeInsertedMedia: EditorControlProps['removeInsertedMedia']; + removeMediaControl: EditorControlProps['removeMediaControl']; + i18n: I18nSettings | undefined; + hasErrors: boolean; + path: string; + query: EditorControlProps['query']; + t: t; + value: T | undefined | null; +} + +export interface WidgetPreviewProps { + entry: Entry; + field: F; + getAsset: GetAssetFunction; + getRemarkPlugins: () => Pluggable[]; + resolveWidget: (name: string) => Widget; + value: T | undefined | null; +} + +export type WidgetPreviewComponent = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | React.ReactElement> + | ComponentType>; + +export interface TemplatePreviewProps { + collection: Collection; + fields: Field[]; + entry: Entry; + getAsset: GetAssetFunction; + widgetFor: (name: string) => ReactNode; + widgetsFor: (name: string) => + | { + data: EntryData | null; + widgets: Record; + } + | { + data: EntryData | null; + widgets: Record; + }[]; +} + +export type TemplatePreviewComponent = + // eslint-disable-next-line @typescript-eslint/no-explicit-any + | React.ReactElement> + | ComponentType; + +export interface WidgetOptions { + validator?: Widget['validator']; + getValidValue?: Widget['getValidValue']; + schema?: Widget['schema']; + allowMapValue?: boolean; +} + +export interface Widget { + control: ComponentType>; + preview?: WidgetPreviewComponent; + validator: FieldValidationMethod; + getValidValue: (value: T | undefined | null) => T | undefined | null; + schema?: PropertiesSchema; + allowMapValue?: boolean; +} + +export interface WidgetParam { + name: string; + controlComponent: Widget['control']; + previewComponent?: Widget['preview']; + options?: WidgetOptions; +} + +export interface PreviewTemplateComponentProps { + entry: Entry; + collection: Collection; + widgetFor: (name: string) => ReactNode; + widgetsFor: (name: string) => + | { + data: EntryData | null; + widgets: Record; + } + | { + data: EntryData | null; + widgets: Record; + }[]; + getAsset: GetAssetFunction; + boundGetAsset: (collection: Collection, path: string) => GetAssetFunction; + config: Config; + fields: Field[]; isLoadingAsset: boolean; window: Window; document: Document; -}; -export type DisplayURLObject = { id: string; path: string }; +} -export type DisplayURL = DisplayURLObject | string; - -export type DataFile = { - path: string; - slug: string; - raw: string; - newPath?: string; -}; - -export type AssetProxy = { - path: string; - fileObj?: File; - toBase64?: () => Promise; -}; - -export type Entry = { - dataFiles: DataFile[]; - assets: AssetProxy[]; -}; - -export type PersistOptions = { +export interface PersistOptions { newEntry?: boolean; commitMessage: string; collectionName?: string; status?: string; -}; - -export type DeleteOptions = {}; - -export type Credentials = { token: string | {}; refresh_token?: string }; - -export type User = Credentials & { - backendName?: string; - login?: string; - name: string; -}; +} export interface ImplementationEntry { data: string; file: { path: string; label?: string; id?: string | null; author?: string; updatedOn?: string }; } -export type ImplementationFile = { - id?: string | null | undefined; - label?: string; +export interface DisplayURLObject { + id: string; path: string; -}; +} + +export type DisplayURL = DisplayURLObject | string; + export interface ImplementationMediaFile { name: string; id: string; @@ -124,98 +369,128 @@ export interface ImplementationMediaFile { draft?: boolean; url?: string; file?: File; + field?: Field; } -export type CursorStoreObject = { - actions: Set; - data: Map; - meta: Map; +export interface DataFile { + path: string; + slug: string; + raw: string; + newPath?: string; +} + +export interface BackendEntry { + dataFiles: DataFile[]; + assets: AssetProxy[]; +} + +export type DeleteOptions = {}; + +export interface Credentials { + token: string | {}; + refresh_token?: string; +} + +export type User = Credentials & { + backendName?: string; + login?: string; + name?: string; + avatar_url?: string; }; -export type CursorStore = { - get( - key: K, - defaultValue?: CursorStoreObject[K], - ): CursorStoreObject[K]; - getIn(path: string[]): V; - set( - key: K, - value: V, - ): CursorStoreObject[K]; - setIn(path: string[], value: unknown): CursorStore; - hasIn(path: string[]): boolean; - mergeIn(path: string[], value: unknown): CursorStore; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - update: (...args: any[]) => CursorStore; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - updateIn: (...args: any[]) => CursorStore; -}; +export interface ImplementationFile { + id?: string | null | undefined; + label?: string; + path: string; +} -export interface Implementation { - authComponent: () => void; - restoreUser: (user: User) => Promise; +export interface AuthenticatorConfig { + site_id?: string; + base_url?: string; + auth_endpoint?: string; + auth_token_endpoint?: string; + auth_url?: string; + app_id?: string; + clearHash?: () => void; +} - authenticate: (credentials: Credentials) => Promise; - logout: () => Promise | void | null; - getToken: () => Promise; +export abstract class BackendClass { + // eslint-disable-next-line @typescript-eslint/no-empty-function + constructor(_config: Config, _options: BackendInitializerOptions) {} - getEntry: (path: string) => Promise; - entriesByFolder: ( + abstract authComponent(): (props: TranslatedProps) => JSX.Element; + abstract restoreUser(user: User): Promise; + + abstract authenticate(credentials: Credentials): Promise; + abstract logout(): Promise | void | null; + abstract getToken(): Promise; + + abstract getEntry(path: string): Promise; + abstract entriesByFolder( folder: string, extension: string, depth: number, - ) => Promise; - entriesByFiles: (files: ImplementationFile[]) => Promise; + ): Promise; + abstract entriesByFiles(files: ImplementationFile[]): Promise; - getMediaDisplayURL?: (displayURL: DisplayURL) => Promise; - getMedia: (folder?: string) => Promise; - getMediaFile: (path: string) => Promise; + abstract getMediaDisplayURL(displayURL: DisplayURL): Promise; + abstract getMedia(folder?: string): Promise; + abstract getMediaFile(path: string): Promise; - persistEntry: (entry: Entry, opts: PersistOptions) => Promise; - persistMedia: (file: AssetProxy, opts: PersistOptions) => Promise; - deleteFiles: (paths: string[], commitMessage: string) => Promise; + abstract persistEntry(entry: BackendEntry, opts: PersistOptions): Promise; + abstract persistMedia(file: AssetProxy, opts: PersistOptions): Promise; + abstract deleteFiles(paths: string[], commitMessage: string): Promise; - allEntriesByFolder?: ( + abstract allEntriesByFolder( folder: string, extension: string, depth: number, - ) => Promise; - traverseCursor?: ( + ): Promise; + abstract traverseCursor( cursor: Cursor, action: string, - ) => Promise<{ entries: ImplementationEntry[]; cursor: Cursor }>; + ): Promise<{ entries: ImplementationEntry[]; cursor: Cursor }>; - isGitBackend?: () => boolean; - status: () => Promise<{ + abstract isGitBackend(): boolean; + abstract status(): Promise<{ auth: { status: boolean }; api: { status: boolean; statusPage: string }; }>; } -export interface CmsRegistryBackend { - init: (args: any) => Implementation; +export interface LocalePhrasesRoot { + [property: string]: LocalePhrases; +} +export type LocalePhrases = string | { [property: string]: LocalePhrases }; + +export type CustomIcon = () => JSX.Element; + +export type WidgetValueSerializer = { + serialize: (value: ValueOrNestedValue) => ValueOrNestedValue; + deserialize: (value: ValueOrNestedValue) => ValueOrNestedValue; +}; + +export type MediaLibraryOptions = Record; + +export interface MediaLibraryInitOptions { + options: Record | undefined; + handleInsert: (url: string | string[]) => void; } -export type CmsLocalePhrases = Record; // TODO: type properly - -export type CmsWidgetValueSerializer = any; // TODO: type properly - -export type CmsMediaLibraryOptions = any; // TODO: type properly - -export interface CmsMediaLibrary { +export interface MediaLibraryExternalLibrary { name: string; - config?: CmsMediaLibraryOptions; + config?: MediaLibraryOptions; + init: ({ options, handleInsert }: MediaLibraryInitOptions) => Promise; } -export interface PreviewStyleOptions { - raw: boolean; +export interface MediaLibraryInternalOptions { + allow_multiple?: boolean; + choose_url?: boolean; } -export interface PreviewStyle extends PreviewStyleOptions { - value: string; -} +export type MediaLibrary = MediaLibraryExternalLibrary | MediaLibraryInternalOptions; -export type CmsBackendType = +export type BackendType = | 'azure' | 'git-gateway' | 'github' @@ -224,9 +499,9 @@ export type CmsBackendType = | 'test-repo' | 'proxy'; -export type CmsMapWidgetType = 'Point' | 'LineString' | 'Polygon'; +export type MapWidgetType = 'Point' | 'LineString' | 'Polygon'; -export type CmsMarkdownWidgetButton = +export type MarkdownWidgetButton = | 'bold' | 'italic' | 'code' @@ -242,32 +517,16 @@ export type CmsMarkdownWidgetButton = | 'bulleted-list' | 'numbered-list'; -export interface CmsSelectWidgetOptionObject { +export interface SelectWidgetOptionObject { label: string; - value: any; + value: string; } -export type CmsCollectionFormatType = - | 'yml' - | 'yaml' - | 'toml' - | 'json' - | 'frontmatter' - | 'yaml-frontmatter' - | 'toml-frontmatter' - | 'json-frontmatter'; +export type AuthScope = 'repo' | 'public_repo'; -export type CmsAuthScope = 'repo' | 'public_repo'; +export type SlugEncoding = 'unicode' | 'ascii'; -export type CmsSlugEncoding = 'unicode' | 'ascii'; - -export interface CmsI18nConfig { - structure: 'multiple_folders' | 'multiple_files' | 'single_file'; - locales: string[]; - default_locale?: string; -} - -export interface CmsFieldBase { +export interface BaseField { name: string; label?: string; required?: boolean; @@ -279,111 +538,101 @@ export interface CmsFieldBase { comment?: string; } -export interface CmsFieldBoolean { +export interface BooleanField extends BaseField { widget: 'boolean'; default?: boolean; } -export interface CmsFieldCode { +export interface CodeField extends BaseField { widget: 'code'; - default?: any; + default?: string | { [key: string]: string }; default_language?: string; allow_language_selection?: boolean; keys?: { code: string; lang: string }; output_code_only?: boolean; + + code_mirror_config: { + extra_keys?: Record; + } & Record; } -export interface CmsFieldColor { +export interface ColorField extends BaseField { widget: 'color'; default?: string; - allowInput?: boolean; - enableAlpha?: boolean; + allow_input?: boolean; + enable_alpha?: boolean; } -export interface CmsFieldDateTime { +export interface DateTimeField extends BaseField { widget: 'datetime'; default?: string; format?: string; date_format?: boolean | string; time_format?: boolean | string; - picker_utc?: boolean; - - /** - * @deprecated Use date_format instead - */ - dateFormat?: boolean | string; - /** - * @deprecated Use time_format instead - */ - timeFormat?: boolean | string; - /** - * @deprecated Use picker_utc instead - */ - pickerUtc?: boolean; + picker_utc?: boolean; // TODO Reimplement } -export interface CmsFieldFileOrImage { +export interface FileOrImageField extends BaseField { widget: 'file' | 'image'; default?: string; - media_library?: CmsMediaLibrary; - allow_multiple?: boolean; - config?: any; + media_library?: MediaLibrary; + private?: boolean; } -export interface CmsFieldObject { +export interface ObjectField extends BaseField { widget: 'object'; - default?: any; + default?: ObjectValue; collapsed?: boolean; summary?: string; - fields: CmsField[]; + fields: Field[]; } -export interface CmsFieldList { +export interface ListField extends BaseField { widget: 'list'; - default?: any; + default?: ObjectValue[]; allow_add?: boolean; collapsed?: boolean; summary?: string; minimize_collapsed?: boolean; label_singular?: string; - field?: CmsField; - fields?: CmsField[]; + fields?: Field[]; max?: number; min?: number; add_to_top?: boolean; - types?: (CmsFieldBase & CmsFieldObject)[]; + types?: ObjectField[]; + typeKey?: string; } -export interface CmsFieldMap { +export interface MapField extends BaseField { widget: 'map'; default?: string; decimals?: number; - type?: CmsMapWidgetType; + type?: MapWidgetType; + height?: string; } -export interface CmsFieldMarkdown { +export interface MarkdownField extends BaseField { widget: 'markdown'; default?: string; minimal?: boolean; - buttons?: CmsMarkdownWidgetButton[]; + buttons?: MarkdownWidgetButton[]; editor_components?: string[]; - modes?: ('raw' | 'rich_text')[]; - /** - * @deprecated Use editor_components instead - */ - editorComponents?: string[]; + sanitize_preview?: boolean; + media_library?: MediaLibrary; + media_folder?: string; + public_folder?: string; } -export interface CmsFieldNumber { +export interface NumberField extends BaseField { widget: 'number'; default?: string | number; @@ -392,24 +641,19 @@ export interface CmsFieldNumber { max?: number; step?: number; - - /** - * @deprecated Use valueType instead - */ - valueType?: 'int' | 'float' | string; } -export interface CmsFieldSelect { +export interface SelectField extends BaseField { widget: 'select'; default?: string | string[]; - options: string[] | CmsSelectWidgetOptionObject[]; + options: string[] | SelectWidgetOptionObject[]; multiple?: boolean; min?: number; max?: number; } -export interface CmsFieldRelation { +export interface RelationField extends BaseField { widget: 'relation'; default?: string | string[]; @@ -419,81 +663,37 @@ export interface CmsFieldRelation { file?: string; display_fields?: string[]; multiple?: boolean; + min?: number; + max?: number; options_length?: number; - - /** - * @deprecated Use value_field instead - */ - valueField?: string; - /** - * @deprecated Use search_fields instead - */ - searchFields?: string[]; - /** - * @deprecated Use display_fields instead - */ - displayFields?: string[]; - /** - * @deprecated Use options_length instead - */ - optionsLength?: number; } -export interface CmsFieldHidden { +export interface HiddenField extends BaseField { widget: 'hidden'; - default?: any; + default?: ValueOrNestedValue; } -export interface CmsFieldStringOrText { +export interface StringOrTextField extends BaseField { // This is the default widget, so declaring its type is optional. widget?: 'string' | 'text'; default?: string; } -export interface CmsFieldMeta { - name: string; - label: string; - widget: string; - required: boolean; - index_file: string; - meta: boolean; -} - -export type CmsField = CmsFieldBase & - ( - | CmsFieldBoolean - | CmsFieldCode - | CmsFieldColor - | CmsFieldDateTime - | CmsFieldFileOrImage - | CmsFieldList - | CmsFieldMap - | CmsFieldMarkdown - | CmsFieldNumber - | CmsFieldObject - | CmsFieldRelation - | CmsFieldSelect - | CmsFieldHidden - | CmsFieldStringOrText - | CmsFieldMeta - ); - -export interface CmsCollectionFile { - name: string; - label: string; - file: string; - fields: CmsField[]; - label_singular?: string; - description?: string; - preview_path?: string; - preview_path_date_field?: string; - i18n?: boolean | CmsI18nConfig; - media_folder?: string; - public_folder?: string; - editor?: { - preview?: boolean; - }; -} +export type Field = + | BooleanField + | CodeField + | ColorField + | DateTimeField + | FileOrImageField + | ListField + | MapField + | MarkdownField + | NumberField + | ObjectField + | RelationField + | SelectField + | HiddenField + | StringOrTextField; export interface ViewFilter { id: string; @@ -515,74 +715,37 @@ export enum SortDirection { None = 'None', } -export interface CmsSortableFieldsDefault { +export interface SortableFieldsDefault { field: string; direction?: SortDirection; } -export interface CmsSortableFields { - default?: CmsSortableFieldsDefault; +export interface SortableFields { + default?: SortableFieldsDefault; fields: string[]; } -export interface CmsCollection { - name: string; - type?: CollectionType; - icon?: string; - label: string; - label_singular?: string; - description?: string; - folder?: string; - files?: CmsCollectionFile[]; - identifier_field?: string; - summary?: string; - slug?: string; - preview_path?: string; - preview_path_date_field?: string; - create?: boolean; - delete?: boolean; - hide?: boolean; - editor?: { - preview?: boolean; - }; - publish?: boolean; - nested?: { - depth: number; - }; - meta?: { path?: { label: string; widget: string; index_file: string } }; - - /** - * It accepts the following values: yml, yaml, toml, json, md, markdown, html - * - * You may also specify a custom extension not included in the list above, by specifying the format value. - */ - extension?: string; - format?: CmsCollectionFormatType; - - frontmatter_delimiter?: string[] | string; - fields?: CmsField[]; - filter?: { field: string; value: any }; - path?: string; - media_folder?: string; - public_folder?: string; - sortable_fields?: CmsSortableFields; - view_filters?: ViewFilter[]; - view_groups?: ViewGroup[]; - i18n?: boolean | CmsI18nConfig; -} - -export interface CmsBackend { - name: CmsBackendType; - auth_scope?: CmsAuthScope; +export interface Backend { + name: BackendType; + auth_scope?: AuthScope; repo?: string; branch?: string; api_root?: string; + api_version?: string; + tenant_id?: string; site_domain?: string; base_url?: string; auth_endpoint?: string; app_id?: string; auth_type?: 'implicit' | 'pkce'; proxy_url?: string; + large_media_url?: string; + login?: boolean; + use_graphql?: boolean; + graphql_api_root?: string; + use_large_media_transforms_in_media_library?: boolean; + identity_url?: string; + gateway_url?: string; commit_messages?: { create?: string; update?: string; @@ -592,40 +755,35 @@ export interface CmsBackend { }; } -export interface CmsSlug { - encoding?: CmsSlugEncoding; +export interface Slug { + encoding?: SlugEncoding; clean_accents?: boolean; sanitize_replacement?: string; } -export interface CmsLocalBackend { +export interface LocalBackend { url?: string; allowed_hosts?: string[]; } -export interface CmsConfig { - backend: CmsBackend; - collections: CmsCollection[]; +export interface Config { + backend: Backend; + collections: Collection[]; locale?: string; + site_id?: string; site_url?: string; display_url?: string; + base_url?: string; logo_url?: string; media_folder?: string; public_folder?: string; media_folder_relative?: boolean; - media_library?: CmsMediaLibrary; + media_library?: MediaLibrary; load_config_file?: boolean; - integrations?: { - hooks: string[]; - provider: string; - collections?: '*' | string[]; - applicationID?: string; - apiKey?: string; - getSignedFormURL?: string; - }[]; - slug?: CmsSlug; - i18n?: CmsI18nConfig; - local_backend?: boolean | CmsLocalBackend; + integrations?: Integration[]; + slug?: Slug; + i18n?: I18nInfo; + local_backend?: boolean | LocalBackend; editor?: { preview?: boolean; }; @@ -633,84 +791,138 @@ export interface CmsConfig { } export interface InitOptions { - config: CmsConfig; + config: Config; } -export type CmsBackendClass = Implementation; +export interface BackendInitializerOptions { + updateUserCredentials: (credentials: Credentials) => void; +} -export interface EditorComponentField { - name: string; - label: string; - widget: string; +export interface BackendInitializer { + init: (config: Config, options: BackendInitializerOptions) => BackendClass; } export interface EditorComponentWidgetOptions { id: string; label: string; - widget: string; + widget?: string; type: string; } -export interface EditorComponentManualOptions { +export interface EditorComponentManualOptions { id: string; label: string; - fields: EditorComponentField[]; + fields: Field[]; pattern: RegExp; allow_add?: boolean; - fromBlock: (match: RegExpMatchArray) => any; - toBlock: (data: any) => string; - toPreview: (data: any) => string; + fromBlock: (match: RegExpMatchArray) => T; + toBlock: (data: T) => string; + toPreview: (data: T, getAsset: GetAssetFunction, fields: Field[]) => ReactNode; } -export type EditorComponentOptions = EditorComponentManualOptions | EditorComponentWidgetOptions; - -export interface CmsEventListener { - name: 'prePublish' | 'postPublish' | 'preSave' | 'postSave'; - handler: ({ - entry, - author, - }: { - entry: Map; - author: { login: string; name: string }; - }) => any; +export function isEditorComponentWidgetOptions( + options: EditorComponentOptions, +): options is EditorComponentWidgetOptions { + return 'widget' in options; } -export type CmsEventListenerOptions = any; // TODO: type properly +export type EditorComponentOptions = + | EditorComponentManualOptions + | EditorComponentWidgetOptions; -export interface CMSApi { - getBackend: (name: string) => CmsRegistryBackend | undefined; - getEditorComponents: () => Map>; - getRemarkPlugins: () => Array; - getLocale: (locale: string) => CmsLocalePhrases | undefined; - getMediaLibrary: (name: string) => CmsMediaLibrary | undefined; - resolveWidget: (name: string) => CmsWidget | undefined; - getPreviewStyles: () => PreviewStyle[]; - getPreviewTemplate: (name: string) => ComponentType | undefined; - getWidget: (name: string) => CmsWidget | undefined; - getWidgetValueSerializer: (widgetName: string) => CmsWidgetValueSerializer | undefined; - init: (options?: InitOptions) => void; - registerBackend: (name: string, backendClass: T) => void; - registerEditorComponent: (options: EditorComponentOptions) => void; - registerRemarkPlugin: (plugin: Pluggable) => void; - registerEventListener: ( - eventListener: CmsEventListener, - options?: CmsEventListenerOptions, - ) => void; - registerLocale: (locale: string, phrases: CmsLocalePhrases) => void; - registerMediaLibrary: (mediaLibrary: CmsMediaLibrary, options?: CmsMediaLibraryOptions) => void; - registerPreviewStyle: (filePath: string, options?: PreviewStyleOptions) => void; - registerPreviewTemplate: ( - name: string, - component: ComponentType, - ) => void; - registerWidget: ( - widget: string | CmsWidgetParam | CmsWidgetParam[], - control?: ComponentType | string, - preview?: ComponentType, - ) => void; - registerWidgetValueSerializer: (widgetName: string, serializer: CmsWidgetValueSerializer) => void; - registerIcon: (iconName: string, icon: ReactNode) => void; - getIcon: (iconName: string) => ReactNode; - registerAdditionalLink: (id: string, title: string, link: string, iconName?: string) => void; - getAdditionalLinks: () => { title: string; link: string; iconName?: string }[]; +export interface EventData { + entry: Entry; + author: { login: string | undefined; name: string }; } + +export interface EventListener { + name: AllowedEvent; + handler: ( + data: EventData, + options: Record, + ) => Promise; +} + +export type EventListenerOptions = Record; + +export interface AdditionalLink { + id: string; + title: string; + data: string | (() => JSX.Element); + options?: { + iconName?: string; + }; +} + +export interface AuthenticationPageProps { + onLogin: (user: User) => void; + inProgress?: boolean; + base_url?: string; + siteId?: string; + authEndpoint?: string; + config: Config; + error?: string | undefined; + clearHash?: () => void; +} + +export type Integration = { + collections?: '*' | string[]; +} & (AlgoliaIntegration | AssetStoreIntegration); + +export type IntegrationProvider = Integration['provider']; +export type SearchIntegrationProvider = 'algolia'; +export type MediaIntegrationProvider = 'assetStore'; + +export interface AlgoliaIntegration extends AlgoliaConfig { + provider: 'algolia'; +} + +export interface AlgoliaConfig { + hooks: ['search' | 'listEntries']; + applicationID: string; + apiKey: string; + indexPrefix?: string; +} + +export interface AssetStoreIntegration extends AssetStoreConfig { + provider: 'assetStore'; +} + +export interface AssetStoreConfig { + hooks: ['assetStore']; + shouldConfirmUpload?: boolean; + getSignedFormURL: string; +} + +export interface SearchResponse { + entries: Entry[]; + pagination: number; +} + +export interface SearchQueryResponse { + hits: Entry[]; + query: string; +} + +export interface EditorPersistOptions { + createNew?: boolean; + duplicate?: boolean; +} + +export interface I18nInfo { + locales: string[]; + defaultLocale: string; + structure?: I18N_STRUCTURE; +} + +export interface ProcessedCodeLanguage { + label: string; + identifiers: string[]; + codemirror_mode: string; + codemirror_mime_type: string; +} + +export type FileMetadata = { + author: string; + updatedOn: string; +}; diff --git a/src/lib/auth/implicit-oauth.js b/src/lib/auth/implicit-oauth.ts similarity index 58% rename from src/lib/auth/implicit-oauth.js rename to src/lib/auth/implicit-oauth.ts index b62af51a..dd4d726e 100644 --- a/src/lib/auth/implicit-oauth.js +++ b/src/lib/auth/implicit-oauth.ts @@ -1,19 +1,29 @@ -import { Map } from 'immutable'; import trim from 'lodash/trim'; import trimEnd from 'lodash/trimEnd'; -import { createNonce, validateNonce, isInsecureProtocol } from './utils'; +import { createNonce, isInsecureProtocol, validateNonce } from './utils'; + +import type { User, AuthenticatorConfig } from '../../interface'; +import type { NetlifyError } from './netlify-auth'; export default class ImplicitAuthenticator { - constructor(config = {}) { + private auth_url: string; + private appID: string; + private clearHash: () => void; + + constructor(config: AuthenticatorConfig = {}) { const baseURL = trimEnd(config.base_url, '/'); const authEndpoint = trim(config.auth_endpoint, '/'); this.auth_url = `${baseURL}/${authEndpoint}`; - this.appID = config.app_id; - this.clearHash = config.clearHash; + this.appID = config.app_id ?? ''; + // eslint-disable-next-line @typescript-eslint/no-empty-function + this.clearHash = config.clearHash ?? (() => {}); } - authenticate(options, cb) { + authenticate( + options: { scope: string; prompt?: string | null; resource?: string | null }, + cb: (error: Error | NetlifyError | null, data?: User) => void, + ) { if (isInsecureProtocol()) { return cb(new Error('Cannot authenticate over insecure protocol!')); } @@ -42,7 +52,7 @@ export default class ImplicitAuthenticator { /** * Complete authentication if we were redirected back to from the provider. */ - completeAuth(cb) { + completeAuth(cb: (error: Error | NetlifyError | null, data?: User) => void) { const hashParams = new URLSearchParams(document.location.hash.replace(/^#?\/?/, '')); if (!hashParams.has('access_token') && !hashParams.has('error')) { return; @@ -50,21 +60,24 @@ export default class ImplicitAuthenticator { // Remove tokens from hash so that token does not remain in browser history. this.clearHash(); - const params = Map(hashParams.entries()); + const params = [...hashParams.entries()].reduce((acc, [key, value]) => { + acc[key] = value; + return acc; + }, {} as Record); - const { nonce } = JSON.parse(params.get('state')); + const { nonce } = JSON.parse(params.state ?? ''); const validNonce = validateNonce(nonce); if (!validNonce) { return cb(new Error('Invalid nonce')); } - if (params.has('error')) { - return cb(new Error(`${params.get('error')}: ${params.get('error_description')}`)); + if ('error' in hashParams) { + return cb(new Error(`${params.error}: ${params.error_description}`)); } - if (params.has('access_token')) { - const { access_token: token, ...data } = params.toJS(); - cb(null, { token, ...data }); + if ('access_token' in params) { + const { access_token: token, ...data } = params; + cb(null, { token, ...data } as User); } } } diff --git a/src/lib/auth/index.d.ts b/src/lib/auth/index.d.ts deleted file mode 100644 index 37784845..00000000 --- a/src/lib/auth/index.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -class NetlifyAuthenticator { - constructor(config = {}); - - refresh: (args: { - provider: string; - refresh_token: string; - }) => Promise<{ token: string; refresh_token: string }>; -} -export { NetlifyAuthenticator }; diff --git a/src/lib/auth/index.js b/src/lib/auth/index.js deleted file mode 100644 index aa863c04..00000000 --- a/src/lib/auth/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import NetlifyAuthenticator from './netlify-auth'; -import ImplicitAuthenticator from './implicit-oauth'; -import PkceAuthenticator from './pkce-oauth'; -export const StaticCmsLibAuth = { NetlifyAuthenticator, ImplicitAuthenticator, PkceAuthenticator }; -export { NetlifyAuthenticator, ImplicitAuthenticator, PkceAuthenticator }; diff --git a/src/lib/auth/index.ts b/src/lib/auth/index.ts new file mode 100644 index 00000000..52eab5e1 --- /dev/null +++ b/src/lib/auth/index.ts @@ -0,0 +1,3 @@ +export { default as NetlifyAuthenticator } from './netlify-auth'; +export { default as ImplicitAuthenticator } from './implicit-oauth'; +export { default as PkceAuthenticator } from './pkce-oauth'; diff --git a/src/lib/auth/netlify-auth.js b/src/lib/auth/netlify-auth.ts similarity index 67% rename from src/lib/auth/netlify-auth.js rename to src/lib/auth/netlify-auth.ts index ad28b360..06637cf2 100644 --- a/src/lib/auth/netlify-auth.js +++ b/src/lib/auth/netlify-auth.ts @@ -1,13 +1,18 @@ import trim from 'lodash/trim'; import trimEnd from 'lodash/trimEnd'; +import type { User, AuthenticatorConfig } from '../../interface'; + const NETLIFY_API = 'https://api.netlify.com'; const AUTH_ENDPOINT = 'auth'; -class NetlifyError { - constructor(err) { +export class NetlifyError { + private err: Error; + + constructor(err: Error) { this.err = err; } + toString() { return this.err && this.err.message; } @@ -30,46 +35,60 @@ const PROVIDERS = { width: 500, height: 400, }, -}; +} as const; class Authenticator { - constructor(config = {}) { + private site_id: string | null; + private base_url: string; + private auth_endpoint: string; + private authWindow: Window | null; + + constructor(config: AuthenticatorConfig = {}) { this.site_id = config.site_id || null; this.base_url = trimEnd(config.base_url, '/') || NETLIFY_API; this.auth_endpoint = trim(config.auth_endpoint, '/') || AUTH_ENDPOINT; + this.authWindow = null; } - handshakeCallback(options, cb) { - const fn = e => { + handshakeCallback( + options: { provider?: keyof typeof PROVIDERS }, + cb: (error: Error | NetlifyError | null, data?: User) => void, + ) { + const fn = (e: { data: string; origin: string }) => { if (e.data === 'authorizing:' + options.provider && e.origin === this.base_url) { window.removeEventListener('message', fn, false); window.addEventListener('message', this.authorizeCallback(options, cb), false); - return this.authWindow.postMessage(e.data, e.origin); + return this.authWindow?.postMessage(e.data, e.origin); } }; return fn; } - authorizeCallback(options, cb) { - const fn = e => { + authorizeCallback( + options: { provider?: keyof typeof PROVIDERS }, + cb: (error: Error | NetlifyError | null, data?: User) => void, + ) { + const fn = (e: { data: string; origin: string }) => { if (e.origin !== this.base_url) { return; } if (e.data.indexOf('authorization:' + options.provider + ':success:') === 0) { const data = JSON.parse( - e.data.match(new RegExp('^authorization:' + options.provider + ':success:(.+)$'))[1], + e.data.match(new RegExp('^authorization:' + options.provider + ':success:(.+)$'))?.[1] ?? + '', ); window.removeEventListener('message', fn, false); - this.authWindow.close(); + this.authWindow?.close(); cb(null, data); } if (e.data.indexOf('authorization:' + options.provider + ':error:') === 0) { const err = JSON.parse( - e.data.match(new RegExp('^authorization:' + options.provider + ':error:(.+)$'))[1], + e.data.match(new RegExp('^authorization:' + options.provider + ':error:(.+)$'))?.[1] ?? + '', ); window.removeEventListener('message', fn, false); - this.authWindow.close(); + this.authWindow?.close(); cb(new NetlifyError(err)); } }; @@ -84,23 +103,33 @@ class Authenticator { return host === 'localhost' ? 'cms.netlify.com' : host; } - authenticate(options, cb) { + authenticate( + options: { + provider?: keyof typeof PROVIDERS; + scope?: string; + login?: boolean; + beta_invite?: string; + invite_code?: string; + }, + cb: (error: Error | NetlifyError | null, data?: User) => void, + ) { const { provider } = options; const siteID = this.getSiteID(); if (!provider) { return cb( - new NetlifyError({ - message: 'You must specify a provider when calling netlify.authenticate', - }), + new NetlifyError( + new Error('You must specify a provider when calling netlify.authenticate'), + ), ); } if (!siteID) { return cb( - new NetlifyError({ - message: + new NetlifyError( + new Error( "You must set a site_id with netlify.configure({site_id: 'your-site-id'}) to make authentication work from localhost", - }), + ), + ), ); } @@ -126,27 +155,34 @@ class Authenticator { 'Netlify Authorization', `width=${conf.width}, height=${conf.height}, top=${top}, left=${left}`, ); - this.authWindow.focus(); + this.authWindow?.focus(); } - refresh(options, cb) { + refresh( + options: { + provider: keyof typeof PROVIDERS; + refresh_token?: string; + }, + cb?: (error: Error | NetlifyError | null, data?: User) => void, + ) { const { provider, refresh_token } = options; const siteID = this.getSiteID(); const onError = cb || Promise.reject.bind(Promise); if (!provider || !refresh_token) { return onError( - new NetlifyError({ - message: 'You must specify a provider and refresh token when calling netlify.refresh', - }), + new NetlifyError( + new Error('You must specify a provider and refresh token when calling netlify.refresh'), + ), ); } if (!siteID) { return onError( - new NetlifyError({ - message: + new NetlifyError( + new Error( "You must set a site_id with netlify.configure({site_id: 'your-site-id'}) to make token refresh work from localhost", - }), + ), + ), ); } const url = `${this.base_url}/${this.auth_endpoint}/refresh?provider=${provider}&site_id=${siteID}&refresh_token=${refresh_token}`; diff --git a/src/lib/auth/pkce-oauth.js b/src/lib/auth/pkce-oauth.ts similarity index 73% rename from src/lib/auth/pkce-oauth.js rename to src/lib/auth/pkce-oauth.ts index 232ccee7..4e28633b 100644 --- a/src/lib/auth/pkce-oauth.js +++ b/src/lib/auth/pkce-oauth.ts @@ -1,9 +1,12 @@ import trim from 'lodash/trim'; import trimEnd from 'lodash/trimEnd'; -import { createNonce, validateNonce, isInsecureProtocol } from './utils'; +import { createNonce, isInsecureProtocol, validateNonce } from './utils'; -async function sha256(text) { +import type { User, AuthenticatorConfig } from '../../interface'; +import type { NetlifyError } from './netlify-auth'; + +async function sha256(text: string) { const encoder = new TextEncoder(); const data = encoder.encode(text); const digest = await window.crypto.subtle.digest('SHA-256', data); @@ -24,7 +27,7 @@ function generateVerifierCode() { .join(''); } -async function createCodeChallenge(codeVerifier) { +async function createCodeChallenge(codeVerifier: string) { const sha = await sha256(codeVerifier); // https://tools.ietf.org/html/rfc7636#appendix-A return btoa(sha).split('=')[0].replace(/\+/g, '-').replace(/\//g, '_'); @@ -47,16 +50,23 @@ function clearCodeVerifier() { } export default class PkceAuthenticator { - constructor(config = {}) { + private auth_url: string; + private auth_token_url: string; + private appID: string; + + constructor(config: AuthenticatorConfig = {}) { const baseURL = trimEnd(config.base_url, '/'); const authEndpoint = trim(config.auth_endpoint, '/'); const authTokenEndpoint = trim(config.auth_token_endpoint, '/'); this.auth_url = `${baseURL}/${authEndpoint}`; this.auth_token_url = `${baseURL}/${authTokenEndpoint}`; - this.appID = config.app_id; + this.appID = config.app_id ?? ''; } - async authenticate(options, cb) { + async authenticate( + options: { scope: string; prompt?: string | null; resource?: string | null }, + cb: (error: Error | NetlifyError | null, data?: User) => void, + ) { if (isInsecureProtocol()) { return cb(new Error('Cannot authenticate over insecure protocol!')); } @@ -82,37 +92,42 @@ export default class PkceAuthenticator { /** * Complete authentication if we were redirected back to from the provider. */ - async completeAuth(cb) { - const params = new URLSearchParams(document.location.search); + async completeAuth(cb: (error: Error | NetlifyError | null, data?: User) => void) { + const searchParams = new URLSearchParams(document.location.search); + + const params = [...searchParams.entries()].reduce((acc, [key, value]) => { + acc[key] = value; + return acc; + }, {} as Record); // Remove code from url window.history.replaceState(null, '', document.location.pathname); - if (!params.has('code') && !params.has('error')) { + if (!('code' in params) && !('error' in params)) { return; } - const { nonce } = JSON.parse(params.get('state')); + const { nonce } = JSON.parse(params.state ?? ''); const validNonce = validateNonce(nonce); if (!validNonce) { return cb(new Error('Invalid nonce')); } - if (params.has('error')) { - return cb(new Error(`${params.get('error')}: ${params.get('error_description')}`)); + if ('error' in params) { + return cb(new Error(`${params.error}: ${params.error_description}`)); } - if (params.has('code')) { - const code = params.get('code'); + if ('code' in params) { + const code = params.code; const authURL = new URL(this.auth_token_url); authURL.searchParams.set('client_id', this.appID); - authURL.searchParams.set('code', code); + authURL.searchParams.set('code', code ?? ''); authURL.searchParams.set('grant_type', 'authorization_code'); authURL.searchParams.set( 'redirect_uri', document.location.origin + document.location.pathname, ); - authURL.searchParams.set('code_verifier', getCodeVerifier()); + authURL.searchParams.set('code_verifier', getCodeVerifier() ?? ''); //no need for verifier code so remove clearCodeVerifier(); diff --git a/src/lib/auth/utils.js b/src/lib/auth/utils.ts similarity index 87% rename from src/lib/auth/utils.js rename to src/lib/auth/utils.ts index 0017a881..63a73c67 100644 --- a/src/lib/auth/utils.js +++ b/src/lib/auth/utils.ts @@ -6,9 +6,9 @@ export function createNonce() { return nonce; } -export function validateNonce(check) { +export function validateNonce(check: string) { const auth = window.sessionStorage.getItem('static-cms-auth'); - const valid = auth && JSON.parse(auth).nonce; + const valid = auth && (JSON.parse(auth).nonce as string); window.localStorage.removeItem('static-cms-auth'); return check === valid; } diff --git a/src/lib/consoleError.js b/src/lib/consoleError.ts similarity index 69% rename from src/lib/consoleError.js rename to src/lib/consoleError.ts index 95415cc4..b7ea43e4 100644 --- a/src/lib/consoleError.js +++ b/src/lib/consoleError.ts @@ -1,4 +1,4 @@ -export default function consoleError(title, description) { +export default function consoleError(title: string, description: string) { console.error( `%c ⛔ ${title}\n` + `%c${description}\n\n`, 'color: black; font-weight: bold; font-size: 16px; line-height: 50px;', diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 88071133..14fb0bcc 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -1,28 +1,20 @@ -import { stripIndent } from 'common-tags'; -import { flow, partialRight, trimEnd, trimStart } from 'lodash'; +import flow from 'lodash/flow'; +import get from 'lodash/get'; +import partialRight from 'lodash/partialRight'; -import { FILES } from '../constants/collectionTypes'; import { COMMIT_AUTHOR, COMMIT_DATE } from '../constants/commitProps'; -import { stringTemplate } from './widgets'; -import { - getFileFromSlug, - selectField, - selectIdentifier, - selectInferedField, -} from '../reducers/collections'; import { sanitizeSlug } from './urlHelper'; - -import type { Map } from 'immutable'; -import type { CmsConfig } from '../interface'; -import type { CmsSlug, Collection, EntryMap } from '../types/redux'; - -const { - compileStringTemplate, - parseDateFromEntry, - SLUG_MISSING_REQUIRED_DATE, - keyToPathArray, +import { selectIdentifier, selectInferedField } from './util/collection.util'; +import { selectField } from './util/field.util'; +import { set } from './util/object.util'; +import { addFileTemplateFields, -} = stringTemplate; + compileStringTemplate, + keyToPathArray, + parseDateFromEntry, +} from './widgets/stringTemplate'; + +import type { Collection, Config, Entry, EntryData, Slug } from '../interface'; const commitMessageTemplates = { create: 'Create {{collection}} “{{slug}}”', @@ -44,7 +36,7 @@ type Options = { export function commitMessageFormatter( type: keyof typeof commitMessageTemplates, - config: CmsConfig, + config: Config, { slug, path, collection, authorLogin, authorName }: Options, ) { const templates = { ...commitMessageTemplates, ...(config.backend.commit_messages || {}) }; @@ -56,7 +48,7 @@ export function commitMessageFormatter( case 'path': return path || ''; case 'collection': - return collection ? collection.get('label_singular') || collection.get('label') : ''; + return collection ? collection.label_singular || collection.label : ''; case 'author-login': return authorLogin || ''; case 'author-name': @@ -83,21 +75,17 @@ export function prepareSlug(slug: string) { ); } -export function getProcessSegment(slugConfig?: CmsSlug, ignoreValues?: string[]) { +export function getProcessSegment(slugConfig?: Slug, ignoreValues?: string[]) { return (value: string) => ignoreValues && ignoreValues.includes(value) ? value : flow([value => String(value), prepareSlug, partialRight(sanitizeSlug, slugConfig)])(value); } -export function slugFormatter( - collection: Collection, - entryData: Map, - slugConfig?: CmsSlug, -) { - const slugTemplate = collection.get('slug') || '{{slug}}'; +export function slugFormatter(collection: Collection, entryData: EntryData, slugConfig?: Slug) { + const slugTemplate = collection.slug || '{{slug}}'; - const identifier = entryData.getIn(keyToPathArray(selectIdentifier(collection) as string)); + const identifier = get(entryData, keyToPathArray(selectIdentifier(collection))); if (!identifier) { throw new Error( 'Collection must have a field name that is a valid entry identifier, or must have `identifier_field` set', @@ -108,104 +96,28 @@ export function slugFormatter( const date = new Date(); const slug = compileStringTemplate(slugTemplate, date, identifier, entryData, processSegment); - if (!collection.has('path')) { + if (!('path' in collection)) { return slug; } else { - const pathTemplate = prepareSlug(collection.get('path') as string); + const pathTemplate = prepareSlug(collection.path as string); return compileStringTemplate(pathTemplate, date, slug, entryData, (value: string) => value === slug ? value : processSegment(value), ); } } -export function previewUrlFormatter( - baseUrl: string, - collection: Collection, - slug: string, - entry: EntryMap, - slugConfig?: CmsSlug, -) { - /** - * Preview URL can't be created without `baseUrl`. This makes preview URLs - * optional for backends that don't support them. - */ - if (!baseUrl) { - return; - } +export function summaryFormatter(summaryTemplate: string, entry: Entry, collection: Collection) { + let entryData = entry.data; + const date = parseDateFromEntry(entry, selectInferedField(collection, 'date')) || null; + const identifier = get(entryData, keyToPathArray(selectIdentifier(collection))); - const basePath = trimEnd(baseUrl, '/'); - - const isFileCollection = collection.get('type') === FILES; - const file = isFileCollection ? getFileFromSlug(collection, entry.get('slug')) : undefined; - - function getPathTemplate() { - return file?.get('preview_path') ?? collection.get('preview_path'); - } - - function getDateField() { - return file?.get('preview_path_date_field') ?? collection.get('preview_path_date_field'); - } - - /** - * If a `previewPath` is provided for the collection/file, use it to construct the - * URL path. - */ - const pathTemplate = getPathTemplate(); - - /** - * Without a `previewPath` for the collection/file (via config), the preview URL - * will be the URL provided by the backend. - */ - if (!pathTemplate) { - return baseUrl; - } - - let fields = entry.get('data') as Map; - fields = addFileTemplateFields(entry.get('path'), fields, collection.get('folder')); - const dateFieldName = getDateField() || selectInferedField(collection, 'date'); - const date = parseDateFromEntry(entry as unknown as Map, dateFieldName); - - // Prepare and sanitize slug variables only, leave the rest of the - // `preview_path` template as is. - const processSegment = getProcessSegment(slugConfig, [fields.get('dirname')]); - let compiledPath; - - try { - compiledPath = compileStringTemplate(pathTemplate, date, slug, fields, processSegment); - } catch (err: any) { - // Print an error and ignore `preview_path` if both: - // 1. Date is invalid (according to Moment), and - // 2. A date expression (eg. `{{year}}`) is used in `preview_path` - if (err.name === SLUG_MISSING_REQUIRED_DATE) { - console.error(stripIndent` - Collection "${collection.get('name')}" configuration error: - \`preview_path_date_field\` must be a field with a valid date. Ignoring \`preview_path\`. - `); - return basePath; - } - throw err; - } - - const previewPath = trimStart(compiledPath, ' /'); - return `${basePath}/${previewPath}`; -} - -export function summaryFormatter(summaryTemplate: string, entry: EntryMap, collection: Collection) { - let entryData = entry.get('data'); - const date = - parseDateFromEntry( - entry as unknown as Map, - selectInferedField(collection, 'date'), - ) || null; - const identifier = entryData.getIn(keyToPathArray(selectIdentifier(collection) as string)); - - entryData = addFileTemplateFields(entry.get('path'), entryData, collection.get('folder')); + entryData = addFileTemplateFields(entry.path, entryData, collection.folder) ?? {}; // allow commit information in summary template - if (entry.get('author') && !selectField(collection, COMMIT_AUTHOR)) { - entryData = entryData.set(COMMIT_AUTHOR, entry.get('author')); + if (entry.author && !selectField(collection, COMMIT_AUTHOR)) { + entryData = set(entryData, COMMIT_AUTHOR, entry.author); } - if (entry.get('updatedOn') && !selectField(collection, COMMIT_DATE)) { - entryData = entryData.set(COMMIT_DATE, entry.get('updatedOn')); + if (entry.updatedOn && !selectField(collection, COMMIT_DATE)) { + entryData = set(entryData, COMMIT_DATE, entry.updatedOn); } const summary = compileStringTemplate(summaryTemplate, date, identifier, entryData); return summary; @@ -213,26 +125,22 @@ export function summaryFormatter(summaryTemplate: string, entry: EntryMap, colle export function folderFormatter( folderTemplate: string, - entry: EntryMap | undefined, + entry: Entry | undefined, collection: Collection, defaultFolder: string, folderKey: string, - slugConfig?: CmsSlug, + slugConfig?: Slug, ) { - if (!entry || !entry.get('data')) { + if (!entry || !entry.data) { return folderTemplate; } - let fields = (entry.get('data') as Map).set(folderKey, defaultFolder); - fields = addFileTemplateFields(entry.get('path'), fields, collection.get('folder')); + let fields = set(entry.data, folderKey, defaultFolder) as EntryData; + fields = addFileTemplateFields(entry.path, fields, collection.folder); - const date = - parseDateFromEntry( - entry as unknown as Map, - selectInferedField(collection, 'date'), - ) || null; - const identifier = fields.getIn(keyToPathArray(selectIdentifier(collection) as string)); - const processSegment = getProcessSegment(slugConfig, [defaultFolder, fields.get('dirname')]); + const date = parseDateFromEntry(entry, selectInferedField(collection, 'date')) || null; + const identifier = get(fields, keyToPathArray(selectIdentifier(collection))); + const processSegment = getProcessSegment(slugConfig, [defaultFolder, fields?.dirname as string]); const mediaFolder = compileStringTemplate( folderTemplate, diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 8c30d806..17a10739 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -1,10 +1,12 @@ -import { Map, List } from 'immutable'; -import { set, groupBy, escapeRegExp } from 'lodash'; +import escapeRegExp from 'lodash/escapeRegExp'; +import get from 'lodash/get'; +import groupBy from 'lodash/groupBy'; -import { selectEntrySlug } from '../reducers/collections'; +import { selectEntrySlug } from './util/collection.util'; +import { set } from './util/object.util'; -import type { Collection, Entry, EntryDraft, EntryField, EntryMap } from '../types/redux'; -import type { EntryValue } from '../valueObjects/Entry'; +import type { Field, Collection, Entry, EntryData, i18nCollection, I18nInfo } from '../interface'; +import type { EntryDraftState } from '../reducers/entryDraft'; export const I18N = 'i18n'; @@ -20,22 +22,17 @@ export enum I18N_FIELD { NONE = 'none', } -export function hasI18n(collection: Collection) { - return collection.has(I18N); +export function hasI18n(collection: Collection): collection is i18nCollection { + return I18N in collection; } -type I18nInfo = { - locales: string[]; - defaultLocale: string; - structure: I18N_STRUCTURE; -}; - -export function getI18nInfo(collection: Collection) { - if (!hasI18n(collection)) { - return {}; +export function getI18nInfo(collection: i18nCollection): I18nInfo; +export function getI18nInfo(collection: Collection): I18nInfo | null; +export function getI18nInfo(collection: Collection): I18nInfo | null { + if (!hasI18n(collection) || typeof collection[I18N] !== 'object') { + return null; } - const { structure, locales, default_locale: defaultLocale } = collection.get(I18N).toJS(); - return { structure, locales, defaultLocale } as I18nInfo; + return collection.i18n; } export function getI18nFilesDepth(collection: Collection, depth: number) { @@ -46,18 +43,18 @@ export function getI18nFilesDepth(collection: Collection, depth: number) { return depth; } -export function isFieldTranslatable(field: EntryField, locale: string, defaultLocale: string) { - const isTranslatable = locale !== defaultLocale && field.get(I18N) === I18N_FIELD.TRANSLATE; +export function isFieldTranslatable(field: Field, locale?: string, defaultLocale?: string) { + const isTranslatable = locale !== defaultLocale && field.i18n === I18N_FIELD.TRANSLATE; return isTranslatable; } -export function isFieldDuplicate(field: EntryField, locale: string, defaultLocale: string) { - const isDuplicate = locale !== defaultLocale && field.get(I18N) === I18N_FIELD.DUPLICATE; +export function isFieldDuplicate(field: Field, locale?: string, defaultLocale?: string) { + const isDuplicate = locale !== defaultLocale && field.i18n === I18N_FIELD.DUPLICATE; return isDuplicate; } -export function isFieldHidden(field: EntryField, locale: string, defaultLocale: string) { - const isHidden = locale !== defaultLocale && field.get(I18N) === I18N_FIELD.NONE; +export function isFieldHidden(field: Field, locale?: string, defaultLocale?: string) { + const isHidden = locale !== defaultLocale && field.i18n === I18N_FIELD.NONE; return isHidden; } @@ -141,26 +138,34 @@ export function normalizeFilePath(structure: I18N_STRUCTURE, path: string, local export function getI18nFiles( collection: Collection, extension: string, - entryDraft: EntryMap, - entryToRaw: (entryDraft: EntryMap) => string, + entryDraft: Entry, + entryToRaw: (entryDraft: Entry) => string, path: string, slug: string, newPath?: string, ) { - const { structure, defaultLocale, locales } = getI18nInfo(collection) as I18nInfo; + const { + structure = I18N_STRUCTURE.SINGLE_FILE, + defaultLocale, + locales, + } = getI18nInfo(collection) as I18nInfo; if (structure === I18N_STRUCTURE.SINGLE_FILE) { const data = locales.reduce((map, locale) => { const dataPath = getDataPath(locale, defaultLocale); - return map.set(locale, entryDraft.getIn(dataPath)); - }, Map({})); - const draft = entryDraft.set('data', data); + if (map) { + map[locale] = get(entryDraft, dataPath); + } + return map; + }, {} as EntryData); + + entryDraft.data = data; return [ { path: getFilePath(structure, extension, path, slug, locales[0]), slug, - raw: entryToRaw(draft), + raw: entryToRaw(entryDraft), ...(newPath && { newPath: getFilePath(structure, extension, newPath, slug, locales[0]), }), @@ -171,11 +176,11 @@ export function getI18nFiles( const dataFiles = locales .map(locale => { const dataPath = getDataPath(locale, defaultLocale); - const draft = entryDraft.set('data', entryDraft.getIn(dataPath)); + entryDraft.data = get(entryDraft, dataPath); return { path: getFilePath(structure, extension, path, slug, locale), slug, - raw: draft.get('data') ? entryToRaw(draft) : '', + raw: entryDraft.data ? entryToRaw(entryDraft) : '', ...(newPath && { newPath: getFilePath(structure, extension, newPath, slug, locale), }), @@ -187,8 +192,8 @@ export function getI18nFiles( export function getI18nBackup( collection: Collection, - entry: EntryMap, - entryToRaw: (entry: EntryMap) => string, + entry: Entry, + entryToRaw: (entry: Entry) => string, ) { const { locales, defaultLocale } = getI18nInfo(collection) as I18nInfo; @@ -196,12 +201,12 @@ export function getI18nBackup( .filter(l => l !== defaultLocale) .reduce((acc, locale) => { const dataPath = getDataPath(locale, defaultLocale); - const data = entry.getIn(dataPath); + const data = get(entry, dataPath); if (!data) { return acc; } - const draft = entry.set('data', data); - return { ...acc, [locale]: { raw: entryToRaw(draft) } }; + entry.data = data; + return { ...acc, [locale]: { raw: entryToRaw(entry) } }; }, {} as Record); return i18nBackup; @@ -209,7 +214,7 @@ export function getI18nBackup( export function formatI18nBackup( i18nBackup: Record, - formatRawData: (raw: string) => EntryValue, + formatRawData: (raw: string) => Entry, ) { const i18n = Object.entries(i18nBackup).reduce((acc, [locale, { raw }]) => { const entry = formatRawData(raw); @@ -223,7 +228,7 @@ function mergeValues( collection: Collection, structure: I18N_STRUCTURE, defaultLocale: string, - values: { locale: string; value: EntryValue }[], + values: { locale: string; value: Entry }[], ) { let defaultEntry = values.find(e => e.locale === defaultLocale); if (!defaultEntry) { @@ -234,12 +239,12 @@ function mergeValues( .filter(e => e.locale !== defaultEntry!.locale) .reduce((acc, { locale, value }) => { const dataPath = getLocaleDataPath(locale); - return set(acc, dataPath, value.data); + return set(acc, dataPath.join('.'), value.data); }, {}); const path = normalizeFilePath(structure, defaultEntry.value.path, defaultLocale); const slug = selectEntrySlug(collection, path) as string; - const entryValue: EntryValue = { + const entryValue: Entry = { ...defaultEntry.value, raw: '', ...i18n, @@ -250,11 +255,11 @@ function mergeValues( return entryValue; } -function mergeSingleFileValue(entryValue: EntryValue, defaultLocale: string, locales: string[]) { - const data = entryValue.data[defaultLocale] || {}; +function mergeSingleFileValue(entryValue: Entry, defaultLocale: string, locales: string[]): Entry { + const data = (entryValue.data?.[defaultLocale] ?? {}) as EntryData; const i18n = locales .filter(l => l !== defaultLocale) - .map(l => ({ locale: l, value: entryValue.data[l] })) + .map(l => ({ locale: l, value: entryValue.data?.[l] })) .filter(e => e.value) .reduce((acc, e) => { return { ...acc, [e.locale]: { data: e.value } }; @@ -273,11 +278,15 @@ export async function getI18nEntry( extension: string, path: string, slug: string, - getEntryValue: (path: string) => Promise, + getEntryValue: (path: string) => Promise, ) { - const { structure, locales, defaultLocale } = getI18nInfo(collection) as I18nInfo; + const { + structure = I18N_STRUCTURE.SINGLE_FILE, + locales, + defaultLocale, + } = getI18nInfo(collection) as I18nInfo; - let entryValue: EntryValue; + let entryValue: Entry; if (structure === I18N_STRUCTURE.SINGLE_FILE) { entryValue = mergeSingleFileValue(await getEntryValue(path), defaultLocale, locales); } else { @@ -290,7 +299,7 @@ export async function getI18nEntry( ); const nonNullValues = entryValues.filter(e => e.value !== null) as { - value: EntryValue; + value: Entry; locale: string; }[]; @@ -300,8 +309,12 @@ export async function getI18nEntry( return entryValue; } -export function groupEntries(collection: Collection, extension: string, entries: EntryValue[]) { - const { structure, defaultLocale, locales } = getI18nInfo(collection) as I18nInfo; +export function groupEntries(collection: Collection, extension: string, entries: Entry[]): Entry[] { + const { + structure = I18N_STRUCTURE.SINGLE_FILE, + defaultLocale, + locales, + } = getI18nInfo(collection) as I18nInfo; if (structure === I18N_STRUCTURE.SINGLE_FILE) { return entries.map(e => mergeSingleFileValue(e, defaultLocale, locales)); } @@ -319,7 +332,7 @@ export function groupEntries(collection: Collection, extension: string, entries: const groupedEntries = Object.values(grouped).reduce((acc, values) => { const entryValue = mergeValues(collection, structure, defaultLocale, values); return [...acc, entryValue]; - }, [] as EntryValue[]); + }, [] as Entry[]); return groupedEntries; } @@ -362,38 +375,30 @@ export function duplicateDefaultI18nFields(collection: Collection, dataFields: a } export function duplicateI18nFields( - entryDraft: EntryDraft, - field: EntryField, + entryDraft: EntryDraftState, + field: Field, locales: string[], defaultLocale: string, - fieldPath: string[] = [field.get('name')], + fieldPath: string[] = [field.name], ) { - const value = entryDraft.getIn(['entry', 'data', ...fieldPath]); - if (field.get(I18N) === I18N_FIELD.DUPLICATE) { + const value = get(entryDraft, ['entry', 'data', ...fieldPath]); + if (field.i18n === I18N_FIELD.DUPLICATE) { locales .filter(l => l !== defaultLocale) .forEach(l => { - entryDraft = entryDraft.setIn( + entryDraft = get( + entryDraft, ['entry', ...getDataPath(l, defaultLocale), ...fieldPath], value, ); }); } - if (field.has('field') && !List.isList(value)) { - const fields = [field.get('field') as EntryField]; - fields.forEach(field => { + if ('fields' in field && !Array.isArray(value)) { + field.fields?.forEach(field => { entryDraft = duplicateI18nFields(entryDraft, field, locales, defaultLocale, [ ...fieldPath, - field.get('name'), - ]); - }); - } else if (field.has('fields') && !List.isList(value)) { - const fields = field.get('fields')!.toArray() as EntryField[]; - fields.forEach(field => { - entryDraft = duplicateI18nFields(entryDraft, field, locales, defaultLocale, [ - ...fieldPath, - field.get('name'), + field.name, ]); }); } @@ -401,11 +406,16 @@ export function duplicateI18nFields( return entryDraft; } -export function getPreviewEntry(entry: EntryMap, locale: string, defaultLocale: string) { - if (locale === defaultLocale) { +export function getPreviewEntry( + entry: Entry, + locale: string | undefined, + defaultLocale: string | undefined, +) { + if (!locale || locale === defaultLocale) { return entry; } - return entry.set('data', entry.getIn([I18N, locale, 'data'])); + entry.data = entry.i18n?.[locale]?.data as EntryData; + return entry; } export function serializeI18n( @@ -420,7 +430,7 @@ export function serializeI18n( .filter(locale => locale !== defaultLocale) .forEach(locale => { const dataPath = getLocaleDataPath(locale); - entry = entry.setIn(dataPath, serializeValues(entry.getIn(dataPath))); + entry = set(entry, dataPath.join('.'), serializeValues(get(entry, dataPath))); }); return entry; diff --git a/src/lib/phrases.js b/src/lib/phrases.ts similarity index 61% rename from src/lib/phrases.js rename to src/lib/phrases.ts index 0b1f06e7..d6b59484 100644 --- a/src/lib/phrases.js +++ b/src/lib/phrases.ts @@ -1,8 +1,8 @@ -import { merge } from 'lodash'; +import merge from 'lodash/merge'; import { getLocale } from './registry'; -export function getPhrases(locale) { +export function getPhrases(locale: string) { const phrases = merge({}, getLocale('en'), getLocale(locale)); return phrases; } diff --git a/src/lib/registry.js b/src/lib/registry.js deleted file mode 100644 index 207d166b..00000000 --- a/src/lib/registry.js +++ /dev/null @@ -1,315 +0,0 @@ -import { Map } from 'immutable'; -import produce from 'immer'; -import { oneLine } from 'common-tags'; - -import EditorComponent from '../valueObjects/EditorComponent'; - -const allowedEvents = [ - 'prePublish', - 'postPublish', - 'preSave', - 'postSave', -]; -const eventHandlers = {}; -allowedEvents.forEach(e => { - eventHandlers[e] = []; -}); - -/** - * Global Registry Object - */ -const registry = { - backends: {}, - templates: {}, - previewStyles: [], - widgets: {}, - icons: {}, - additionalLinks: {}, - editorComponents: Map(), - remarkPlugins: [], - widgetValueSerializers: {}, - mediaLibraries: [], - locales: {}, - eventHandlers, -}; - -export default { - registerPreviewStyle, - getPreviewStyles, - registerPreviewTemplate, - getPreviewTemplate, - registerWidget, - getWidget, - getWidgets, - resolveWidget, - registerEditorComponent, - getEditorComponents, - registerRemarkPlugin, - getRemarkPlugins, - registerWidgetValueSerializer, - getWidgetValueSerializer, - registerBackend, - getBackend, - registerMediaLibrary, - getMediaLibrary, - registerLocale, - getLocale, - registerEventListener, - removeEventListener, - getEventListeners, - invokeEvent, - registerIcon, - getIcon, - registerAdditionalLink, - getAdditionalLinks -}; - -/** - * Preview Styles - * - * Valid options: - * - raw {boolean} if `true`, `style` value is expected to be a CSS string - */ -export function registerPreviewStyle(style, opts) { - registry.previewStyles.push({ ...opts, value: style }); -} -export function getPreviewStyles() { - return registry.previewStyles; -} - -/** - * Preview Templates - */ -export function registerPreviewTemplate(name, component) { - registry.templates[name] = component; -} -export function getPreviewTemplate(name) { - return registry.templates[name]; -} - -/** - * Editor Widgets - */ -export function registerWidget(name, control, preview, validtor = () => {}, schema = {}) { - if (Array.isArray(name)) { - name.forEach(widget => { - if (typeof widget !== 'object') { - console.error(`Cannot register widget: ${widget}`); - } else { - registerWidget(widget); - } - }); - } else if (typeof name === 'string') { - // A registered widget control can be reused by a new widget, allowing - // multiple copies with different previews. - const newControl = typeof control === 'string' ? registry.widgets[control].control : control; - registry.widgets[name] = { control: newControl, preview, validtor, schema }; - } else if (typeof name === 'object') { - const { - name: widgetName, - controlComponent: control, - previewComponent: preview, - validtor = () => {}, - schema = {}, - allowMapValue, - globalStyles, - ...options - } = name; - if (registry.widgets[widgetName]) { - console.warn(oneLine` - Multiple widgets registered with name "${widgetName}". Only the last widget registered with - this name will be used. - `); - } - if (!control) { - throw Error(`Widget "${widgetName}" registered without \`controlComponent\`.`); - } - registry.widgets[widgetName] = { - control, - preview, - validtor, - schema, - globalStyles, - allowMapValue, - ...options, - }; - } else { - console.error('`registerWidget` failed, called with incorrect arguments.'); - } -} -export function getWidget(name) { - return registry.widgets[name]; -} -export function getWidgets() { - return produce(Object.entries(registry.widgets), draft => { - return draft.map(([key, value]) => ({ name: key, ...value })); - }); -} -export function resolveWidget(name) { - return getWidget(name || 'string') || getWidget('unknown'); -} - -/** - * Markdown Editor Custom Components - */ -export function registerEditorComponent(component) { - const plugin = EditorComponent(component); - if (plugin.type === 'code-block') { - const codeBlock = registry.editorComponents.find(c => c.type === 'code-block'); - - if (codeBlock) { - console.warn(oneLine` - Only one editor component of type "code-block" may be registered. Previously registered code - block component(s) will be overwritten. - `); - registry.editorComponents = registry.editorComponents.delete(codeBlock.id); - } - } - - registry.editorComponents = registry.editorComponents.set(plugin.id, plugin); -} -export function getEditorComponents() { - return registry.editorComponents; -} - -/** - * Remark plugins - */ -/** @typedef {import('unified').Pluggable} RemarkPlugin */ -/** @type {(plugin: RemarkPlugin) => void} */ -export function registerRemarkPlugin(plugin) { - registry.remarkPlugins.push(plugin); -} -/** @type {() => Array} */ -export function getRemarkPlugins() { - return registry.remarkPlugins; -} - -/** - * Widget Serializers - */ -export function registerWidgetValueSerializer(widgetName, serializer) { - registry.widgetValueSerializers[widgetName] = serializer; -} -export function getWidgetValueSerializer(widgetName) { - return registry.widgetValueSerializers[widgetName]; -} - -/** - * Backend API - */ -export function registerBackend(name, BackendClass) { - if (!name || !BackendClass) { - console.error( - "Backend parameters invalid. example: CMS.registerBackend('myBackend', BackendClass)", - ); - } else if (registry.backends[name]) { - console.error(`Backend [${name}] already registered. Please choose a different name.`); - } else { - registry.backends[name] = { - init: (...args) => new BackendClass(...args), - }; - } -} - -export function getBackend(name) { - return registry.backends[name]; -} - -/** - * Media Libraries - */ -export function registerMediaLibrary(mediaLibrary, options) { - if (registry.mediaLibraries.find(ml => mediaLibrary.name === ml.name)) { - throw new Error(`A media library named ${mediaLibrary.name} has already been registered.`); - } - registry.mediaLibraries.push({ ...mediaLibrary, options }); -} - -export function getMediaLibrary(name) { - return registry.mediaLibraries.find(ml => ml.name === name); -} - -export function getFiles(name) { - return registry.mediaLibraries.find(ml => ml.name === name); -} - -function validateEventName(name) { - if (!allowedEvents.includes(name)) { - throw new Error(`Invalid event name '${name}'`); - } -} - -export function getEventListeners(name) { - validateEventName(name); - return [...registry.eventHandlers[name]]; -} - -export function registerEventListener({ name, handler }, options = {}) { - validateEventName(name); - registry.eventHandlers[name].push({ handler, options }); -} - -export async function invokeEvent({ name, data }) { - validateEventName(name); - const handlers = registry.eventHandlers[name]; - - let _data = { ...data }; - for (const { handler, options } of handlers) { - const result = await handler(_data, options); - if (result !== undefined) { - const entry = _data.entry.set('data', result); - _data = { ...data, entry }; - } - } - return _data.entry.get('data'); -} - -export function removeEventListener({ name, handler }) { - validateEventName(name); - if (handler) { - registry.eventHandlers[name] = registry.eventHandlers[name].filter( - item => item.handler !== handler, - ); - } else { - registry.eventHandlers[name] = []; - } -} - -/** - * Locales - */ -export function registerLocale(locale, phrases) { - if (!locale || !phrases) { - console.error("Locale parameters invalid. example: CMS.registerLocale('locale', phrases)"); - } else { - registry.locales[locale] = phrases; - } -} - -export function getLocale(locale) { - return registry.locales[locale]; -} - -/** - * Icons - */ -export function registerIcon(name, icon) { - registry.icons[name] = icon; -} -export function getIcon(name) { - return registry.icons[name]; -} - -/** - * Icons - */ -export function registerAdditionalLink(id, title, data, iconName) { - registry.additionalLinks[id] = { id, title, data, iconName }; -} -export function getAdditionalLinks() { - return registry.additionalLinks; -} -export function getAdditionalLink(id) { - return registry.additionalLinks[id]; -} diff --git a/src/lib/registry.ts b/src/lib/registry.ts new file mode 100644 index 00000000..2bf73850 --- /dev/null +++ b/src/lib/registry.ts @@ -0,0 +1,385 @@ +import { oneLine } from 'common-tags'; + +import EditorComponent from '../valueObjects/EditorComponent'; + +import type { Pluggable } from 'unified'; +import type { + AdditionalLink, + BackendClass, + BackendInitializer, + BackendInitializerOptions, + Config, + EventListener, + Field, + CustomIcon, + LocalePhrasesRoot, + MediaLibraryExternalLibrary, + MediaLibraryOptions, + TemplatePreviewComponent, + WidgetParam, + WidgetValueSerializer, + EditorComponentOptions, + Entry, + EventData, + Widget, + WidgetOptions, +} from '../interface'; + +export const allowedEvents = ['prePublish', 'postPublish', 'preSave', 'postSave'] as const; +export type AllowedEvent = typeof allowedEvents[number]; + +const eventHandlers = allowedEvents.reduce((acc, e) => { + acc[e] = []; + return acc; +}, {} as Record }[]>); + +interface Registry { + backends: Record; + templates: Record; + widgets: Record; + icons: Record; + additionalLinks: Record; + editorComponents: Record; + remarkPlugins: Pluggable[]; + widgetValueSerializers: Record; + mediaLibraries: (MediaLibraryExternalLibrary & { options: MediaLibraryOptions })[]; + locales: Record; + eventHandlers: typeof eventHandlers; +} + +/** + * Global Registry Object + */ +const registry: Registry = { + backends: {}, + templates: {}, + widgets: {}, + icons: {}, + additionalLinks: {}, + editorComponents: {}, + remarkPlugins: [], + widgetValueSerializers: {}, + mediaLibraries: [], + locales: {}, + eventHandlers, +}; + +export default { + registerPreviewTemplate, + getPreviewTemplate, + registerWidget, + getWidget, + getWidgets, + resolveWidget, + registerEditorComponent, + getEditorComponents, + registerRemarkPlugin, + getRemarkPlugins, + registerWidgetValueSerializer, + getWidgetValueSerializer, + registerBackend, + getBackend, + registerMediaLibrary, + getMediaLibrary, + registerLocale, + getLocale, + registerEventListener, + removeEventListener, + getEventListeners, + invokeEvent, + registerIcon, + getIcon, + registerAdditionalLink, + getAdditionalLinks, +}; + +/** + * Preview Templates + */ +export function registerPreviewTemplate(name: string, component: TemplatePreviewComponent) { + registry.templates[name] = component; +} + +export function getPreviewTemplate(name: string): TemplatePreviewComponent { + return registry.templates[name]; +} + +/** + * Editor Widgets + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function registerWidget(widgets: WidgetParam[]): void; +export function registerWidget(widget: WidgetParam): void; +export function registerWidget( + name: string, + control: string | Widget['control'], + preview: Widget['preview'], + options?: WidgetOptions, +): void; +export function registerWidget( + name: string | WidgetParam | WidgetParam[], + control?: string | Widget['control'], + preview?: Widget['preview'], + { schema, validator, getValidValue }: WidgetOptions = {}, +): void { + if (Array.isArray(name)) { + name.forEach(widget => { + if (typeof widget !== 'object') { + console.error(`Cannot register widget: ${widget}`); + } else { + registerWidget(widget); + } + }); + } else if (typeof name === 'string') { + // A registered widget control can be reused by a new widget, allowing + // multiple copies with different previews. + const newControl = ( + typeof control === 'string' ? registry.widgets[control]?.control : control + ) as Widget['control']; + if (newControl) { + registry.widgets[name] = { + control: newControl, + preview: preview as Widget['preview'], + validator: validator as Widget['validator'], + getValidValue: getValidValue as Widget['getValidValue'], + schema, + }; + } + } else if (typeof name === 'object') { + const { + name: widgetName, + controlComponent: control, + previewComponent: preview, + options: { + validator = () => false, + getValidValue = (value: T | undefined | null) => value, + schema, + allowMapValue, + } = {}, + } = name; + if (registry.widgets[widgetName]) { + console.warn(oneLine` + Multiple widgets registered with name "${widgetName}". Only the last widget registered with + this name will be used. + `); + } + if (!control) { + throw Error(`Widget "${widgetName}" registered without \`controlComponent\`.`); + } + registry.widgets[widgetName] = { + control: control as Widget['control'], + preview: preview as Widget['preview'], + validator: validator as Widget['validator'], + getValidValue: getValidValue as Widget['getValidValue'], + schema, + allowMapValue, + }; + } else { + console.error('`registerWidget` failed, called with incorrect arguments.'); + } +} + +export function getWidget(name: string): Widget { + return registry.widgets[name] as unknown as Widget; +} + +export function getWidgets(): ({ + name: string; +} & Widget)[] { + return Object.entries(registry.widgets).map(([name, widget]: [string, Widget]) => ({ + name, + ...widget, + })); +} + +export function resolveWidget(name?: string): Widget { + return getWidget(name || 'string') || getWidget('unknown'); +} + +/** + * Markdown Editor Custom Components + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function registerEditorComponent(component: EditorComponentOptions) { + const plugin = EditorComponent(component); + if ('type' in plugin && plugin.type === 'code-block') { + const codeBlock = Object.values(registry.editorComponents).find( + c => 'type' in c && c.type === 'code-block', + ); + + if (codeBlock) { + console.warn(oneLine` + Only one editor component of type "code-block" may be registered. Previously registered code + block component(s) will be overwritten. + `); + } + } + + registry.editorComponents[plugin.id] = plugin; +} + +export function getEditorComponents(): Record { + return registry.editorComponents; +} + +/** + * Remark plugins + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function registerRemarkPlugin(plugin: Pluggable) { + registry.remarkPlugins.push(plugin); +} + +export function getRemarkPlugins(): Pluggable[] { + return registry.remarkPlugins; +} + +/** + * Widget Serializers + */ +export function registerWidgetValueSerializer( + widgetName: string, + serializer: WidgetValueSerializer, +) { + registry.widgetValueSerializers[widgetName] = serializer; +} + +export function getWidgetValueSerializer(widgetName: string): WidgetValueSerializer | undefined { + return registry.widgetValueSerializers[widgetName]; +} + +/** + * Backends + */ +export function registerBackend< + T extends { new (config: Config, options: BackendInitializerOptions): BackendClass }, +>(name: string, BackendClass: T) { + if (!name || !BackendClass) { + console.error( + "Backend parameters invalid. example: CMS.registerBackend('myBackend', BackendClass)", + ); + } else if (registry.backends[name]) { + console.error(`Backend [${name}] already registered. Please choose a different name.`); + } else { + registry.backends[name] = { + init: (config: Config, options: BackendInitializerOptions) => + new BackendClass(config, options), + }; + } +} + +export function getBackend(name: string): BackendInitializer { + return registry.backends[name]; +} + +/** + * Media Libraries + */ +export function registerMediaLibrary( + mediaLibrary: MediaLibraryExternalLibrary, + options: MediaLibraryOptions = {}, +) { + if (registry.mediaLibraries.find(ml => mediaLibrary.name === ml.name)) { + throw new Error(`A media library named ${mediaLibrary.name} has already been registered.`); + } + registry.mediaLibraries.push({ ...mediaLibrary, options }); +} + +export function getMediaLibrary( + name: string, +): (MediaLibraryExternalLibrary & { options: MediaLibraryOptions }) | undefined { + return registry.mediaLibraries.find(ml => ml.name === name); +} + +/** + * Event Handlers + */ +function validateEventName(name: string) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!allowedEvents.includes(name as any)) { + throw new Error(`Invalid event name '${name}'`); + } +} + +export function getEventListeners(name: AllowedEvent) { + validateEventName(name); + return [...registry.eventHandlers[name]]; +} + +export function registerEventListener( + { name, handler }: EventListener, + options: Record = {}, +) { + validateEventName(name); + registry.eventHandlers[name].push({ handler, options }); +} + +export async function invokeEvent({ name, data }: { name: AllowedEvent; data: EventData }) { + validateEventName(name); + const handlers = registry.eventHandlers[name]; + + let _data = { ...data }; + for (const { handler, options } of handlers) { + const result = await handler(_data, options); + if (result !== undefined) { + const entry = { + ..._data.entry, + data: result, + } as Entry; + _data = { ...data, entry }; + } + } + return _data.entry.data; +} + +export function removeEventListener({ name, handler }: EventListener) { + validateEventName(name); + if (handler) { + registry.eventHandlers[name] = registry.eventHandlers[name].filter( + item => item.handler !== handler, + ); + } else { + registry.eventHandlers[name] = []; + } +} + +/** + * Locales + */ +export function registerLocale(locale: string, phrases: LocalePhrasesRoot) { + if (!locale || !phrases) { + console.error("Locale parameters invalid. example: CMS.registerLocale('locale', phrases)"); + } else { + registry.locales[locale] = phrases; + } +} + +export function getLocale(locale: string): LocalePhrasesRoot | undefined { + return registry.locales[locale]; +} + +/** + * Icons + */ +export function registerIcon(name: string, icon: CustomIcon) { + registry.icons[name] = icon; +} + +export function getIcon(name: string): CustomIcon | null { + return registry.icons[name] ?? null; +} + +/** + * Additional Links + */ +export function registerAdditionalLink(link: AdditionalLink) { + registry.additionalLinks[link.id] = link; +} + +export function getAdditionalLinks(): Record { + return registry.additionalLinks; +} + +export function getAdditionalLink(id: string): AdditionalLink | undefined { + return registry.additionalLinks[id]; +} diff --git a/src/lib/serializeEntryValues.js b/src/lib/serializeEntryValues.js deleted file mode 100644 index 578ffa38..00000000 --- a/src/lib/serializeEntryValues.js +++ /dev/null @@ -1,75 +0,0 @@ -import { isNil } from 'lodash'; -import { Map, List } from 'immutable'; - -import { getWidgetValueSerializer } from './registry'; - -/** - * Methods for serializing/deserializing entry field values. Most widgets don't - * require this for their values, and those that do can typically serialize/ - * deserialize on every change from within the widget. The serialization - * handlers here are for widgets whose values require heavy serialization that - * would hurt performance if run for every change. - - * An example of this is the markdown widget, whose value is stored as a - * markdown string. Instead of stringifying on every change of that field, a - * deserialization method is registered from the widget's control module that - * converts the stored markdown string to an AST, and that AST serves as the - * widget model during editing. - * - * Serialization handlers should be registered for each widget that requires - * them, and the registration method is exposed through the registry. Any - * registered deserialization handlers run on entry load, and serialization - * handlers run on persist. - */ -function runSerializer(values, fields, method) { - /** - * Reduce the list of fields to a map where keys are field names and values - * are field values, serializing the values of fields whose widgets have - * registered serializers. If the field is a list or object, call recursively - * for nested fields. - */ - let serializedData = fields.reduce((acc, field) => { - const fieldName = field.get('name'); - const value = values.get(fieldName); - const serializer = getWidgetValueSerializer(field.get('widget')); - const nestedFields = field.get('fields'); - - // Call recursively for fields within lists - if (nestedFields && List.isList(value)) { - return acc.set( - fieldName, - value.map(val => runSerializer(val, nestedFields, method)), - ); - } - - // Call recursively for fields within objects - if (nestedFields && Map.isMap(value)) { - return acc.set(fieldName, runSerializer(value, nestedFields, method)); - } - - // Run serialization method on value if not null or undefined - if (serializer && !isNil(value)) { - return acc.set(fieldName, serializer[method](value)); - } - - // If no serializer is registered for the field's widget, use the field as is - if (!isNil(value)) { - return acc.set(fieldName, value); - } - - return acc; - }, Map()); - - //preserve unknown fields value - serializedData = values.mergeDeep(serializedData); - - return serializedData; -} - -export function serializeValues(values, fields) { - return runSerializer(values, fields, 'serialize'); -} - -export function deserializeValues(values, fields) { - return runSerializer(values, fields, 'deserialize'); -} diff --git a/src/lib/serializeEntryValues.ts b/src/lib/serializeEntryValues.ts new file mode 100644 index 00000000..443c5ebc --- /dev/null +++ b/src/lib/serializeEntryValues.ts @@ -0,0 +1,88 @@ +import merge from 'lodash/merge'; + +import { getWidgetValueSerializer } from './registry'; +import { isNullish } from './util/null.util'; + +import type { EntryData, Field, ObjectValue } from '../interface'; + +/** + * Methods for serializing/deserializing entry field values. Most widgets don't + * require this for their values, and those that do can typically serialize/ + * deserialize on every change from within the widget. The serialization + * handlers here are for widgets whose values require heavy serialization that + * would hurt performance if run for every change. + + * An example of this is the markdown widget, whose value is stored as a + * markdown string. Instead of stringifying on every change of that field, a + * deserialization method is registered from the widget's control module that + * converts the stored markdown string to an AST, and that AST serves as the + * widget model during editing. + * + * Serialization handlers should be registered for each widget that requires + * them, and the registration method is exposed through the registry. Any + * registered deserialization handlers run on entry load, and serialization + * handlers run on persist. + */ +function runSerializer( + values: EntryData, + fields: Field[] | undefined, + method: 'serialize' | 'deserialize', +) { + /** + * Reduce the list of fields to a map where keys are field names and values + * are field values, serializing the values of fields whose widgets have + * registered serializers. If the field is a list or object, call recursively + * for nested fields. + */ + let serializedData = + fields?.reduce((acc, field) => { + const fieldName = field.name; + const value = values?.[fieldName]; + const serializer = + 'widget' in field && field.widget ? getWidgetValueSerializer(field.widget) : undefined; + const nestedFields = 'fields' in field ? field.fields : undefined; + + // Call recursively for fields within lists + if (nestedFields && Array.isArray(value)) { + for (const val of value) { + if (typeof val === 'object') { + acc[fieldName] = runSerializer(val as Record, nestedFields, method); + } + } + return acc; + } + + // Call recursively for fields within objects + if (nestedFields && typeof value === 'object') { + acc[fieldName] = runSerializer(value as Record, nestedFields, method); + return acc; + } + + // Run serialization method on value if not null or undefined + if (serializer && !isNullish(value)) { + acc[fieldName] = serializer[method](value); + return acc; + } + + // If no serializer is registered for the field's widget, use the field as is + if (!isNullish(value)) { + acc[fieldName] = value; + return acc; + } + + return acc; + }, {} as ObjectValue) ?? {}; + + //preserve unknown fields value + serializedData = merge(values, serializedData); + + return serializedData; +} + +export function serializeValues(values: EntryData, fields: Field[] | undefined) { + return runSerializer(values, fields, 'serialize'); +} + +export function deserializeValues(values: EntryData, fields: Field[] | undefined) { + return runSerializer(values, fields, 'deserialize'); +} diff --git a/src/lib/textHelper.js b/src/lib/textHelper.ts similarity index 84% rename from src/lib/textHelper.js rename to src/lib/textHelper.ts index 3a8c8df1..e1e4fd22 100644 --- a/src/lib/textHelper.js +++ b/src/lib/textHelper.ts @@ -1,4 +1,4 @@ -export function stringToRGB(str) { +export function stringToRGB(str: string) { if (!str) return '000000'; let hash = 0; for (let i = 0; i < str.length; i++) { diff --git a/src/lib/urlHelper.ts b/src/lib/urlHelper.ts index 1ffc35d5..2ebfdf07 100644 --- a/src/lib/urlHelper.ts +++ b/src/lib/urlHelper.ts @@ -2,9 +2,12 @@ import url from 'url'; import urlJoin from 'url-join'; import diacritics from 'diacritics'; import sanitizeFilename from 'sanitize-filename'; -import { isString, escapeRegExp, flow, partialRight } from 'lodash'; +import isString from 'lodash/isString'; +import escapeRegExp from 'lodash/escapeRegExp'; +import flow from 'lodash/flow'; +import partialRight from 'lodash/partialRight'; -import type { CmsSlug } from '../types/redux'; +import type { Slug } from '../interface'; function getUrl(urlString: string, direct?: boolean) { return `${direct ? '/#' : ''}${urlString}`; @@ -69,7 +72,7 @@ export function getCharReplacer(encoding: string, replacement: string) { // `sanitizeURI` does not actually URI-encode the chars (that is the browser's and server's job), just removes the ones that are not allowed. export function sanitizeURI( str: string, - options?: { replacement: CmsSlug['sanitize_replacement']; encoding: CmsSlug['encoding'] }, + options?: { replacement: Slug['sanitize_replacement']; encoding: Slug['encoding'] }, ) { const { replacement = '', encoding = 'unicode' } = options || {}; @@ -85,12 +88,12 @@ export function sanitizeURI( return Array.from(str).map(getCharReplacer(encoding, replacement)).join(''); } -export function sanitizeChar(char: string, options?: CmsSlug) { +export function sanitizeChar(char: string, options?: Slug) { const { encoding = 'unicode', sanitize_replacement: replacement = '' } = options || {}; return getCharReplacer(encoding, replacement)(char); } -export function sanitizeSlug(str: string, options?: CmsSlug) { +export function sanitizeSlug(str: string, options?: Slug) { if (!isString(str)) { throw new Error('The input slug must be a string.'); } diff --git a/src/lib/util/API.ts b/src/lib/util/API.ts index 09560965..8e99b5b2 100644 --- a/src/lib/util/API.ts +++ b/src/lib/util/API.ts @@ -3,9 +3,15 @@ import unsentRequest from './unsentRequest'; import APIError from './APIError'; import type { AsyncLock } from './asyncLock'; +import type { FileMetadata } from '../../interface'; -export interface FetchError extends Error { +export class FetchError extends Error { status: number; + + constructor(message: string, status: number) { + super(message); + this.status = status; + } } interface API { @@ -14,14 +20,12 @@ interface API { requestFunction?: (req: ApiRequest) => Promise; } -export type ApiRequestObject = { +export interface ApiRequestURL { url: string; - params?: Record; - method?: 'POST' | 'PUT' | 'DELETE' | 'HEAD' | 'PATCH'; - headers?: Record; - body?: string | FormData; - cache?: 'no-store'; -}; + params?: Record; +} + +export type ApiRequestObject = RequestInit & ApiRequestURL; export type ApiRequest = ApiRequestObject | string; @@ -63,7 +67,7 @@ export async function requestWithBackoff( if (json.message.match('API rate limit exceeded')) { const now = new Date(); const nextWindowInSeconds = response.headers.has('X-RateLimit-Reset') - ? parseInt(response.headers.get('X-RateLimit-Reset')!) + ? parseInt(response.headers.get('X-RateLimit-Reset') ?? '0') : now.getTime() / 1000 + 60; throw new RateLimitError(json.message, nextWindowInSeconds); @@ -71,28 +75,31 @@ export async function requestWithBackoff( response.json = () => Promise.resolve(json); } return response; - } catch (err: any) { - if (attempt > 5 || err.message === "Can't refresh access token when using implicit auth") { - throw err; - } else { - if (!api.rateLimiter) { - const timeout = err.resetSeconds || attempt * attempt; - console.info( - `Pausing requests for ${timeout} ${ - attempt === 1 ? 'second' : 'seconds' - } due to fetch failures:`, - err.message, - ); - api.rateLimiter = asyncLock(); - api.rateLimiter.acquire(); - setTimeout(() => { - api.rateLimiter?.release(); - api.rateLimiter = undefined; - console.info(`Done pausing requests`); - }, 1000 * timeout); + } catch (error: unknown) { + if (error instanceof Error) { + if (attempt > 5 || error.message === "Can't refresh access token when using implicit auth") { + throw error; + } else if (error instanceof RateLimitError) { + if (!api.rateLimiter) { + const timeout = error.resetSeconds || attempt * attempt; + console.info( + `Pausing requests for ${timeout} ${ + attempt === 1 ? 'second' : 'seconds' + } due to fetch failures:`, + error.message, + ); + api.rateLimiter = asyncLock(); + api.rateLimiter.acquire(); + setTimeout(() => { + api.rateLimiter?.release(); + api.rateLimiter = undefined; + console.info(`Done pausing requests`); + }, 1000 * timeout); + } + return requestWithBackoff(api, req, attempt + 1); } - return requestWithBackoff(api, req, attempt + 1); } + throw error; } } @@ -115,11 +122,6 @@ export async function readFile( return content; } -export type FileMetadata = { - author: string; - updatedOn: string; -}; - function getFileMetadataKey(id: string) { return `gh.${id}.meta`; } diff --git a/src/lib/util/Cursor.ts b/src/lib/util/Cursor.ts index 4edd5cdc..25987a15 100644 --- a/src/lib/util/Cursor.ts +++ b/src/lib/util/Cursor.ts @@ -1,44 +1,12 @@ -import { fromJS, Map, Set } from 'immutable'; - -type CursorStoreObject = { +export interface CursorStore { actions: Set; - data: Map; - meta: Map; -}; - -export type CursorStore = { - get( - key: K, - defaultValue?: CursorStoreObject[K], - ): CursorStoreObject[K]; - getIn(path: string[]): V; - set( - key: K, - value: V, - ): CursorStoreObject[K]; - setIn(path: string[], value: unknown): CursorStore; - hasIn(path: string[]): boolean; - mergeIn(path: string[], value: unknown): CursorStore; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - update: (...args: any[]) => CursorStore; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - updateIn: (...args: any[]) => CursorStore; -}; + data: Record; + meta: Record; +} type ActionHandler = (action: string) => unknown; -function jsToMap(obj: {}) { - if (obj === undefined) { - return Map(); - } - const immutableObj = fromJS(obj); - if (!Map.isMap(immutableObj)) { - throw new Error('Object must be equivalent to a Map.'); - } - return immutableObj; -} - -const knownMetaKeys = Set([ +const knownMetaKeys = [ 'index', 'page', 'count', @@ -48,53 +16,55 @@ const knownMetaKeys = Set([ 'extension', 'folder', 'depth', -]); +]; -function filterUnknownMetaKeys(meta: Map) { - return meta.filter((_v, k) => knownMetaKeys.has(k as string)); +function filterUnknownMetaKeys(meta: Record) { + return Object.keys(meta).reduce((acc, k) => { + if (knownMetaKeys.includes(k)) { + acc[k] = meta[k]; + } + return acc; + }, {} as Record); } /* createCursorMap takes one of three signatures: - () -> cursor with empty actions, data, and meta - - (cursorMap: ) -> cursor - - (actions: , data: , meta: ) -> cursor + - (cursorMap: ) -> cursor + - (actions: , data: , meta: ) -> cursor */ -function createCursorStore(...args: {}[]) { +function createCursorStore(...args: unknown[]) { const { actions, data, meta } = args.length === 1 - ? jsToMap(args[0]).toObject() - : { actions: args[0], data: args[1], meta: args[2] }; - return Map({ - // actions are a Set, rather than a List, to ensure an efficient .has - actions: Set(actions), + ? ((args[0] ?? { actions: new Set(), data: {}, meta: {} }) as CursorStore) + : ({ actions: args[0], data: args[1], meta: args[2] } as CursorStore); + return { + // actions are a Set, rather than a List, to ensure an efficient .has + actions: new Set(...actions), // data and meta are Maps - data: jsToMap(data), - meta: jsToMap(meta).update(filterUnknownMetaKeys), - }) as CursorStore; + data, + meta: filterUnknownMetaKeys(meta), + } as CursorStore; } function hasAction(store: CursorStore, action: string) { - return store.hasIn(['actions', action]); + return store.actions.has(action); } function getActionHandlers(store: CursorStore, handler: ActionHandler) { - return store - .get('actions', Set()) - .toMap() - .map(action => handler(action as string)); + for (const action in store.actions) { + handler(action); + } } // The cursor logic is entirely functional, so this class simply // provides a chainable interface export default class Cursor { - store?: CursorStore; - actions?: Set; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data?: Map; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - meta?: Map; + store: CursorStore; + actions: Set; + data: Record; + meta: Record; static create(...args: {}[]) { return new Cursor(...args); @@ -102,71 +72,118 @@ export default class Cursor { constructor(...args: {}[]) { if (args[0] instanceof Cursor) { - return args[0] as Cursor; + this.store = args[0].store; + this.actions = args[0].actions; + this.data = args[0].data; + this.meta = args[0].meta; + return; } this.store = createCursorStore(...args); - this.actions = this.store.get('actions'); - this.data = this.store.get('data'); - this.meta = this.store.get('meta'); + this.actions = this.store.actions; + this.data = this.store.data; + this.meta = this.store.meta; } // eslint-disable-next-line @typescript-eslint/no-explicit-any - updateStore(...args: any[]) { - return new Cursor(this.store!.update(...args)); - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - updateInStore(...args: any[]) { - return new Cursor(this.store!.updateIn(...args)); + updateStore(update: (store: CursorStore) => CursorStore) { + return new Cursor(update(this.store)); } hasAction(action: string) { - return hasAction(this.store!, action); + return hasAction(this.store, action); } + addAction(action: string) { - return this.updateStore('actions', (actions: Set) => actions.add(action)); + return this.updateStore(store => ({ + ...store, + actions: new Set(...store.actions, action), + })); } + removeAction(action: string) { - return this.updateStore('actions', (actions: Set) => actions.delete(action)); + return this.updateStore(store => { + const newActions = new Set(...store.actions); + newActions.delete(action); + + return { + ...store, + actions: newActions, + }; + }); } + setActions(actions: Iterable) { - return this.updateStore((store: CursorStore) => store.set('actions', Set(actions))); + return this.updateStore(store => ({ + ...store, + actions: new Set(actions), + })); } + mergeActions(actions: Set) { - return this.updateStore('actions', (oldActions: Set) => oldActions.union(actions)); + return this.updateStore(store => ({ + ...store, + actions: new Set({ ...store.actions, ...actions }), + })); } + getActionHandlers(handler: ActionHandler) { - return getActionHandlers(this.store!, handler); + return getActionHandlers(this.store, handler); } - setData(data: {}) { - return new Cursor(this.store!.set('data', jsToMap(data))); + setData(data: Record) { + return this.updateStore(store => ({ + ...store, + data, + })); } - mergeData(data: {}) { - return new Cursor(this.store!.mergeIn(['data'], jsToMap(data))); + + mergeData(data: Record) { + return this.updateStore(store => ({ + ...store, + data: { ...store.data, ...data }, + })); } - wrapData(data: {}) { - return this.updateStore('data', (oldData: Map) => - jsToMap(data).set('wrapped_cursor_data', oldData), - ); + + wrapData(data: Record) { + return this.updateStore(store => ({ + ...store, + data: { + ...data, + wrapped_cursor_data: store.data, + }, + })); } - unwrapData() { + + unwrapData(): [CursorStore['data'], Cursor] { return [ - this.store!.get('data').delete('wrapped_cursor_data'), - this.updateStore('data', (data: Map) => data.get('wrapped_cursor_data')), - ] as [Map, Cursor]; - } - clearData() { - return this.updateStore('data', () => Map()); + this.store.data, + this.updateStore(store => ({ + ...store, + data: store.data.wrapped_cursor_data as Record, + })), + ]; } - setMeta(meta: {}) { - return this.updateStore((store: CursorStore) => store.set('meta', jsToMap(meta))); + clearData() { + return this.updateStore(store => ({ + ...store, + data: {}, + })); } - mergeMeta(meta: {}) { - return this.updateStore((store: CursorStore) => - store.update('meta', (oldMeta: Map) => oldMeta.merge(jsToMap(meta))), - ); + + setMeta(meta: Record) { + return this.updateStore(store => ({ + ...store, + meta, + })); + } + + mergeMeta(meta: Record) { + return this.updateStore(store => ({ + ...store, + meta: { ...store.meta, ...meta }, + })); } } diff --git a/src/lib/util/__tests__/object.util.ts b/src/lib/util/__tests__/object.util.ts new file mode 100644 index 00000000..4aba7e4f --- /dev/null +++ b/src/lib/util/__tests__/object.util.ts @@ -0,0 +1,159 @@ +import { set } from '../object.util'; + +describe('object.util', () => { + describe('set', () => { + describe('simple object', () => { + test('existing key', () => { + const testObject = { + something: '12345', + somethingElse: 5, + }; + + const updatedObject = set(testObject, 'something', '54321'); + + expect(testObject.something).toBe('12345'); + expect(updatedObject.something).toBe('54321'); + }); + + test('new key', () => { + const testObject = { + something: '12345', + somethingElse: 5, + } as { + something: string; + somethingElse: number; + somethingNew?: string; + }; + + const updatedObject = set(testObject, 'somethingNew', 'aNewValue'); + + expect(testObject.somethingNew).toBeUndefined(); + expect(updatedObject.somethingNew).toBe('aNewValue'); + }); + }); + + describe('nested object', () => { + test('existing key', () => { + const testObject = { + something: '12345', + somethingElse: { + nestedValue: 65, + }, + }; + + const updatedObject = set(testObject, 'somethingElse.nestedValue', 125); + + expect(testObject.somethingElse.nestedValue).toBe(65); + expect(updatedObject.somethingElse.nestedValue).toBe(125); + }); + + test('new key', () => { + const testObject = { + something: '12345', + somethingElse: { + nestedValue: 65, + }, + } as { + something: string; + somethingElse: { + nestedValue: number; + }; + somethingNew?: { + nestedLayer: { + anotherNestedLayer: string; + }; + }; + }; + + const updatedObject = set( + testObject, + 'somethingNew.nestedLayer.anotherNestedLayer', + 'aNewNestedValue', + ); + + expect(testObject.somethingNew?.nestedLayer.anotherNestedLayer).toBeUndefined(); + expect(updatedObject.somethingNew?.nestedLayer.anotherNestedLayer).toBe('aNewNestedValue'); + }); + }); + + describe('simple array', () => { + test('existing key', () => { + const testObject = { + something: '12345', + somethingElse: [6, 5, 3], + }; + + const updatedObject = set(testObject, 'somethingElse.1', 13); + + expect(updatedObject.somethingElse).toStrictEqual([6, 13, 3]); + }); + + test('new index should be ignored', () => { + const testObject = { + something: '12345', + somethingElse: [6, 5, 3], + }; + + const updatedObject = set(testObject, 'somethingElse.3', 84); + + expect(updatedObject.somethingElse).toStrictEqual([6, 5, 3]); + }); + }); + + describe('object array', () => { + test('existing key', () => { + const testObject = { + something: '12345', + somethingElse: [ + { name: 'one', value: '11111' }, + { name: 'two', value: '22222' }, + { name: 'three', value: '33333' }, + ], + }; + + const updatedObject = set(testObject, 'somethingElse.1.value', 'aNewValue'); + + expect(testObject.somethingElse[1].value).toBe('22222'); + expect(updatedObject.somethingElse[1].value).toBe('aNewValue'); + }); + + test('new index should be ignored', () => { + const testObject = { + something: '12345', + somethingElse: [ + { name: 'one', value: '11111' }, + { name: 'two', value: '22222' }, + { name: 'three', value: '33333' }, + ], + }; + + const updatedObject = set(testObject, 'somethingElse.3.value', 'valueToBeIgnored'); + + expect(updatedObject.somethingElse.length).toBe(3); + }); + + test('new key inside existing index', () => { + const testObject = { + something: '12345', + somethingElse: [ + { name: 'one', value: '11111' }, + { name: 'two', value: '22222' }, + { name: 'three', value: '33333' }, + ], + } as { + something: string; + somethingElse: { + name: string; + value: string; + newKey?: string; + }[]; + }; + + const updatedObject = set(testObject, 'somethingElse.1.newKey', 'newValueToBeAdded'); + + expect(testObject.somethingElse[1].newKey).toBeUndefined(); + expect(updatedObject.somethingElse[1].newKey).toBe('newValueToBeAdded'); + }); + }); + }); +}); diff --git a/src/lib/util/asyncLock.ts b/src/lib/util/asyncLock.ts index 2a45f7d5..f076d74b 100644 --- a/src/lib/util/asyncLock.ts +++ b/src/lib/util/asyncLock.ts @@ -27,10 +27,10 @@ export function asyncLock(): AsyncLock { try { // suppress too many calls to leave error lock.leave(); - } catch (e: any) { + } catch (e: unknown) { // calling 'leave' too many times might not be good behavior // but there is no reason to completely fail on it - if (e.message !== 'leave called too many times.') { + if (e instanceof Error && e.message !== 'leave called too many times.') { throw e; } else { console.warn('leave called too many times.'); diff --git a/src/lib/util/backendUtil.ts b/src/lib/util/backendUtil.ts index d26de566..2547c251 100644 --- a/src/lib/util/backendUtil.ts +++ b/src/lib/util/backendUtil.ts @@ -1,30 +1,16 @@ -import { flow, fromPairs } from 'lodash'; +import flow from 'lodash/flow'; +import fromPairs from 'lodash/fromPairs'; import { map } from 'lodash/fp'; -import { fromJS } from 'immutable'; import unsentRequest from './unsentRequest'; import APIError from './APIError'; -type Formatter = (res: Response) => Promise; - export function filterByExtension(file: { path: string }, extension: string) { const path = file?.path || ''; return path.endsWith(extension.startsWith('.') ? extension : `.${extension}`); } -function catchFormatErrors(format: string, formatter: Formatter) { - return (res: Response) => { - try { - return formatter(res); - } catch (err: any) { - throw new Error( - `Response cannot be parsed into the expected format (${format}): ${err.message}`, - ); - } - }; -} - -const responseFormatters = fromJS({ +const formatters = { json: async (res: Response) => { const contentType = res.headers.get('Content-Type') || ''; if (!contentType.startsWith('application/json') && !contentType.startsWith('text/json')) { @@ -34,18 +20,52 @@ const responseFormatters = fromJS({ }, text: async (res: Response) => res.text(), blob: async (res: Response) => res.blob(), -}).mapEntries(([format, formatter]: [string, Formatter]) => [ - format, - catchFormatErrors(format, formatter), -]); +} as const; -export async function parseResponse( - res: Response, - { expectingOk = true, format = 'text', apiName = '' }, +function catchFormatErrors( + format: T, + formatter: typeof formatters[T], ) { - let body; + return (res: Response) => { + try { + return formatter(res) as ReturnType; + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error( + `Response cannot be parsed into the expected format (${format}): ${error.message}`, + ); + } + + throw error; + } + }; +} + +const responseFormatters = { + json: catchFormatErrors('json', async (res: Response) => { + const contentType = res.headers.get('Content-Type') || ''; + if (!contentType.startsWith('application/json') && !contentType.startsWith('text/json')) { + throw new Error(`${contentType} is not a valid JSON Content-Type`); + } + return res.json(); + }), + text: catchFormatErrors('text', async (res: Response) => res.text()), + blob: catchFormatErrors('blob', async (res: Response) => res.blob()), +} as const; + +interface ParseResponseOptions { + expectingOk?: boolean; + format?: keyof typeof responseFormatters; + apiName?: string; +} + +export async function parseResponse( + res: Response, + { expectingOk = true, format = 'text', apiName = '' }: ParseResponseOptions, +): Promise>> { + let body: Awaited>; try { - const formatter = responseFormatters.get(format, false); + const formatter = responseFormatters[format] ?? false; if (!formatter) { throw new Error(`${format} is not a supported response format.`); } @@ -53,20 +73,22 @@ export async function parseResponse( } catch (err: any) { throw new APIError(err.message, res.status, apiName); } + if (expectingOk && !res.ok) { const isJSON = format === 'json'; const message = isJSON ? body.message || body.msg || body.error?.message : body; throw new APIError(isJSON && message ? message : body, res.status, apiName); } + return body; } -export function responseParser(options: { +export function responseParser(options: { expectingOk?: boolean; - format: string; + format: T; apiName: string; }) { - return (res: Response) => parseResponse(res, options); + return (res: Response) => parseResponse(res, options); } export function parseLinkHeader(header: string | null) { diff --git a/src/lib/util/collection.util.ts b/src/lib/util/collection.util.ts new file mode 100644 index 00000000..3e127e69 --- /dev/null +++ b/src/lib/util/collection.util.ts @@ -0,0 +1,417 @@ +import get from 'lodash/get'; + +import { FILES, FOLDER } from '../../constants/collectionTypes'; +import { COMMIT_AUTHOR, COMMIT_DATE } from '../../constants/commitProps'; +import { + IDENTIFIER_FIELDS, + INFERABLE_FIELDS, + SORTABLE_FIELDS, +} from '../../constants/fieldInference'; +import { formatExtensions } from '../../formats/formats'; +import consoleError from '../consoleError'; +import { summaryFormatter } from '../formatters'; +import { keyToPathArray } from '../widgets/stringTemplate'; +import { selectField } from './field.util'; +import { selectMediaFolder } from './media.util'; + +import type { Backend } from '../../backend'; +import type { + Collection, + CollectionFile, + Config, + Entry, + Field, + ObjectField, + SortableField, +} from '../../interface'; + +const selectors = { + [FOLDER]: { + entryExtension(collection: Collection) { + return (collection.extension || formatExtensions[collection.format ?? 'frontmatter']).replace( + /^\./, + '', + ); + }, + fields(collection: Collection) { + return collection.fields; + }, + entryPath(collection: Collection, slug: string) { + const folder = (collection.folder as string).replace(/\/$/, ''); + return `${folder}/${slug}.${this.entryExtension(collection)}`; + }, + entrySlug(collection: Collection, path: string) { + const folder = (collection.folder as string).replace(/\/$/, ''); + const slug = path + .split(folder + '/') + .pop() + ?.replace(new RegExp(`\\.${this.entryExtension(collection)}$`), ''); + + return slug; + }, + allowNewEntries(collection: Collection) { + return collection.create; + }, + allowDeletion(collection: Collection) { + return collection.delete ?? true; + }, + templateName(collection: Collection) { + return collection.name; + }, + }, + [FILES]: { + fileForEntry(collection: Collection, slug?: string) { + const files = collection.files; + if (!slug) { + return files?.[0]; + } + return files && files.filter(f => f?.name === slug)?.[0]; + }, + fields(collection: Collection, slug?: string) { + const file = this.fileForEntry(collection, slug); + return file && file.fields; + }, + entryPath(collection: Collection, slug: string) { + const file = this.fileForEntry(collection, slug); + return file && file.file; + }, + entrySlug(collection: Collection, path: string) { + const file = (collection.files as CollectionFile[]).filter(f => f?.file === path)?.[0]; + return file && file.name; + }, + entryLabel(collection: Collection, slug: string) { + const file = this.fileForEntry(collection, slug); + return file && file.label; + }, + allowNewEntries() { + return false; + }, + allowDeletion(collection: Collection) { + return collection.delete ?? false; + }, + templateName(_collection: Collection, slug: string) { + return slug; + }, + }, +}; + +export function selectFields(collection: Collection, slug?: string) { + return selectors[collection.type].fields(collection, slug); +} + +export function selectFolderEntryExtension(collection: Collection) { + return selectors[FOLDER].entryExtension(collection); +} + +export function selectFileEntryLabel(collection: Collection, slug: string) { + return selectors[FILES].entryLabel(collection, slug); +} + +export function selectEntryPath(collection: Collection, slug: string) { + return selectors[collection.type].entryPath(collection, slug); +} + +export function selectEntrySlug(collection: Collection, path: string) { + return selectors[collection.type].entrySlug(collection, path); +} + +export function selectAllowNewEntries(collection: Collection) { + return selectors[collection.type].allowNewEntries(collection); +} + +export function selectAllowDeletion(collection: Collection) { + return selectors[collection.type].allowDeletion(collection); +} + +export function selectTemplateName(collection: Collection, slug: string) { + return selectors[collection.type].templateName(collection, slug); +} + +export function selectEntryCollectionTitle(collection: Collection, entry: Entry): string { + // prefer formatted summary over everything else + const summaryTemplate = collection.summary; + if (summaryTemplate) { + return summaryFormatter(summaryTemplate, entry, collection); + } + + // if the collection is a file collection return the label of the entry + if (collection.type == FILES) { + const label = selectFileEntryLabel(collection, entry.slug); + if (label) { + return label; + } + } + + // try to infer a title field from the entry data + const entryData = entry.data; + const titleField = selectInferedField(collection, 'title'); + const result = titleField && get(entryData, keyToPathArray(titleField)); + + // if the custom field does not yield a result, fallback to 'title' + if (!result && titleField !== 'title') { + return get(entryData, keyToPathArray('title')); + } + + return result; +} + +export function selectDefaultSortableFields( + collection: Collection, + backend: Backend, + hasIntegration: boolean, +) { + let defaultSortable = SORTABLE_FIELDS.map((type: string) => { + const field = selectInferedField(collection, type); + if (backend.isGitBackend() && type === 'author' && !field && !hasIntegration) { + // default to commit author if not author field is found + return COMMIT_AUTHOR; + } + return field; + }).filter(Boolean); + + if (backend.isGitBackend() && !hasIntegration) { + // always have commit date by default + defaultSortable = [COMMIT_DATE, ...defaultSortable]; + } + + return defaultSortable as string[]; +} + +export function selectSortableFields( + collection: Collection, + t: (key: string) => string, +): SortableField[] { + const fields = (collection.sortable_fields?.fields ?? []) + .map(key => { + if (key === COMMIT_DATE) { + return { key, field: { name: key, label: t('collection.defaultFields.updatedOn.label') } }; + } + const field = selectField(collection, key); + if (key === COMMIT_AUTHOR && !field) { + return { key, field: { name: key, label: t('collection.defaultFields.author.label') } }; + } + + return { key, field }; + }) + .filter(item => !!item.field) + .map(item => ({ ...item.field, key: item.key })) as SortableField[]; + + return fields; +} + +export function selectViewFilters(collection: Collection) { + return collection.view_filters; +} + +export function selectViewGroups(collection: Collection) { + return collection.view_groups; +} + +export function selectFieldsComments(collection: Collection, entryMap: Entry) { + let fields: Field[] = []; + if ('folder' in collection) { + fields = collection.fields; + } else if ('files' in collection) { + const file = collection.files!.find(f => f?.name === entryMap.slug); + if (file) { + fields = file.fields; + } + } + const comments: Record = {}; + const names = getFieldsNames(fields); + names.forEach(name => { + const field = selectField(collection, name); + if (field && 'comment' in field) { + comments[name] = field.comment!; + } + }); + + return comments; +} +function getFieldsWithMediaFolders(fields: Field[]) { + const fieldsWithMediaFolders = fields.reduce((acc, f) => { + if ('media_folder' in f) { + acc = [...acc, f]; + } + + if ('fields' in f) { + const fields = f.fields ?? []; + acc = [...acc, ...getFieldsWithMediaFolders(fields)]; + } else if ('types' in f) { + const types = f.types ?? []; + acc = [...acc, ...getFieldsWithMediaFolders(types)]; + } + + return acc; + }, [] as Field[]); + + return fieldsWithMediaFolders; +} + +export function getFileFromSlug(collection: Collection, slug: string) { + return collection.files?.find(f => f.name === slug); +} + +export function selectFieldsWithMediaFolders(collection: Collection, slug: string) { + if ('folder' in collection) { + const fields = collection.fields; + return getFieldsWithMediaFolders(fields); + } else if ('files' in collection) { + const fields = getFileFromSlug(collection, slug)?.fields || []; + return getFieldsWithMediaFolders(fields); + } + + return []; +} + +export function selectMediaFolders(config: Config, collection: Collection, entry: Entry) { + const fields = selectFieldsWithMediaFolders(collection, entry.slug); + const folders = fields.map(f => selectMediaFolder(config, collection, entry, f)); + if ('files' in collection) { + const file = getFileFromSlug(collection, entry.slug); + if (file) { + folders.unshift(selectMediaFolder(config, collection, entry, undefined)); + } + } + if ('media_folder' in collection) { + // stop evaluating media folders at collection level + const newCollection = { ...collection }; + delete newCollection.files; + folders.unshift(selectMediaFolder(config, newCollection, entry, undefined)); + } + + return [...new Set(...folders)]; +} +export function getFieldsNames(fields: (Field | Field)[] | undefined, prefix = '') { + let names = fields?.map(f => `${prefix}${f.name}`) ?? []; + + fields?.forEach((f, index) => { + if ('fields' in f) { + const fields = f.fields; + names = [...names, ...getFieldsNames(fields, `${names[index]}.`)]; + } else if ('types' in f) { + const types = f.types; + names = [...names, ...getFieldsNames(types, `${names[index]}.`)]; + } + }); + + return names; +} + +export function traverseFields( + fields: Field[], + updater: (field: Field) => Field, + done = () => false, +) { + if (done()) { + return fields; + } + + return fields.map(f => { + const field = updater(f as Field); + if (done()) { + return field; + } else if ('fields' in field) { + field.fields = traverseFields(field.fields ?? [], updater, done); + return field; + } else if ('types' in field) { + field.types = traverseFields(field.types ?? [], updater, done) as ObjectField[]; + return field; + } else { + return field; + } + }); +} + +export function updateFieldByKey( + collection: Collection, + key: string, + updater: (field: Field) => Field, +): Collection { + const selected = selectField(collection, key); + if (!selected) { + return collection; + } + + let updated = false; + + function updateAndBreak(f: Field) { + const field = f as Field; + if (field === selected) { + updated = true; + return updater(field); + } else { + return field; + } + } + + collection.fields = traverseFields(collection.fields ?? [], updateAndBreak, () => updated); + + return collection; +} + +export function selectIdentifier(collection: Collection) { + const identifier = collection.identifier_field; + const identifierFields = identifier ? [identifier, ...IDENTIFIER_FIELDS] : [...IDENTIFIER_FIELDS]; + const fieldNames = getFieldsNames(collection.fields ?? []); + return identifierFields.find(id => + fieldNames.find(name => name.toLowerCase().trim() === id.toLowerCase().trim()), + ); +} + +export function selectInferedField(collection: Collection, fieldName: string) { + if (fieldName === 'title' && collection.identifier_field) { + return selectIdentifier(collection); + } + const inferableField = ( + INFERABLE_FIELDS as Record< + string, + { + type: string; + synonyms: string[]; + secondaryTypes: string[]; + fallbackToFirstField: boolean; + showError: boolean; + } + > + )[fieldName]; + const fields = collection.fields as (Field | Field)[]; + let field; + + // If collection has no fields or fieldName is not defined within inferables list, return null + if (!fields || !inferableField) { + return null; + } + // Try to return a field of the specified type with one of the synonyms + const mainTypeFields = fields + .filter((f: Field | Field) => (f.widget ?? 'string') === inferableField.type) + .map(f => f?.name); + field = mainTypeFields.filter(f => inferableField.synonyms.indexOf(f as string) !== -1); + if (field && field.length > 0) { + return field[0]; + } + + // Try to return a field for each of the specified secondary types + const secondaryTypeFields = fields + .filter(f => inferableField.secondaryTypes.indexOf(f.widget ?? 'string') !== -1) + .map(f => f?.name); + field = secondaryTypeFields.filter(f => inferableField.synonyms.indexOf(f as string) !== -1); + if (field && field.length > 0) { + return field[0]; + } + + // Try to return the first field of the specified type + if (inferableField.fallbackToFirstField && mainTypeFields.length > 0) { + return mainTypeFields[0]; + } + + // Coundn't infer the field. Show error and return null. + if (inferableField.showError) { + consoleError( + `The Field ${fieldName} is missing for the collection “${collection.name}”`, + `Static CMS tries to infer the entry ${fieldName} automatically, but one couldn't be found for entries of the collection “${collection.name}”. Please check your site configuration.`, + ); + } + + return null; +} diff --git a/src/lib/util/field.util.ts b/src/lib/util/field.util.ts new file mode 100644 index 00000000..de9fbb60 --- /dev/null +++ b/src/lib/util/field.util.ts @@ -0,0 +1,29 @@ +import { keyToPathArray } from '../widgets/stringTemplate'; + +import type { t } from 'react-polyglot'; +import type { Collection, Field } from '../../interface'; + +export function selectField(collection: Collection, key: string) { + const array = keyToPathArray(key); + let name: string | undefined; + let field; + let fields = collection.fields ?? []; + while ((name = array.shift()) && fields) { + field = fields.find(f => f.name === name); + if (field) { + if ('fields' in field) { + fields = field?.fields ?? []; + } else if ('types' in field) { + fields = field?.types ?? []; + } + } + } + + return field; +} + +export function getFieldLabel(field: Field, t: t) { + return `${field.label ?? field.name} ${`${ + field.required === false ? ` (${t('editor.editorControl.field.optional')})` : '' + }`}`; +} diff --git a/src/lib/util/git-lfs.ts b/src/lib/util/git-lfs.ts index 1ad54ffb..a2a5a025 100644 --- a/src/lib/util/git-lfs.ts +++ b/src/lib/util/git-lfs.ts @@ -5,7 +5,7 @@ import { filter, flow, fromPairs, map } from 'lodash/fp'; import getBlobSHA from './getBlobSHA'; -import type { AssetProxy } from './implementation'; +import type AssetProxy from '../../valueObjects/AssetProxy'; export interface PointerFile { size: number; diff --git a/src/lib/util/implementation.ts b/src/lib/util/implementation.ts index c9afbd9a..7a85e8fb 100644 --- a/src/lib/util/implementation.ts +++ b/src/lib/util/implementation.ts @@ -1,102 +1,21 @@ -import { sortBy, unionBy } from 'lodash'; +import sortBy from 'lodash/sortBy'; +import unionBy from 'lodash/unionBy'; import semaphore from 'semaphore'; import { basename } from './path'; import type { Semaphore } from 'semaphore'; -import type { Implementation as I, ImplementationEntry } from '../../interface'; -import type { FileMetadata } from './API'; +import type { + DisplayURL, + DisplayURLObject, + FileMetadata, + ImplementationEntry, + ImplementationFile, +} from '../../interface'; import type { AsyncLock } from './asyncLock'; -export type DisplayURLObject = { id: string; path: string }; - -export type DisplayURL = DisplayURLObject | string; - -export interface ImplementationMediaFile { - name: string; - id: string; - size?: number; - displayURL?: DisplayURL; - path: string; - draft?: boolean; - url?: string; - file?: File; -} - -export interface Map { - get: (key: string, defaultValue?: T) => T; - getIn: (key: string[], defaultValue?: T) => T; - setIn: (key: string[], value: T) => Map; - set: (key: string, value: T) => Map; -} - -export type DataFile = { - path: string; - slug: string; - raw: string; - newPath?: string; -}; - -export type AssetProxy = { - path: string; - fileObj?: File; - toBase64?: () => Promise; -}; - -export type Entry = { - dataFiles: DataFile[]; - assets: AssetProxy[]; -}; - -export type PersistOptions = { - newEntry?: boolean; - commitMessage: string; - collectionName?: string; - status?: string; -}; - -export type DeleteOptions = {}; - -export type Credentials = { token: string | {}; refresh_token?: string }; - -export type User = Credentials & { - backendName?: string; - login?: string; - name: string; -}; - -export type Config = { - backend: { - repo?: string | null; - branch?: string; - api_root?: string; - use_graphql?: boolean; - graphql_api_root?: string; - preview_context?: string; - identity_url?: string; - gateway_url?: string; - large_media_url?: string; - use_large_media_transforms_in_media_library?: boolean; - proxy_url?: string; - auth_type?: string; - app_id?: string; - api_version?: string; - }; - media_folder: string; - base_url?: string; - site_id?: string; -}; - -export type Implementation = I; - const MAX_CONCURRENT_DOWNLOADS = 10; -export type ImplementationFile = { - id?: string | null | undefined; - label?: string; - path: string; -}; - type ReadFile = ( path: string, id: string | null | undefined, diff --git a/src/lib/util/index.ts b/src/lib/util/index.ts index 67e674d9..3780c5cc 100644 --- a/src/lib/util/index.ts +++ b/src/lib/util/index.ts @@ -1,159 +1,41 @@ -import AccessTokenError from './AccessTokenError'; -import { readFile, readFileMetadata, requestWithBackoff, throwOnConflictingBranches } from './API'; -import APIError from './APIError'; -import { - generateContentKey, - parseContentKey -} from './APIUtils'; -import { asyncLock } from './asyncLock'; -import { - filterByExtension, - getAllResponses, - getPathDepth, - parseLinkHeader, - parseResponse, - responseParser -} from './backendUtil'; -import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from './Cursor'; -import getBlobSHA from './getBlobSHA'; -import { - createPointerFile, - getLargeMediaFilteredMediaFiles, - getLargeMediaPatternsFromGitAttributesFile, - getPointerFileForMediaFileObj, - parsePointerFile -} from './git-lfs'; -import { - allEntriesByFolder, - blobToFileObj, - entriesByFiles, - entriesByFolder, - getMediaAsBlob, - getMediaDisplayURL, - runWithLock -} from './implementation'; -import loadScript from './loadScript'; -import localForage from './localForage'; -import { basename, fileExtension, fileExtensionWithSeparator, isAbsolutePath } from './path'; -import { flowAsync, onlySuccessfulPromises, then } from './promise'; -import transientOptions from './transientOptions'; -import unsentRequest from './unsentRequest'; - -import type { ApiRequest as AR, FetchError as FE } from './API'; -import type { AsyncLock as AL } from './asyncLock'; -import type { PointerFile as PF } from './git-lfs'; -import type { - AssetProxy as AP, - Config as C, - Credentials as Cred, - DataFile as DF, - DisplayURL as DU, - DisplayURLObject as DUO, - Entry as E, - Implementation as I, - ImplementationFile as IF, - ImplementationMediaFile as IMF, - PersistOptions as PO, - User as U -} from './implementation'; - -export type AsyncLock = AL; -export type Implementation = I; -export type ImplementationMediaFile = IMF; -export type ImplementationFile = IF; -export type DisplayURL = DU; -export type DisplayURLObject = DUO; -export type Credentials = Cred; -export type User = U; -export type Entry = E; -export type PersistOptions = PO; -export type AssetProxy = AP; -export type ApiRequest = AR; -export type Config = C; -export type FetchError = FE; -export type PointerFile = PF; -export type DataFile = DF; - -export const StaticCmsLibUtil = { - APIError, - Cursor, - CURSOR_COMPATIBILITY_SYMBOL, - localForage, - basename, - fileExtensionWithSeparator, - fileExtension, - onlySuccessfulPromises, - flowAsync, - then, - unsentRequest, - filterByExtension, - parseLinkHeader, - parseResponse, - responseParser, - loadScript, - getBlobSHA, - getPathDepth, - entriesByFiles, - entriesByFolder, - getMediaDisplayURL, - getMediaAsBlob, - readFile, - readFileMetadata, - generateContentKey, - runWithLock, - parseContentKey, - createPointerFile, - getLargeMediaFilteredMediaFiles, - getLargeMediaPatternsFromGitAttributesFile, - parsePointerFile, - getPointerFileForMediaFileObj, - blobToFileObj, - requestWithBackoff, - allEntriesByFolder, - AccessTokenError, - throwOnConflictingBranches, - transientOptions, -}; +export { default as AccessTokenError } from './AccessTokenError'; +export { readFile, readFileMetadata, requestWithBackoff, throwOnConflictingBranches } from './API'; +export { default as APIError } from './APIError'; +export { generateContentKey, parseContentKey } from './APIUtils'; +export { asyncLock } from './asyncLock'; export { - APIError, - Cursor, - CURSOR_COMPATIBILITY_SYMBOL, - localForage, - basename, - fileExtensionWithSeparator, - fileExtension, - onlySuccessfulPromises, - flowAsync, - then, - unsentRequest, filterByExtension, - parseLinkHeader, getAllResponses, + getPathDepth, + parseLinkHeader, parseResponse, responseParser, - loadScript, - getBlobSHA, - asyncLock, - isAbsolutePath, - getPathDepth, - entriesByFiles, - entriesByFolder, - getMediaDisplayURL, - getMediaAsBlob, - readFile, - readFileMetadata, - generateContentKey, - runWithLock, - parseContentKey, +} from './backendUtil'; +export { default as Cursor, CURSOR_COMPATIBILITY_SYMBOL } from './Cursor'; +export { default as getBlobSHA } from './getBlobSHA'; +export { createPointerFile, getLargeMediaFilteredMediaFiles, getLargeMediaPatternsFromGitAttributesFile, - parsePointerFile, getPointerFileForMediaFileObj, - blobToFileObj, - requestWithBackoff, + parsePointerFile, +} from './git-lfs'; +export { allEntriesByFolder, - AccessTokenError, - throwOnConflictingBranches, - transientOptions, -}; + blobToFileObj, + entriesByFiles, + entriesByFolder, + getMediaAsBlob, + getMediaDisplayURL, + runWithLock, +} from './implementation'; +export { default as loadScript } from './loadScript'; +export { default as localForage } from './localForage'; +export { basename, fileExtension, fileExtensionWithSeparator, isAbsolutePath } from './path'; +export { flowAsync, onlySuccessfulPromises, then } from './promise'; +export { default as transientOptions } from './transientOptions'; +export { default as unsentRequest } from './unsentRequest'; + +export type { ApiRequest, FetchError } from './API'; +export type { AsyncLock } from './asyncLock'; +export type { PointerFile } from './git-lfs'; diff --git a/src/lib/util/loadScript.js b/src/lib/util/loadScript.js deleted file mode 100644 index 95a62355..00000000 --- a/src/lib/util/loadScript.js +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Simple script loader that returns a promise. - */ -export default function loadScript(url) { - return new Promise((resolve, reject) => { - let done = false; - const head = document.getElementsByTagName('head')[0]; - const script = document.createElement('script'); - script.src = url; - script.onload = script.onreadystatechange = function () { - if ( - !done && - (!this.readyState || this.readyState === 'loaded' || this.readyState === 'complete') - ) { - done = true; - resolve(); - } else { - reject(); - } - }; - script.onerror = error => reject(error); - head.appendChild(script); - }); -} diff --git a/src/lib/util/loadScript.ts b/src/lib/util/loadScript.ts new file mode 100644 index 00000000..bcc382f7 --- /dev/null +++ b/src/lib/util/loadScript.ts @@ -0,0 +1,17 @@ +/** + * Simple script loader that returns a promise. + */ +export default function loadScript(url: string) { + return new Promise((resolve, reject) => { + const head = document.getElementsByTagName('head')[0]; + const script: HTMLScriptElement = document.createElement('script'); + script.src = url; + script.onload = () => { + resolve(); + }; + script.onerror = error => { + reject(error); + }; + head.appendChild(script); + }); +} diff --git a/src/lib/util/media.util.ts b/src/lib/util/media.util.ts new file mode 100644 index 00000000..6a81e781 --- /dev/null +++ b/src/lib/util/media.util.ts @@ -0,0 +1,271 @@ +import { dirname, join } from 'path'; +import trim from 'lodash/trim'; + +import { folderFormatter } from '../formatters'; +import { joinUrlPath } from '../urlHelper'; +import { basename, isAbsolutePath } from '.'; + +import type { Config, Field, Collection, CollectionFile, Entry } from '../../interface'; + +export const DRAFT_MEDIA_FILES = 'DRAFT_MEDIA_FILES'; + +function getFileField(collectionFiles: CollectionFile[], slug: string | undefined) { + const file = collectionFiles.find(f => f?.name === slug); + return file; +} + +function hasCustomFolder( + folderKey: 'media_folder' | 'public_folder', + collection: Collection | undefined | null, + slug: string | undefined, + field: Field | undefined, +) { + if (!collection) { + return false; + } + + if (field && field[folderKey]) { + return true; + } + + if (collection.files) { + const file = getFileField(collection.files, slug); + if (file && file[folderKey]) { + return true; + } + } + + if (collection[folderKey]) { + return true; + } + + return false; +} + +function evaluateFolder( + folderKey: 'media_folder' | 'public_folder', + config: Config, + c: Collection, + entryMap: Entry | undefined, + field: Field | undefined, +) { + let currentFolder = config[folderKey]!; + + const collection = { ...c }; + // add identity template if doesn't exist + if (!collection[folderKey]) { + collection[folderKey] = `{{${folderKey}}}`; + } + + if (collection.files) { + // files collection evaluate the collection template + // then move on to the specific file configuration denoted by the slug + currentFolder = folderFormatter( + collection[folderKey]!, + entryMap, + collection, + currentFolder, + folderKey, + config.slug, + ); + + const f = getFileField(collection.files!, entryMap?.slug); + if (f) { + const file = { ...f }; + if (!file[folderKey]) { + // add identity template if doesn't exist + file[folderKey] = `{{${folderKey}}}`; + } + + // evaluate the file template and keep evaluating until we match our field + currentFolder = folderFormatter( + file[folderKey]!, + entryMap, + collection, + currentFolder, + folderKey, + config.slug, + ); + + if (field) { + const fieldFolder = traverseFields( + folderKey, + config, + collection, + entryMap, + field, + file.fields! as Field[], + currentFolder, + ); + + if (fieldFolder !== null) { + currentFolder = fieldFolder; + } + } + } + } else { + // folder collection, evaluate the collection template + // and keep evaluating until we match our field + currentFolder = folderFormatter( + collection[folderKey]!, + entryMap, + collection, + currentFolder, + folderKey, + config.slug, + ); + + if (field) { + const fieldFolder = traverseFields( + folderKey, + config, + collection, + entryMap, + field, + collection.fields! as Field[], + currentFolder, + ); + + if (fieldFolder !== null) { + currentFolder = fieldFolder; + } + } + } + + return currentFolder; +} + +function traverseFields( + folderKey: 'media_folder' | 'public_folder', + config: Config, + collection: Collection, + entryMap: Entry | undefined, + field: Field, + fields: Field[], + currentFolder: string, +): string | null { + const matchedField = fields.filter(f => f === field)[0]; + if (matchedField) { + return folderFormatter( + matchedField[folderKey] ? matchedField[folderKey]! : `{{${folderKey}}}`, + entryMap, + collection, + currentFolder, + folderKey, + config.slug, + ); + } + + for (const f of fields) { + const field = { ...f }; + if (!field[folderKey]) { + // add identity template if doesn't exist + field[folderKey] = `{{${folderKey}}}`; + } + const folder = folderFormatter( + field[folderKey]!, + entryMap, + collection, + currentFolder, + folderKey, + config.slug, + ); + let fieldFolder = null; + if ('fields' in field && field.fields) { + fieldFolder = traverseFields( + folderKey, + config, + collection, + entryMap, + field, + field.fields, + folder, + ); + } else if ('types' in field && field.types) { + fieldFolder = traverseFields( + folderKey, + config, + collection, + entryMap, + field, + field.types, + folder, + ); + } + if (fieldFolder != null) { + return fieldFolder; + } + } + + return null; +} + +export function selectMediaFolder( + config: Config, + collection: Collection | undefined | null, + entryMap: Entry | undefined, + field: Field | undefined, +) { + const name = 'media_folder'; + let mediaFolder = config[name]; + + const customFolder = hasCustomFolder(name, collection, entryMap?.slug, field); + + if (customFolder) { + const folder = evaluateFolder(name, config, collection!, entryMap, field); + if (folder.startsWith('/')) { + // return absolute paths as is + mediaFolder = join(folder); + } else { + const entryPath = entryMap?.path; + mediaFolder = entryPath + ? join(dirname(entryPath), folder) + : join(collection!.folder as string, DRAFT_MEDIA_FILES); + } + } + + return trim(mediaFolder, '/'); +} + +export function selectMediaFilePublicPath( + config: Config, + collection: Collection | null, + mediaPath: string, + entryMap: Entry | undefined, + field: Field | undefined, +) { + if (isAbsolutePath(mediaPath)) { + return mediaPath; + } + + const name = 'public_folder'; + let publicFolder = config[name]!; + + const customFolder = hasCustomFolder(name, collection, entryMap?.slug, field); + + if (customFolder) { + publicFolder = evaluateFolder(name, config, collection!, entryMap, field); + } + + if (isAbsolutePath(publicFolder)) { + return joinUrlPath(publicFolder, basename(mediaPath)); + } + + return join(publicFolder, basename(mediaPath)); +} + +export function selectMediaFilePath( + config: Config, + collection: Collection | null, + entryMap: Entry | undefined, + mediaPath: string, + field: Field | undefined, +) { + if (isAbsolutePath(mediaPath)) { + return mediaPath; + } + + const mediaFolder = selectMediaFolder(config, collection, entryMap, field); + + return join(mediaFolder, basename(mediaPath)); +} diff --git a/src/lib/util/null.util.ts b/src/lib/util/null.util.ts new file mode 100644 index 00000000..d34192bc --- /dev/null +++ b/src/lib/util/null.util.ts @@ -0,0 +1,11 @@ +export function isNotNullish(value: T | null | undefined): value is T { + return value !== undefined && value !== null; +} + +export function isNullish(value: T | null | undefined): value is null | undefined { + return value === undefined || value === null; +} + +export function filterNullish(value: (T | null | undefined)[] | null | undefined): T[] { + return value?.filter(isNotNullish) ?? []; +} diff --git a/src/lib/util/object.util.ts b/src/lib/util/object.util.ts new file mode 100644 index 00000000..3d493e0f --- /dev/null +++ b/src/lib/util/object.util.ts @@ -0,0 +1,45 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +function setIn(target: any, path: (string | number)[], value: unknown): any { + if (path.length === 0) { + return value; + } + + const pathSegment = path[0]; + const restOfPath = path.slice(1); + + if (Array.isArray(target)) { + const localTarget = [...(target ?? [])]; + if (Number.isNaN(+pathSegment)) { + return localTarget; + } + + const index = +pathSegment; + if (index < 0 || index >= localTarget.length) { + return localTarget; + } + + localTarget[index] = setIn(localTarget[index], restOfPath, value); + return localTarget; + } + + const localTarget = target ?? {}; + return { + ...localTarget, + [pathSegment]: setIn(localTarget[pathSegment], restOfPath, value), + }; +} + +export function set(target: T, path: string | undefined | null, value: unknown): T; +export function set(target: any, path: string | undefined | null, value: unknown): any { + return setIn( + target, + (path ?? '').split('.').map(part => { + if (Number.isNaN(+part)) { + return part; + } + + return +part; + }), + value, + ); +} diff --git a/src/lib/util/sort.util.ts b/src/lib/util/sort.util.ts new file mode 100644 index 00000000..163502e9 --- /dev/null +++ b/src/lib/util/sort.util.ts @@ -0,0 +1,14 @@ +import { COMMIT_AUTHOR, COMMIT_DATE } from '../../constants/commitProps'; +import { selectField } from './field.util'; + +import type { Collection } from '../../interface'; + +export function selectSortDataPath(collection: Collection, key: string) { + if (key === COMMIT_DATE) { + return 'updatedOn'; + } else if (key === COMMIT_AUTHOR && !selectField(collection, key)) { + return 'author'; + } else { + return `data.${key}`; + } +} diff --git a/src/lib/util/string.util.ts b/src/lib/util/string.util.ts new file mode 100644 index 00000000..93903147 --- /dev/null +++ b/src/lib/util/string.util.ts @@ -0,0 +1,24 @@ +import { isNotNullish, isNullish } from './null.util'; + +export function isEmpty(value: string | null | undefined): value is null | undefined { + return isNullish(value) || value === ''; +} + +export function isNotEmpty(value: string | null | undefined): value is string { + return isNotNullish(value) && value !== ''; +} + +export function toTitleCase(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); +} + +export function toTitleCaseFromKey(str: string) { + return str.replace(/_/g, ' ').replace(/\w\S*/g, toTitleCase); +} + +export function toTitleCaseFromVariableName(str: string) { + return str + .split(/(?=[A-Z])/) + .join(' ') + .replace(/\w\S*/g, toTitleCase); +} diff --git a/src/lib/util/types/semaphore.d.ts b/src/lib/util/types/semaphore.d.ts deleted file mode 100644 index 8c09e2a0..00000000 --- a/src/lib/util/types/semaphore.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -declare module 'semaphore' { - export type Semaphore = { take: (f: Function) => void; leave: () => void }; - const semaphore: (count: number) => Semaphore; - export default semaphore; -} diff --git a/src/lib/util/unsentRequest.js b/src/lib/util/unsentRequest.js deleted file mode 100644 index 1c077d6f..00000000 --- a/src/lib/util/unsentRequest.js +++ /dev/null @@ -1,133 +0,0 @@ -import { fromJS, List, Map } from 'immutable'; -import curry from 'lodash/curry'; -import flow from 'lodash/flow'; -import isString from 'lodash/isString'; - -function isAbortControllerSupported() { - if (typeof window !== 'undefined') { - return !!window.AbortController; - } - return false; -} - -const timeout = 60; - -function fetchWithTimeout(input, init) { - if ((init && init.signal) || !isAbortControllerSupported()) { - return fetch(input, init); - } - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout * 1000); - return fetch(input, { ...init, signal: controller.signal }) - .then(res => { - clearTimeout(timeoutId); - return res; - }) - .catch(e => { - if (e.name === 'AbortError' || e.name === 'DOMException') { - throw new Error(`Request timed out after ${timeout} seconds`); - } - throw e; - }); -} - -function decodeParams(paramsString) { - return List(paramsString.split('&')) - .map(s => List(s.split('=')).map(decodeURIComponent)) - .update(Map); -} - -function fromURL(wholeURL) { - const [url, allParamsString] = wholeURL.split('?'); - return Map({ url, ...(allParamsString ? { params: decodeParams(allParamsString) } : {}) }); -} - -function fromFetchArguments(wholeURL, options) { - return fromURL(wholeURL).merge( - (options ? fromJS(options) : Map()).remove('url').remove('params'), - ); -} - -function encodeParams(params) { - return params - .entrySeq() - .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) - .join('&'); -} - -function toURL(req) { - return `${req.get('url')}${req.get('params') ? `?${encodeParams(req.get('params'))}` : ''}`; -} - -function toFetchArguments(req) { - return [toURL(req), req.remove('url').remove('params').toJS()]; -} - -function maybeRequestArg(req) { - if (isString(req)) { - return fromURL(req); - } - if (req) { - return fromJS(req); - } - return Map(); -} - -function ensureRequestArg(func) { - return req => func(maybeRequestArg(req)); -} - -function ensureRequestArg2(func) { - return (arg, req) => func(arg, maybeRequestArg(req)); -} - -// This actually performs the built request object -const performRequest = ensureRequestArg(req => { - const args = toFetchArguments(req); - return fetchWithTimeout(...args); -}); - -// Each of the following functions takes options and returns another -// function that performs the requested action on a request. -const getCurriedRequestProcessor = flow([ensureRequestArg2, curry]); - -function getPropSetFunction(path) { - return getCurriedRequestProcessor((val, req) => req.setIn(path, val)); -} - -function getPropMergeFunction(path) { - return getCurriedRequestProcessor((obj, req) => req.updateIn(path, (p = Map()) => p.merge(obj))); -} - -const withMethod = getPropSetFunction(['method']); -const withBody = getPropSetFunction(['body']); -const withNoCache = getPropSetFunction(['cache'])('no-cache'); -const withParams = getPropMergeFunction(['params']); -const withHeaders = getPropMergeFunction(['headers']); - -// withRoot sets a root URL, unless the URL is already absolute -const absolutePath = new RegExp('^(?:[a-z]+:)?//', 'i'); -const withRoot = getCurriedRequestProcessor((root, req) => - req.update('url', p => { - if (absolutePath.test(p)) { - return p; - } - return root && p && p[0] !== '/' && root[root.length - 1] !== '/' - ? `${root}/${p}` - : `${root}${p}`; - }), -); - -export default { - toURL, - fromURL, - fromFetchArguments, - performRequest, - withMethod, - withBody, - withHeaders, - withParams, - withRoot, - withNoCache, - fetchWithTimeout, -}; diff --git a/src/lib/util/unsentRequest.ts b/src/lib/util/unsentRequest.ts new file mode 100644 index 00000000..428cc116 --- /dev/null +++ b/src/lib/util/unsentRequest.ts @@ -0,0 +1,142 @@ +import type { ApiRequest, ApiRequestObject, ApiRequestURL } from './API'; + +function isAbortControllerSupported() { + if (typeof window !== 'undefined') { + return !!window.AbortController; + } + return false; +} + +const timeout = 60; + +function fetchWithTimeout( + input: RequestInfo | URL, + init?: RequestInit | undefined, +): Promise { + if ((init && init.signal) || !isAbortControllerSupported()) { + return fetch(input, init); + } + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout * 1000); + return fetch(input, { ...init, signal: controller.signal }) + .then(res => { + clearTimeout(timeoutId); + return res; + }) + .catch((e: unknown) => { + if (e instanceof DOMException) { + if (e.name === 'AbortError' || e.name === 'DOMException') { + throw new Error(`Request timed out after ${timeout} seconds`); + } + } + throw e; + }); +} + +function decodeParams(paramsString: string): Record { + return paramsString + .split('&') + .map(s => s.split('=')) + .reduce((acc, [key, value]) => { + acc[key] = decodeURIComponent(value); + return acc; + }, {} as Record); +} + +function fromURL(wholeURL: string): ApiRequestURL { + const [url, allParamsString] = wholeURL.split('?'); + return { url, ...(allParamsString ? { params: decodeParams(allParamsString) } : {}) }; +} + +function fromFetchArguments(wholeURL: string, options?: RequestInit): ApiRequestObject { + return { + ...fromURL(wholeURL), + ...(options ? options : {}), + }; +} + +function encodeParams(params: Required['params']): string { + return Object.entries(params) + .map(([v, k]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&'); +} + +function toURL(req: ApiRequestURL): string { + return `${req.url}${req.params ? `?${encodeParams(req.params)}` : ''}`; +} + +function toFetchArguments(req: ApiRequestObject): { + input: RequestInfo | URL; + init?: RequestInit | undefined; +} { + const { url, params, ...rest } = req; + + return { input: toURL({ url, params }), init: rest }; +} + +function maybeRequestArg(req: ApiRequest): ApiRequestObject { + if (typeof req === 'string') { + return fromURL(req); + } + + return req; +} + +function ensureRequestArg(func: (req: ApiRequestObject) => Promise) { + return (req: ApiRequest) => func(maybeRequestArg(req)); +} + +// This actually performs the built request object +const performRequest = ensureRequestArg((req: ApiRequestObject) => { + const { input, init } = toFetchArguments(req); + return fetchWithTimeout(input, init); +}); + +// withRoot sets a root URL, unless the URL is already absolute +const absolutePath = new RegExp('^(?:[a-z]+:)?//', 'i'); +const getAbsoluteRoot = (root: string, url: string) => { + if (absolutePath.test(url)) { + return url; + } + return root && url && url[0] !== '/' && root[root.length - 1] !== '/' + ? `${root}/${url}` + : `${root}${url}`; +}; + +const withWrapper = + (key: K) => + (value: ApiRequestObject[K], req: ApiRequest): ApiRequestObject => { + if (typeof req === 'string') { + return fromFetchArguments(req, { [key]: value }); + } + + return { + ...req, + [key]: value, + }; + }; + +const withRoot = (root: string) => (req: ApiRequest) => { + return withWrapper('url')(getAbsoluteRoot(root, typeof req === 'string' ? req : req.url), req); +}; +const withMethod = withWrapper('method'); +const withBody = withWrapper('body'); +const withHeaders = withWrapper('headers'); +const withParams = withWrapper('params'); +const withCache = withWrapper('cache'); +const withNoCache = (req: ApiRequest) => withCache('no-cache', req); + +export default { + fetchWithTimeout, + fromURL, + toURL, + fromFetchArguments, + performRequest, + getAbsoluteRoot, + withRoot, + withMethod, + withBody, + withHeaders, + withParams, + withNoCache, +}; diff --git a/src/lib/util/validation.util.ts b/src/lib/util/validation.util.ts new file mode 100644 index 00000000..e3a61e25 --- /dev/null +++ b/src/lib/util/validation.util.ts @@ -0,0 +1,105 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import ValidationErrorTypes from '../../constants/validationErrorTypes'; + +import type { t } from 'react-polyglot'; +import type { + Field, + FieldError, + FieldValidationMethod, + FieldValidationMethodProps, + ValueOrNestedValue, + Widget +} from '../../interface'; + +export function isEmpty(value: ValueOrNestedValue) { + return ( + value === null || + value === undefined || + (Array.isArray(value) && value.length === 0) || + (value.constructor === Object && Object.keys(value).length === 0) || + (typeof value === 'string' && value === '') + ); +} + +export function validatePresence({ + field, + value, + t, +}: FieldValidationMethodProps): false | FieldError { + const isRequired = field.required ?? true; + if (isRequired && isEmpty(value)) { + const error = { + type: ValidationErrorTypes.PRESENCE, + message: t('editor.editorControlPane.widget.required', { + fieldLabel: field.label ?? field.name, + }), + }; + + return error; + } + + return false; +} + +export function validatePattern({ + field, + value, + t, +}: FieldValidationMethodProps): false | FieldError { + const pattern = field.pattern ?? false; + + if (isEmpty(value)) { + return false; + } + + let valueToCheck: string; + if (typeof value === 'string') { + valueToCheck = value; + } else if (typeof value === 'number' || typeof value === 'boolean') { + valueToCheck = `${value}`; + } else { + valueToCheck = JSON.stringify(value); + } + + if (pattern && !isEmpty(valueToCheck) && !RegExp(pattern[0]).test(valueToCheck)) { + const error = { + type: ValidationErrorTypes.PATTERN, + message: t('editor.editorControlPane.widget.regexPattern', { + fieldLabel: field.label ?? field.name, + pattern: pattern[1], + }), + }; + + return error; + } + + return false; +} + +// TODO Fix typings +export async function validate( + path: string, + field: Field, + value: ValueOrNestedValue, + widget: Widget, + onValidate: (path: string, errors: FieldError[]) => void, + t: t, +): Promise { + const validValue = widget.getValidValue(value); + const errors: FieldError[] = []; + const validations: FieldValidationMethod[] = [ + validatePresence, + validatePattern, + widget.validator, + ]; + + for (const validation of validations) { + const response = await validation({ field, value: validValue, t }); + if (response) { + errors.push(response); + } + } + + onValidate(path, errors); + return errors; +} diff --git a/src/lib/util/window.util.ts b/src/lib/util/window.util.ts index d15a8130..0adee83c 100644 --- a/src/lib/util/window.util.ts +++ b/src/lib/util/window.util.ts @@ -26,5 +26,5 @@ export function useWindowEvent( return () => { window.removeEventListener(eventName, callback); }; - }, []); + }, [callback, eventName]); } diff --git a/src/lib/widgets/index.ts b/src/lib/widgets/index.ts index 457a29bc..9ceb1c87 100644 --- a/src/lib/widgets/index.ts +++ b/src/lib/widgets/index.ts @@ -1,8 +1,2 @@ -import * as stringTemplate from './stringTemplate'; -import * as validations from './validations'; - -export const StaticCmsLibWidgets = { - stringTemplate, - validations, -}; -export { stringTemplate, validations }; +export * as stringTemplate from './stringTemplate'; +export * as validations from './validations'; diff --git a/src/lib/widgets/stringTemplate.ts b/src/lib/widgets/stringTemplate.ts index 3c0afd75..50196151 100644 --- a/src/lib/widgets/stringTemplate.ts +++ b/src/lib/widgets/stringTemplate.ts @@ -1,8 +1,11 @@ -import { Map } from 'immutable'; -import { get, trimEnd, truncate } from 'lodash'; +import get from 'lodash/get'; +import trimEnd from 'lodash/trimEnd'; +import truncate from 'lodash/truncate'; import moment from 'moment'; import { basename, dirname, extname } from 'path'; +import type { Entry, EntryData, ObjectValue } from '../../interface'; + const filters = [ { pattern: /^upper$/, transform: (str: string) => str.toUpperCase() }, { @@ -74,13 +77,18 @@ export const dateParsers: Record string> = { second: (date: Date) => formatDate(date.getUTCSeconds()), }; -export function parseDateFromEntry(entry: Map, dateFieldName?: string | null) { +export function parseDateFromEntry(entry: Entry, dateFieldName?: string | null) { if (!dateFieldName) { return; } - const dateValue = entry.getIn(['data', dateFieldName]); - const dateMoment = dateValue && moment(dateValue); + const dateValue = entry.data?.[dateFieldName]; + if (dateValue instanceof Date) { + return dateValue; + } + + const dateMoment = + typeof dateValue === 'string' || typeof dateValue === 'number' ? moment(dateValue) : null; if (dateMoment && dateMoment.isValid()) { return dateMoment.toDate(); } @@ -119,7 +127,7 @@ export function expandPath({ path, paths = [], }: { - data: Record; + data: EntryData; path: string; paths?: string[]; }) { @@ -151,12 +159,12 @@ export function expandPath({ // Allow `fields.` prefix in placeholder to override built in replacements // like "slug" and "year" with values from fields of the same name. -function getExplicitFieldReplacement(key: string, data: Map) { +function getExplicitFieldReplacement(key: string, data: ObjectValue | undefined | null) { if (!key.startsWith(FIELD_PREFIX)) { return; } const fieldName = key.slice(FIELD_PREFIX.length); - const value = data.getIn(keyToPathArray(fieldName)); + const value = get(data, keyToPathArray(fieldName)); if (typeof value === 'object' && value !== null) { return JSON.stringify(value); } @@ -182,7 +190,7 @@ export function compileStringTemplate( template: string, date: Date | undefined | null, identifier = '', - data = Map(), + data: ObjectValue | undefined | null = {}, processor?: (value: string) => string, ) { let missingRequiredDate; @@ -207,7 +215,7 @@ export function compileStringTemplate( } else if (key === 'slug') { replacement = identifier; } else { - replacement = data.getIn(keyToPathArray(key), '') as string; + replacement = get(data, keyToPathArray(key), '') as string; } if (processor) { @@ -250,7 +258,7 @@ export function extractTemplateVars(template: string) { * eg: `addFileTemplateFields('foo/bar/baz.ext', fields, 'foo')` * will result in: `{ dirname: 'bar', filename: 'baz', extension: 'ext' }` */ -export function addFileTemplateFields(entryPath: string, fields: Map, folder = '') { +export function addFileTemplateFields(entryPath: string, fields: EntryData, folder = '') { if (!entryPath) { return fields; } @@ -258,11 +266,11 @@ export function addFileTemplateFields(entryPath: string, fields: Map { - map.set('dirname', dirnameExcludingFolder); - map.set('filename', filename); - map.set('extension', extension === '' ? extension : extension.slice(1)); - }); - return fields; + return { + ...fields, + dirname: dirnameExcludingFolder, + filename, + extension: extension === '' ? extension : extension.slice(1), + }; } diff --git a/src/lib/widgets/validations.ts b/src/lib/widgets/validations.ts index 4903bc70..f89c4b6d 100644 --- a/src/lib/widgets/validations.ts +++ b/src/lib/widgets/validations.ts @@ -1,11 +1,9 @@ -import { isNumber } from 'lodash'; - -import type { List } from 'immutable'; +import isNumber from 'lodash/isNumber'; export function validateMinMax( t: (key: string, options: unknown) => string, fieldLabel: string, - value?: List, + value?: string | string[] | undefined | null, min?: number, max?: number, ) { @@ -21,11 +19,11 @@ export function validateMinMax( }; } - if ([min, max, value?.size].every(isNumber) && (value!.size < min! || value!.size > max!)) { + if ([min, max, value?.length].every(isNumber) && (value!.length < min! || value!.length > max!)) { return minMaxError(min === max ? 'rangeCountExact' : 'rangeCount'); - } else if (isNumber(min) && min > 0 && value?.size && value.size < min) { + } else if (isNumber(min) && min > 0 && value?.length && value.length < min) { return minMaxError('rangeMin'); - } else if (isNumber(max) && value?.size && value.size > max) { + } else if (isNumber(max) && value?.length && value.length > max) { return minMaxError('rangeMax'); } } diff --git a/src/locales/bg/index.js b/src/locales/bg/index.ts similarity index 99% rename from src/locales/bg/index.js rename to src/locales/bg/index.ts index aac86b8f..0c89cf63 100644 --- a/src/locales/bg/index.js +++ b/src/locales/bg/index.ts @@ -1,4 +1,6 @@ -const bg = { +import type { LocalePhrasesRoot } from '../../interface'; + +const bg: LocalePhrasesRoot = { auth: { login: 'Вход', loggingIn: 'Влизане...', diff --git a/src/locales/ca/index.js b/src/locales/ca/index.ts similarity index 99% rename from src/locales/ca/index.js rename to src/locales/ca/index.ts index 03f5bd83..74fccc61 100644 --- a/src/locales/ca/index.js +++ b/src/locales/ca/index.ts @@ -1,4 +1,6 @@ -const ca = { +import type { LocalePhrasesRoot } from '../../interface'; + +const ca: LocalePhrasesRoot = { auth: { login: 'Iniciar sessió', loggingIn: 'Iniciant sessió...', diff --git a/src/locales/cs/index.js b/src/locales/cs/index.ts similarity index 99% rename from src/locales/cs/index.js rename to src/locales/cs/index.ts index 1d883f41..7f85918a 100644 --- a/src/locales/cs/index.js +++ b/src/locales/cs/index.ts @@ -1,4 +1,6 @@ -const cs = { +import type { LocalePhrasesRoot } from '../../interface'; + +const cs: LocalePhrasesRoot = { auth: { login: 'Přihlásit', loggingIn: 'Přihlašování…', diff --git a/src/locales/da/index.js b/src/locales/da/index.ts similarity index 99% rename from src/locales/da/index.js rename to src/locales/da/index.ts index 2c92d230..8677535c 100644 --- a/src/locales/da/index.js +++ b/src/locales/da/index.ts @@ -1,4 +1,6 @@ -const da = { +import type { LocalePhrasesRoot } from '../../interface'; + +const da: LocalePhrasesRoot = { auth: { login: 'Log ind', loggingIn: 'Logger ind...', diff --git a/src/locales/de/index.js b/src/locales/de/index.ts similarity index 99% rename from src/locales/de/index.js rename to src/locales/de/index.ts index d625e8dc..660721bb 100644 --- a/src/locales/de/index.js +++ b/src/locales/de/index.ts @@ -1,4 +1,6 @@ -const de = { +import type { LocalePhrasesRoot } from '../../interface'; + +const de: LocalePhrasesRoot = { auth: { login: 'Login', loggingIn: 'Sie werden eingeloggt...', diff --git a/src/locales/en/index.js b/src/locales/en/index.ts similarity index 97% rename from src/locales/en/index.js rename to src/locales/en/index.ts index 283afdb4..95810fdf 100644 --- a/src/locales/en/index.js +++ b/src/locales/en/index.ts @@ -1,8 +1,10 @@ -const en = { +import type { LocalePhrasesRoot } from '../../interface'; + +const en: LocalePhrasesRoot = { auth: { login: 'Login', loggingIn: 'Logging in...', - loginWithSimpleIdentity: 'Login with Simple Identity', + loginWithNetlifyIdentity: 'Login with Netlify Identity', loginWithAzure: 'Login with Azure', loginWithBitbucket: 'Login with Bitbucket', loginWithGitHub: 'Login with GitHub', @@ -94,8 +96,7 @@ const en = { i18n: { writingInLocale: 'Writing in %{locale}', copyFromLocale: 'Fill in from another locale', - copyFromLocaleConfirmTitle: - 'Fill in data from locale', + copyFromLocaleConfirmTitle: 'Fill in data from locale', copyFromLocaleConfirmBody: 'Do you want to fill in data from %{locale} locale?\nAll existing content will be overwritten.', }, @@ -229,7 +230,7 @@ const en = { fileTooLargeTitle: 'File too large', fileTooLargeBody: 'File too large.\nConfigured to not allow files greater than %{size} kB.', alreadyExistsTitle: 'File already exists', - alreadyExistsBody: `%{filename} already exists. Do you want to replace it?` + alreadyExistsBody: `%{filename} already exists. Do you want to replace it?`, }, mediaLibraryModal: { loading: 'Loading...', diff --git a/src/locales/es/index.js b/src/locales/es/index.ts similarity index 99% rename from src/locales/es/index.js rename to src/locales/es/index.ts index 0630b7a4..d96e834a 100644 --- a/src/locales/es/index.js +++ b/src/locales/es/index.ts @@ -1,4 +1,6 @@ -const es = { +import type { LocalePhrasesRoot } from '../../interface'; + +const es: LocalePhrasesRoot = { auth: { login: 'Iniciar sesión', loggingIn: 'Iniciando sesión...', diff --git a/src/locales/fr/index.js b/src/locales/fr/index.ts similarity index 99% rename from src/locales/fr/index.js rename to src/locales/fr/index.ts index 8f227cb4..58cfef35 100644 --- a/src/locales/fr/index.js +++ b/src/locales/fr/index.ts @@ -1,4 +1,6 @@ -const fr = { +import type { LocalePhrasesRoot } from '../../interface'; + +const fr: LocalePhrasesRoot = { auth: { login: 'Se connecter', loggingIn: 'Connexion en cours...', diff --git a/src/locales/gr/index.js b/src/locales/gr/index.ts similarity index 99% rename from src/locales/gr/index.js rename to src/locales/gr/index.ts index f1e9dfa6..debafb11 100644 --- a/src/locales/gr/index.js +++ b/src/locales/gr/index.ts @@ -1,4 +1,6 @@ -const gr = { +import type { LocalePhrasesRoot } from '../../interface'; + +const gr: LocalePhrasesRoot = { auth: { login: 'Σύνδεση', loggingIn: 'Σύνδεση στο...', diff --git a/src/locales/he/index.js b/src/locales/he/index.ts similarity index 99% rename from src/locales/he/index.js rename to src/locales/he/index.ts index 76f58294..d5c2eaee 100644 --- a/src/locales/he/index.js +++ b/src/locales/he/index.ts @@ -1,4 +1,6 @@ -const he = { +import type { LocalePhrasesRoot } from '../../interface'; + +const he: LocalePhrasesRoot = { auth: { login: 'התחברות', loggingIn: 'התחברות...', diff --git a/src/locales/hr/index.js b/src/locales/hr/index.ts similarity index 99% rename from src/locales/hr/index.js rename to src/locales/hr/index.ts index 8fee4e0a..3f6cd4f8 100644 --- a/src/locales/hr/index.js +++ b/src/locales/hr/index.ts @@ -1,4 +1,6 @@ -const hr = { +import type { LocalePhrasesRoot } from '../../interface'; + +const hr: LocalePhrasesRoot = { auth: { login: 'Prijava', loggingIn: 'Prijava u tijeku...', diff --git a/src/locales/hu/index.js b/src/locales/hu/index.ts similarity index 98% rename from src/locales/hu/index.js rename to src/locales/hu/index.ts index f0ca6814..091af09e 100644 --- a/src/locales/hu/index.js +++ b/src/locales/hu/index.ts @@ -1,4 +1,6 @@ -const hu = { +import type { LocalePhrasesRoot } from '../../interface'; + +const hu: LocalePhrasesRoot = { app: { header: { content: 'Tartalom', diff --git a/src/locales/index.ts b/src/locales/index.ts index 636cbc09..369f8553 100644 --- a/src/locales/index.ts +++ b/src/locales/index.ts @@ -29,7 +29,9 @@ import bg from './bg'; import zh_Hans from './zh_Hans'; import he from './he'; -export const locales: Record> = { +import type { LocalePhrasesRoot } from '../interface'; + +export const locales: Record = { cs, da, de, diff --git a/src/locales/it/index.js b/src/locales/it/index.ts similarity index 98% rename from src/locales/it/index.js rename to src/locales/it/index.ts index b2fbe99e..70a31e16 100644 --- a/src/locales/it/index.js +++ b/src/locales/it/index.ts @@ -1,4 +1,6 @@ -const it = { +import type { LocalePhrasesRoot } from '../../interface'; + +const it: LocalePhrasesRoot = { auth: { login: 'Accedi', loggingIn: "Effettuando l'accesso...", diff --git a/src/locales/ja/index.js b/src/locales/ja/index.ts similarity index 99% rename from src/locales/ja/index.js rename to src/locales/ja/index.ts index 31f9c72f..96637a21 100644 --- a/src/locales/ja/index.js +++ b/src/locales/ja/index.ts @@ -1,4 +1,6 @@ -const ja = { +import type { LocalePhrasesRoot } from '../../interface'; + +const ja: LocalePhrasesRoot = { auth: { login: 'ログイン', loggingIn: 'ログインしています...', diff --git a/src/locales/ko/index.js b/src/locales/ko/index.ts similarity index 99% rename from src/locales/ko/index.js rename to src/locales/ko/index.ts index a25d6f25..843164f0 100644 --- a/src/locales/ko/index.js +++ b/src/locales/ko/index.ts @@ -1,4 +1,6 @@ -const ko = { +import type { LocalePhrasesRoot } from '../../interface'; + +const ko: LocalePhrasesRoot = { auth: { login: '로그인', loggingIn: '로그인 중...', diff --git a/src/locales/lt/index.js b/src/locales/lt/index.ts similarity index 99% rename from src/locales/lt/index.js rename to src/locales/lt/index.ts index aacd8749..5d131bf6 100644 --- a/src/locales/lt/index.js +++ b/src/locales/lt/index.ts @@ -1,4 +1,6 @@ -const lt = { +import type { LocalePhrasesRoot } from '../../interface'; + +const lt: LocalePhrasesRoot = { auth: { login: 'Prisijungti', loggingIn: 'Prisijungiama...', diff --git a/src/locales/nb_no/index.js b/src/locales/nb_no/index.ts similarity index 98% rename from src/locales/nb_no/index.js rename to src/locales/nb_no/index.ts index d7385c02..18468203 100644 --- a/src/locales/nb_no/index.js +++ b/src/locales/nb_no/index.ts @@ -1,4 +1,6 @@ -const nb_no = { +import type { LocalePhrasesRoot } from '../../interface'; + +const nb_no: LocalePhrasesRoot = { auth: { login: 'Logg inn', loggingIn: 'Logger inn..', diff --git a/src/locales/nl/index.js b/src/locales/nl/index.ts similarity index 99% rename from src/locales/nl/index.js rename to src/locales/nl/index.ts index da653880..19f10d06 100644 --- a/src/locales/nl/index.js +++ b/src/locales/nl/index.ts @@ -1,4 +1,6 @@ -const nl = { +import type { LocalePhrasesRoot } from '../../interface'; + +const nl: LocalePhrasesRoot = { auth: { login: 'Inloggen', loggingIn: 'Inloggen...', diff --git a/src/locales/nn_no/index.js b/src/locales/nn_no/index.ts similarity index 98% rename from src/locales/nn_no/index.js rename to src/locales/nn_no/index.ts index 26f5d248..242233a7 100644 --- a/src/locales/nn_no/index.js +++ b/src/locales/nn_no/index.ts @@ -1,4 +1,6 @@ -const nn_no = { +import type { LocalePhrasesRoot } from '../../interface'; + +const nn_no: LocalePhrasesRoot = { auth: { login: 'Logg inn', loggingIn: 'Loggar inn..', diff --git a/src/locales/pl/index.js b/src/locales/pl/index.ts similarity index 99% rename from src/locales/pl/index.js rename to src/locales/pl/index.ts index dc71854b..34124dbc 100644 --- a/src/locales/pl/index.js +++ b/src/locales/pl/index.ts @@ -1,4 +1,6 @@ -const pl = { +import type { LocalePhrasesRoot } from '../../interface'; + +const pl: LocalePhrasesRoot = { auth: { login: 'Zaloguj się', loggingIn: 'Logowanie...', diff --git a/src/locales/pt/index.js b/src/locales/pt/index.ts similarity index 99% rename from src/locales/pt/index.js rename to src/locales/pt/index.ts index 5cbfc4fe..1165be0e 100644 --- a/src/locales/pt/index.js +++ b/src/locales/pt/index.ts @@ -1,4 +1,6 @@ -const pt = { +import type { LocalePhrasesRoot } from '../../interface'; + +const pt: LocalePhrasesRoot = { auth: { login: 'Entrar', loggingIn: 'Entrando...', diff --git a/src/locales/ro/index.js b/src/locales/ro/index.ts similarity index 99% rename from src/locales/ro/index.js rename to src/locales/ro/index.ts index a20411b9..be79667b 100644 --- a/src/locales/ro/index.js +++ b/src/locales/ro/index.ts @@ -1,4 +1,6 @@ -const ro = { +import type { LocalePhrasesRoot } from '../../interface'; + +const ro: LocalePhrasesRoot = { auth: { login: 'Autentifică-te', loggingIn: 'Te autentificăm...', diff --git a/src/locales/ru/index.js b/src/locales/ru/index.ts similarity index 99% rename from src/locales/ru/index.js rename to src/locales/ru/index.ts index 2c01b3a9..987d8aaa 100644 --- a/src/locales/ru/index.js +++ b/src/locales/ru/index.ts @@ -1,4 +1,6 @@ -const ru = { +import type { LocalePhrasesRoot } from '../../interface'; + +const ru: LocalePhrasesRoot = { auth: { login: 'Войти', loggingIn: 'Вхожу...', diff --git a/src/locales/sv/index.js b/src/locales/sv/index.ts similarity index 99% rename from src/locales/sv/index.js rename to src/locales/sv/index.ts index f67378b3..29b6d7c2 100644 --- a/src/locales/sv/index.js +++ b/src/locales/sv/index.ts @@ -1,4 +1,6 @@ -const sv = { +import type { LocalePhrasesRoot } from '../../interface'; + +const sv: LocalePhrasesRoot = { auth: { login: 'Logga in', loggingIn: 'Loggar in...', diff --git a/src/locales/th/index.js b/src/locales/th/index.ts similarity index 99% rename from src/locales/th/index.js rename to src/locales/th/index.ts index 43d81438..cb6394b9 100644 --- a/src/locales/th/index.js +++ b/src/locales/th/index.ts @@ -1,4 +1,6 @@ -const th = { +import type { LocalePhrasesRoot } from '../../interface'; + +const th: LocalePhrasesRoot = { auth: { login: 'เข้าสู่ระบบ', loggingIn: 'กำลังเข้าสู่ระบบ...', diff --git a/src/locales/tr/index.js b/src/locales/tr/index.ts similarity index 99% rename from src/locales/tr/index.js rename to src/locales/tr/index.ts index 0df960fd..b2a5347a 100644 --- a/src/locales/tr/index.js +++ b/src/locales/tr/index.ts @@ -1,4 +1,6 @@ -const tr = { +import type { LocalePhrasesRoot } from '../../interface'; + +const tr: LocalePhrasesRoot = { auth: { login: 'Giriş', loggingIn: 'Giriş yapılıyor..', diff --git a/src/locales/uk/index.js b/src/locales/uk/index.ts similarity index 98% rename from src/locales/uk/index.js rename to src/locales/uk/index.ts index d4b9c611..68ae75c3 100644 --- a/src/locales/uk/index.js +++ b/src/locales/uk/index.ts @@ -1,4 +1,6 @@ -const uk = { +import type { LocalePhrasesRoot } from '../../interface'; + +const uk: LocalePhrasesRoot = { app: { header: { content: 'Зміст', diff --git a/src/locales/vi/index.js b/src/locales/vi/index.ts similarity index 99% rename from src/locales/vi/index.js rename to src/locales/vi/index.ts index 393035f4..b02841fb 100644 --- a/src/locales/vi/index.js +++ b/src/locales/vi/index.ts @@ -1,4 +1,6 @@ -const vi = { +import type { LocalePhrasesRoot } from '../../interface'; + +const vi: LocalePhrasesRoot = { auth: { login: 'Đăng nhập', loggingIn: 'Đang đăng nhập...', diff --git a/src/locales/zh_Hans/index.js b/src/locales/zh_Hans/index.ts similarity index 99% rename from src/locales/zh_Hans/index.js rename to src/locales/zh_Hans/index.ts index 51f95966..1722bfd2 100644 --- a/src/locales/zh_Hans/index.js +++ b/src/locales/zh_Hans/index.ts @@ -1,4 +1,6 @@ -const zh_Hans = { +import type { LocalePhrasesRoot } from '../../interface'; + +const zh_Hans: LocalePhrasesRoot = { auth: { login: '登录', loggingIn: '正在登录...', diff --git a/src/locales/zh_Hant/index.js b/src/locales/zh_Hant/index.ts similarity index 99% rename from src/locales/zh_Hant/index.js rename to src/locales/zh_Hant/index.ts index 85a12eb2..50cad6ed 100644 --- a/src/locales/zh_Hant/index.js +++ b/src/locales/zh_Hant/index.ts @@ -1,4 +1,6 @@ -const zh_Hant = { +import type { LocalePhrasesRoot } from '../../interface'; + +const zh_Hant: LocalePhrasesRoot = { auth: { login: '登入', loggingIn: '正在登入...', diff --git a/src/media-libraries/cloudinary/index.js b/src/media-libraries/cloudinary/index.ts similarity index 65% rename from src/media-libraries/cloudinary/index.js rename to src/media-libraries/cloudinary/index.ts index 8ba46dda..b314c4ba 100644 --- a/src/media-libraries/cloudinary/index.js +++ b/src/media-libraries/cloudinary/index.ts @@ -1,7 +1,15 @@ -import { pick } from 'lodash'; +import pick from 'lodash/pick'; import { loadScript } from '../../lib/util'; +import type { MediaLibraryInitOptions, MediaLibraryInstance } from '../../interface'; + +interface GetAssetOptions { + use_secure_url: boolean; + use_transformations: boolean; + output_filename_only: boolean; +} + const defaultOptions = { use_secure_url: true, use_transformations: true, @@ -22,7 +30,37 @@ const defaultConfig = { multiple: false, }; -function getAssetUrl(asset, { use_secure_url, use_transformations, output_filename_only }) { +interface CloudinaryAsset { + public_id: string; + format: string; + secure_url: string; + url: string; + derived?: [ + { + secure_url: string; + url: string; + }, + ]; +} + +declare global { + interface Window { + cloudinary: { + createMediaLibrary: ( + config: Record, + hadlers: { insertHandler: (data: { assets: CloudinaryAsset[] }) => void }, + ) => { + show: (config: Record) => void; + hide: () => void; + }; + }; + } +} + +function getAssetUrl( + asset: CloudinaryAsset, + { use_secure_url, use_transformations, output_filename_only }: GetAssetOptions, +): string { /** * Allow output of the file name only, in which case the rest of the url (including) * transformations) can be handled by the static site generator. @@ -46,12 +84,16 @@ function getAssetUrl(asset, { use_secure_url, use_transformations, output_filena return urlObject[urlKey]; } -async function init({ options = {}, handleInsert } = {}) { +async function init({ + options, + handleInsert, +}: MediaLibraryInitOptions): Promise { /** * Configuration is specific to Cloudinary, while options are specific to this * integration. */ - const { config: providedConfig = {}, ...integrationOptions } = options; + const { config = {}, ...integrationOptions } = options ?? {}; + const providedConfig = config as { multiple?: boolean }; const resolvedOptions = { ...defaultOptions, ...integrationOptions }; const cloudinaryConfig = { ...defaultConfig, ...providedConfig, ...enforcedConfig }; const cloudinaryBehaviorConfigKeys = ['default_transformations', 'max_files', 'multiple']; @@ -59,7 +101,7 @@ async function init({ options = {}, handleInsert } = {}) { await loadScript('https://media-library.cloudinary.com/global/all.js'); - function insertHandler(data) { + function insertHandler(data: { assets: CloudinaryAsset[] }) { const assets = data.assets.map(asset => getAssetUrl(asset, resolvedOptions)); handleInsert(providedConfig.multiple || assets.length > 1 ? assets : assets[0]); } @@ -67,7 +109,7 @@ async function init({ options = {}, handleInsert } = {}) { const mediaLibrary = window.cloudinary.createMediaLibrary(cloudinaryConfig, { insertHandler }); return { - show: ({ config: instanceConfig = {}, allowMultiple } = {}) => { + show: ({ config: instanceConfig = {}, allowMultiple }) => { /** * Ensure multiple selection is not available if the field is configured * to disallow it. @@ -84,5 +126,5 @@ async function init({ options = {}, handleInsert } = {}) { const cloudinaryMediaLibrary = { name: 'cloudinary', init }; -export const StaticCmsMediaLibraryCloudinary = cloudinaryMediaLibrary; +export const StaticMediaLibraryCloudinary = cloudinaryMediaLibrary; export default cloudinaryMediaLibrary; diff --git a/src/media-libraries/index.tsx b/src/media-libraries/index.tsx index d8023220..4d24ed57 100644 --- a/src/media-libraries/index.tsx +++ b/src/media-libraries/index.tsx @@ -1,4 +1,2 @@ -import MediaLibraryCloudinary from './cloudinary'; -import MediaLibraryUploadcare from './uploadcare'; - -export { MediaLibraryCloudinary, MediaLibraryUploadcare }; +export { default as MediaLibraryCloudinary } from './cloudinary'; +export { default as MediaLibraryUploadcare } from './uploadcare'; diff --git a/src/media-libraries/uploadcare/index.js b/src/media-libraries/uploadcare/index.ts similarity index 70% rename from src/media-libraries/uploadcare/index.js rename to src/media-libraries/uploadcare/index.ts index 26103323..bd834f7c 100644 --- a/src/media-libraries/uploadcare/index.js +++ b/src/media-libraries/uploadcare/index.ts @@ -1,6 +1,15 @@ import uploadcare from 'uploadcare-widget'; import uploadcareTabEffects from 'uploadcare-widget-tab-effects'; -import { Iterable } from 'immutable'; + +import type { MediaLibraryInitOptions, MediaLibraryInstance } from '../../interface'; + +declare global { + interface Window { + UPLOADCARE_PUBLIC_KEY: string; + UPLOADCARE_LIVE: boolean; + UPLOADCARE_MANUAL_START: boolean; + } +} window.UPLOADCARE_LIVE = false; window.UPLOADCARE_MANUAL_START = true; @@ -21,10 +30,10 @@ const defaultConfig = { * group urls. If they've been changed or any are missing, a new group will need * to be created to represent the current values. */ -function isFileGroup(files) { +function isFileGroup(files: string[]) { const basePatternString = `~${files.length}/nth/`; - function mapExpression(val, idx) { + function mapExpression(_val: string, idx: number) { return new RegExp(`${basePatternString}${idx}/$`); } @@ -32,14 +41,20 @@ function isFileGroup(files) { return expressions.every(exp => files.some(url => exp.test(url))); } +export interface UploadcareFileGroupInfo { + cdnUrl: string; + name: string; + isImage: boolean; +} + /** * Returns a fileGroupInfo object wrapped in a promise-like object. */ -function getFileGroup(files) { +function getFileGroup(files: string[]): Promise { /** * Capture the group id from the first file in the files array. */ - const groupId = new RegExp(`^.+/([^/]+~${files.length})/nth/`).exec(files[0])[1]; + const groupId = new RegExp(`^.+/([^/]+~${files.length})/nth/`).exec(files[0])?.[1]; /** * The `openDialog` method handles the jQuery promise object returned by @@ -54,10 +69,11 @@ function getFileGroup(files) { * promises, or Uploadcare groups when possible. Output is wrapped in a promise * because the value we're returning may be a promise that we created. */ -function getFiles(value) { - if (Array.isArray(value) || Iterable.isIterable(value)) { - const arr = Array.isArray(value) ? value : value.toJS(); - return isFileGroup(arr) ? getFileGroup(arr) : Promise.all(arr.map(val => getFile(val))); +function getFiles( + value: string[] | string | undefined, +): Promise | null { + if (Array.isArray(value)) { + return isFileGroup(value) ? getFileGroup(value) : Promise.all(value.map(val => getFile(val))); } return value && typeof value === 'string' ? getFile(value) : null; } @@ -67,24 +83,47 @@ function getFiles(value) { * object. Group urls that get passed here were not a part of a complete and * untouched group, so they'll be uploaded as new images (only way to do it). */ -function getFile(url) { +function getFile(url: string): Promise { const groupPattern = /~\d+\/nth\/\d+\//; const uploaded = url.startsWith(CDN_BASE_URL) && !groupPattern.test(url); return uploadcare.fileFrom(uploaded ? 'uploaded' : 'url', url); } +interface OpenDialogOptions { + files: + | UploadcareFileGroupInfo + | UploadcareFileGroupInfo[] + | Promise + | null; + config: { + multiple: boolean; + imagesOnly: boolean; + previewStep: boolean; + integration: string; + }; + handleInsert: (url: string | string[]) => void; + settings: { + defaultOperations?: string; + autoFilename?: boolean; + }; +} + /** * Open the standalone dialog. A single instance is created and destroyed for * each use. */ -function openDialog({ files, config, handleInsert, settings = {} }) { +function openDialog({ files, config, handleInsert, settings = {} }: OpenDialogOptions) { + if (!files) { + return; + } + if (settings.defaultOperations && !settings.defaultOperations.startsWith('/')) { console.warn( 'Uploadcare default operations should start with `/`. Example: `/preview/-/resize/100x100/image.png`', ); } - function buildUrl(fileInfo) { + function buildUrl(fileInfo: UploadcareFileGroupInfo) { const { cdnUrl, name, isImage } = fileInfo; let url = @@ -117,8 +156,13 @@ function openDialog({ files, config, handleInsert, settings = {} }) { * Initialization function will only run once, returns an API object for Simple * CMS to call methods on. */ -async function init({ options = { config: {}, settings: {} }, handleInsert } = {}) { - const { publicKey, ...globalConfig } = options.config; +async function init({ + options = { config: {}, settings: {} }, + handleInsert, +}: MediaLibraryInitOptions): Promise { + const { publicKey, ...globalConfig } = options.config as { + publicKey: string; + } & Record; const baseConfig = { ...defaultConfig, ...globalConfig }; window.UPLOADCARE_PUBLIC_KEY = publicKey; @@ -134,9 +178,14 @@ async function init({ options = { config: {}, settings: {} }, handleInsert } = { * On show, create a new widget, cache it in the widgets object, and open. * No hide method is provided because the widget doesn't provide it. */ - show: ({ value, config: instanceConfig = {}, allowMultiple, imagesOnly = false } = {}) => { - const config = { ...baseConfig, imagesOnly, ...instanceConfig }; - const multiple = allowMultiple === false ? false : !!config.multiple; + show: ({ value, config: instanceConfig = {}, allowMultiple, imagesOnly = false }) => { + const config = { ...baseConfig, imagesOnly, ...instanceConfig } as { + imagesOnly: boolean; + previewStep: boolean; + integration: string; + multiple?: boolean; + }; + const multiple = allowMultiple === false ? false : Boolean(config.multiple); const resolvedConfig = { ...config, multiple }; const files = getFiles(value); @@ -144,12 +193,12 @@ async function init({ options = { config: {}, settings: {} }, handleInsert } = { * Resolve the promise only if it's ours. Only the jQuery promise objects * from the Uploadcare library will have a `state` method. */ - if (files && !files.state) { + if (files && !('state' in files)) { return files.then(result => openDialog({ files: result, config: resolvedConfig, - settings: options.settings, + settings: options.settings as Record, handleInsert, }), ); @@ -157,7 +206,7 @@ async function init({ options = { config: {}, settings: {} }, handleInsert } = { return openDialog({ files, config: resolvedConfig, - settings: options.settings, + settings: options.settings as Record, handleInsert, }); } @@ -179,5 +228,5 @@ async function init({ options = { config: {}, settings: {} }, handleInsert } = { */ const uploadcareMediaLibrary = { name: 'uploadcare', init }; -export const StaticCmsMediaLibraryUploadcare = uploadcareMediaLibrary; +export const StaticMediaLibraryUploadcare = uploadcareMediaLibrary; export default uploadcareMediaLibrary; diff --git a/src/mediaLibrary.ts b/src/mediaLibrary.ts index 1e9750d0..7148c842 100644 --- a/src/mediaLibrary.ts +++ b/src/mediaLibrary.ts @@ -2,49 +2,50 @@ * This module is currently concerned only with external media libraries * registered via `registerMediaLibrary`. */ -import { once } from 'lodash'; +import once from 'lodash/once'; -import { getMediaLibrary } from './lib/registry'; -import { store } from './store'; import { configFailed } from './actions/config'; import { createMediaLibrary, insertMedia } from './actions/mediaLibrary'; +import { getMediaLibrary } from './lib/registry'; +import { store } from './store'; -import type { MediaLibraryInstance, State } from './types/redux'; +import type { MediaLibrary, MediaLibraryExternalLibrary } from './interface'; +import type { RootState } from './store'; -type MediaLibraryOptions = {}; - -interface MediaLibrary { - init: (args: { - options: MediaLibraryOptions; - handleInsert: (url: string) => void; - }) => MediaLibraryInstance; -} - -function handleInsert(url: string) { +function handleInsert(url: string | string[]) { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore return store.dispatch(insertMedia(url, undefined)); } -const initializeMediaLibrary = once(async function initializeMediaLibrary(name, options) { - const lib = getMediaLibrary(name) as unknown as MediaLibrary | undefined; +const initializeMediaLibrary = once(async function initializeMediaLibrary( + name: string, + { config }: MediaLibraryExternalLibrary, +) { + const lib = getMediaLibrary(name); if (!lib) { const err = new Error( `Missing external media library '${name}'. Please use 'registerMediaLibrary' to register it.`, ); store.dispatch(configFailed(err)); } else { - const instance = await lib.init({ options, handleInsert }); + const instance = await lib.init({ options: config, handleInsert }); store.dispatch(createMediaLibrary(instance)); } }); +function isExternalMediaLibraryConfig( + config: MediaLibrary | undefined, +): config is MediaLibraryExternalLibrary { + return Boolean(config && 'name' in config); +} + store.subscribe(() => { - const state = store.getState() as unknown as State; - if (state) { - const mediaLibraryName = state.config.media_library?.name; - if (mediaLibraryName && !state.mediaLibrary.get('externalLibrary')) { - const mediaLibraryConfig = state.config.media_library; + const state = store.getState() as unknown as RootState; + if (state.config.config && isExternalMediaLibraryConfig(state.config.config.media_library)) { + const mediaLibraryName = state.config.config.media_library?.name; + if (mediaLibraryName && !state.mediaLibrary.externalLibrary) { + const mediaLibraryConfig = state.config.config.media_library; initializeMediaLibrary(mediaLibraryName, mediaLibraryConfig); } } diff --git a/src/reducers/auth.ts b/src/reducers/auth.ts index 2ce7b873..4379e80f 100644 --- a/src/reducers/auth.ts +++ b/src/reducers/auth.ts @@ -8,22 +8,22 @@ import { LOGOUT, } from '../actions/auth'; -import type { User } from '../lib/util'; +import type { User } from '../interface'; import type { AuthAction } from '../actions/auth'; -export type Auth = { +export type AuthState = { isFetching: boolean; user: User | undefined; error: string | undefined; }; -export const defaultState: Auth = { +export const defaultState: AuthState = { isFetching: false, user: undefined, error: undefined, }; -const auth = produce((state: Auth, action: AuthAction) => { +const auth = produce((state: AuthState, action: AuthAction) => { switch (action.type) { case AUTH_REQUEST: state.isFetching = true; diff --git a/src/reducers/collections.ts b/src/reducers/collections.ts index 15344385..62285c46 100644 --- a/src/reducers/collections.ts +++ b/src/reducers/collections.ts @@ -1,480 +1,27 @@ -import { fromJS, List, OrderedMap, Set } from 'immutable'; -import { escapeRegExp, get } from 'lodash'; - import { CONFIG_SUCCESS } from '../actions/config'; -import { FILES, FOLDER } from '../constants/collectionTypes'; -import { COMMIT_AUTHOR, COMMIT_DATE } from '../constants/commitProps'; -import { IDENTIFIER_FIELDS, INFERABLE_FIELDS, SORTABLE_FIELDS } from '../constants/fieldInference'; -import { formatExtensions } from '../formats/formats'; -import consoleError from '../lib/consoleError'; -import { summaryFormatter } from '../lib/formatters'; -import { stringTemplate } from '../lib/widgets'; -import { selectMediaFolder } from './entries'; import type { ConfigAction } from '../actions/config'; -import type { Backend } from '../backend'; -import type { CmsConfig, ViewFilter, ViewGroup } from '../interface'; -import type { - Collection, - CollectionFiles, - Collections, - EntryField, - EntryMap, -} from '../types/redux'; +import type { Collection, Collections } from '../interface'; -const { keyToPathArray } = stringTemplate; +export type CollectionsState = Collections; -const defaultState: Collections = fromJS({}); +const defaultState: CollectionsState = {}; -function collections(state = defaultState, action: ConfigAction) { +function collections( + state: CollectionsState = defaultState, + action: ConfigAction, +): CollectionsState { switch (action.type) { case CONFIG_SUCCESS: { const collections = action.payload.collections; - let newState = OrderedMap({}); - collections.forEach(collection => { - newState = newState.set(collection.name, fromJS(collection)); - }); - return newState; + return collections.reduce((acc, collection) => { + acc[collection.name] = collection as Collection; + return acc; + }, {} as Record); } default: return state; } } -const selectors = { - [FOLDER]: { - entryExtension(collection: Collection) { - return ( - collection.get('extension') || - get(formatExtensions, collection.get('format') || 'frontmatter') - ).replace(/^\./, ''); - }, - fields(collection: Collection) { - return collection.get('fields'); - }, - entryPath(collection: Collection, slug: string) { - const folder = (collection.get('folder') as string).replace(/\/$/, ''); - return `${folder}/${slug}.${this.entryExtension(collection)}`; - }, - entrySlug(collection: Collection, path: string) { - const folder = (collection.get('folder') as string).replace(/\/$/, ''); - const slug = path - .split(folder + '/') - .pop() - ?.replace(new RegExp(`\\.${escapeRegExp(this.entryExtension(collection))}$`), ''); - - return slug; - }, - allowNewEntries(collection: Collection) { - return collection.get('create'); - }, - allowDeletion(collection: Collection) { - return collection.get('delete', true); - }, - templateName(collection: Collection) { - return collection.get('name'); - }, - }, - [FILES]: { - fileForEntry(collection: Collection, slug: string) { - const files = collection.get('files'); - return files && files.filter(f => f?.get('name') === slug).get(0); - }, - fields(collection: Collection, slug: string) { - const file = this.fileForEntry(collection, slug); - return file && file.get('fields'); - }, - entryPath(collection: Collection, slug: string) { - const file = this.fileForEntry(collection, slug); - return file && file.get('file'); - }, - entrySlug(collection: Collection, path: string) { - const file = (collection.get('files') as CollectionFiles) - .filter(f => f?.get('file') === path) - .get(0); - return file && file.get('name'); - }, - entryLabel(collection: Collection, slug: string) { - const file = this.fileForEntry(collection, slug); - return file && file.get('label'); - }, - allowNewEntries() { - return false; - }, - allowDeletion(collection: Collection) { - return collection.get('delete', false); - }, - templateName(_collection: Collection, slug: string) { - return slug; - }, - }, -}; - -function getFieldsWithMediaFolders(fields: EntryField[]) { - const fieldsWithMediaFolders = fields.reduce((acc, f) => { - if (f.has('media_folder')) { - acc = [...acc, f]; - } - - if (f.has('fields')) { - const fields = f.get('fields')?.toArray() as EntryField[]; - acc = [...acc, ...getFieldsWithMediaFolders(fields)]; - } else if (f.has('field')) { - const field = f.get('field') as EntryField; - acc = [...acc, ...getFieldsWithMediaFolders([field])]; - } else if (f.has('types')) { - const types = f.get('types')?.toArray() as EntryField[]; - acc = [...acc, ...getFieldsWithMediaFolders(types)]; - } - - return acc; - }, [] as EntryField[]); - - return fieldsWithMediaFolders; -} - -export function getFileFromSlug(collection: Collection, slug: string) { - return collection - .get('files') - ?.toArray() - .find(f => f.get('name') === slug); -} - -export function selectFieldsWithMediaFolders(collection: Collection, slug: string) { - if (collection.has('folder')) { - const fields = collection.get('fields').toArray(); - return getFieldsWithMediaFolders(fields); - } else if (collection.has('files')) { - const fields = getFileFromSlug(collection, slug)?.get('fields').toArray() || []; - return getFieldsWithMediaFolders(fields); - } - - return []; -} - -export function selectMediaFolders(config: CmsConfig, collection: Collection, entry: EntryMap) { - const fields = selectFieldsWithMediaFolders(collection, entry.get('slug')); - const folders = fields.map(f => selectMediaFolder(config, collection, entry, f)); - if (collection.has('files')) { - const file = getFileFromSlug(collection, entry.get('slug')); - if (file) { - folders.unshift(selectMediaFolder(config, collection, entry, undefined)); - } - } - if (collection.has('media_folder')) { - // stop evaluating media folders at collection level - collection = collection.delete('files'); - folders.unshift(selectMediaFolder(config, collection, entry, undefined)); - } - - return Set(folders).toArray(); -} - -export function selectFields(collection: Collection, slug: string) { - return selectors[collection.get('type')].fields(collection, slug); -} - -export function selectFolderEntryExtension(collection: Collection) { - return selectors[FOLDER].entryExtension(collection); -} - -export function selectFileEntryLabel(collection: Collection, slug: string) { - return selectors[FILES].entryLabel(collection, slug); -} - -export function selectEntryPath(collection: Collection, slug: string) { - return selectors[collection.get('type')].entryPath(collection, slug); -} - -export function selectEntrySlug(collection: Collection, path: string) { - return selectors[collection.get('type')].entrySlug(collection, path); -} - -export function selectAllowNewEntries(collection: Collection) { - return selectors[collection.get('type')].allowNewEntries(collection); -} - -export function selectAllowDeletion(collection: Collection) { - return selectors[collection.get('type')].allowDeletion(collection); -} - -export function selectTemplateName(collection: Collection, slug: string) { - return selectors[collection.get('type')].templateName(collection, slug); -} - -export function getFieldsNames(fields: EntryField[], prefix = '') { - let names = fields.map(f => `${prefix}${f.get('name')}`); - - fields.forEach((f, index) => { - if (f.has('fields')) { - const fields = f.get('fields')?.toArray() as EntryField[]; - names = [...names, ...getFieldsNames(fields, `${names[index]}.`)]; - } else if (f.has('field')) { - const field = f.get('field') as EntryField; - names = [...names, ...getFieldsNames([field], `${names[index]}.`)]; - } else if (f.has('types')) { - const types = f.get('types')?.toArray() as EntryField[]; - names = [...names, ...getFieldsNames(types, `${names[index]}.`)]; - } - }); - - return names; -} - -export function selectField(collection: Collection, key: string) { - const array = keyToPathArray(key); - let name: string | undefined; - let field; - let fields = collection.get('fields', List()).toArray(); - while ((name = array.shift()) && fields) { - field = fields.find(f => f.get('name') === name); - if (field?.has('fields')) { - fields = field?.get('fields')?.toArray() as EntryField[]; - } else if (field?.has('field')) { - fields = [field?.get('field') as EntryField]; - } else if (field?.has('types')) { - fields = field?.get('types')?.toArray() as EntryField[]; - } - } - - return field; -} - -export function traverseFields( - fields: List, - updater: (field: EntryField) => EntryField, - done = () => false, -) { - if (done()) { - return fields; - } - - fields = fields - .map(f => { - const field = updater(f as EntryField); - if (done()) { - return field; - } else if (field.has('fields')) { - return field.set('fields', traverseFields(field.get('fields')!, updater, done)); - } else if (field.has('field')) { - return field.set( - 'field', - traverseFields(List([field.get('field')!]), updater, done).get(0), - ); - } else if (field.has('types')) { - return field.set('types', traverseFields(field.get('types')!, updater, done)); - } else { - return field; - } - }) - .toList() as List; - - return fields; -} - -export function updateFieldByKey( - collection: Collection, - key: string, - updater: (field: EntryField) => EntryField, -) { - const selected = selectField(collection, key); - if (!selected) { - return collection; - } - - let updated = false; - - function updateAndBreak(f: EntryField) { - const field = f as EntryField; - if (field === selected) { - updated = true; - return updater(field); - } else { - return field; - } - } - - collection = collection.set( - 'fields', - traverseFields(collection.get('fields', List()), updateAndBreak, () => updated), - ); - - return collection; -} - -export function selectIdentifier(collection: Collection) { - const identifier = collection.get('identifier_field'); - const identifierFields = identifier ? [identifier, ...IDENTIFIER_FIELDS] : [...IDENTIFIER_FIELDS]; - const fieldNames = getFieldsNames(collection.get('fields', List()).toArray()); - return identifierFields.find(id => - fieldNames.find(name => name.toLowerCase().trim() === id.toLowerCase().trim()), - ); -} - -export function selectInferedField(collection: Collection, fieldName: string) { - if (fieldName === 'title' && collection.get('identifier_field')) { - return selectIdentifier(collection); - } - const inferableField = ( - INFERABLE_FIELDS as Record< - string, - { - type: string; - synonyms: string[]; - secondaryTypes: string[]; - fallbackToFirstField: boolean; - showError: boolean; - } - > - )[fieldName]; - const fields = collection.get('fields'); - let field; - - // If collection has no fields or fieldName is not defined within inferables list, return null - if (!fields || !inferableField) return null; - // Try to return a field of the specified type with one of the synonyms - const mainTypeFields = fields - .filter(f => f?.get('widget', 'string') === inferableField.type) - .map(f => f?.get('name')); - field = mainTypeFields.filter(f => inferableField.synonyms.indexOf(f as string) !== -1); - if (field && field.size > 0) return field.first(); - - // Try to return a field for each of the specified secondary types - const secondaryTypeFields = fields - .filter(f => inferableField.secondaryTypes.indexOf(f?.get('widget', 'string') as string) !== -1) - .map(f => f?.get('name')); - field = secondaryTypeFields.filter(f => inferableField.synonyms.indexOf(f as string) !== -1); - if (field && field.size > 0) return field.first(); - - // Try to return the first field of the specified type - if (inferableField.fallbackToFirstField && mainTypeFields.size > 0) return mainTypeFields.first(); - - // Coundn't infer the field. Show error and return null. - if (inferableField.showError) { - consoleError( - `The Field ${fieldName} is missing for the collection “${collection.get('name')}”`, - `Static CMS tries to infer the entry ${fieldName} automatically, but one couldn't be found for entries of the collection “${collection.get( - 'name', - )}”. Please check your site configuration.`, - ); - } - - return null; -} - -export function selectEntryCollectionTitle(collection: Collection, entry: EntryMap) { - // prefer formatted summary over everything else - const summaryTemplate = collection.get('summary'); - if (summaryTemplate) return summaryFormatter(summaryTemplate, entry, collection); - - // if the collection is a file collection return the label of the entry - if (collection.get('type') == FILES) { - const label = selectFileEntryLabel(collection, entry.get('slug')); - if (label) return label; - } - - // try to infer a title field from the entry data - const entryData = entry.get('data'); - const titleField = selectInferedField(collection, 'title'); - const result = titleField && entryData.getIn(keyToPathArray(titleField)); - - // if the custom field does not yield a result, fallback to 'title' - if (!result && titleField !== 'title') { - return entryData.getIn(keyToPathArray('title')); - } - - return result; -} - -export function selectDefaultSortableFields( - collection: Collection, - backend: Backend, - hasIntegration: boolean, -) { - let defaultSortable = SORTABLE_FIELDS.map((type: string) => { - const field = selectInferedField(collection, type); - if (backend.isGitBackend() && type === 'author' && !field && !hasIntegration) { - // default to commit author if not author field is found - return COMMIT_AUTHOR; - } - return field; - }).filter(Boolean); - - if (backend.isGitBackend() && !hasIntegration) { - // always have commit date by default - defaultSortable = [COMMIT_DATE, ...defaultSortable]; - } - - return defaultSortable as string[]; -} - -export function selectSortableFields(collection: Collection, t: (key: string) => string) { - const fields = (collection.getIn(['sortable_fields', 'fields']) as List) - .toArray() - .map(key => { - if (key === COMMIT_DATE) { - return { key, field: { name: key, label: t('collection.defaultFields.updatedOn.label') } }; - } - const field = selectField(collection, key); - if (key === COMMIT_AUTHOR && !field) { - return { key, field: { name: key, label: t('collection.defaultFields.author.label') } }; - } - - return { key, field: field?.toJS() }; - }) - .filter(item => !!item.field) - .map(item => ({ ...item.field, key: item.key })); - - return fields; -} - -export function selectSortDataPath(collection: Collection, key: string) { - if (key === COMMIT_DATE) { - return 'updatedOn'; - } else if (key === COMMIT_AUTHOR && !selectField(collection, key)) { - return 'author'; - } else { - return `data.${key}`; - } -} - -export function selectViewFilters(collection: Collection) { - const viewFilters = collection.get('view_filters').toJS() as ViewFilter[]; - return viewFilters; -} - -export function selectViewGroups(collection: Collection) { - const viewGroups = collection.get('view_groups').toJS() as ViewGroup[]; - return viewGroups; -} - -export function selectFieldsComments(collection: Collection, entryMap: EntryMap) { - let fields: EntryField[] = []; - if (collection.has('folder')) { - fields = collection.get('fields').toArray(); - } else if (collection.has('files')) { - const file = collection.get('files')!.find(f => f?.get('name') === entryMap.get('slug')); - fields = file.get('fields').toArray(); - } - const comments: Record = {}; - const names = getFieldsNames(fields); - names.forEach(name => { - const field = selectField(collection, name); - if (field?.has('comment')) { - comments[name] = field.get('comment')!; - } - }); - - return comments; -} - -export function selectHasMetaPath(collection: Collection) { - return ( - collection.has('folder') && - collection.get('type') === FOLDER && - collection.has('meta') && - collection.get('meta')?.has('path') - ); -} - export default collections; diff --git a/src/reducers/config.ts b/src/reducers/config.ts index 44926c8a..07d5e025 100644 --- a/src/reducers/config.ts +++ b/src/reducers/config.ts @@ -3,9 +3,10 @@ import { produce } from 'immer'; import { CONFIG_FAILURE, CONFIG_REQUEST, CONFIG_SUCCESS } from '../actions/config'; import type { ConfigAction } from '../actions/config'; -import type { CmsConfig } from '../interface'; +import type { Config } from '../interface'; -export interface ConfigState extends Partial { +export interface ConfigState { + config?: Config; isFetching: boolean; error?: string; } @@ -21,7 +22,7 @@ const config = produce((state: ConfigState, action: ConfigAction) => { break; case CONFIG_SUCCESS: return { - ...action.payload, + config: action.payload, isFetching: false, error: undefined, }; @@ -31,8 +32,8 @@ const config = produce((state: ConfigState, action: ConfigAction) => { } }, defaultState); -export function selectLocale(state: CmsConfig) { - return state.locale || 'en'; +export function selectLocale(state?: Config) { + return state?.locale || 'en'; } export default config; diff --git a/src/reducers/cursors.js b/src/reducers/cursors.js deleted file mode 100644 index 7cc0c8b3..00000000 --- a/src/reducers/cursors.js +++ /dev/null @@ -1,36 +0,0 @@ -import { fromJS } from 'immutable'; - -import { Cursor } from '../lib/util'; -import { - ENTRIES_SUCCESS, - SORT_ENTRIES_SUCCESS, - FILTER_ENTRIES_SUCCESS, - GROUP_ENTRIES_SUCCESS, -} from '../actions/entries'; - -// Since pagination can be used for a variety of views (collections -// and searches are the most common examples), we namespace cursors by -// their type before storing them in the state. -export function selectCollectionEntriesCursor(state, collectionName) { - return new Cursor(state.getIn(['cursorsByType', 'collectionEntries', collectionName])); -} - -function cursors(state = fromJS({ cursorsByType: { collectionEntries: {} } }), action) { - switch (action.type) { - case ENTRIES_SUCCESS: { - return state.setIn( - ['cursorsByType', 'collectionEntries', action.payload.collection], - Cursor.create(action.payload.cursor).store, - ); - } - case FILTER_ENTRIES_SUCCESS: - case GROUP_ENTRIES_SUCCESS: - case SORT_ENTRIES_SUCCESS: { - return state.deleteIn(['cursorsByType', 'collectionEntries', action.payload.collection]); - } - default: - return state; - } -} - -export default cursors; diff --git a/src/reducers/cursors.ts b/src/reducers/cursors.ts new file mode 100644 index 00000000..14d14c5c --- /dev/null +++ b/src/reducers/cursors.ts @@ -0,0 +1,60 @@ +import { + ENTRIES_SUCCESS, + FILTER_ENTRIES_SUCCESS, + GROUP_ENTRIES_SUCCESS, + SORT_ENTRIES_SUCCESS, +} from '../actions/entries'; +import { Cursor } from '../lib/util'; + +import type { EntriesAction } from '../actions/entries'; +import type { CursorStore } from '../lib/util/Cursor'; + +export interface CursorsState { + cursorsByType: { + collectionEntries: Record; + }; +} + +function cursors( + state: CursorsState = { cursorsByType: { collectionEntries: {} } }, + action: EntriesAction, +): CursorsState { + switch (action.type) { + case ENTRIES_SUCCESS: { + return { + cursorsByType: { + collectionEntries: { + ...state.cursorsByType.collectionEntries, + [action.payload.collection]: Cursor.create(action.payload.cursor).store, + }, + }, + }; + } + case FILTER_ENTRIES_SUCCESS: + case GROUP_ENTRIES_SUCCESS: + case SORT_ENTRIES_SUCCESS: { + const newCollectionEntries = { + ...state.cursorsByType.collectionEntries, + }; + + delete newCollectionEntries[action.payload.collection]; + + return { + cursorsByType: { + collectionEntries: newCollectionEntries, + }, + }; + } + default: + return state; + } +} + +// Since pagination can be used for a variety of views (collections +// and searches are the most common examples), we namespace cursors by +// their type before storing them in the state. +export function selectCollectionEntriesCursor(state: CursorsState, collectionName: string) { + return new Cursor(state.cursorsByType.collectionEntries[collectionName]); +} + +export default cursors; diff --git a/src/reducers/entries.ts b/src/reducers/entries.ts index 1cbeca33..ec927efc 100644 --- a/src/reducers/entries.ts +++ b/src/reducers/entries.ts @@ -1,6 +1,8 @@ -import { fromJS, List, Map, OrderedMap, Set } from 'immutable'; -import { groupBy, once, orderBy, set, sortBy, trim } from 'lodash'; -import { dirname, join } from 'path'; +import get from 'lodash/get'; +import groupBy from 'lodash/groupBy'; +import once from 'lodash/once'; +import orderBy from 'lodash/orderBy'; +import sortBy from 'lodash/sortBy'; import { CHANGE_VIEW_STYLE, @@ -24,55 +26,30 @@ import { import { SEARCH_ENTRIES_SUCCESS } from '../actions/search'; import { VIEW_STYLE_LIST } from '../constants/collectionViews'; import { SortDirection } from '../interface'; -import { folderFormatter } from '../lib/formatters'; -import { joinUrlPath } from '../lib/urlHelper'; -import { basename, isAbsolutePath } from '../lib/util'; -import { stringTemplate } from '../lib/widgets'; -import { selectSortDataPath } from './collections'; +import { set } from '../lib/util/object.util'; +import { selectSortDataPath } from '../lib/util/sort.util'; -import type { CmsConfig } from '../interface'; +import type { EntriesAction } from '../actions/entries'; +import type { SearchAction } from '../actions/search'; +import type { CollectionViewStyle } from '../constants/collectionViews'; import type { - ChangeViewStylePayload, Collection, - CollectionFiles, - Entries, - EntriesAction, - EntriesFilterFailurePayload, - EntriesFilterRequestPayload, - EntriesGroupFailurePayload, - EntriesGroupRequestPayload, - EntriesRequestPayload, - EntriesSortFailurePayload, - EntriesSortRequestPayload, - EntriesSuccessPayload, - EntryDeletePayload, - EntryDraft, - EntryFailurePayload, - EntryField, - EntryMap, - EntryObject, - EntryRequestPayload, - EntrySuccessPayload, + Entities, + Entry, Filter, FilterMap, Group, GroupMap, GroupOfEntries, + Pages, Sort, SortMap, SortObject, -} from '../types/redux'; +} from '../interface'; +import type { EntryDraftState } from './entryDraft'; -const { keyToPathArray } = stringTemplate; - -let collection: string; -let loadedEntries: EntryObject[]; -let append: boolean; -let page: number; -let slug: string; - -const storageSortKey = 'static-cms.entries.sort'; -const viewStyleKey = 'static-cms.entries.viewStyle'; +const storageSortKey = '../netlify-cms.entries.sort'; +const viewStyleKey = '../netlify-cms.entries.viewStyle'; type StorageSortObject = SortObject & { index: number }; type StorageSort = { [collection: string]: { [key: string]: StorageSortObject } }; @@ -81,21 +58,21 @@ const loadSort = once(() => { if (sortString) { try { const sort: StorageSort = JSON.parse(sortString); - let map = Map() as Sort; + const map: Sort = {}; Object.entries(sort).forEach(([collection, sort]) => { - let orderedMap = OrderedMap() as SortMap; + const orderedMap: SortMap = {}; sortBy(Object.values(sort), ['index']).forEach(value => { const { key, direction } = value; - orderedMap = orderedMap.set(key, fromJS({ key, direction })); + orderedMap[key] = { key, direction }; }); - map = map.set(collection, orderedMap); + map[collection] = orderedMap; }); return map; - } catch (e) { - return Map() as Sort; + } catch (e: unknown) { + return {} as Sort; } } - return Map() as Sort; + return {} as Sort; }); function clearSort() { @@ -105,14 +82,14 @@ function clearSort() { function persistSort(sort: Sort | undefined) { if (sort) { const storageSort: StorageSort = {}; - sort.keySeq().forEach(key => { + Object.keys(sort).forEach(key => { const collection = key as string; - const sortObjects = (sort.get(collection).valueSeq().toJS() as SortObject[]).map( - (value, index) => ({ ...value, index }), - ); + const sortObjects = ( + (sort[collection] ? Object.values(sort[collection]) : []) as SortObject[] + ).map((value, index) => ({ ...value, index })); sortObjects.forEach(value => { - set(storageSort, [collection, value.key], value); + set(storageSort, `${collection}.${value.key}`, value); }); }); localStorage.setItem(storageSortKey, JSON.stringify(storageSort)); @@ -122,7 +99,7 @@ function persistSort(sort: Sort | undefined) { } const loadViewStyle = once(() => { - const viewStyle = localStorage.getItem(viewStyleKey); + const viewStyle = localStorage.getItem(viewStyleKey) as CollectionViewStyle; if (viewStyle) { return viewStyle; } @@ -143,204 +120,424 @@ function persistViewStyle(viewStyle: string | undefined) { } } +export type EntriesState = { + pages: Pages; + entities: Entities; + sort: Sort; + filter?: Filter; + group?: Group; + viewStyle: CollectionViewStyle; +}; + function entries( - state = Map({ entities: Map(), pages: Map(), sort: loadSort(), viewStyle: loadViewStyle() }), - action: EntriesAction, -) { + state: EntriesState = { entities: {}, pages: {}, sort: loadSort(), viewStyle: loadViewStyle() }, + action: EntriesAction | SearchAction, +): EntriesState { switch (action.type) { case ENTRY_REQUEST: { - const payload = action.payload as EntryRequestPayload; - return state.setIn(['entities', `${payload.collection}.${payload.slug}`, 'isFetching'], true); + const payload = action.payload; + + const key = `${payload.collection}.${payload.slug}`; + const newEntity: Entry = { + ...(state.entities[key] ?? {}), + }; + + newEntity.isFetching = true; + + return { + ...state, + entities: { + ...state.entities, + [key]: newEntity, + }, + }; } case ENTRY_SUCCESS: { - const payload = action.payload as EntrySuccessPayload; - collection = payload.collection; - slug = payload.entry.slug; - return state.withMutations(map => { - map.setIn(['entities', `${collection}.${slug}`], fromJS(payload.entry)); - const ids = map.getIn(['pages', collection, 'ids'], List()); - if (!ids.includes(slug)) { - map.setIn(['pages', collection, 'ids'], ids.unshift(slug)); - } - }); + const payload = action.payload; + + return { + ...state, + entities: { + ...state.entities, + [`${payload.collection}.${payload.entry.slug}`]: payload.entry, + }, + }; } case ENTRIES_REQUEST: { - const payload = action.payload as EntriesRequestPayload; - const newState = state.withMutations(map => { - map.setIn(['pages', payload.collection, 'isFetching'], true); - }); + const payload = action.payload; - return newState; + const pages = { + ...state.pages, + }; + + if (payload.collection in pages) { + const newCollection = { + ...(pages[payload.collection] ?? {}), + }; + + newCollection.isFetching = true; + + pages[payload.collection] = newCollection; + } + + return { ...state, pages }; } case ENTRIES_SUCCESS: { - const payload = action.payload as EntriesSuccessPayload; - collection = payload.collection; - loadedEntries = payload.entries; - append = payload.append; - page = payload.page; - return state.withMutations(map => { - loadedEntries.forEach(entry => - map.setIn( - ['entities', `${collection}.${entry.slug}`], - fromJS(entry).set('isFetching', false), - ), - ); + const payload = action.payload; + const loadedEntries = payload.entries; + const page = payload.page; - const ids = List(loadedEntries.map(entry => entry.slug)); - map.setIn( - ['pages', collection], - Map({ - page, - ids: append ? map.getIn(['pages', collection, 'ids'], List()).concat(ids) : ids, - }), - ); + const entities = { + ...state.entities, + }; + + loadedEntries.forEach(entry => { + entities[`${payload.collection}.${entry.slug}`] = { ...entry, isFetching: false }; }); + + const pages = { + ...state.pages, + }; + + pages[payload.collection] = { + page: page ?? undefined, + ids: loadedEntries.map(entry => entry.slug), + isFetching: false, + }; + + return { ...state, entities, pages }; + } + + case ENTRIES_FAILURE: { + const pages = { + ...state.pages, + }; + + if (action.meta.collection in pages) { + const newCollection = { + ...(pages[action.meta.collection] ?? {}), + }; + + newCollection.isFetching = false; + + pages[action.meta.collection] = newCollection; + } + + return { ...state, pages }; } - case ENTRIES_FAILURE: - return state.setIn(['pages', action.meta.collection, 'isFetching'], false); case ENTRY_FAILURE: { - const payload = action.payload as EntryFailurePayload; - return state.withMutations(map => { - map.setIn(['entities', `${payload.collection}.${payload.slug}`, 'isFetching'], false); - map.setIn( - ['entities', `${payload.collection}.${payload.slug}`, 'error'], - payload.error.message, - ); - }); + const payload = action.payload; + const key = `${payload.collection}.${payload.slug}`; + + return { + ...state, + entities: { + ...state.entities, + [key]: { + ...(state.entities[key] ?? {}), + isFetching: false, + error: payload.error.message, + }, + }, + }; } case SEARCH_ENTRIES_SUCCESS: { - const payload = action.payload as EntriesSuccessPayload; - loadedEntries = payload.entries; - return state.withMutations(map => { - loadedEntries.forEach(entry => - map.setIn( - ['entities', `${entry.collection}.${entry.slug}`], - fromJS(entry).set('isFetching', false), - ), - ); + const payload = action.payload; + const loadedEntries = payload.entries; + + const entities = { + ...state.entities, + }; + + loadedEntries.forEach(entry => { + entities[`${entry.collection}.${entry.slug}`] = { + ...entry, + isFetching: false, + }; }); + + return { ...state, entities }; } case ENTRY_DELETE_SUCCESS: { - const payload = action.payload as EntryDeletePayload; - return state.withMutations(map => { - map.deleteIn(['entities', `${payload.collectionName}.${payload.entrySlug}`]); - map.updateIn(['pages', payload.collectionName, 'ids'], (ids: string[]) => - ids.filter(id => id !== payload.entrySlug), - ); - }); + const payload = action.payload; + const collection = payload.collectionName; + const slug = payload.entrySlug; + + const entities = { + ...state.entities, + }; + + delete entities[`${collection}.${slug}`]; + + const pages = { + ...state.pages, + }; + + const newPagesCollection = { + ...(pages[collection] ?? {}), + }; + + if (!newPagesCollection.ids) { + newPagesCollection.ids = []; + } + + newPagesCollection.ids = newPagesCollection.ids.filter( + (id: string) => id !== payload.entrySlug, + ); + + pages[collection] = newPagesCollection; + + return { + ...state, + entities, + pages, + }; } case SORT_ENTRIES_REQUEST: { - const payload = action.payload as EntriesSortRequestPayload; + const payload = action.payload; const { collection, key, direction } = payload; - const newState = state.withMutations(map => { - const sort = OrderedMap({ [key]: Map({ key, direction }) }); - map.setIn(['sort', collection], sort); - map.setIn(['pages', collection, 'isFetching'], true); - map.deleteIn(['pages', collection, 'page']); - }); - persistSort(newState.get('sort') as Sort); - return newState; + + const sort = { + ...state.sort, + }; + + sort[collection] = { [key]: { key, direction } } as SortMap; + + const pages = { + ...state.pages, + }; + + const newPagesCollection = { + ...(pages[collection] ?? {}), + }; + + newPagesCollection.isFetching = true; + delete newPagesCollection.page; + + pages[collection] = newPagesCollection; + + persistSort(sort); + + return { + ...state, + sort, + pages, + }; } case GROUP_ENTRIES_SUCCESS: case FILTER_ENTRIES_SUCCESS: case SORT_ENTRIES_SUCCESS: { - const payload = action.payload as { collection: string; entries: EntryObject[] }; + const payload = action.payload as { collection: string; entries: Entry[] }; const { collection, entries } = payload; - loadedEntries = entries; - const newState = state.withMutations(map => { - loadedEntries.forEach(entry => - map.setIn( - ['entities', `${entry.collection}.${entry.slug}`], - fromJS(entry).set('isFetching', false), - ), - ); - map.setIn(['pages', collection, 'isFetching'], false); - const ids = List(loadedEntries.map(entry => entry.slug)); - map.setIn( - ['pages', collection], - Map({ - page: 1, - ids, - }), - ); + + const entities = { + ...state.entities, + }; + + entries.forEach(entry => { + entities[`${entry.collection}.${entry.slug}`] = { + ...entry, + isFetching: false, + }; }); - return newState; + + const pages = { + ...state.pages, + }; + + const ids = entries.map(entry => entry.slug); + + pages[collection] = { + page: 1, + ids, + isFetching: false, + }; + + return { + ...state, + entities, + pages, + }; } case SORT_ENTRIES_FAILURE: { - const payload = action.payload as EntriesSortFailurePayload; + const payload = action.payload; const { collection, key } = payload; - const newState = state.withMutations(map => { - map.deleteIn(['sort', collection, key]); - map.setIn(['pages', collection, 'isFetching'], false); - }); - persistSort(newState.get('sort') as Sort); - return newState; + + const sort = { + ...state.sort, + }; + + const newSortCollection = { + ...(sort[collection] ?? {}), + }; + + delete newSortCollection[key]; + + sort[collection] = newSortCollection; + + const pages = { + ...state.pages, + }; + + const newPagesCollection = { + ...(pages[collection] ?? {}), + }; + + newPagesCollection.isFetching = false; + delete newPagesCollection.page; + + pages[collection] = newPagesCollection; + + persistSort(sort); + + return { + ...state, + sort, + pages, + }; } case FILTER_ENTRIES_REQUEST: { - const payload = action.payload as EntriesFilterRequestPayload; - const { collection, filter } = payload; - const newState = state.withMutations(map => { - const current: FilterMap = map.getIn(['filter', collection, filter.id], fromJS(filter)); - map.setIn( - ['filter', collection, current.get('id')], - current.set('active', !current.get('active')), - ); - }); - return newState; + const payload = action.payload; + const { collection, filter: viewFilter } = payload; + + const filter = { + ...state.filter, + }; + + const newFilterCollection = { + ...(filter[collection] ?? {}), + }; + + let newFilter: FilterMap; + if (viewFilter.id in newFilterCollection) { + newFilter = { ...newFilterCollection[viewFilter.id] }; + } else { + newFilter = { ...viewFilter }; + } + + newFilter.active = !newFilter.active; + newFilterCollection[viewFilter.id] = newFilter; + filter[collection] = newFilterCollection; + + return { + ...state, + filter, + }; } case FILTER_ENTRIES_FAILURE: { - const payload = action.payload as EntriesFilterFailurePayload; - const { collection, filter } = payload; - const newState = state.withMutations(map => { - map.deleteIn(['filter', collection, filter.id]); - map.setIn(['pages', collection, 'isFetching'], false); - }); - return newState; + const payload = action.payload; + const { collection, filter: viewFilter } = payload; + + const filter = { + ...state.filter, + }; + + const newFilterCollection = { + ...(filter[collection] ?? {}), + }; + + delete newFilterCollection[viewFilter.id]; + filter[collection] = newFilterCollection; + + const pages = { + ...state.pages, + }; + + const newPagesCollection = { + ...(pages[collection] ?? {}), + }; + + newPagesCollection.isFetching = false; + + pages[collection] = newPagesCollection; + + return { + ...state, + filter, + pages, + }; } case GROUP_ENTRIES_REQUEST: { - const payload = action.payload as EntriesGroupRequestPayload; - const { collection, group } = payload; - const newState = state.withMutations(map => { - const current: GroupMap = map.getIn(['group', collection, group.id], fromJS(group)); - map.deleteIn(['group', collection]); - map.setIn( - ['group', collection, current.get('id')], - current.set('active', !current.get('active')), - ); - }); - return newState; + const payload = action.payload; + const { collection, group: groupBy } = payload; + + const group = { + ...state.group, + }; + + let newGroup: GroupMap; + if (group[collection] && groupBy.id in group[collection]) { + newGroup = { ...group[collection][groupBy.id] }; + } else { + newGroup = { ...groupBy }; + } + + newGroup.active = !newGroup.active; + group[collection] = { + [groupBy.id]: newGroup, + }; + + return { + ...state, + group, + }; } case GROUP_ENTRIES_FAILURE: { - const payload = action.payload as EntriesGroupFailurePayload; - const { collection, group } = payload; - const newState = state.withMutations(map => { - map.deleteIn(['group', collection, group.id]); - map.setIn(['pages', collection, 'isFetching'], false); - }); - return newState; + const payload = action.payload; + const { collection, group: groupBy } = payload; + + const group = { + ...state.group, + }; + + const newGroupCollection = { + ...(group[collection] ?? {}), + }; + + delete newGroupCollection[groupBy.id]; + + group[collection] = newGroupCollection; + + const pages = { + ...state.pages, + }; + + const newPagesCollection = { + ...(pages[collection] ?? {}), + }; + + newPagesCollection.isFetching = false; + + pages[collection] = newPagesCollection; + + return { + ...state, + group, + pages, + }; } case CHANGE_VIEW_STYLE: { - const payload = action.payload as unknown as ChangeViewStylePayload; + const payload = action.payload; const { style } = payload; - const newState = state.withMutations(map => { - map.setIn(['viewStyle'], style); - }); - persistViewStyle(newState.get('viewStyle') as string); - return newState; + persistViewStyle(style); + return { + ...state, + viewStyle: style, + }; } default: @@ -348,107 +545,93 @@ function entries( } } -export function selectEntriesSort(entries: Entries, collection: string) { - const sort = entries.get('sort') as Sort | undefined; - return sort?.get(collection); +export function selectEntriesSort(entries: EntriesState, collection: string) { + const sort = entries.sort as Sort | undefined; + return sort?.[collection]; } -export function selectEntriesFilter(entries: Entries, collection: string) { - const filter = entries.get('filter') as Filter | undefined; - return filter?.get(collection) || Map(); +export function selectEntriesFilter(entries: EntriesState, collection: string) { + const filter = entries.filter as Filter | undefined; + return filter?.[collection] || {}; } -export function selectEntriesGroup(entries: Entries, collection: string) { - const group = entries.get('group') as Group | undefined; - return group?.get(collection) || Map(); +export function selectEntriesGroup(entries: EntriesState, collection: string) { + const group = entries.group as Group | undefined; + return group?.[collection] || {}; } -export function selectEntriesGroupField(entries: Entries, collection: string) { +export function selectEntriesGroupField(entries: EntriesState, collection: string) { const groups = selectEntriesGroup(entries, collection); - const value = groups?.valueSeq().find(v => v?.get('active') === true); + const value = Object.values(groups ?? {}).find(v => v?.active === true); return value; } -export function selectEntriesSortFields(entries: Entries, collection: string) { +export function selectEntriesSortFields(entries: EntriesState, collection: string) { const sort = selectEntriesSort(entries, collection); - const values = - sort - ?.valueSeq() - .filter(v => v?.get('direction') !== SortDirection.None) - .toArray() || []; + const values = Object.values(sort ?? {}).filter(v => v?.direction !== SortDirection.None) || []; return values; } -export function selectEntriesFilterFields(entries: Entries, collection: string) { +export function selectEntriesFilterFields(entries: EntriesState, collection: string) { const filter = selectEntriesFilter(entries, collection); - const values = - filter - ?.valueSeq() - .filter(v => v?.get('active') === true) - .toArray() || []; + const values = Object.values(filter ?? {}).filter(v => v?.active === true) || []; return values; } -export function selectViewStyle(entries: Entries) { - return entries.get('viewStyle'); +export function selectViewStyle(entries: EntriesState): CollectionViewStyle { + return entries.viewStyle; } -export function selectEntry(state: Entries, collection: string, slug: string) { - return state.getIn(['entities', `${collection}.${slug}`]); +export function selectEntry(state: EntriesState, collection: string, slug: string) { + return state.entities[`${collection}.${slug}`]; } -export function selectPublishedSlugs(state: Entries, collection: string) { - return state.getIn(['pages', collection, 'ids'], List()); +export function selectPublishedSlugs(state: EntriesState, collection: string) { + return state.pages[collection]?.ids ?? []; } -function getPublishedEntries(state: Entries, collectionName: string) { +function getPublishedEntries(state: EntriesState, collectionName: string) { const slugs = selectPublishedSlugs(state, collectionName); const entries = - slugs && - (slugs.map(slug => selectEntry(state, collectionName, slug as string)) as List); + slugs && (slugs.map(slug => selectEntry(state, collectionName, slug as string)) as Entry[]); return entries; } -export function selectEntries(state: Entries, collection: Collection) { - const collectionName = collection.get('name'); +export function selectEntries(state: EntriesState, collection: Collection) { + const collectionName = collection.name; let entries = getPublishedEntries(state, collectionName); const sortFields = selectEntriesSortFields(state, collectionName); if (sortFields && sortFields.length > 0) { - const keys = sortFields.map(v => selectSortDataPath(collection, v.get('key'))); - const orders = sortFields.map(v => - v.get('direction') === SortDirection.Ascending ? 'asc' : 'desc', - ); - entries = fromJS(orderBy(entries.toJS(), keys, orders)); + const keys = sortFields.map(v => selectSortDataPath(collection, v.key)); + const orders = sortFields.map(v => (v.direction === SortDirection.Ascending ? 'asc' : 'desc')); + entries = orderBy(entries, keys, orders); } const filters = selectEntriesFilterFields(state, collectionName); if (filters && filters.length > 0) { - entries = entries - .filter(e => { - const allMatched = filters.every(f => { - const pattern = f.get('pattern'); - const field = f.get('field'); - const data = e!.get('data') || Map(); - const toMatch = data.getIn(keyToPathArray(field)); - const matched = - toMatch !== undefined && new RegExp(String(pattern)).test(String(toMatch)); - return matched; - }); - return allMatched; - }) - .toList(); + entries = entries.filter(e => { + const allMatched = filters.every(f => { + const pattern = f.pattern; + const field = f.field; + const data = e!.data || {}; + const toMatch = get(data, field); + const matched = toMatch !== undefined && new RegExp(String(pattern)).test(String(toMatch)); + return matched; + }); + return allMatched; + }); } return entries; } -function getGroup(entry: EntryMap, selectedGroup: GroupMap) { - const label = selectedGroup.get('label'); - const field = selectedGroup.get('field'); +function getGroup(entry: Entry, selectedGroup: GroupMap) { + const label = selectedGroup.label; + const field = selectedGroup.field; - const fieldData = entry.getIn(['data', ...keyToPathArray(field)]); + const fieldData = get(entry.data, field); if (fieldData === undefined) { return { id: 'missing_value', @@ -458,8 +641,8 @@ function getGroup(entry: EntryMap, selectedGroup: GroupMap) { } const dataAsString = String(fieldData); - if (selectedGroup.has('pattern')) { - const pattern = selectedGroup.get('pattern') ?? ''; + if (selectedGroup.pattern) { + const pattern = selectedGroup.pattern; let value = ''; try { const regex = new RegExp(pattern); @@ -467,7 +650,7 @@ function getGroup(entry: EntryMap, selectedGroup: GroupMap) { if (matched) { value = matched[0]; } - } catch (e) { + } catch (e: unknown) { console.warn(`Invalid view group pattern '${pattern}' for field '${field}'`, e); } return { @@ -484,8 +667,8 @@ function getGroup(entry: EntryMap, selectedGroup: GroupMap) { }; } -export function selectGroups(state: Entries, collection: Collection) { - const collectionName = collection.get('name'); +export function selectGroups(state: EntriesState, collection: Collection) { + const collectionName = collection.name; const entries = getPublishedEntries(state, collectionName); const selectedGroup = selectEntriesGroupField(state, collectionName); @@ -495,7 +678,7 @@ export function selectGroups(state: Entries, collection: Collection) { let groups: Record = {}; - const groupedEntries = groupBy(entries.toArray(), entry => { + const groupedEntries = groupBy(entries, entry => { const group = getGroup(entry, selectedGroup); groups = { ...groups, [group.id]: group }; return group.id; @@ -504,302 +687,31 @@ export function selectGroups(state: Entries, collection: Collection) { const groupsArray: GroupOfEntries[] = Object.entries(groupedEntries).map(([id, entries]) => { return { ...groups[id], - paths: Set(entries.map(entry => entry.get('path'))), + paths: new Set(entries.map(entry => entry.path)), }; }); return groupsArray; } -export function selectEntryByPath(state: Entries, collection: string, path: string) { +export function selectEntryByPath(state: EntriesState, collection: string, path: string) { const slugs = selectPublishedSlugs(state, collection); const entries = - slugs && (slugs.map(slug => selectEntry(state, collection, slug as string)) as List); + slugs && (slugs.map(slug => selectEntry(state, collection, slug as string)) as Entry[]); - return entries && entries.find(e => e?.get('path') === path); + return entries && entries.find(e => e?.path === path); } -export function selectEntriesLoaded(state: Entries, collection: string) { - return !!state.getIn(['pages', collection]); +export function selectEntriesLoaded(state: EntriesState, collection: string) { + return !!state.pages[collection]; } -export function selectIsFetching(state: Entries, collection: string) { - return state.getIn(['pages', collection, 'isFetching'], false); +export function selectIsFetching(state: EntriesState, collection: string) { + return state.pages[collection]?.isFetching ?? false; } -const DRAFT_MEDIA_FILES = 'DRAFT_MEDIA_FILES'; - -function getFileField(collectionFiles: CollectionFiles, slug: string | undefined) { - const file = collectionFiles.find(f => f?.get('name') === slug); - return file; -} - -function hasCustomFolder( - folderKey: 'media_folder' | 'public_folder', - collection: Collection | null, - slug: string | undefined, - field: EntryField | undefined, -) { - if (!collection) { - return false; - } - - if (field && field.has(folderKey)) { - return true; - } - - if (collection.has('files')) { - const file = getFileField(collection.get('files')!, slug); - if (file && file.has(folderKey)) { - return true; - } - } - - if (collection.has(folderKey)) { - return true; - } - - return false; -} - -function traverseFields( - folderKey: 'media_folder' | 'public_folder', - config: CmsConfig, - collection: Collection, - entryMap: EntryMap | undefined, - field: EntryField, - fields: EntryField[], - currentFolder: string, -): string | null { - const matchedField = fields.filter(f => f === field)[0]; - if (matchedField) { - return folderFormatter( - matchedField.has(folderKey) ? matchedField.get(folderKey)! : `{{${folderKey}}}`, - entryMap, - collection, - currentFolder, - folderKey, - config.slug, - ); - } - - for (let f of fields) { - if (!f.has(folderKey)) { - // add identity template if doesn't exist - f = f.set(folderKey, `{{${folderKey}}}`); - } - const folder = folderFormatter( - f.get(folderKey)!, - entryMap, - collection, - currentFolder, - folderKey, - config.slug, - ); - let fieldFolder = null; - if (f.has('fields')) { - fieldFolder = traverseFields( - folderKey, - config, - collection, - entryMap, - field, - f.get('fields')!.toArray(), - folder, - ); - } else if (f.has('field')) { - fieldFolder = traverseFields( - folderKey, - config, - collection, - entryMap, - field, - [f.get('field')!], - folder, - ); - } else if (f.has('types')) { - fieldFolder = traverseFields( - folderKey, - config, - collection, - entryMap, - field, - f.get('types')!.toArray(), - folder, - ); - } - if (fieldFolder != null) { - return fieldFolder; - } - } - - return null; -} - -function evaluateFolder( - folderKey: 'media_folder' | 'public_folder', - config: CmsConfig, - collection: Collection, - entryMap: EntryMap | undefined, - field: EntryField | undefined, -) { - let currentFolder = config[folderKey]!; - - // add identity template if doesn't exist - if (!collection.has(folderKey)) { - collection = collection.set(folderKey, `{{${folderKey}}}`); - } - - if (collection.has('files')) { - // files collection evaluate the collection template - // then move on to the specific file configuration denoted by the slug - currentFolder = folderFormatter( - collection.get(folderKey)!, - entryMap, - collection, - currentFolder, - folderKey, - config.slug, - ); - - let file = getFileField(collection.get('files')!, entryMap?.get('slug')); - if (file) { - if (!file.has(folderKey)) { - // add identity template if doesn't exist - file = file.set(folderKey, `{{${folderKey}}}`); - } - - // evaluate the file template and keep evaluating until we match our field - currentFolder = folderFormatter( - file.get(folderKey)!, - entryMap, - collection, - currentFolder, - folderKey, - config.slug, - ); - - if (field) { - const fieldFolder = traverseFields( - folderKey, - config, - collection, - entryMap, - field, - file.get('fields')!.toArray(), - currentFolder, - ); - - if (fieldFolder !== null) { - currentFolder = fieldFolder; - } - } - } - } else { - // folder collection, evaluate the collection template - // and keep evaluating until we match our field - currentFolder = folderFormatter( - collection.get(folderKey)!, - entryMap, - collection, - currentFolder, - folderKey, - config.slug, - ); - - if (field) { - const fieldFolder = traverseFields( - folderKey, - config, - collection, - entryMap, - field, - collection.get('fields')!.toArray(), - currentFolder, - ); - - if (fieldFolder !== null) { - currentFolder = fieldFolder; - } - } - } - - return currentFolder; -} - -export function selectMediaFolder( - config: CmsConfig, - collection: Collection | null, - entryMap: EntryMap | undefined, - field: EntryField | undefined, -) { - const name = 'media_folder'; - let mediaFolder = config[name]; - - const customFolder = hasCustomFolder(name, collection, entryMap?.get('slug'), field); - - if (customFolder) { - const folder = evaluateFolder(name, config, collection!, entryMap, field); - if (folder.startsWith('/')) { - // return absolute paths as is - mediaFolder = join(folder); - } else { - const entryPath = entryMap?.get('path'); - mediaFolder = entryPath - ? join(dirname(entryPath), folder) - : join(collection!.get('folder') as string, DRAFT_MEDIA_FILES); - } - } - - return trim(mediaFolder, '/'); -} - -export function selectMediaFilePath( - config: CmsConfig, - collection: Collection | null, - entryMap: EntryMap | undefined, - mediaPath: string, - field: EntryField | undefined, -) { - if (isAbsolutePath(mediaPath)) { - return mediaPath; - } - - const mediaFolder = selectMediaFolder(config, collection, entryMap, field); - - return join(mediaFolder, basename(mediaPath)); -} - -export function selectMediaFilePublicPath( - config: CmsConfig, - collection: Collection | null, - mediaPath: string, - entryMap: EntryMap | undefined, - field: EntryField | undefined, -) { - if (isAbsolutePath(mediaPath)) { - return mediaPath; - } - - const name = 'public_folder'; - let publicFolder = config[name]!; - - const customFolder = hasCustomFolder(name, collection, entryMap?.get('slug'), field); - - if (customFolder) { - publicFolder = evaluateFolder(name, config, collection!, entryMap, field); - } - - if (isAbsolutePath(publicFolder)) { - return joinUrlPath(publicFolder, basename(mediaPath)); - } - - return join(publicFolder, basename(mediaPath)); -} - -export function selectEditingDraft(state: EntryDraft) { - const entry = state.get('entry'); - return entry && !entry.isEmpty(); +export function selectEditingDraft(state: EntryDraftState) { + return state.entry; } export default entries; diff --git a/src/reducers/entryDraft.js b/src/reducers/entryDraft.js deleted file mode 100644 index 44c629ca..00000000 --- a/src/reducers/entryDraft.js +++ /dev/null @@ -1,193 +0,0 @@ -import { Map, List, fromJS } from 'immutable'; -import uuid from 'uuid/v4'; -import { get } from 'lodash'; -import { join } from 'path'; - -import { - DRAFT_CREATE_FROM_ENTRY, - DRAFT_CREATE_EMPTY, - DRAFT_DISCARD, - DRAFT_CHANGE_FIELD, - DRAFT_VALIDATION_ERRORS, - DRAFT_CLEAR_ERRORS, - DRAFT_LOCAL_BACKUP_RETRIEVED, - DRAFT_CREATE_FROM_LOCAL_BACKUP, - DRAFT_CREATE_DUPLICATE_FROM_ENTRY, - ENTRY_PERSIST_REQUEST, - ENTRY_PERSIST_SUCCESS, - ENTRY_PERSIST_FAILURE, - ENTRY_DELETE_SUCCESS, - ADD_DRAFT_ENTRY_MEDIA_FILE, - REMOVE_DRAFT_ENTRY_MEDIA_FILE, -} from '../actions/entries'; -import { selectFolderEntryExtension, selectHasMetaPath } from './collections'; -import { getDataPath, duplicateI18nFields } from '../lib/i18n'; - -const initialState = Map({ - entry: Map(), - fieldsMetaData: Map(), - fieldsErrors: Map(), - hasChanged: false, - key: '', -}); - -function entryDraftReducer(state = Map(), action) { - switch (action.type) { - case DRAFT_CREATE_FROM_ENTRY: - // Existing Entry - return state.withMutations(state => { - state.set('entry', fromJS(action.payload.entry)); - state.setIn(['entry', 'newRecord'], false); - state.set('fieldsMetaData', Map()); - state.set('fieldsErrors', Map()); - state.set('hasChanged', false); - state.set('key', uuid()); - }); - case DRAFT_CREATE_EMPTY: - // New Entry - return state.withMutations(state => { - state.set('entry', fromJS(action.payload)); - state.setIn(['entry', 'newRecord'], true); - state.set('fieldsMetaData', Map()); - state.set('fieldsErrors', Map()); - state.set('hasChanged', false); - state.set('key', uuid()); - }); - case DRAFT_CREATE_FROM_LOCAL_BACKUP: - // Local Backup - return state.withMutations(state => { - const backupDraftEntry = state.get('localBackup'); - const backupEntry = backupDraftEntry.get('entry'); - state.delete('localBackup'); - state.set('entry', backupEntry); - state.setIn(['entry', 'newRecord'], !backupEntry.get('path')); - state.set('fieldsMetaData', Map()); - state.set('fieldsErrors', Map()); - state.set('hasChanged', true); - state.set('key', uuid()); - }); - case DRAFT_CREATE_DUPLICATE_FROM_ENTRY: - // Duplicate Entry - return state.withMutations(state => { - state.set('entry', fromJS(action.payload)); - state.setIn(['entry', 'newRecord'], true); - state.set('mediaFiles', List()); - state.set('fieldsMetaData', Map()); - state.set('fieldsErrors', Map()); - state.set('hasChanged', true); - }); - case DRAFT_DISCARD: - return initialState; - case DRAFT_LOCAL_BACKUP_RETRIEVED: { - const { entry } = action.payload; - const newState = new Map({ - entry: fromJS(entry), - }); - return state.set('localBackup', newState); - } - case DRAFT_CHANGE_FIELD: { - return state.withMutations(state => { - const { field, value, metadata, entries, i18n } = action.payload; - const name = field.get('name'); - const meta = field.get('meta'); - - const dataPath = (i18n && getDataPath(i18n.currentLocale, i18n.defaultLocale)) || ['data']; - if (meta) { - state.setIn(['entry', 'meta', name], value); - } else { - state.setIn(['entry', ...dataPath, name], value); - if (i18n) { - state = duplicateI18nFields(state, field, i18n.locales, i18n.defaultLocale); - } - } - state.mergeDeepIn(['fieldsMetaData'], fromJS(metadata)); - const newData = state.getIn(['entry', ...dataPath]); - const newMeta = state.getIn(['entry', 'meta']); - if (entries.length === 0) { - return; - } - state.set( - 'hasChanged', - !newData.equals(entries[0].get(...dataPath)) || - !newMeta.equals(entries[0].get('meta')), - ); - }); - } - case DRAFT_VALIDATION_ERRORS: - if (action.payload.errors.length === 0) { - return state.deleteIn(['fieldsErrors', action.payload.uniquefieldId]); - } else { - return state.setIn(['fieldsErrors', action.payload.uniquefieldId], action.payload.errors); - } - - case DRAFT_CLEAR_ERRORS: { - return state.set('fieldsErrors', Map()); - } - - case ENTRY_PERSIST_REQUEST: { - return state.setIn(['entry', 'isPersisting'], true); - } - - case ENTRY_PERSIST_FAILURE:{ - return state.deleteIn(['entry', 'isPersisting']); - } - - case ENTRY_PERSIST_SUCCESS: - return state.withMutations(state => { - state.deleteIn(['entry', 'isPersisting']); - state.set('hasChanged', false); - if (!state.getIn(['entry', 'slug'])) { - state.setIn(['entry', 'slug'], action.payload.slug); - } - }); - - case ENTRY_DELETE_SUCCESS: - return state.withMutations(state => { - state.deleteIn(['entry', 'isPersisting']); - state.set('hasChanged', false); - }); - - case ADD_DRAFT_ENTRY_MEDIA_FILE: { - return state.withMutations(state => { - const mediaFiles = state.getIn(['entry', 'mediaFiles']); - - state.setIn( - ['entry', 'mediaFiles'], - mediaFiles - .filterNot(file => file.get('id') === action.payload.id) - .insert(0, fromJS(action.payload)), - ); - state.set('hasChanged', true); - }); - } - - case REMOVE_DRAFT_ENTRY_MEDIA_FILE: { - return state.withMutations(state => { - const mediaFiles = state.getIn(['entry', 'mediaFiles']); - - state.setIn( - ['entry', 'mediaFiles'], - mediaFiles.filterNot(file => file.get('id') === action.payload.id), - ); - state.set('hasChanged', true); - }); - } - - default: - return state; - } -} - -export function selectCustomPath(collection, entryDraft) { - if (!selectHasMetaPath(collection)) { - return; - } - const meta = entryDraft.getIn(['entry', 'meta']); - const path = meta && meta.get('path'); - const indexFile = get(collection.toJS(), ['meta', 'path', 'index_file']); - const extension = selectFolderEntryExtension(collection); - const customPath = path && join(collection.get('folder'), path, `${indexFile}.${extension}`); - return customPath; -} - -export default entryDraftReducer; diff --git a/src/reducers/entryDraft.ts b/src/reducers/entryDraft.ts new file mode 100644 index 00000000..924eff20 --- /dev/null +++ b/src/reducers/entryDraft.ts @@ -0,0 +1,292 @@ +import get from 'lodash/get'; +import isEqual from 'lodash/isEqual'; +import { v4 as uuid } from 'uuid'; + +import { + ADD_DRAFT_ENTRY_MEDIA_FILE, + DRAFT_CHANGE_FIELD, + DRAFT_CLEAR_ERRORS, + DRAFT_CREATE_DUPLICATE_FROM_ENTRY, + DRAFT_CREATE_EMPTY, + DRAFT_CREATE_FROM_ENTRY, + DRAFT_CREATE_FROM_LOCAL_BACKUP, + DRAFT_DISCARD, + DRAFT_LOCAL_BACKUP_DELETE, + DRAFT_LOCAL_BACKUP_RETRIEVED, + DRAFT_VALIDATION_ERRORS, + ENTRY_DELETE_SUCCESS, + ENTRY_PERSIST_FAILURE, + ENTRY_PERSIST_REQUEST, + ENTRY_PERSIST_SUCCESS, + REMOVE_DRAFT_ENTRY_MEDIA_FILE, +} from '../actions/entries'; +import { duplicateI18nFields, getDataPath } from '../lib/i18n'; +import { set } from '../lib/util/object.util'; + +import type { EntriesAction } from '../actions/entries'; +import type { Entry, FieldsErrors } from '../interface'; + +export interface EntryDraftState { + entry?: Entry; + fieldsErrors: FieldsErrors; + hasChanged: boolean; + key: string; + localBackup?: { + entry: Entry; + }; +} + +const initialState: EntryDraftState = { + fieldsErrors: {}, + hasChanged: false, + key: '', +}; + +function entryDraftReducer( + state: EntryDraftState = initialState, + action: EntriesAction, +): EntryDraftState { + switch (action.type) { + case DRAFT_CREATE_FROM_ENTRY: { + const newState = { ...state }; + delete newState.localBackup; + + // Existing Entry + return { + ...newState, + entry: { + ...action.payload.entry, + newRecord: false, + }, + fieldsErrors: {}, + hasChanged: false, + key: uuid(), + }; + } + case DRAFT_CREATE_EMPTY: { + const newState = { ...state }; + delete newState.localBackup; + + // New Entry + return { + ...newState, + entry: { + ...action.payload, + newRecord: true, + }, + fieldsErrors: {}, + hasChanged: false, + key: uuid(), + }; + } + case DRAFT_CREATE_FROM_LOCAL_BACKUP: { + const backupDraftEntry = state.localBackup; + if (!backupDraftEntry) { + return state; + } + + const backupEntry = backupDraftEntry?.['entry']; + + const newState = { ...state }; + delete newState.localBackup; + + // Local Backup + return { + ...state, + entry: { + ...backupEntry, + newRecord: !backupEntry?.path, + }, + fieldsErrors: {}, + hasChanged: true, + key: uuid(), + }; + } + + case DRAFT_CREATE_DUPLICATE_FROM_ENTRY: { + const newState = { ...state }; + delete newState.localBackup; + + // Duplicate Entry + return { + ...newState, + entry: { + ...action.payload, + newRecord: true, + }, + fieldsErrors: {}, + hasChanged: true, + }; + } + case DRAFT_DISCARD: + return initialState; + case DRAFT_LOCAL_BACKUP_RETRIEVED: { + const { entry } = action.payload; + const newState = { + entry, + }; + return { + ...state, + localBackup: newState, + }; + } + + case DRAFT_LOCAL_BACKUP_DELETE: { + const newState = { ...state }; + delete newState.localBackup; + return newState; + } + + case DRAFT_CHANGE_FIELD: { + let newState = { ...state }; + if (!newState.entry) { + return state; + } + + const { path, field, value, entry, i18n } = action.payload; + const dataPath = (i18n && getDataPath(i18n.currentLocale, i18n.defaultLocale)) || ['data']; + + newState = { + ...newState, + entry: set(newState.entry, `${dataPath.join('.')}.${path}`, value), + }; + + if (i18n) { + newState = duplicateI18nFields(newState, field, i18n.locales, i18n.defaultLocale); + } + + const newData = get(newState.entry, dataPath) ?? {}; + + return { + ...newState, + hasChanged: !entry || !isEqual(newData, get(entry, dataPath)), + }; + } + + case DRAFT_VALIDATION_ERRORS: { + const { path, errors } = action.payload; + const fieldsErrors = { ...state.fieldsErrors }; + if (errors.length === 0) { + delete fieldsErrors[path]; + } else { + fieldsErrors[path] = action.payload.errors; + } + return { + ...state, + fieldsErrors, + }; + } + + case DRAFT_CLEAR_ERRORS: { + return { + ...state, + fieldsErrors: {}, + }; + } + + case ENTRY_PERSIST_REQUEST: { + if (!state.entry) { + return state; + } + + return { + ...state, + entry: { + ...state.entry, + isPersisting: true, + }, + }; + } + + case ENTRY_PERSIST_FAILURE: { + if (!state.entry) { + return state; + } + + return { + ...state, + entry: { + ...state.entry, + isPersisting: false, + }, + }; + } + + case ENTRY_PERSIST_SUCCESS: { + if (!state.entry) { + return state; + } + + const newState = { ...state }; + delete newState.localBackup; + + return { + ...newState, + hasChanged: false, + entry: { + ...state.entry, + slug: action.payload.slug, + isPersisting: false, + }, + }; + } + + case ENTRY_DELETE_SUCCESS: { + if (!state.entry) { + return state; + } + + const newState = { ...state }; + delete newState.localBackup; + + return { + ...newState, + hasChanged: false, + entry: { + ...state.entry, + isPersisting: false, + }, + }; + } + + case ADD_DRAFT_ENTRY_MEDIA_FILE: { + if (!state.entry) { + return state; + } + + const mediaFiles = state.entry.mediaFiles.filter(file => file.id !== action.payload.id); + mediaFiles.unshift(action.payload); + + return { + ...state, + hasChanged: true, + entry: { + ...state.entry, + mediaFiles, + }, + }; + } + + case REMOVE_DRAFT_ENTRY_MEDIA_FILE: { + if (!state.entry) { + return state; + } + + const mediaFiles = state.entry.mediaFiles.filter(file => file.id !== action.payload.id); + + return { + ...state, + hasChanged: true, + entry: { + ...state.entry, + mediaFiles, + }, + }; + } + + default: + return state; + } +} + +export default entryDraftReducer; diff --git a/src/reducers/globalUI.ts b/src/reducers/globalUI.ts index cdeaf028..05c268b6 100644 --- a/src/reducers/globalUI.ts +++ b/src/reducers/globalUI.ts @@ -1,25 +1,29 @@ -import { produce } from 'immer'; - import type { AnyAction } from 'redux'; -export type GlobalUI = { +export type GlobalUIState = { isFetching: boolean; }; -const defaultState: GlobalUI = { +const defaultState: GlobalUIState = { isFetching: false, }; /** * Reducer for some global UI state that we want to share between components */ -const globalUI = produce((state: GlobalUI, action: AnyAction) => { +const globalUI = (state: GlobalUIState = defaultState, action: AnyAction): GlobalUIState => { // Generic, global loading indicator - if (!action.type.includes('REQUEST')) { - state.isFetching = true; + if (action.type.includes('REQUEST')) { + return { + isFetching: true, + }; } else if (action.type.includes('SUCCESS') || action.type.includes('FAILURE')) { - state.isFetching = false; + return { + isFetching: false, + }; } -}, defaultState); + + return state; +}; export default globalUI; diff --git a/src/reducers/index.ts b/src/reducers/index.ts index b98e0af2..5871b061 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -1,20 +1,20 @@ -import { List } from 'immutable'; - import auth from './auth'; -import config from './config'; -import integrations, * as fromIntegrations from './integrations'; -import entries, * as fromEntries from './entries'; -import cursors from './cursors'; -import entryDraft from './entryDraft'; import collections from './collections'; -import search from './search'; -import medias from './medias'; -import mediaLibrary from './mediaLibrary'; +import config from './config'; +import cursors from './cursors'; +import entries, * as fromEntries from './entries'; +import entryDraft from './entryDraft'; import globalUI from './globalUI'; -import status from './status'; +import integrations, * as fromIntegrations from './integrations'; +import mediaLibrary from './mediaLibrary'; +import medias from './medias'; import scroll from './scroll'; +import search from './search'; +import status from './status'; -import type { State, Collection } from '../types/redux'; +import type { Collection } from '../interface'; +import type { RootState } from '../store'; +import type { IntegrationHooks } from './integrations'; const reducers = { auth, @@ -37,25 +37,29 @@ export default reducers; /* * Selectors */ -export function selectEntry(state: State, collection: string, slug: string) { +export function selectEntry(state: RootState, collection: string, slug: string) { return fromEntries.selectEntry(state.entries, collection, slug); } -export function selectEntries(state: State, collection: Collection) { +export function selectEntries(state: RootState, collection: Collection) { return fromEntries.selectEntries(state.entries, collection); } -export function selectPublishedSlugs(state: State, collection: string) { +export function selectPublishedSlugs(state: RootState, collection: string) { return fromEntries.selectPublishedSlugs(state.entries, collection); } -export function selectSearchedEntries(state: State, availableCollections: string[]) { +export function selectSearchedEntries(state: RootState, availableCollections: string[]) { // only return search results for actually available collections - return List(state.search.entryIds) + return state.search.entryIds .filter(entryId => availableCollections.indexOf(entryId!.collection) !== -1) .map(entryId => fromEntries.selectEntry(state.entries, entryId!.collection, entryId!.slug)); } -export function selectIntegration(state: State, collection: string | null, hook: string) { +export function selectIntegration( + state: RootState, + collection: string | null, + hook: K, +): IntegrationHooks[K] | false { return fromIntegrations.selectIntegration(state.integrations, collection, hook); } diff --git a/src/reducers/integrations.ts b/src/reducers/integrations.ts index b20b99b9..3fc80b0b 100644 --- a/src/reducers/integrations.ts +++ b/src/reducers/integrations.ts @@ -1,47 +1,69 @@ -import { fromJS } from 'immutable'; +import get from 'lodash/get'; import { CONFIG_SUCCESS } from '../actions/config'; import type { ConfigAction } from '../actions/config'; -import type { CmsConfig } from '../interface'; -import type { Integrations } from '../types/redux'; +import type { + AlgoliaConfig, + AssetStoreConfig, + Config, + MediaIntegrationProvider, + SearchIntegrationProvider, +} from '../interface'; -interface Acc { - providers: Record; - hooks: Record>; +export interface IntegrationHooks { + search?: SearchIntegrationProvider; + listEntries?: SearchIntegrationProvider; + assetStore?: MediaIntegrationProvider; } -export function getIntegrations(config: CmsConfig) { +export interface IntegrationsState { + providers: { + algolia?: AlgoliaConfig; + assetStore?: AssetStoreConfig; + }; + hooks: IntegrationHooks; + collectionHooks: Record; +} + +export function getIntegrations(config: Config): IntegrationsState { const integrations = config.integrations || []; const newState = integrations.reduce( (acc, integration) => { - const { hooks, collections, provider, ...providerData } = integration; - acc.providers[provider] = { ...providerData }; - if (!collections) { - hooks.forEach(hook => { - acc.hooks[hook] = provider; - }); - return acc; - } + const { collections, ...providerData } = integration; const integrationCollections = collections === '*' ? config.collections.map(collection => collection.name) : collections; - integrationCollections.forEach(collection => { - hooks.forEach(hook => { - acc.hooks[collection] - ? ((acc.hooks[collection] as Record)[hook] = provider) - : (acc.hooks[collection] = { [hook]: provider }); + + if (providerData.provider === 'algolia') { + acc.providers[providerData.provider] = providerData; + + if (!collections) { + providerData.hooks.forEach(hook => (acc.hooks[hook] = providerData.provider)); + return acc; + } + + integrationCollections?.forEach(collection => { + providerData.hooks.forEach( + hook => (acc.collectionHooks[collection][hook] = providerData.provider), + ); }); - }); + } else if (providerData.provider === 'assetStore') { + acc.providers[providerData.provider] = providerData; + } return acc; }, - { providers: {}, hooks: {} } as Acc, + { providers: {}, hooks: {} } as IntegrationsState, ); - return fromJS(newState); + + return newState; } -const defaultState = fromJS({ providers: {}, hooks: {} }); +const defaultState: IntegrationsState = { providers: {}, hooks: {}, collectionHooks: {} }; -function integrations(state = defaultState, action: ConfigAction): Integrations | null { +function integrations( + state: IntegrationsState = defaultState, + action: ConfigAction, +): IntegrationsState { switch (action.type) { case CONFIG_SUCCESS: { return getIntegrations(action.payload); @@ -51,10 +73,14 @@ function integrations(state = defaultState, action: ConfigAction): Integrations } } -export function selectIntegration(state: Integrations, collection: string | null, hook: string) { +export function selectIntegration( + state: IntegrationsState, + collection: string | null, + hook: string, +): IntegrationHooks[K] | false { return collection - ? state.getIn(['hooks', collection, hook], false) - : state.getIn(['hooks', hook], false); + ? get(state, ['collectionHooks', collection, hook], false) + : get(state, ['hooks', hook], false); } export default integrations; diff --git a/src/reducers/mediaLibrary.ts b/src/reducers/mediaLibrary.ts index 813c99a4..680c4034 100644 --- a/src/reducers/mediaLibrary.ts +++ b/src/reducers/mediaLibrary.ts @@ -1,116 +1,148 @@ -import { Map, List } from 'immutable'; -import uuid from 'uuid/v4'; +import get from 'lodash/get'; import { dirname } from 'path'; +import { v4 as uuid } from 'uuid'; import { - MEDIA_LIBRARY_OPEN, - MEDIA_LIBRARY_CLOSE, - MEDIA_LIBRARY_CREATE, - MEDIA_INSERT, - MEDIA_REMOVE_INSERTED, - MEDIA_LOAD_REQUEST, - MEDIA_LOAD_SUCCESS, - MEDIA_LOAD_FAILURE, - MEDIA_PERSIST_REQUEST, - MEDIA_PERSIST_SUCCESS, - MEDIA_PERSIST_FAILURE, + MEDIA_DELETE_FAILURE, MEDIA_DELETE_REQUEST, MEDIA_DELETE_SUCCESS, - MEDIA_DELETE_FAILURE, + MEDIA_DISPLAY_URL_FAILURE, MEDIA_DISPLAY_URL_REQUEST, MEDIA_DISPLAY_URL_SUCCESS, - MEDIA_DISPLAY_URL_FAILURE, + MEDIA_INSERT, + MEDIA_LIBRARY_CLOSE, + MEDIA_LIBRARY_CREATE, + MEDIA_LIBRARY_OPEN, + MEDIA_LOAD_FAILURE, + MEDIA_LOAD_REQUEST, + MEDIA_LOAD_SUCCESS, + MEDIA_PERSIST_FAILURE, + MEDIA_PERSIST_REQUEST, + MEDIA_PERSIST_SUCCESS, + MEDIA_REMOVE_INSERTED, } from '../actions/mediaLibrary'; -import { selectEditingDraft, selectMediaFolder } from './entries'; import { selectIntegration } from './'; +import { selectEditingDraft } from './entries'; +import { selectMediaFolder } from '../lib/util/media.util'; import type { MediaLibraryAction } from '../actions/mediaLibrary'; -import type { - State, - MediaLibraryInstance, - MediaFile, - MediaFileMap, - DisplayURLState, - EntryField, -} from '../types/redux'; +import type { Field, DisplayURLState, MediaFile, MediaLibraryInstance } from '../interface'; +import type { RootState } from '../store'; -const defaultState: { +export interface MediaLibraryDisplayURL { + url?: string; + isFetching: boolean; + err?: unknown; +} + +export type MediaLibraryState = { isVisible: boolean; showMediaButton: boolean; - controlMedia: Map; - displayURLs: Map; + controlMedia: Record; + displayURLs: Record; externalLibrary?: MediaLibraryInstance; controlID?: string; page?: number; files?: MediaFile[]; - config: Map; - field?: EntryField; + config: Record; + field?: Field; value?: string | string[]; replaceIndex?: number; -} = { - isVisible: false, - showMediaButton: true, - controlMedia: Map(), - displayURLs: Map(), - config: Map(), + canInsert?: boolean; + privateUpload?: boolean; + isLoading?: boolean; + dynamicSearch?: boolean; + dynamicSearchActive?: boolean; + dynamicSearchQuery?: string; + forImage?: boolean; + isPersisting?: boolean; + isDeleting?: boolean; + hasNextPage?: boolean; + isPaginating?: boolean; }; -function mediaLibrary(state = Map(defaultState), action: MediaLibraryAction) { +const defaultState: MediaLibraryState = { + isVisible: false, + showMediaButton: true, + controlMedia: {}, + displayURLs: {}, + config: {}, +}; + +function mediaLibrary( + state: MediaLibraryState = defaultState, + action: MediaLibraryAction, +): MediaLibraryState { switch (action.type) { case MEDIA_LIBRARY_CREATE: - return state.withMutations(map => { - map.set('externalLibrary', action.payload); - map.set('showMediaButton', action.payload.enableStandalone()); - }); + return { + ...state, + externalLibrary: action.payload, + showMediaButton: action.payload.enableStandalone(), + }; case MEDIA_LIBRARY_OPEN: { const { controlID, forImage, privateUpload, config, field, value, replaceIndex } = action.payload; - const libConfig = config || Map(); - const privateUploadChanged = state.get('privateUpload') !== privateUpload; + const libConfig = config || {}; + const privateUploadChanged = state.privateUpload !== privateUpload; if (privateUploadChanged) { - return Map({ + return { + ...state, isVisible: true, forImage, controlID, - canInsert: !!controlID, + canInsert: Boolean(controlID), privateUpload, config: libConfig, - controlMedia: Map(), - displayURLs: Map(), + controlMedia: {}, + displayURLs: {}, field, value, replaceIndex, - }); + }; } - return state.withMutations((map: Map) => { - map.set('isVisible', true); - map.set('forImage', forImage); - map.set('controlID', controlID); - map.set('canInsert', !!controlID); - map.set('privateUpload', privateUpload); - map.set('config', libConfig); - map.set('field', field); - map.set('value', value); - map.set('replaceIndex', replaceIndex); - }); + + return { + ...state, + isVisible: true, + forImage: Boolean(forImage), + controlID, + canInsert: !!controlID, + privateUpload: Boolean(privateUpload), + config: libConfig, + field, + value, + replaceIndex, + }; } case MEDIA_LIBRARY_CLOSE: - return state.set('isVisible', false); + return { + ...state, + isVisible: false, + }; case MEDIA_INSERT: { const { mediaPath } = action.payload; - const controlID = state.get('controlID'); - const value = state.get('value'); - - if (!Array.isArray(value)) { - return state.withMutations(map => { - map.setIn(['controlMedia', controlID], mediaPath); - }); + const controlID = state.controlID; + if (!controlID) { + return state; } - const replaceIndex = state.get('replaceIndex'); + const value = state.value; + + if (!Array.isArray(value)) { + return { + ...state, + controlMedia: { + ...state.controlMedia, + [controlID]: mediaPath, + }, + }; + } + + const replaceIndex = state.replaceIndex; const mediaArray = Array.isArray(mediaPath) ? mediaPath : [mediaPath]; const valueArray = value as string[]; if (typeof replaceIndex == 'number') { @@ -119,21 +151,33 @@ function mediaLibrary(state = Map(defaultState), action: MediaLibraryAction) { valueArray.push(...mediaArray); } - return state.withMutations(map => { - map.setIn(['controlMedia', controlID], valueArray); - }); + return { + ...state, + controlMedia: { + ...state.controlMedia, + [controlID]: valueArray, + }, + }; } case MEDIA_REMOVE_INSERTED: { const controlID = action.payload.controlID; - return state.setIn(['controlMedia', controlID], ''); + + return { + ...state, + controlMedia: { + ...state.controlMedia, + [controlID]: '', + }, + }; } case MEDIA_LOAD_REQUEST: - return state.withMutations(map => { - map.set('isLoading', true); - map.set('isPaginating', action.payload.page > 1); - }); + return { + ...state, + isLoading: true, + isPaginating: action.payload.page > 1, + }; case MEDIA_LOAD_SUCCESS: { const { @@ -144,111 +188,155 @@ function mediaLibrary(state = Map(defaultState), action: MediaLibraryAction) { dynamicSearchQuery, privateUpload, } = action.payload; - const privateUploadChanged = state.get('privateUpload') !== privateUpload; + const privateUploadChanged = state.privateUpload !== privateUpload; if (privateUploadChanged) { return state; } const filesWithKeys = files.map(file => ({ ...file, key: uuid() })); - return state.withMutations((map: Map) => { - map.set('isLoading', false); - map.set('isPaginating', false); - map.set('page', page); - map.set('hasNextPage', canPaginate && files.length > 0); - map.set('dynamicSearch', dynamicSearch); - map.set('dynamicSearchQuery', dynamicSearchQuery); - map.set('dynamicSearchActive', !!dynamicSearchQuery); - if (page && page > 1) { - const updatedFiles = (map.get('files') as MediaFile[]).concat(filesWithKeys); - map.set('files', updatedFiles); - } else { - map.set('files', filesWithKeys); - } - }); + return { + ...state, + isLoading: false, + isPaginating: false, + page: page ?? 1, + hasNextPage: Boolean(canPaginate && files.length > 0), + dynamicSearch: Boolean(dynamicSearch), + dynamicSearchQuery: dynamicSearchQuery ?? '', + dynamicSearchActive: !!dynamicSearchQuery, + files: + page && page > 1 ? (state.files as MediaFile[]).concat(filesWithKeys) : filesWithKeys, + }; } case MEDIA_LOAD_FAILURE: { - const privateUploadChanged = state.get('privateUpload') !== action.payload.privateUpload; + const privateUploadChanged = state.privateUpload !== action.payload.privateUpload; if (privateUploadChanged) { return state; } - return state.set('isLoading', false); + + return { + ...state, + isLoading: false, + }; } case MEDIA_PERSIST_REQUEST: - return state.set('isPersisting', true); + return { + ...state, + isPersisting: true, + }; case MEDIA_PERSIST_SUCCESS: { const { file, privateUpload } = action.payload; - const privateUploadChanged = state.get('privateUpload') !== privateUpload; + const privateUploadChanged = state.privateUpload !== privateUpload; if (privateUploadChanged) { return state; } - return state.withMutations(map => { - const fileWithKey = { ...file, key: uuid() }; - const files = map.get('files') as MediaFile[]; - const updatedFiles = [fileWithKey, ...files]; - map.set('files', updatedFiles); - map.set('isPersisting', false); - }); + + const fileWithKey = { ...file, key: uuid() }; + const files = state.files as MediaFile[]; + const updatedFiles = [fileWithKey, ...files]; + return { + ...state, + files: updatedFiles, + isPersisting: false, + }; } case MEDIA_PERSIST_FAILURE: { - const privateUploadChanged = state.get('privateUpload') !== action.payload.privateUpload; + const privateUploadChanged = state.privateUpload !== action.payload.privateUpload; if (privateUploadChanged) { return state; } - return state.set('isPersisting', false); + + return { + ...state, + isPersisting: false, + }; } case MEDIA_DELETE_REQUEST: - return state.set('isDeleting', true); + return { + ...state, + isDeleting: true, + }; case MEDIA_DELETE_SUCCESS: { const { file, privateUpload } = action.payload; const { key, id } = file; - const privateUploadChanged = state.get('privateUpload') !== privateUpload; + const privateUploadChanged = state.privateUpload !== privateUpload; if (privateUploadChanged) { return state; } - return state.withMutations(map => { - const files = map.get('files') as MediaFile[]; - const updatedFiles = files.filter(file => (key ? file.key !== key : file.id !== id)); - map.set('files', updatedFiles); - map.deleteIn(['displayURLs', id]); - map.set('isDeleting', false); - }); + + const files = state.files as MediaFile[]; + const updatedFiles = files.filter(file => (key ? file.key !== key : file.id !== id)); + + const displayURLs = { + ...state.displayURLs, + }; + + delete displayURLs[id]; + + return { + ...state, + files: updatedFiles, + displayURLs, + isDeleting: false, + }; } case MEDIA_DELETE_FAILURE: { - const privateUploadChanged = state.get('privateUpload') !== action.payload.privateUpload; + const privateUploadChanged = state.privateUpload !== action.payload.privateUpload; if (privateUploadChanged) { return state; } - return state.set('isDeleting', false); + + return { + ...state, + isDeleting: false, + }; } case MEDIA_DISPLAY_URL_REQUEST: - return state.setIn(['displayURLs', action.payload.key, 'isFetching'], true); + return { + ...state, + displayURLs: { + ...state.displayURLs, + [action.payload.key]: { + ...state.displayURLs[action.payload.key], + isFetching: true, + }, + }, + }; case MEDIA_DISPLAY_URL_SUCCESS: { - const displayURLPath = ['displayURLs', action.payload.key]; - return state - .setIn([...displayURLPath, 'isFetching'], false) - .setIn([...displayURLPath, 'url'], action.payload.url); + return { + ...state, + displayURLs: { + ...state.displayURLs, + [action.payload.key]: { + url: action.payload.url, + isFetching: false, + }, + }, + }; } case MEDIA_DISPLAY_URL_FAILURE: { - const displayURLPath = ['displayURLs', action.payload.key]; - return ( - state - .setIn([...displayURLPath, 'isFetching'], false) - // make sure that err is set so the CMS won't attempt to load - // the image again - .setIn([...displayURLPath, 'err'], action.payload.err || true) - .deleteIn([...displayURLPath, 'url']) - ); + const displayUrl = { ...state.displayURLs[action.payload.key] }; + delete displayUrl.url; + displayUrl.isFetching = false; + displayUrl.err = action.payload.err ?? true; + + return { + ...state, + displayURLs: { + ...state.displayURLs, + [action.payload.key]: displayUrl, + }, + }; } default: @@ -256,40 +344,41 @@ function mediaLibrary(state = Map(defaultState), action: MediaLibraryAction) { } } -export function selectMediaFiles(state: State, field?: EntryField) { +export function selectMediaFiles(state: RootState, field?: Field): MediaFile[] { const { mediaLibrary, entryDraft } = state; - const editingDraft = selectEditingDraft(state.entryDraft); + if (!entryDraft.entry) { + return []; + } + + const editingDraft = selectEditingDraft(entryDraft); const integration = selectIntegration(state, null, 'assetStore'); - let files; + let files: MediaFile[] = []; if (editingDraft && !integration) { - const entryFiles = entryDraft - .getIn(['entry', 'mediaFiles'], List()) - .toJS() as MediaFile[]; - const entry = entryDraft.get('entry'); - const collection = state.collections.get(entry?.get('collection')); - const mediaFolder = selectMediaFolder(state.config, collection, entry, field); - files = entryFiles - .filter(f => dirname(f.path) === mediaFolder) - .map(file => ({ key: file.id, ...file })); + const entryFiles = (get(entryDraft, ['entry', 'mediaFiles']) ?? []) as MediaFile[]; + const entry = entryDraft['entry']; + const collection = state.collections[entry?.collection]; + if (state.config.config) { + const mediaFolder = selectMediaFolder(state.config.config, collection, entry, field); + files = entryFiles + .filter(f => dirname(f.path) === mediaFolder) + .map(file => ({ key: file.id, ...file })); + } } else { - files = mediaLibrary.get('files') || []; + files = mediaLibrary.files || []; } return files; } -export function selectMediaFileByPath(state: State, path: string) { +export function selectMediaFileByPath(state: RootState, path: string) { const files = selectMediaFiles(state); const file = files.find(file => file.path === path); return file; } -export function selectMediaDisplayURL(state: State, id: string) { - const displayUrlState = state.mediaLibrary.getIn( - ['displayURLs', id], - Map() as unknown as DisplayURLState, - ); +export function selectMediaDisplayURL(state: RootState, id: string) { + const displayUrlState = (get(state.mediaLibrary, ['displayURLs', id]) ?? {}) as DisplayURLState; return displayUrlState; } diff --git a/src/reducers/medias.ts b/src/reducers/medias.ts index 778692da..51f5ab20 100644 --- a/src/reducers/medias.ts +++ b/src/reducers/medias.ts @@ -12,13 +12,13 @@ import { import type { MediasAction } from '../actions/media'; import type AssetProxy from '../valueObjects/AssetProxy'; -export type Medias = { +export interface MediasState { [path: string]: { asset: AssetProxy | undefined; isLoading: boolean; error: Error | null }; -}; +} -const defaultState: Medias = {}; +const defaultState: MediasState = {}; -const medias = produce((state: Medias, action: MediasAction) => { +const medias = produce((state: MediasState, action: MediasAction) => { switch (action.type) { case ADD_ASSETS: { const assets = action.payload; @@ -59,7 +59,7 @@ const medias = produce((state: Medias, action: MediasAction) => { } }, defaultState); -export function selectIsLoadingAsset(state: Medias) { +export function selectIsLoadingAsset(state: MediasState) { return Object.values(state).some(state => state.isLoading); } diff --git a/src/reducers/scroll.ts b/src/reducers/scroll.ts index 5f6a5322..a7fea961 100644 --- a/src/reducers/scroll.ts +++ b/src/reducers/scroll.ts @@ -4,9 +4,9 @@ import { SCROLL_SYNC_ENABLED, SET_SCROLL, TOGGLE_SCROLL } from '../actions/scrol import type { ScrollAction } from '../actions/scroll'; -export type ScrollState = { +export interface ScrollState { isScrolling: boolean; -}; +} const defaultState: ScrollState = { isScrolling: true, diff --git a/src/reducers/search.ts b/src/reducers/search.ts index 9e469cc6..cb3a3119 100644 --- a/src/reducers/search.ts +++ b/src/reducers/search.ts @@ -1,5 +1,3 @@ -import { produce } from 'immer'; - import { QUERY_FAILURE, QUERY_REQUEST, @@ -11,79 +9,80 @@ import { } from '../actions/search'; import type { SearchAction } from '../actions/search'; -import type { EntryValue } from '../valueObjects/Entry'; -export type Search = { +export interface SearchState { isFetching: boolean; term: string; collections: string[]; page: number; entryIds: { collection: string; slug: string }[]; - queryHits: Record; error: Error | undefined; -}; +} -const defaultState: Search = { +const defaultState: SearchState = { isFetching: false, term: '', collections: [], page: 0, entryIds: [], - queryHits: {}, error: undefined, }; -const search = produce((state: Search, action: SearchAction) => { +const search = (state: SearchState = defaultState, action: SearchAction): SearchState => { switch (action.type) { case SEARCH_CLEAR: return defaultState; case SEARCH_ENTRIES_REQUEST: { const { page, searchTerm, searchCollections } = action.payload; - state.isFetching = true; - state.term = searchTerm; - state.collections = searchCollections; - state.page = page; - break; + return { + ...state, + isFetching: true, + term: searchTerm, + collections: searchCollections, + page, + }; } case SEARCH_ENTRIES_SUCCESS: { const { entries, page } = action.payload; const entryIds = entries.map(entry => ({ collection: entry.collection, slug: entry.slug })); - state.isFetching = false; - state.page = page; - state.entryIds = - !page || isNaN(page) || page === 0 ? entryIds : state.entryIds.concat(entryIds); - break; + return { + ...state, + isFetching: false, + page, + entryIds: !page || isNaN(page) || page === 0 ? entryIds : state.entryIds.concat(entryIds), + }; } + case QUERY_FAILURE: case SEARCH_ENTRIES_FAILURE: { const { error } = action.payload; - state.isFetching = false; - state.error = error; - break; + return { + ...state, + isFetching: false, + error, + }; } case QUERY_REQUEST: { const { searchTerm } = action.payload; - state.isFetching = true; - state.term = searchTerm; - break; + return { + ...state, + isFetching: true, + term: searchTerm, + }; } case QUERY_SUCCESS: { - const { namespace, hits } = action.payload; - state.isFetching = false; - state.queryHits[namespace] = hits; - break; - } - - case QUERY_FAILURE: { - const { error } = action.payload; - state.isFetching = false; - state.error = error; + return { + ...state, + isFetching: false, + }; } } -}, defaultState); + + return state; +}; export default search; diff --git a/src/reducers/status.ts b/src/reducers/status.ts index cabf74ed..997e591a 100644 --- a/src/reducers/status.ts +++ b/src/reducers/status.ts @@ -4,16 +4,16 @@ import { STATUS_REQUEST, STATUS_SUCCESS, STATUS_FAILURE } from '../actions/statu import type { StatusAction } from '../actions/status'; -export type Status = { +export interface StatusState { isFetching: boolean; status: { auth: { status: boolean }; api: { status: boolean; statusPage: string }; }; error: Error | undefined; -}; +} -const defaultState: Status = { +const defaultState: StatusState = { isFetching: false, status: { auth: { status: true }, @@ -22,7 +22,7 @@ const defaultState: Status = { error: undefined, }; -const status = produce((state: Status, action: StatusAction) => { +const status = produce((state: StatusState, action: StatusAction) => { switch (action.type) { case STATUS_REQUEST: state.isFetching = true; diff --git a/src/store/slices/snackbars.ts b/src/store/slices/snackbars.ts index a197fc1f..25c3965f 100644 --- a/src/store/slices/snackbars.ts +++ b/src/store/slices/snackbars.ts @@ -9,9 +9,9 @@ type MessageType = 'error' | 'warning' | 'info' | 'success'; export interface SnackbarMessage { id: string; type: MessageType; - message: { - key: string, - } & Record; + message: string | { + key: string; + } & Record; } // Define a type for the slice state @@ -30,17 +30,13 @@ export const SnackbarSlice = createSlice({ initialState, reducers: { addSnackbar: (state, action: PayloadAction>) => { - const messages = [...state.messages]; - messages.push({ + state.messages.push({ id: uuid(), ...action.payload, }); - return { ...state, messages }; }, removeSnackbarById: (state, action: PayloadAction) => { - const messages = [...state.messages]; - const filteredMessages = messages.filter(message => message.id !== action.payload); - return { ...state, messages: filteredMessages }; + state.messages = state.messages.filter(message => message.id !== action.payload); }, }, }); diff --git a/src/types/constants.d.ts b/src/types/constants.d.ts new file mode 100644 index 00000000..a94128ec --- /dev/null +++ b/src/types/constants.d.ts @@ -0,0 +1 @@ +declare const STATIC_CMS_CORE_VERSION: string; diff --git a/src/types/css.d.ts b/src/types/css.d.ts new file mode 100644 index 00000000..31f07ea5 --- /dev/null +++ b/src/types/css.d.ts @@ -0,0 +1,4 @@ +declare module '*.css' { + const content: string; + export default content; +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 92d34243..8cfb28f0 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -1,10 +1,16 @@ export {}; -declare global { - import type { CmsConfig } from './interface'; +import type { Config } from '../interface'; +import type CmsAPI from '../index'; +import type createReactClass from 'create-react-class'; +import type { createElement } from 'react'; +declare global { interface Window { - CMS_CONFIG?: CmsConfig; + CMS?: CmsAPI; + CMS_CONFIG?: Config; CMS_ENV?: string; + createClass: createReactClass; + h: createElement; } } diff --git a/src/types/immutable.ts b/src/types/immutable.ts deleted file mode 100644 index 0c482193..00000000 --- a/src/types/immutable.ts +++ /dev/null @@ -1,40 +0,0 @@ -export interface StaticallyTypedRecord { - get(key: K, defaultValue?: T[K]): T[K]; - set(key: K, value: V): StaticallyTypedRecord & T; - has(key: K): boolean; - delete(key: K): StaticallyTypedRecord; - getIn( - keys: [K1, K2], - defaultValue?: V, - ): T[K1][K2]; - getIn< - K1 extends keyof T, - K2 extends keyof T[K1], - K3 extends keyof T[K1][K2], - V extends T[K1][K2][K3], - >( - keys: [K1, K2, K3], - defaultValue?: V, - ): T[K1][K2][K3]; - getIn(keys: string[]): unknown; - setIn( - keys: [K1, K2], - value: V, - ): StaticallyTypedRecord; - setIn(keys: string[], value: unknown): StaticallyTypedRecord & T; - toJS(): T; - isEmpty(): boolean; - some(predicate: (value: T[K], key: K, iter: this) => boolean): boolean; - mapKeys(mapFunc: (key: K, value: StaticallyTypedRecord) => V): V[]; - find(findFunc: (value: T[K]) => boolean): T[K]; - filter( - predicate: (value: T[K], key: K, iter: this) => boolean, - ): StaticallyTypedRecord; - valueSeq(): T[K][] & { toArray: () => T[K][] }; - map( - mapFunc: (value: T[K]) => V, - ): StaticallyTypedRecord<{ [key: string]: V }>; - keySeq(): { toArray: () => K[] }; - withMutations(mutator: (mutable: StaticallyTypedRecord) => unknown): StaticallyTypedRecord; - first(): any; -} diff --git a/src/backends/git-gateway/types/ini.d.ts b/src/types/ini.d.ts similarity index 100% rename from src/backends/git-gateway/types/ini.d.ts rename to src/types/ini.d.ts diff --git a/src/types/markdown.d.ts b/src/types/markdown.d.ts new file mode 100644 index 00000000..9a793475 --- /dev/null +++ b/src/types/markdown.d.ts @@ -0,0 +1,5 @@ +declare module 'mdast-util-definitions'; +declare module 'unist-builder'; +declare module 'rehype-stringify'; +declare module 'remark-parse'; +declare module 'remark-rehype'; diff --git a/src/types/redux.ts b/src/types/redux.ts deleted file mode 100644 index 5920dfd6..00000000 --- a/src/types/redux.ts +++ /dev/null @@ -1,674 +0,0 @@ -import type { List, Map, OrderedMap, Set } from 'immutable'; -import type { Action } from 'redux'; -import type { MediaFile as BackendMediaFile } from '../backend'; -import type { formatExtensions } from '../formats/formats'; -import type { CmsConfig, CmsSortableFields, SortDirection, ViewFilter, ViewGroup } from '../interface'; -import type { Auth } from '../reducers/auth'; -import type { GlobalUI } from '../reducers/globalUI'; -import type { Medias } from '../reducers/medias'; -import type { ScrollState } from '../reducers/scroll'; -import type { Search } from '../reducers/search'; -import type { Status } from '../reducers/status'; -import type { SnackbarState } from '../store/slices/snackbars'; -import type { StaticallyTypedRecord } from './immutable'; - -export type CmsBackendType = - | 'azure' - | 'git-gateway' - | 'github' - | 'gitlab' - | 'bitbucket' - | 'test-repo' - | 'proxy'; - -export type CmsMapWidgetType = 'Point' | 'LineString' | 'Polygon'; - -export type CmsMarkdownWidgetButton = - | 'bold' - | 'italic' - | 'code' - | 'link' - | 'heading-one' - | 'heading-two' - | 'heading-three' - | 'heading-four' - | 'heading-five' - | 'heading-six' - | 'quote' - | 'code-block' - | 'bulleted-list' - | 'numbered-list'; - -export interface CmsSelectWidgetOptionObject { - label: string; - value: unknown; -} - -export type CmsCollectionFormatType = - | 'yml' - | 'yaml' - | 'toml' - | 'json' - | 'frontmatter' - | 'yaml-frontmatter' - | 'toml-frontmatter' - | 'json-frontmatter'; - -export type CmsAuthScope = 'repo' | 'public_repo'; - -export type CmsSlugEncoding = 'unicode' | 'ascii'; - -export interface CmsI18nConfig { - structure: 'multiple_folders' | 'multiple_files' | 'single_file'; - locales: string[]; - default_locale?: string; -} - -export interface CmsFieldBase { - name: string; - label?: string; - required?: boolean; - hint?: string; - pattern?: [string, string]; - i18n?: boolean | 'translate' | 'duplicate' | 'none'; - media_folder?: string; - public_folder?: string; - comment?: string; -} - -export interface CmsFieldBoolean { - widget: 'boolean'; - default?: boolean; -} - -export interface CmsFieldCode { - widget: 'code'; - default?: unknown; - - default_language?: string; - allow_language_selection?: boolean; - keys?: { code: string; lang: string }; - output_code_only?: boolean; -} - -export interface CmsFieldColor { - widget: 'color'; - default?: string; - - allowInput?: boolean; - enableAlpha?: boolean; -} - -export interface CmsFieldDateTime { - widget: 'datetime'; - default?: string; - - format?: string; - date_format?: boolean | string; - time_format?: boolean | string; - picker_utc?: boolean; - - /** - * @deprecated Use date_format instead - */ - dateFormat?: boolean | string; - /** - * @deprecated Use time_format instead - */ - timeFormat?: boolean | string; - /** - * @deprecated Use picker_utc instead - */ - pickerUtc?: boolean; -} - -export interface CmsFieldFileOrImage { - widget: 'file' | 'image'; - default?: string; - - media_library?: CmsMediaLibrary; - allow_multiple?: boolean; - config?: unknown; -} - -export interface CmsFieldObject { - widget: 'object'; - default?: unknown; - - collapsed?: boolean; - summary?: string; - fields: CmsField[]; -} - -export interface CmsFieldList { - widget: 'list'; - default?: unknown; - - allow_add?: boolean; - collapsed?: boolean; - summary?: string; - minimize_collapsed?: boolean; - label_singular?: string; - field?: CmsField; - fields?: CmsField[]; - max?: number; - min?: number; - add_to_top?: boolean; - types?: (CmsFieldBase & CmsFieldObject)[]; -} - -export interface CmsFieldMap { - widget: 'map'; - default?: string; - - decimals?: number; - type?: CmsMapWidgetType; -} - -export interface CmsFieldMarkdown { - widget: 'markdown'; - default?: string; - - minimal?: boolean; - buttons?: CmsMarkdownWidgetButton[]; - editor_components?: string[]; - modes?: ('raw' | 'rich_text')[]; - - /** - * @deprecated Use editor_components instead - */ - editorComponents?: string[]; -} - -export interface CmsFieldNumber { - widget: 'number'; - default?: string | number; - - value_type?: 'int' | 'float' | string; - min?: number; - max?: number; - - step?: number; - - /** - * @deprecated Use valueType instead - */ - valueType?: 'int' | 'float' | string; -} - -export interface CmsFieldSelect { - widget: 'select'; - default?: string | string[]; - - options: string[] | CmsSelectWidgetOptionObject[]; - multiple?: boolean; - min?: number; - max?: number; -} - -export interface CmsFieldRelation { - widget: 'relation'; - default?: string | string[]; - - collection: string; - value_field: string; - search_fields: string[]; - file?: string; - display_fields?: string[]; - multiple?: boolean; - options_length?: number; - - /** - * @deprecated Use value_field instead - */ - valueField?: string; - /** - * @deprecated Use search_fields instead - */ - searchFields?: string[]; - /** - * @deprecated Use display_fields instead - */ - displayFields?: string[]; - /** - * @deprecated Use options_length instead - */ - optionsLength?: number; -} - -export interface CmsFieldHidden { - widget: 'hidden'; - default?: unknown; -} - -export interface CmsFieldStringOrText { - // This is the default widget, so declaring its type is optional. - widget?: 'string' | 'text'; - default?: string; -} - -export interface CmsFieldMeta { - name: string; - label: string; - widget: string; - required: boolean; - index_file: string; - meta: boolean; -} - -export type CmsField = CmsFieldBase & - ( - | CmsFieldBoolean - | CmsFieldCode - | CmsFieldColor - | CmsFieldDateTime - | CmsFieldFileOrImage - | CmsFieldList - | CmsFieldMap - | CmsFieldMarkdown - | CmsFieldNumber - | CmsFieldObject - | CmsFieldRelation - | CmsFieldSelect - | CmsFieldHidden - | CmsFieldStringOrText - | CmsFieldMeta - ); - -export interface CmsCollectionFile { - name: string; - label: string; - file: string; - fields: CmsField[]; - label_singular?: string; - description?: string; - preview_path?: string; - preview_path_date_field?: string; - i18n?: boolean | CmsI18nConfig; - media_folder?: string; - public_folder?: string; -} - -export interface CmsBackend { - name: CmsBackendType; - auth_scope?: CmsAuthScope; - repo?: string; - branch?: string; - api_root?: string; - site_domain?: string; - base_url?: string; - auth_endpoint?: string; - proxy_url?: string; - commit_messages?: { - create?: string; - update?: string; - delete?: string; - uploadMedia?: string; - deleteMedia?: string; - }; -} - -export interface CmsSlug { - encoding?: CmsSlugEncoding; - clean_accents?: boolean; - sanitize_replacement?: string; -} - -export interface CmsLocalBackend { - url?: string; - allowed_hosts?: string[]; -} - -export type CmsMediaLibraryOptions = unknown; // TODO: type properly - -export interface CmsMediaLibrary { - name: string; - config?: CmsMediaLibraryOptions; -} - -export type SlugConfig = StaticallyTypedRecord<{ - encoding: string; - clean_accents: boolean; - sanitize_replacement: string; -}>; - -type BackendObject = { - name: string; - repo?: string | null; - branch?: string; - api_root?: string; - use_graphql?: boolean; - preview_context?: string; - identity_url?: string; - gateway_url?: string; - large_media_url?: string; - use_large_media_transforms_in_media_library?: boolean; - commit_messages: Map; -}; - -type Backend = StaticallyTypedRecord & BackendObject; - -export type Config = StaticallyTypedRecord<{ - backend: Backend; - media_folder: string; - public_folder: string; - media_library: StaticallyTypedRecord<{ name: string }> & { name: string }; - locale?: string; - slug: SlugConfig; - media_folder_relative?: boolean; - base_url?: string; - site_id?: string; - site_url?: string; - isFetching?: boolean; - integrations: List; - collections: List>; -}>; - -type PagesObject = { - [collection: string]: { isFetching: boolean; page: number; ids: List }; -}; - -type Pages = StaticallyTypedRecord; - -type EntitiesObject = { [key: string]: EntryMap }; - -export type SortObject = { key: string; direction: SortDirection }; - -export type SortMap = OrderedMap>; - -export type Sort = Map; - -export type FilterMap = StaticallyTypedRecord; - -export type GroupMap = StaticallyTypedRecord; - -export type Filter = Map>; // collection.field.active - -export type Group = Map>; // collection.field.active - -export type GroupOfEntries = { - id: string; - label: string; - value: string | boolean | undefined; - paths: Set; -}; - -export type Entities = StaticallyTypedRecord; - -export type Entries = StaticallyTypedRecord<{ - pages: Pages & PagesObject; - entities: Entities & EntitiesObject; - sort: Sort; - filter: Filter; - group: Group; - viewStyle: string; -}>; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type EntryObject = { - path: string; - slug: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: any; - collection: string; - mediaFiles: List; - newRecord: boolean; - author?: string; - updatedOn?: string; - status: string; - meta: StaticallyTypedRecord<{ path: string }>; -}; - -export type EntryMap = StaticallyTypedRecord; - -export type Entry = EntryMap & EntryObject; - -export type FieldsErrors = StaticallyTypedRecord<{ [field: string]: { type: string }[] }>; - -export type EntryDraft = StaticallyTypedRecord<{ - entry: Entry; - fieldsErrors: FieldsErrors; - fieldsMetaData?: Map>; -}>; - -export type EntryField = StaticallyTypedRecord<{ - field?: EntryField; - fields?: List; - types?: List; - widget: string; - name: string; - default: string | null | boolean | List; - media_folder?: string; - public_folder?: string; - comment?: string; - meta?: boolean; - i18n: 'translate' | 'duplicate' | 'none'; -}>; - -export type EntryFields = List; - -export type FilterRule = StaticallyTypedRecord<{ - value: string; - field: string; -}>; - -export type CollectionFile = StaticallyTypedRecord<{ - file: string; - name: string; - fields: EntryFields; - label: string; - media_folder?: string; - public_folder?: string; - preview_path?: string; - preview_path_date_field?: string; -}>; - -export type CollectionFiles = List; - -type NestedObject = { depth: number }; - -type Nested = StaticallyTypedRecord; - -type PathObject = { label: string; widget: string; index_file: string }; - -type MetaObject = { - path?: StaticallyTypedRecord; -}; - -type Meta = StaticallyTypedRecord; - -type i18n = StaticallyTypedRecord<{ - structure: string; - locales: string[]; - default_locale: string; -}>; - -export type Format = keyof typeof formatExtensions; - -type CollectionObject = { - name: string; - icon?: string; - folder?: string; - files?: CollectionFiles; - fields: EntryFields; - isFetching: boolean; - media_folder?: string; - public_folder?: string; - preview_path?: string; - preview_path_date_field?: string; - summary?: string; - filter?: FilterRule; - type: 'file_based_collection' | 'folder_based_collection'; - extension?: string; - format?: Format; - frontmatter_delimiter?: List | string | [string, string]; - create?: boolean; - delete?: boolean; - identifier_field?: string; - path?: string; - slug?: string; - label_singular?: string; - label: string; - sortable_fields: StaticallyTypedRecord; - view_filters: List>; - view_groups: List>; - nested?: Nested; - meta?: Meta; - i18n: i18n; -}; - -export type Collection = StaticallyTypedRecord; - -export type Collections = StaticallyTypedRecord<{ [path: string]: Collection & CollectionObject }>; - -export interface MediaLibraryInstance { - show: (args: { - id?: string; - value?: string; - config: StaticallyTypedRecord<{}>; - allowMultiple?: boolean; - imagesOnly?: boolean; - }) => void; - hide: () => void; - onClearControl: (args: { id: string }) => void; - onRemoveControl: (args: { id: string }) => void; - enableStandalone: () => boolean; -} - -export type DisplayURL = { id: string; path: string } | string; - -export type MediaFile = BackendMediaFile & { key?: string }; - -export type MediaFileMap = StaticallyTypedRecord; - -type DisplayURLStateObject = { - isFetching: boolean; - url?: string; - err?: Error; -}; - -export type DisplayURLState = StaticallyTypedRecord; - -interface DisplayURLsObject { - [id: string]: DisplayURLState; -} - -export type MediaLibrary = StaticallyTypedRecord<{ - externalLibrary?: MediaLibraryInstance; - files: MediaFile[]; - displayURLs: StaticallyTypedRecord & DisplayURLsObject; - isLoading: boolean; -}>; - -export type Hook = string | boolean; - -export type Integrations = StaticallyTypedRecord<{ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - hooks: { [collectionOrHook: string]: any }; -}>; - -export type Cursors = StaticallyTypedRecord<{}>; - -export interface State { - auth: Auth; - config: CmsConfig; - cursors: Cursors; - collections: Collections; - globalUI: GlobalUI; - entries: Entries; - entryDraft: EntryDraft; - integrations: Integrations; - medias: Medias; - mediaLibrary: MediaLibrary; - search: Search; - status: Status; - scroll: ScrollState; - snackbar: SnackbarState; -} - -export interface Integration { - hooks: string[]; - collections?: string | string[]; - provider: string; -} - -interface EntryPayload { - collection: string; -} - -export interface EntryRequestPayload extends EntryPayload { - slug: string; -} - -export interface EntrySuccessPayload extends EntryPayload { - entry: EntryObject; -} - -export interface EntryFailurePayload extends EntryPayload { - slug: string; - error: Error; -} - -export interface EntryDeletePayload { - entrySlug: string; - collectionName: string; -} - -export type EntriesRequestPayload = EntryPayload; - -export interface EntriesSuccessPayload extends EntryPayload { - entries: EntryObject[]; - append: boolean; - page: number; -} -export interface EntriesSortRequestPayload extends EntryPayload { - key: string; - direction: string; -} - -export interface EntriesSortFailurePayload extends EntriesSortRequestPayload { - error: Error; -} - -export interface EntriesFilterRequestPayload { - filter: ViewFilter; - collection: string; -} - -export interface EntriesFilterFailurePayload { - filter: ViewFilter; - collection: string; - error: Error; -} - -export interface EntriesGroupRequestPayload { - group: ViewGroup; - collection: string; -} - -export interface EntriesGroupFailurePayload { - group: ViewGroup; - collection: string; - error: Error; -} - -export interface ChangeViewStylePayload { - style: string; -} - -export interface EntriesMoveSuccessPayload extends EntryPayload { - entries: EntryObject[]; -} - -export interface EntriesAction extends Action { - payload: - | EntryRequestPayload - | EntrySuccessPayload - | EntryFailurePayload - | EntriesSuccessPayload - | EntriesRequestPayload - | EntryDeletePayload; - meta: { - collection: string; - }; -} diff --git a/src/backends/bitbucket/types/semaphore.d.ts b/src/types/semaphore.d.ts similarity index 100% rename from src/backends/bitbucket/types/semaphore.d.ts rename to src/types/semaphore.d.ts diff --git a/src/types/svg.d.ts b/src/types/svg.d.ts new file mode 100644 index 00000000..0b4e63ac --- /dev/null +++ b/src/types/svg.d.ts @@ -0,0 +1,4 @@ +declare module '*.svg' { + const content: () => JSX.Element; + export default content; +} diff --git a/src/types/uploadcare.d.ts b/src/types/uploadcare.d.ts new file mode 100644 index 00000000..d2b5286d --- /dev/null +++ b/src/types/uploadcare.d.ts @@ -0,0 +1,33 @@ +declare module 'uploadcare-widget-tab-effects'; + +declare module 'uploadcare-widget' { + interface UploadcareFileGroupInfo { + cdnUrl: string; + name: string; + isImage: boolean; + } + + const Uploadcare: { + loadFileGroup: (groupId: string | undefined) => { + done: (callback: (group: UploadcareFileGroupInfo) => void) => void; + }; + fileFrom: (uploadedOrUrl: 'uploaded' | 'url', url: string) => Promise; + registerTab: (tab: string, tabContent: unknown) => void; + openDialog: ( + files: + | UploadcareFileGroupInfo + | UploadcareFileGroupInfo[] + | Promise, + settings: Record, + ) => { + done: ( + callback: (values: { + promise: () => Promise; + files: () => Promise[]; + }) => void, + ) => void; + }; + }; + + export default Uploadcare; +} diff --git a/src/backends/bitbucket/types/what-the-diff.d.ts b/src/types/what-the-diff.d.ts similarity index 100% rename from src/backends/bitbucket/types/what-the-diff.d.ts rename to src/types/what-the-diff.d.ts diff --git a/src/ui/AuthenticationPage.js b/src/ui/AuthenticationPage.js deleted file mode 100644 index 3a0cdd6a..00000000 --- a/src/ui/AuthenticationPage.js +++ /dev/null @@ -1,115 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; - -import Icon from './Icon'; -import { buttons, shadows } from './styles'; -import GoBackButton from './GoBackButton'; - -const StyledAuthenticationPage = styled.section` - display: flex; - flex-flow: column nowrap; - align-items: center; - justify-content: center; - height: 100vh; -`; - -const CustomIconWrapper = styled.span` - width: 300px; - height: 200px; - margin-top: -150px; -`; - -const SimpleLogoIcon = styled(Icon)` - color: #c4c6d2; - margin-top: -300px; -`; - -const StaticCmsIcon = styled(Icon)` - color: #c4c6d2; - position: absolute; - bottom: 10px; -`; - -function CustomLogoIcon({ url }) { - return ( - - Logo - - ); -} - -function renderPageLogo(logoUrl) { - if (logoUrl) { - return ; - } - return ; -} - -const LoginButton = styled.button` - ${buttons.button}; - ${shadows.dropDeep}; - ${buttons.default}; - ${buttons.gray}; - &[disabled] { - ${buttons.disabled}; - } - - padding: 0 12px; - margin-top: -40px; - display: flex; - align-items: center; - position: relative; -`; - -const TextButton = styled.button` - ${buttons.button}; - ${buttons.default}; - ${buttons.grayText}; - - margin-top: 40px; - display: flex; - align-items: center; - position: relative; -`; - -function AuthenticationPage({ - onLogin, - loginDisabled, - loginErrorMessage, - renderButtonContent, - renderPageContent, - logoUrl, - siteUrl, - t, -}) { - return ( - - {renderPageLogo(logoUrl)} - {loginErrorMessage ?

    {loginErrorMessage}

    : null} - {!renderPageContent - ? null - : renderPageContent({ LoginButton, TextButton, showAbortButton: !siteUrl })} - {!renderButtonContent ? null : ( - - {renderButtonContent()} - - )} - {siteUrl && } - {logoUrl ? : null} -
    - ); -} - -AuthenticationPage.propTypes = { - onLogin: PropTypes.func, - logoUrl: PropTypes.string, - siteUrl: PropTypes.string, - loginDisabled: PropTypes.bool, - loginErrorMessage: PropTypes.node, - renderButtonContent: PropTypes.func, - renderPageContent: PropTypes.func, - t: PropTypes.func.isRequired, -}; - -export default AuthenticationPage; diff --git a/src/ui/Dropdown.js b/src/ui/Dropdown.js deleted file mode 100644 index 08e84ac0..00000000 --- a/src/ui/Dropdown.js +++ /dev/null @@ -1,176 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { css } from '@emotion/react'; -import styled from '@emotion/styled'; -import { Wrapper, Button as DropdownButton, Menu, MenuItem } from 'react-aria-menubutton'; - -import { colors, buttons, components, zIndex } from './styles'; -import Icon from './Icon'; - -const StyledWrapper = styled(Wrapper)` - position: relative; - font-size: 14px; - user-select: none; -`; - -const StyledDropdownButton = styled(DropdownButton)` - ${buttons.button}; - ${buttons.default}; - display: block; - padding-left: 20px!important; - padding-right: 28px!important; - position: relative; - - &:after { - ${components.caretDown}; - content: ''; - display: block; - position: absolute; - top: 16px; - right: 10px; - color: currentColor; - } -`; - -const DropdownList = styled.ul` - ${components.dropdownList}; - margin: 0; - position: absolute; - top: 0; - left: 0; - min-width: 100%; - z-index: ${zIndex.zIndex299}; - - ${props => css` - width: ${props.width}; - top: ${props.top}; - left: ${props.position === 'left' ? 0 : 'auto'}; - right: ${props.position === 'right' ? 0 : 'auto'}; - `}; -`; - -function StyledMenuItem({ isActive, isCheckedItem = false, ...props }) { - return ( - - ); -} - -const MenuItemIconContainer = styled.div` - flex: 1 0 32px; - text-align: right; - position: relative; - top: ${props => (props.iconSmall ? '0' : '2px')}; -`; - -function Dropdown({ - closeOnSelection = true, - renderButton, - dropdownWidth = 'auto', - dropdownPosition = 'left', - dropdownTopOverlap = '0', - className, - children, -}) { - return ( - handler()} - className={className} - > - {renderButton()} - - - {children} - - - - ); -} - -Dropdown.propTypes = { - renderButton: PropTypes.func.isRequired, - dropdownWidth: PropTypes.string, - dropdownPosition: PropTypes.string, - dropdownTopOverlap: PropTypes.string, - className: PropTypes.string, - children: PropTypes.node, -}; - -function DropdownItem({ label, icon, iconDirection, iconSmall, isActive, onClick, className }) { - return ( - - {label} - {icon ? ( - - - - ) : null} - - ); -} - -DropdownItem.propTypes = { - label: PropTypes.string, - icon: PropTypes.string, - iconDirection: PropTypes.string, - onClick: PropTypes.func, - className: PropTypes.string, -}; - -function StyledDropdownCheckbox({ checked, id }) { - return ( - - ); -} - -function DropdownCheckedItem({ label, id, checked, onClick }) { - return ( - - - {label} - - ); -} - -DropdownCheckedItem.propTypes = { - label: PropTypes.string.isRequired, - id: PropTypes.string.isRequired, - checked: PropTypes.bool.isRequired, - onClick: PropTypes.func.isRequired, -}; - -export { - Dropdown as default, - DropdownItem, - DropdownCheckedItem, - DropdownButton, - StyledDropdownButton, -}; diff --git a/src/ui/FieldLabel.js b/src/ui/FieldLabel.js deleted file mode 100644 index fc41c0c1..00000000 --- a/src/ui/FieldLabel.js +++ /dev/null @@ -1,59 +0,0 @@ -import styled from '@emotion/styled'; - -import { colors, colorsRaw, transitions, text } from './styles'; - -const stateColors = { - default: { - background: colors.textFieldBorder, - text: colors.controlLabel, - }, - active: { - background: colors.active, - text: colors.textLight, - }, - error: { - background: colors.errorText, - text: colorsRaw.white, - }, -}; - -function getStateColors({ isActive, hasErrors }) { - if (hasErrors) return stateColors.error; - if (isActive) return stateColors.active; - return stateColors.default; -} - -const FieldLabel = styled.label` - ${text.fieldLabel}; - color: ${props => getStateColors(props).text}; - background-color: ${props => getStateColors(props).background}; - display: inline-block; - border: 0; - border-radius: 3px 3px 0 0; - padding: 3px 6px 2px; - margin: 0; - transition: all ${transitions.main}; - position: relative; - - /** - * Faux outside curve into top of input - */ - &:before, - &:after { - content: ''; - display: block; - position: absolute; - top: 0; - right: -4px; - height: 100%; - width: 4px; - background-color: inherit; - } - - &:after { - border-bottom-left-radius: 3px; - background-color: #fff; - } -`; - -export default FieldLabel; diff --git a/src/ui/GoBackButton.js b/src/ui/GoBackButton.js deleted file mode 100644 index 5a151db3..00000000 --- a/src/ui/GoBackButton.js +++ /dev/null @@ -1,39 +0,0 @@ -import styled from '@emotion/styled'; -import React from 'react'; -import PropTypes from 'prop-types'; - -import { colorsRaw } from './styles'; -import Icon from './Icon'; - -const GoBackButtonStyle = styled.a` - display: flex; - align-items: center; - - margin-top: 50px; - padding: 10px; - - font-size: 14px; -`; - -const ButtonText = styled.p` - color: ${colorsRaw.gray}; - margin: 0 10px; -`; - -export default class GoBackButton extends React.Component { - static propTypes = { - href: PropTypes.string.isRequired, - t: PropTypes.func.isRequired, - }; - - render() { - const { href, t } = this.props; - - return ( - - - {t('ui.default.goBackToSite')} - - ); - } -} diff --git a/src/ui/Icon.js b/src/ui/Icon.js deleted file mode 100644 index 11da3de5..00000000 --- a/src/ui/Icon.js +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; - -import icons from './Icon/icons'; - -const IconWrapper = styled.span` - display: inline-block; - line-height: 0; - width: ${props => props.size}; - height: ${props => props.size}; - transform: ${props => `rotate(${props.rotation})`}; - - & path:not(.no-fill), - & circle:not(.no-fill), - & polygon:not(.no-fill), - & rect:not(.no-fill) { - fill: currentColor; - } - - & path.clipped { - fill: transparent; - } - - svg { - width: 100%; - height: 100%; - } -`; - -/** - * Calculates rotation for icons that have a `direction` property configured - * in the imported icon definition object. If no direction is configured, a - * neutral rotation value is returned. - * - * Returned value is a string of shape `${degrees}deg`, for use in a CSS - * transform. - */ -function getRotation(iconDirection, newDirection) { - if (!iconDirection || !newDirection) { - return '0deg'; - } - const rotations = { right: 90, down: 180, left: 270, up: 360 }; - const degrees = rotations[newDirection] - rotations[iconDirection]; - return `${degrees}deg`; -} - -const sizes = { - xsmall: '12px', - small: '18px', - medium: '24px', - large: '32px', -}; - -function Icon({ type, direction, size = 'medium', className }) { - const IconSvg = icons[type].image; - - return ( - - - - ); -} - -Icon.propTypes = { - type: PropTypes.string.isRequired, - direction: PropTypes.oneOf(['right', 'down', 'left', 'up']), - size: PropTypes.string, - className: PropTypes.string, -}; - -export default styled(Icon)``; diff --git a/src/ui/Icon/images/_index.js b/src/ui/Icon/images/_index.js deleted file mode 100644 index 7e7b0ce0..00000000 --- a/src/ui/Icon/images/_index.js +++ /dev/null @@ -1,99 +0,0 @@ -import iconAdd from './add.svg'; -import iconAddWith from './add-with.svg'; -import iconArrow from './arrow.svg'; -import iconAzure from './azure.svg'; -import iconBitbucket from './bitbucket.svg'; -import iconBold from './bold.svg'; -import iconCheck from './check.svg'; -import iconChevron from './chevron.svg'; -import iconChevronDouble from './chevron-double.svg'; -import iconCircle from './circle.svg'; -import iconClose from './close.svg'; -import iconCode from './code.svg'; -import iconCodeBlock from './code-block.svg'; -import iconDragHandle from './drag-handle.svg'; -import iconEye from './eye.svg'; -import iconFolder from './folder.svg'; -import iconGithub from './github.svg'; -import iconGitlab from './gitlab.svg'; -import iconGrid from './grid.svg'; -import iconH1 from './h1.svg'; -import iconH2 from './h2.svg'; -import iconHOptions from './h-options.svg'; -import iconHome from './home.svg'; -import iconImage from './image.svg'; -import iconInfoCircle from './info-circle.svg'; -import iconItalic from './italic.svg'; -import iconLink from './link.svg'; -import iconList from './list.svg'; -import iconListBulleted from './list-bulleted.svg'; -import iconListNumbered from './list-numbered.svg'; -import iconMarkdown from './markdown.svg'; -import iconMedia from './media.svg'; -import iconMediaAlt from './media-alt.svg'; -import iconNetlify from './netlify.svg'; -import iconStaticCms from './static-cms-logo.svg'; -import iconNewTab from './new-tab.svg'; -import iconPage from './page.svg'; -import iconPages from './pages.svg'; -import iconPagesAlt from './pages-alt.svg'; -import iconQuote from './quote.svg'; -import iconRefresh from './refresh.svg'; -import iconScroll from './scroll.svg'; -import iconSearch from './search.svg'; -import iconSettings from './settings.svg'; -import iconUser from './user.svg'; -import iconWrite from './write.svg'; - -const iconix = iconAdd; - -const images = { - add: iconix, - 'add-with': iconAddWith, - arrow: iconArrow, - azure: iconAzure, - bitbucket: iconBitbucket, - bold: iconBold, - check: iconCheck, - chevron: iconChevron, - 'chevron-double': iconChevronDouble, - circle: iconCircle, - close: iconClose, - code: iconCode, - 'code-block': iconCodeBlock, - 'drag-handle': iconDragHandle, - eye: iconEye, - folder: iconFolder, - github: iconGithub, - gitlab: iconGitlab, - grid: iconGrid, - h1: iconH1, - h2: iconH2, - hOptions: iconHOptions, - home: iconHome, - image: iconImage, - 'info-circle': iconInfoCircle, - italic: iconItalic, - link: iconLink, - list: iconList, - 'list-bulleted': iconListBulleted, - 'list-numbered': iconListNumbered, - markdown: iconMarkdown, - media: iconMedia, - 'media-alt': iconMediaAlt, - netlify: iconNetlify, - 'static-cms': iconStaticCms, - 'new-tab': iconNewTab, - page: iconPage, - pages: iconPages, - 'pages-alt': iconPagesAlt, - quote: iconQuote, - refresh: iconRefresh, - scroll: iconScroll, - search: iconSearch, - settings: iconSettings, - user: iconUser, - write: iconWrite, -}; - -export default images; diff --git a/src/ui/Icon/images/add-with.svg b/src/ui/Icon/images/add-with.svg deleted file mode 100644 index 0112bf2b..00000000 --- a/src/ui/Icon/images/add-with.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/add.svg b/src/ui/Icon/images/add.svg deleted file mode 100644 index 3920a139..00000000 --- a/src/ui/Icon/images/add.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/arrow.svg b/src/ui/Icon/images/arrow.svg deleted file mode 100644 index 75851573..00000000 --- a/src/ui/Icon/images/arrow.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/bold.svg b/src/ui/Icon/images/bold.svg deleted file mode 100644 index a672c897..00000000 --- a/src/ui/Icon/images/bold.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/check.svg b/src/ui/Icon/images/check.svg deleted file mode 100644 index d5f8eb64..00000000 --- a/src/ui/Icon/images/check.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/chevron-double.svg b/src/ui/Icon/images/chevron-double.svg deleted file mode 100644 index 4df690a0..00000000 --- a/src/ui/Icon/images/chevron-double.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/src/ui/Icon/images/chevron.svg b/src/ui/Icon/images/chevron.svg deleted file mode 100644 index afe6fc21..00000000 --- a/src/ui/Icon/images/chevron.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/circle.svg b/src/ui/Icon/images/circle.svg deleted file mode 100644 index 5033f272..00000000 --- a/src/ui/Icon/images/circle.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/close.svg b/src/ui/Icon/images/close.svg deleted file mode 100644 index 6c5baec8..00000000 --- a/src/ui/Icon/images/close.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/code-block.svg b/src/ui/Icon/images/code-block.svg deleted file mode 100644 index e0deb079..00000000 --- a/src/ui/Icon/images/code-block.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/code.svg b/src/ui/Icon/images/code.svg deleted file mode 100644 index 953afde7..00000000 --- a/src/ui/Icon/images/code.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/drag-handle.svg b/src/ui/Icon/images/drag-handle.svg deleted file mode 100644 index c9e4d2e0..00000000 --- a/src/ui/Icon/images/drag-handle.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/eye.svg b/src/ui/Icon/images/eye.svg deleted file mode 100644 index 714f9a46..00000000 --- a/src/ui/Icon/images/eye.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/folder.svg b/src/ui/Icon/images/folder.svg deleted file mode 100644 index 92077f9e..00000000 --- a/src/ui/Icon/images/folder.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/grid.svg b/src/ui/Icon/images/grid.svg deleted file mode 100644 index 94f3375c..00000000 --- a/src/ui/Icon/images/grid.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/h-options.svg b/src/ui/Icon/images/h-options.svg deleted file mode 100644 index 77785e12..00000000 --- a/src/ui/Icon/images/h-options.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/h1.svg b/src/ui/Icon/images/h1.svg deleted file mode 100644 index 6bb3370d..00000000 --- a/src/ui/Icon/images/h1.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/h2.svg b/src/ui/Icon/images/h2.svg deleted file mode 100644 index 1dd8963e..00000000 --- a/src/ui/Icon/images/h2.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/home.svg b/src/ui/Icon/images/home.svg deleted file mode 100644 index fbf2b012..00000000 --- a/src/ui/Icon/images/home.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/image.svg b/src/ui/Icon/images/image.svg deleted file mode 100644 index ba0f9035..00000000 --- a/src/ui/Icon/images/image.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/info-circle.svg b/src/ui/Icon/images/info-circle.svg deleted file mode 100644 index 8f48f86c..00000000 --- a/src/ui/Icon/images/info-circle.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - \ No newline at end of file diff --git a/src/ui/Icon/images/italic.svg b/src/ui/Icon/images/italic.svg deleted file mode 100644 index 7df44d58..00000000 --- a/src/ui/Icon/images/italic.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/link.svg b/src/ui/Icon/images/link.svg deleted file mode 100644 index b27e5efc..00000000 --- a/src/ui/Icon/images/link.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/list-bulleted.svg b/src/ui/Icon/images/list-bulleted.svg deleted file mode 100644 index d9fcc26b..00000000 --- a/src/ui/Icon/images/list-bulleted.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/list-numbered.svg b/src/ui/Icon/images/list-numbered.svg deleted file mode 100644 index 56fafa6d..00000000 --- a/src/ui/Icon/images/list-numbered.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/list.svg b/src/ui/Icon/images/list.svg deleted file mode 100644 index 91738ab6..00000000 --- a/src/ui/Icon/images/list.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/markdown.svg b/src/ui/Icon/images/markdown.svg deleted file mode 100644 index a844d873..00000000 --- a/src/ui/Icon/images/markdown.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/media-alt.svg b/src/ui/Icon/images/media-alt.svg deleted file mode 100644 index f2b34f4e..00000000 --- a/src/ui/Icon/images/media-alt.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/media.svg b/src/ui/Icon/images/media.svg deleted file mode 100644 index 261db15e..00000000 --- a/src/ui/Icon/images/media.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/netlify.svg b/src/ui/Icon/images/netlify.svg deleted file mode 100644 index c3f240ce..00000000 --- a/src/ui/Icon/images/netlify.svg +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/src/ui/Icon/images/new-tab.svg b/src/ui/Icon/images/new-tab.svg deleted file mode 100644 index 896e6b39..00000000 --- a/src/ui/Icon/images/new-tab.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/page.svg b/src/ui/Icon/images/page.svg deleted file mode 100644 index e2af3a88..00000000 --- a/src/ui/Icon/images/page.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/pages-alt.svg b/src/ui/Icon/images/pages-alt.svg deleted file mode 100644 index 63eec717..00000000 --- a/src/ui/Icon/images/pages-alt.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/ui/Icon/images/pages.svg b/src/ui/Icon/images/pages.svg deleted file mode 100644 index 833d5a77..00000000 --- a/src/ui/Icon/images/pages.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/ui/Icon/images/quote.svg b/src/ui/Icon/images/quote.svg deleted file mode 100644 index b33c1910..00000000 --- a/src/ui/Icon/images/quote.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/refresh.svg b/src/ui/Icon/images/refresh.svg deleted file mode 100644 index 658be99f..00000000 --- a/src/ui/Icon/images/refresh.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/scroll.svg b/src/ui/Icon/images/scroll.svg deleted file mode 100644 index d94341aa..00000000 --- a/src/ui/Icon/images/scroll.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/search.svg b/src/ui/Icon/images/search.svg deleted file mode 100644 index 5ca70f4e..00000000 --- a/src/ui/Icon/images/search.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/settings.svg b/src/ui/Icon/images/settings.svg deleted file mode 100644 index 85dc66c6..00000000 --- a/src/ui/Icon/images/settings.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/user.svg b/src/ui/Icon/images/user.svg deleted file mode 100644 index a2cb6424..00000000 --- a/src/ui/Icon/images/user.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/Icon/images/write.svg b/src/ui/Icon/images/write.svg deleted file mode 100644 index 4b0cde43..00000000 --- a/src/ui/Icon/images/write.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/ui/IconButton.js b/src/ui/IconButton.js deleted file mode 100644 index 1a87e1e7..00000000 --- a/src/ui/IconButton.js +++ /dev/null @@ -1,40 +0,0 @@ -import React from 'react'; -import styled from '@emotion/styled'; - -import Icon from './Icon'; -import { buttons, colors, colorsRaw, shadows } from './styles'; - -const sizes = { - small: '28px', - large: '40px', -}; - -const ButtonRound = styled.button` - ${buttons.button}; - ${shadows.dropMiddle}; - background-color: ${colorsRaw.white}; - color: ${props => colors[props.isActive ? `active` : `inactive`]}; - border-radius: 32px; - display: flex; - justify-content: center; - align-items: center; - width: ${props => sizes[props.size]}; - height: ${props => sizes[props.size]}; - padding: 0; -`; - -function IconButton({ size, isActive, type, onClick, className, title }) { - return ( - - - - ); -} - -export default IconButton; diff --git a/src/ui/ListItemTopBar.js b/src/ui/ListItemTopBar.js deleted file mode 100644 index a80fa291..00000000 --- a/src/ui/ListItemTopBar.js +++ /dev/null @@ -1,112 +0,0 @@ -import styled from '@emotion/styled'; -import PropTypes from 'prop-types'; -import React from 'react'; - -import { transientOptions } from '../lib/util'; -import Icon from './Icon'; -import { buttons, colors, lengths } from './styles'; - -const TopBar = styled( - 'div', - transientOptions, -)( - ({ $isVariableTypesList, $collapsed }) => ` - display: flex; - justify-content: space-between; - height: 32px!important; - border-radius: ${ - !$isVariableTypesList - ? $collapsed ? lengths.borderRadius : `${lengths.borderRadius} ${lengths.borderRadius} 0 0` - : $collapsed ? `0 ${lengths.borderRadius} ${lengths.borderRadius} ${lengths.borderRadius}` : `0 ${lengths.borderRadius} 0 0` - }!important; - position: relative; - `, -); - -const TopBarButton = styled.button` - ${buttons.button}; - color: ${colors.controlLabel}; - background: transparent; - font-size: 16px; - line-height: 1; - padding: 0; - width: 32px; - text-align: center; - cursor: pointer; - display: flex; - justify-content: center; - align-items: center; - position: relative; -`; - -const StyledTitle = styled.div` - position: absolute; - left: 36px; - line-height: 30px; - white-space: nowrap; - cursor: pointer; - z-index: 1; -`; - -const TopBarButtonSpan = TopBarButton.withComponent('span'); - -const DragIconContainer = styled(TopBarButtonSpan)` - width: 100%; - cursor: move; -`; - -function DragHandle({ dragHandleHOC }) { - const Handle = dragHandleHOC(() => ( - - - - )); - return ; -} - -function ListItemTopBar({ - className, - title, - collapsed, - onCollapseToggle, - onRemove, - dragHandleHOC, - isVariableTypesList, -}) { - return ( - - {onCollapseToggle ? ( - - - - ) : null} - {title ? {title} : null} - {dragHandleHOC ? : null} - {onRemove ? ( - - - - ) : null} - - ); -} - -ListItemTopBar.propTypes = { - className: PropTypes.string, - title: PropTypes.node, - collapsed: PropTypes.bool, - onCollapseToggle: PropTypes.func, - onRemove: PropTypes.func, - isVariableTypesList: PropTypes.bool, -}; - -const StyledListItemTopBar = styled(ListItemTopBar)` - display: flex; - justify-content: space-between; - height: 26px; - border-radius: ${lengths.borderRadius} ${lengths.borderRadius} 0 0; - position: relative; - border-top-left-radius: 0; -`; - -export default StyledListItemTopBar; diff --git a/src/ui/Loader.js b/src/ui/Loader.js deleted file mode 100644 index ad2d9bf0..00000000 --- a/src/ui/Loader.js +++ /dev/null @@ -1,157 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; -import { css, keyframes } from '@emotion/react'; -import { CSSTransition } from 'react-transition-group'; - -import { colors, zIndex } from './styles'; - -const styles = { - disabled: css` - display: none; - `, - active: css` - display: block; - `, - enter: css` - opacity: 0.01; - `, - enterActive: css` - opacity: 1; - transition: opacity 500ms ease-in; - `, - exit: css` - opacity: 1; - `, - exitActive: css` - opacity: 0.01; - transition: opacity 300ms ease-in; - `, -}; - -const animations = { - loader: keyframes` - from { - transform: rotate(0deg); - } - - to { - transform: rotate(360deg); - } - `, -}; - -const LoaderText = styled.div` - width: auto !important; - height: auto !important; - text-align: center; - color: #767676; - margin-top: 55px; - line-height: 35px; -`; - -const LoaderItem = styled.div` - position: absolute; - white-space: nowrap; - transform: translateX(-50%); -`; - -export class Loader extends React.Component { - static propTypes = { - children: PropTypes.node, - className: PropTypes.string, - }; - - state = { - currentItem: 0, - }; - - componentWillUnmount() { - if (this.interval) { - clearInterval(this.interval); - } - } - - setAnimation = () => { - if (this.interval) return; - const { children } = this.props; - - this.interval = setInterval(() => { - const nextItem = - this.state.currentItem === children.length - 1 ? 0 : this.state.currentItem + 1; - this.setState({ currentItem: nextItem }); - }, 5000); - }; - - renderChild = () => { - const { children } = this.props; - const { currentItem } = this.state; - if (!children) { - return null; - } else if (typeof children == 'string') { - return {children}; - } else if (Array.isArray(children)) { - this.setAnimation(); - return ( - - - {children[currentItem]} - - - ); - } - }; - - render() { - const { className } = this.props; - return
    {this.renderChild()}
    ; - } -} - -const StyledLoader = styled(Loader)` - display: ${props => (props.active ? 'block' : 'none')}; - position: absolute; - top: 50%; - left: 50%; - margin: 0; - text-align: center; - z-index: ${zIndex.zIndex1000}; - transform: translateX(-50%) translateY(-50%); - - &:before, - &:after { - content: ''; - position: absolute; - top: 0%; - left: 50%; - width: 2.2857rem; - height: 2.2857rem; - margin: 0 0 0 -1.1429rem; - border-radius: 500rem; - border-style: solid; - border-width: 0.2em; - } - - /* Static Shape */ - &:before { - border-color: rgba(0, 0, 0, 0.1); - } - - /* Active Shape */ - &:after { - animation: ${animations.loader} 0.6s linear; - animation-iteration-count: infinite; - border-color: ${colors.active} transparent transparent; - box-shadow: 0 0 0 1px transparent; - } -`; - -export default StyledLoader; diff --git a/src/ui/ObjectWidgetTopBar.js b/src/ui/ObjectWidgetTopBar.js deleted file mode 100644 index 2c2d290b..00000000 --- a/src/ui/ObjectWidgetTopBar.js +++ /dev/null @@ -1,123 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; -import { css } from '@emotion/react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; - -import Icon from './Icon'; -import { colors, buttons } from './styles'; -import Dropdown, { StyledDropdownButton, DropdownItem } from './Dropdown'; - -const TopBarContainer = styled.div` - align-items: center; - background-color: ${colors.textFieldBorder}; - display: flex; - justify-content: space-between; - margin: 0 -14px; - padding: 6px 13px; -`; - -const ExpandButtonContainer = styled.div` - ${props => - props.hasHeading && - css` - display: flex; - align-items: center; - font-size: 14px; - font-weight: 500; - line-height: 1; - `}; -`; - -const ExpandButton = styled.button` - ${buttons.button}; - padding: 4px; - background-color: transparent; - color: inherit; - - &:last-of-type { - margin-right: 4px; - } -`; - -const AddButton = styled.button` - ${buttons.button} - ${buttons.widget} - padding: 4px 12px; - - ${Icon} { - margin-left: 6px; - } -`; - -class ObjectWidgetTopBar extends React.Component { - static propTypes = { - allowAdd: PropTypes.bool, - types: ImmutablePropTypes.list, - onAdd: PropTypes.func, - onAddType: PropTypes.func, - onCollapseToggle: PropTypes.func, - collapsed: PropTypes.bool, - heading: PropTypes.node, - label: PropTypes.string, - t: PropTypes.func.isRequired, - }; - - renderAddUI() { - if (!this.props.allowAdd) { - return null; - } - if (this.props.types && this.props.types.size > 0) { - return this.renderTypesDropdown(this.props.types); - } else { - return this.renderAddButton(); - } - } - - renderTypesDropdown(types) { - return ( - ( - - {this.props.t('editor.editorWidgets.list.addType', { item: this.props.label })} - - )} - > - {types.map((type, idx) => ( - this.props.onAddType(type.get('name'))} - /> - ))} - - ); - } - - renderAddButton() { - return ( - - {this.props.t('editor.editorWidgets.list.add', { item: this.props.label })} - - - ); - } - - render() { - const { onCollapseToggle, collapsed, heading = null } = this.props; - - return ( - - - - - - {heading} - - {this.renderAddUI()} - - ); - } -} - -export default ObjectWidgetTopBar; diff --git a/src/ui/Toggle.js b/src/ui/Toggle.js deleted file mode 100644 index ac9314fc..00000000 --- a/src/ui/Toggle.js +++ /dev/null @@ -1,95 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; -import { css } from '@emotion/react'; -import ReactToggled from 'react-toggled'; - -import { colors, colorsRaw, shadows, transitions } from './styles'; - -const ToggleContainer = styled.button` - display: inline-flex; - align-items: center; - justify-content: center; - position: relative; - width: 40px; - height: 20px; - cursor: pointer; - border: none; - padding: 0; - margin: 0; - background: transparent; -`; - -const ToggleHandle = styled.span` - ${shadows.dropDeep}; - position: absolute; - left: 0; - top: 0; - width: 20px; - height: 20px; - border-radius: 50%; - background-color: ${colorsRaw.white}; - transition: transform ${transitions.main}; - - ${props => - props.isActive && - css` - transform: translateX(20px); - `}; -`; - -const ToggleBackground = styled.span` - width: 34px; - height: 14px; - border-radius: 10px; - background-color: ${colors.active}; -`; - -function Toggle({ - id, - active, - onChange, - onFocus, - onBlur, - className, - Container = ToggleContainer, - Background = ToggleBackground, - Handle = ToggleHandle, -}) { - return ( - - {({ on, getTogglerProps }) => ( - - - - - )} - - ); -} - -Toggle.propTypes = { - id: PropTypes.string, - active: PropTypes.bool, - onChange: PropTypes.func, - onFocus: PropTypes.func, - onBlur: PropTypes.func, - className: PropTypes.string, - Container: PropTypes.func, - Background: PropTypes.func, - Handle: PropTypes.func, -}; - -const StyledToggle = styled(Toggle)``; - -export { StyledToggle as default, ToggleContainer, ToggleBackground, ToggleHandle }; diff --git a/src/ui/WidgetPreviewContainer.js b/src/ui/WidgetPreviewContainer.js deleted file mode 100644 index 0b141262..00000000 --- a/src/ui/WidgetPreviewContainer.js +++ /dev/null @@ -1,7 +0,0 @@ -import styled from '@emotion/styled'; - -const WidgetPreviewContainer = styled.div` - margin: 15px 2px; -`; - -export default WidgetPreviewContainer; diff --git a/src/ui/index.js b/src/ui/index.js deleted file mode 100644 index 75f9f00c..00000000 --- a/src/ui/index.js +++ /dev/null @@ -1,103 +0,0 @@ -import Dropdown, { - DropdownItem, - DropdownCheckedItem, - DropdownButton, - StyledDropdownButton, -} from './Dropdown'; -import Icon from './Icon'; -import ListItemTopBar from './ListItemTopBar'; -import Loader from './Loader'; -import FieldLabel from './FieldLabel'; -import IconButton from './IconButton'; -import Toggle, { ToggleContainer, ToggleBackground, ToggleHandle } from './Toggle'; -import AuthenticationPage from './AuthenticationPage'; -import WidgetPreviewContainer from './WidgetPreviewContainer'; -import ObjectWidgetTopBar from './ObjectWidgetTopBar'; -import GoBackButton from './GoBackButton'; -import images from './Icon/images/_index'; -import { - fonts, - colorsRaw, - colors, - lengths, - components, - buttons, - text, - shadows, - borders, - transitions, - effects, - zIndex, - reactSelectStyles, - GlobalStyles, -} from './styles'; - -export const StaticCmsUiDefault = { - Dropdown, - DropdownItem, - DropdownCheckedItem, - DropdownButton, - StyledDropdownButton, - ListItemTopBar, - FieldLabel, - Icon, - IconButton, - Loader, - Toggle, - ToggleContainer, - ToggleBackground, - ToggleHandle, - AuthenticationPage, - WidgetPreviewContainer, - ObjectWidgetTopBar, - fonts, - colorsRaw, - colors, - lengths, - components, - buttons, - shadows, - text, - borders, - transitions, - effects, - zIndex, - reactSelectStyles, - GlobalStyles, - images, -}; -export { - Dropdown, - DropdownItem, - DropdownCheckedItem, - DropdownButton, - StyledDropdownButton, - ListItemTopBar, - FieldLabel, - Icon, - IconButton, - Loader, - Toggle, - ToggleContainer, - ToggleBackground, - ToggleHandle, - AuthenticationPage, - WidgetPreviewContainer, - ObjectWidgetTopBar, - fonts, - colorsRaw, - colors, - lengths, - components, - buttons, - shadows, - text, - borders, - transitions, - effects, - zIndex, - reactSelectStyles, - GlobalStyles, - GoBackButton, - images, -}; diff --git a/src/valueObjects/AssetProxy.ts b/src/valueObjects/AssetProxy.ts index 86f73799..89f1af90 100644 --- a/src/valueObjects/AssetProxy.ts +++ b/src/valueObjects/AssetProxy.ts @@ -1,17 +1,17 @@ -import type { EntryField } from '../types/redux'; +import type { Field } from '../interface'; interface AssetProxyArgs { path: string; url?: string; file?: File; - field?: EntryField; + field?: Field; } export default class AssetProxy { url: string; fileObj?: File; path: string; - field?: EntryField; + field?: Field; constructor({ url, file, path, field }: AssetProxyArgs) { this.url = url ? url : window.URL.createObjectURL(file as Blob); diff --git a/src/valueObjects/EditorComponent.js b/src/valueObjects/EditorComponent.js deleted file mode 100644 index 710f3470..00000000 --- a/src/valueObjects/EditorComponent.js +++ /dev/null @@ -1,38 +0,0 @@ -import { fromJS } from 'immutable'; -import { isFunction } from 'lodash'; - -const catchesNothing = /.^/; - -function bind(fn) { - return isFunction(fn) && fn.bind(null); -} - -export default function createEditorComponent(config) { - const { - id = null, - label = 'unnamed component', - icon = 'exclamation-triangle', - type = 'shortcode', - widget = 'object', - pattern = catchesNothing, - fields = [], - fromBlock, - toBlock, - toPreview, - ...remainingConfig - } = config; - - return { - id: id || label.replace(/[^A-Z0-9]+/gi, '_'), - label, - type, - icon, - widget, - pattern, - fromBlock: bind(fromBlock) || (() => ({})), - toBlock: bind(toBlock) || (() => 'Plugin'), - toPreview: bind(toPreview) || (!widget && (bind(toBlock) || (() => 'Plugin'))), - fields: fromJS(fields), - ...remainingConfig, - }; -} diff --git a/src/valueObjects/EditorComponent.ts b/src/valueObjects/EditorComponent.ts new file mode 100644 index 00000000..91e8a102 --- /dev/null +++ b/src/valueObjects/EditorComponent.ts @@ -0,0 +1,55 @@ +import isFunction from 'lodash/isFunction'; + +import { isEditorComponentWidgetOptions } from '../interface'; + +import type { EditorComponentOptions } from '../interface'; + +const catchesNothing = /.^/; + +function bind(fn: unknown) { + return isFunction(fn) && fn.bind(null); +} + +export default function createEditorComponent( + options: EditorComponentOptions, +): EditorComponentOptions { + if (isEditorComponentWidgetOptions(options)) { + const { + id = null, + label = 'unnamed component', + type = 'shortcode', + widget = 'object', + ...remainingConfig + } = options; + + return { + id: id || label.replace(/[^A-Z0-9]+/gi, '_'), + label, + type, + widget, + ...remainingConfig, + }; + } + + const { + id = null, + label = 'unnamed component', + pattern = catchesNothing, + fields = [], + fromBlock, + toBlock, + toPreview, + ...remainingConfig + } = options; + + return { + id: id || label.replace(/[^A-Z0-9]+/gi, '_'), + label, + pattern, + fromBlock: bind(fromBlock) || (() => ({})), + toBlock: bind(toBlock) || (() => 'Plugin'), + toPreview: bind(toPreview) || bind(toBlock) || (() => 'Plugin'), + fields, + ...remainingConfig, + }; +} diff --git a/src/valueObjects/Entry.ts b/src/valueObjects/Entry.ts index 6507215e..2996bb4d 100644 --- a/src/valueObjects/Entry.ts +++ b/src/valueObjects/Entry.ts @@ -1,6 +1,7 @@ -import { isBoolean } from 'lodash'; +import isBoolean from 'lodash/isBoolean'; import type { MediaFile } from '../backend'; +import type { Entry } from '../interface'; interface Options { partial?: boolean; @@ -13,28 +14,6 @@ interface Options { author?: string; updatedOn?: string; status?: string; - meta?: { path?: string }; - i18n?: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [locale: string]: any; - }; -} - -export interface EntryValue { - collection: string; - slug: string; - path: string; - partial: boolean; - raw: string; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - data: any; - label: string | null; - isModification: boolean | null; - mediaFiles: MediaFile[]; - author: string; - updatedOn: string; - status?: string; - meta: { path?: string }; i18n?: { // eslint-disable-next-line @typescript-eslint/no-explicit-any [locale: string]: any; @@ -42,7 +21,7 @@ export interface EntryValue { } export function createEntry(collection: string, slug = '', path = '', options: Options = {}) { - const returnObj: EntryValue = { + const returnObj: Entry = { collection, slug, path, @@ -55,7 +34,6 @@ export function createEntry(collection: string, slug = '', path = '', options: O author: options.author || '', updatedOn: options.updatedOn || '', status: options.status || '', - meta: options.meta || {}, i18n: options.i18n || {}, }; diff --git a/src/widgets/boolean/BooleanControl.js b/src/widgets/boolean/BooleanControl.js deleted file mode 100644 index cb4482bd..00000000 --- a/src/widgets/boolean/BooleanControl.js +++ /dev/null @@ -1,50 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { css } from '@emotion/react'; - -import { Toggle, ToggleBackground, colors } from '../../ui'; - -function BooleanBackground({ isActive, ...props }) { - return ( - - ); -} - -export default class BooleanControl extends React.Component { - render() { - const { value, forID, onChange, classNameWrapper, setActiveStyle, setInactiveStyle } = - this.props; - return ( -
    - -
    - ); - } -} - -BooleanControl.propTypes = { - field: ImmutablePropTypes.map.isRequired, - onChange: PropTypes.func.isRequired, - classNameWrapper: PropTypes.string.isRequired, - setActiveStyle: PropTypes.func.isRequired, - setInactiveStyle: PropTypes.func.isRequired, - forID: PropTypes.string, - value: PropTypes.bool, -}; - -BooleanControl.defaultProps = { - value: false, -}; diff --git a/src/widgets/boolean/BooleanControl.tsx b/src/widgets/boolean/BooleanControl.tsx new file mode 100644 index 00000000..fde5c7f1 --- /dev/null +++ b/src/widgets/boolean/BooleanControl.tsx @@ -0,0 +1,47 @@ +import { colors } from '@mui/material'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Switch from '@mui/material/Switch'; +import React, { useCallback, useEffect, useState } from 'react'; + +import type { ChangeEvent } from 'react'; +import type { BooleanField, WidgetControlProps } from '../../interface'; + +const BooleanControl = ({ + value, + label, + onChange, + hasErrors, +}: WidgetControlProps) => { + const [internalValue, setInternalValue] = useState(value); + + const handleChange = useCallback( + (event: ChangeEvent) => { + setInternalValue(event.target.checked); + onChange(event.target.checked); + }, + [onChange], + ); + + useEffect(() => { + if (typeof internalValue !== 'boolean') { + setInternalValue(false); + setTimeout(() => { + onChange(false); + }); + } + }, [internalValue, onChange]); + + return ( + + } + label={label} + labelPlacement="start" + sx={{ marginLeft: '4px', color: hasErrors ? colors.red[500] : undefined }} + /> + ); +}; + +export default BooleanControl; diff --git a/src/widgets/boolean/index.js b/src/widgets/boolean/index.js deleted file mode 100644 index 631a4615..00000000 --- a/src/widgets/boolean/index.js +++ /dev/null @@ -1,12 +0,0 @@ -import controlComponent from './BooleanControl'; - -function Widget(opts = {}) { - return { - name: 'boolean', - controlComponent, - ...opts, - }; -} - -export const StaticCmsWidgetBoolean = { Widget, controlComponent }; -export default StaticCmsWidgetBoolean; diff --git a/src/widgets/boolean/index.ts b/src/widgets/boolean/index.ts new file mode 100644 index 00000000..a7df1144 --- /dev/null +++ b/src/widgets/boolean/index.ts @@ -0,0 +1,12 @@ +import controlComponent from './BooleanControl'; + +import type { BooleanField, WidgetParam } from '../../interface'; + +const BooleanWidget = (): WidgetParam => { + return { + name: 'boolean', + controlComponent, + }; +}; + +export default BooleanWidget; diff --git a/src/widgets/code/CodeControl.js b/src/widgets/code/CodeControl.js deleted file mode 100644 index e490ee61..00000000 --- a/src/widgets/code/CodeControl.js +++ /dev/null @@ -1,318 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { ClassNames } from '@emotion/react'; -import { Map } from 'immutable'; -import { uniq, isEqual, isEmpty } from 'lodash'; -import uuid from 'uuid/v4'; -import { UnControlled as ReactCodeMirror } from 'react-codemirror2'; -import CodeMirror from 'codemirror'; -import 'codemirror/keymap/vim'; -import 'codemirror/keymap/sublime'; -import 'codemirror/keymap/emacs'; -import codeMirrorStyles from 'codemirror/lib/codemirror.css'; -import materialTheme from 'codemirror/theme/material.css'; - -import SettingsPane from './SettingsPane'; -import SettingsButton from './SettingsButton'; -import languageData from './data/languages.json'; - -// TODO: relocate as a utility function -function getChangedProps(previous, next, keys) { - const propNames = keys || uniq(Object.keys(previous), Object.keys(next)); - const changedProps = propNames.reduce((acc, prop) => { - if (previous[prop] !== next[prop]) { - acc[prop] = next[prop]; - } - return acc; - }, {}); - if (!isEmpty(changedProps)) { - return changedProps; - } -} - -const languages = languageData.map(lang => ({ - label: lang.label, - name: lang.identifiers[0], - mode: lang.codemirror_mode, - mimeType: lang.codemirror_mime_type, -})); - -const styleString = ` - padding: 0; -`; - -const defaultLang = { name: '', mode: '', label: 'none' }; - -function valueToOption(val) { - if (typeof val === 'string') { - return { value: val, label: val }; - } - return { value: val.name, label: val.label || val.name }; -} - -const modes = languages.map(valueToOption); - -const themes = ['default', 'material']; - -const settingsPersistKeys = { - theme: 'cms.codemirror.theme', - keyMap: 'cms.codemirror.keymap', -}; - -export default class CodeControl extends React.Component { - static propTypes = { - field: ImmutablePropTypes.map.isRequired, - onChange: PropTypes.func.isRequired, - value: PropTypes.node, - forID: PropTypes.string.isRequired, - classNameWrapper: PropTypes.string.isRequired, - widget: PropTypes.object.isRequired, - }; - - keys = this.getKeys(this.props.field); - - state = { - isActive: false, - unknownLang: null, - lang: '', - keyMap: localStorage.getItem(settingsPersistKeys['keyMap']) || 'default', - settingsVisible: false, - codeMirrorKey: uuid(), - theme: localStorage.getItem(settingsPersistKeys['theme']) || themes[themes.length - 1], - lastKnownValue: this.valueIsMap() ? this.props.value?.get(this.keys.code) : this.props.value, - }; - - shouldComponentUpdate(nextProps, nextState) { - return ( - !isEqual(this.state, nextState) || this.props.classNameWrapper !== nextProps.classNameWrapper - ); - } - - componentDidMount() { - this.setState({ - lang: this.getInitialLang() || '', - }); - } - - componentDidUpdate(prevProps, prevState) { - this.updateCodeMirrorProps(prevState); - } - - updateCodeMirrorProps(prevState) { - const keys = ['lang', 'theme', 'keyMap']; - const changedProps = getChangedProps(prevState, this.state, keys); - if (changedProps) { - this.handleChangeCodeMirrorProps(changedProps); - } - } - - getLanguageByName = name => { - return languages.find(lang => lang.name === name); - }; - - getKeyMapOptions = () => { - return Object.keys(CodeMirror.keyMap) - .sort() - .filter(keyMap => ['emacs', 'vim', 'sublime', 'default'].includes(keyMap)) - .map(keyMap => ({ value: keyMap, label: keyMap })); - }; - - // This widget is not fully controlled, it only takes a value through props - // upon initialization. - getInitialLang = () => { - const { value, field } = this.props; - const lang = - (this.valueIsMap() && value && value.get(this.keys.lang)) || field.get('default_language'); - const langInfo = this.getLanguageByName(lang); - if (lang && !langInfo) { - this.setState({ unknownLang: lang }); - } - return lang; - }; - - // If `allow_language_selection` is not set, default to true. Otherwise, use - // its value. - allowLanguageSelection = - !this.props.field.has('allow_language_selection') || - !!this.props.field.get('allow_language_selection'); - - toValue = this.valueIsMap() - ? (type, value) => (this.props.value || Map()).set(this.keys[type], value) - : (type, value) => (type === 'code' ? value : this.props.value); - - // If the value is a map, keys can be customized via config. - getKeys(field) { - const defaults = { - code: 'code', - lang: 'lang', - }; - - // Force default keys if widget is an editor component code block. - if (this.props.isEditorComponent) { - return defaults; - } - - const keys = field.get('keys', Map()).toJS(); - return { ...defaults, ...keys }; - } - - // Determine if the persisted value is a map rather than a plain string. A map - // value allows both the code string and the language to be persisted. - valueIsMap() { - const { field, isEditorComponent } = this.props; - return !field.get('output_code_only') || isEditorComponent; - } - - async handleChangeCodeMirrorProps(changedProps) { - const { onChange } = this.props; - - if (changedProps.lang) { - const { mode } = this.getLanguageByName(changedProps.lang) || {}; - if (mode) { - require(`codemirror/mode/${mode}/${mode}.js`); - } - } - - // Changing CodeMirror props requires re-initializing the - // detached/uncontrolled React CodeMirror component, so here we save and - // restore the selections and cursor position after the state change. - if (this.cm) { - const cursor = this.cm.doc.getCursor(); - const selections = this.cm.doc.listSelections(); - this.setState({ codeMirrorKey: uuid() }, () => { - this.cm.doc.setCursor(cursor); - this.cm.doc.setSelections(selections); - }); - } - - for (const key of ['theme', 'keyMap']) { - if (changedProps[key]) { - localStorage.setItem(settingsPersistKeys[key], changedProps[key]); - } - } - - // Only persist the language change if supported - requires the value to be - // a map rather than just a code string. - if (changedProps.lang && this.valueIsMap()) { - onChange(this.toValue('lang', changedProps.lang)); - } - } - - handleChange(newValue) { - const cursor = this.cm.doc.getCursor(); - const selections = this.cm.doc.listSelections(); - this.setState({ lastKnownValue: newValue }); - this.props.onChange(this.toValue('code', newValue), { cursor, selections }); - } - - showSettings = () => { - this.setState({ settingsVisible: true }); - }; - - hideSettings = () => { - if (this.state.settingsVisible) { - this.setState({ settingsVisible: false }); - } - this.cm.focus(); - }; - - handleFocus = () => { - this.hideSettings(); - this.props.setActiveStyle(); - this.setActive(); - }; - - handleBlur = () => { - this.setInactive(); - this.props.setInactiveStyle(); - }; - - setActive = () => this.setState({ isActive: true }); - setInactive = () => this.setState({ isActive: false }); - - render() { - const { classNameWrapper, forID, widget, isNewEditorComponent } = this.props; - const { lang, settingsVisible, keyMap, codeMirrorKey, theme, lastKnownValue } = this.state; - const langInfo = this.getLanguageByName(lang); - const mode = langInfo?.mimeType || langInfo?.mode; - - return ( - - {({ css, cx }) => ( -
    - {!settingsVisible && } - {settingsVisible && ( - t === theme)} - themes={themes} - keyMap={{ value: keyMap, label: keyMap }} - keyMaps={this.getKeyMapOptions()} - allowLanguageSelection={this.allowLanguageSelection} - onChangeLang={newLang => this.setState({ lang: newLang })} - onChangeTheme={newTheme => this.setState({ theme: newTheme })} - onChangeKeyMap={newKeyMap => this.setState({ keyMap: newKeyMap })} - /> - )} - { - this.cm = cm; - if (isNewEditorComponent) { - this.handleFocus(); - } - }} - value={lastKnownValue} - onChange={(editor, data, newValue) => this.handleChange(newValue)} - onFocus={this.handleFocus} - onBlur={this.handleBlur} - /> -
    - )} -
    - ); - } -} diff --git a/src/widgets/code/CodeControl.tsx b/src/widgets/code/CodeControl.tsx new file mode 100644 index 00000000..8912de4c --- /dev/null +++ b/src/widgets/code/CodeControl.tsx @@ -0,0 +1,382 @@ +import { ClassNames } from '@emotion/react'; +import { styled } from '@mui/material/styles'; +import 'codemirror/keymap/emacs'; +import 'codemirror/keymap/sublime'; +import 'codemirror/keymap/vim'; +import codeMirrorStyles from 'codemirror/lib/codemirror.css'; +import materialTheme from 'codemirror/theme/material.css'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { UnControlled as ReactCodeMirror } from 'react-codemirror2'; +import uuid from 'uuid/v4'; + +import ObjectWidgetTopBar from '../../components/UI/ObjectWidgetTopBar'; +import Outline from '../../components/UI/Outline'; +import transientOptions from '../../lib/util/transientOptions'; +import languageData from './data/languages'; +import SettingsButton from './SettingsButton'; +import SettingsPane from './SettingsPane'; + +import type { Editor } from 'codemirror'; +import type { CodeField, WidgetControlProps } from '../../interface'; + +const StyledCodeControlWrapper = styled('div')` + display: flex; + flex-direction: column; + position: relative; + width: 100%; +`; + +interface StyledCodeControlContentProps { + $collapsed: boolean; +} + +const StyledCodeControlContent = styled( + 'div', + transientOptions, +)( + ({ $collapsed }) => ` + display: flex; + ${ + $collapsed + ? ` + visibility: hidden; + height: 0; + width: 0; + ` + : '' + } + `, +); + +interface CodeLanguage { + label: string; + name: string; + mode: string; + mimeType: string; +} + +const languages: CodeLanguage[] = languageData.map(lang => ({ + label: lang.label, + name: lang.identifiers[0], + mode: lang.codemirror_mode, + mimeType: lang.codemirror_mime_type, +})); + +const styleString = ` + padding: 0; +`; + +const defaultLang = { name: '', mode: '', label: 'none' }; + +function valueToOption(val: string | { name: string; label?: string }): { + value: string; + label: string; +} { + if (typeof val === 'string') { + return { value: val, label: val }; + } + return { value: val.name, label: val.label || val.name }; +} + +const modes = languages.map(valueToOption); + +const themes = ['default', 'material']; + +const settingsPersistKeys = { + theme: 'cms.codemirror.theme', + keyMap: 'cms.codemirror.keymap', +}; + +const CodeControl = ({ + isEditorComponent, + isNewEditorComponent, + field, + onChange, + hasErrors, + value, + t, +}: WidgetControlProps) => { + const [internalValue, setInternalValue] = useState(value ?? ''); + const [collapsed, setCollapsed] = useState(false); + + const handleCollapseToggle = useCallback(() => { + setCollapsed(!collapsed); + }, [collapsed]); + + const handleOnChange = useCallback( + (newValue: string | { [key: string]: string } | null | undefined) => { + setInternalValue(newValue ?? ''); + onChange(newValue ?? ''); + }, + [onChange], + ); + + // If the value is a map, keys can be customized via config. + const getKeys = useCallback( + (field: CodeField) => { + const defaults = { + code: 'code', + lang: 'lang', + }; + + // Force default keys if widget is an editor component code block. + if (isEditorComponent) { + return defaults; + } + + const keys = field.keys ?? {}; + return { ...defaults, ...keys }; + }, + [isEditorComponent], + ); + + const keys = useMemo(() => getKeys(field), [field, getKeys]); + + // Determine if the persisted value is a map rather than a plain string. A map value allows both the code string and the language to be persisted. + const valueIsMap = useMemo( + () => Boolean(!field.output_code_only || isEditorComponent), + [field.output_code_only, isEditorComponent], + ); + + // This widget is not fully controlled, it only takes a value through props upon initialization. + const getInitialLang = useCallback(() => { + return valueIsMap && typeof internalValue !== 'string' + ? internalValue && internalValue[keys.lang] + : field.default_language; + }, [field.default_language, keys.lang, internalValue, valueIsMap]); + + const [codemirrorEditor, setCodemirrorEditor] = useState(null); + const [lang, setLang] = useState(getInitialLang() ?? ''); + const [keyMap, setKeyMap] = useState( + localStorage.getItem(settingsPersistKeys['keyMap']) || 'default', + ); + const [settingsVisible, setSettingsVisible] = useState(false); + const [codemirrorKey, setCodemirrorKey] = useState(uuid()); + const [theme, setTheme] = useState( + localStorage.getItem(settingsPersistKeys['theme']) || themes[themes.length - 1], + ); + const [lastKnownValue, setLastKnownValue] = useState( + (internalValue && typeof internalValue === 'object' + ? internalValue[keys.code] + : internalValue) ?? '', + ); + + const getLanguageByName = useCallback((name: string) => { + return languages.find(lang => lang.name === name); + }, []); + + const handleChangeCodemirrorProps = useCallback( + async (changedProps: { lang?: string; theme?: string; keyMap?: string }) => { + if (changedProps.lang) { + const { mode } = getLanguageByName(changedProps.lang) || {}; + if (mode) { + require(`codemirror/mode/${mode}/${mode}.js`); + } + } + + // Changing CodeMirror props requires re-initializing the + // detached/uncontrolled React CodeMirror component, so here we save and + // restore the selections and cursor position after the state change. + if (codemirrorEditor) { + const cursor = codemirrorEditor.getDoc().getCursor(); + const selections = codemirrorEditor.getDoc().listSelections(); + setCodemirrorKey(uuid()); + codemirrorEditor.getDoc().setCursor(cursor); + codemirrorEditor.getDoc().setSelections(selections); + } + + if (changedProps.theme) { + localStorage.setItem(settingsPersistKeys.theme, changedProps.theme); + } + + if (changedProps.keyMap) { + localStorage.setItem(settingsPersistKeys.keyMap, changedProps.keyMap); + } + + // Only persist the language change if supported - requires the value to be + // a map rather than just a code string. + if (changedProps.lang && valueIsMap) { + handleOnChange({ + ...(typeof internalValue !== 'string' ? internalValue : {}), + lang: changedProps.lang, + }); + } + }, + [codemirrorEditor, getLanguageByName, handleOnChange, internalValue, valueIsMap], + ); + + const [prevLang, setPrevLang] = useState(); + useEffect(() => { + if (prevLang !== lang) { + setPrevLang(lang); + setTimeout(() => { + handleChangeCodemirrorProps({ lang }); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lang]); + + const [prevTheme, setPrevTheme] = useState(); + useEffect(() => { + if (prevTheme !== theme) { + setPrevTheme(theme); + setTimeout(() => { + handleChangeCodemirrorProps({ theme }); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [theme]); + + const [prevKeyMap, setPrevKeyMap] = useState(); + useEffect(() => { + if (prevKeyMap !== keyMap) { + setPrevKeyMap(keyMap); + setTimeout(() => { + handleChangeCodemirrorProps({ keyMap }); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [keyMap]); + + const getKeyMapOptions = useCallback(() => { + return ['emacs', 'vim', 'sublime', 'default'].map(keyMap => ({ value: keyMap, label: keyMap })); + }, []); + + // If `allow_language_selection` is not set, default to true. Otherwise, use its value. + const allowLanguageSelection = useMemo( + () => Boolean(field.allow_language_selection), + [field.allow_language_selection], + ); + + const handleChange = useCallback( + (newValue: string) => { + if (!codemirrorEditor) { + return; + } + + setLastKnownValue(newValue); + + if (valueIsMap) { + handleOnChange({ + ...(typeof internalValue !== 'string' ? internalValue : {}), + code: newValue, + }); + } + handleOnChange(newValue); + }, + [codemirrorEditor, handleOnChange, internalValue, valueIsMap], + ); + + const showSettings = useCallback(() => { + setSettingsVisible(true); + }, []); + + const hideSettings = useCallback(() => { + if (settingsVisible) { + setSettingsVisible(false); + } + codemirrorEditor?.focus(); + }, [codemirrorEditor, settingsVisible]); + + const handleFocus = useCallback(() => { + hideSettings(); + }, [hideSettings]); + + const langInfo = useMemo(() => getLanguageByName(lang), [getLanguageByName, lang]); + const mode = langInfo?.mimeType || langInfo?.mode; + + const uniqueId = useMemo(() => uuid(), []); + + return ( + + + + + {({ css, cx }) => ( +
    + {!settingsVisible && } + {settingsVisible && ( + t === theme) ?? themes[0]} + themes={themes} + keyMap={{ value: keyMap, label: keyMap }} + keyMaps={getKeyMapOptions()} + allowLanguageSelection={allowLanguageSelection} + onChangeLang={newLang => setLang(newLang)} + onChangeTheme={newTheme => setTheme(newTheme)} + onChangeKeyMap={newKeyMap => setKeyMap(newKeyMap)} + /> + )} + { + setCodemirrorEditor(cm); + if (isNewEditorComponent) { + handleFocus(); + } + }} + value={lastKnownValue} + onChange={(_editor, _data, newValue) => handleChange(newValue)} + onFocus={handleFocus} + /> +
    + )} +
    +
    + +
    + ); +}; + +export default CodeControl; diff --git a/src/widgets/code/CodePreview.js b/src/widgets/code/CodePreview.js deleted file mode 100644 index 77eabf80..00000000 --- a/src/widgets/code/CodePreview.js +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Map } from 'immutable'; -import { isString } from 'lodash'; - -import { WidgetPreviewContainer } from '../../ui'; - -function toValue(value, field) { - if (isString(value)) { - return value; - } - if (Map.isMap(value)) { - return value.get(field.getIn(['keys', 'code'], 'code'), ''); - } - return ''; -} - -function CodePreview(props) { - return ( - -
    -        {toValue(props.value, props.field)}
    -      
    -
    - ); -} - -CodePreview.propTypes = { - value: PropTypes.node, -}; - -export default CodePreview; diff --git a/src/widgets/code/CodePreview.tsx b/src/widgets/code/CodePreview.tsx new file mode 100644 index 00000000..afa3b37d --- /dev/null +++ b/src/widgets/code/CodePreview.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import isString from 'lodash/isString'; + +import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer'; + +import type { CodeField, WidgetPreviewProps } from '../../interface'; + +function toValue(value: string | Record | undefined | null, field: CodeField) { + if (isString(value)) { + return value; + } + + if (value) { + return value[field.keys?.code ?? 'code'] ?? ''; + } + + return ''; +} + +const CodePreview = ({ + value, + field, +}: WidgetPreviewProps, CodeField>) => { + return ( + +
    +        {toValue(value, field)}
    +      
    +
    + ); +}; + +export default CodePreview; diff --git a/src/widgets/code/SettingsButton.js b/src/widgets/code/SettingsButton.js deleted file mode 100644 index 4f338a78..00000000 --- a/src/widgets/code/SettingsButton.js +++ /dev/null @@ -1,34 +0,0 @@ -import React from 'react'; -import styled from '@emotion/styled'; - -import { Icon, buttons, shadows, zIndex } from '../../ui'; - -const StyledSettingsButton = styled.button` - ${buttons.button}; - ${buttons.default}; - ${shadows.drop}; - display: block; - position: absolute; - z-index: ${zIndex.zIndex100}; - right: 8px; - top: 8px; - opacity: 0.8; - padding: 2px 4px; - line-height: 1; - height: auto; - - ${Icon} { - position: relative; - top: 1px; - } -`; - -function SettingsButton({ showClose, onClick }) { - return ( - - - - ); -} - -export default SettingsButton; diff --git a/src/widgets/code/SettingsButton.tsx b/src/widgets/code/SettingsButton.tsx new file mode 100644 index 00000000..d1fcd81b --- /dev/null +++ b/src/widgets/code/SettingsButton.tsx @@ -0,0 +1,46 @@ +import CloseIcon from '@mui/icons-material/Close'; +import SettingsIcon from '@mui/icons-material/Settings'; +import IconButton from '@mui/material/IconButton'; +import { styled } from '@mui/material/styles'; +import React from 'react'; + +import { zIndex } from '../../components/UI/styles'; +import { transientOptions } from '../../lib'; + +import type { MouseEvent } from 'react'; + +interface StyledSettingsButtonProps { + $showClose: boolean; +} + +const StyledSettingsButton = styled( + IconButton, + transientOptions, +)( + ({ $showClose }) => ` + position: absolute; + z-index: ${zIndex.zIndex100}; + right: 8px; + top: 8px; + opacity: 0.8; + padding: 2px 4px; + line-height: 1; + height: auto; + color: ${$showClose ? '#000' : '#fff'}; + `, +); + +interface SettingsButtonProps { + showClose?: boolean; + onClick: (event: MouseEvent) => void; +} + +const SettingsButton = ({ showClose = false, onClick }: SettingsButtonProps) => { + return ( + + {showClose ? : } + + ); +}; + +export default SettingsButton; diff --git a/src/widgets/code/SettingsPane.js b/src/widgets/code/SettingsPane.js deleted file mode 100644 index e60a1f7d..00000000 --- a/src/widgets/code/SettingsPane.js +++ /dev/null @@ -1,116 +0,0 @@ -import React from 'react'; -import styled from '@emotion/styled'; -import Select from 'react-select'; -import isHotkey from 'is-hotkey'; - -import { text, shadows, zIndex } from '../../ui'; -import SettingsButton from './SettingsButton'; -import languageSelectStyles from './languageSelectStyles'; - -const SettingsPaneContainer = styled.div` - position: absolute; - right: 0; - width: 200px; - z-index: ${zIndex.zIndex10}; - height: 100%; - background-color: #fff; - overflow: hidden; - overflow-y: scroll; - padding: 12px; - border-radius: 0 3px 3px 0; - ${shadows.drop}; -`; - -const SettingsFieldLabel = styled.label` - ${text.fieldLabel}; - font-size: 11px; - display: block; - margin-top: 8px; - margin-bottom: 2px; -`; - -const SettingsSectionTitle = styled.h3` - font-size: 14px; - margin-top: 14px; - margin-bottom: 0; - - &:first-of-type { - margin-top: 4px; - } -`; - -function SettingsSelect({ value, options, onChange, forID, type, autoFocus }) { - return ( - + {options.map(({ label, value }) => + value ? ( + + {label} + + ) : null, + )} + + + ); +}; + +interface SettingsPaneProps { + hideSettings: () => void; + uniqueId: string; + modes: { + value: string; + label: string; + }[]; + mode: { + value: string; + label: string; + }; + theme: string; + themes: string[]; + keyMap: { value: string; label: string }; + keyMaps: { + value: string; + label: string; + }[]; + allowLanguageSelection: boolean; + onChangeLang: (lang: string) => void; + onChangeTheme: (theme: string) => void; + onChangeKeyMap: (keyMap: string) => void; +} + +const SettingsPane = ({ + hideSettings, + uniqueId, + modes, + mode, + theme, + themes, + keyMap, + keyMaps, + allowLanguageSelection, + onChangeLang, + onChangeTheme, + onChangeKeyMap, +}: SettingsPaneProps) => { + return ( + isHotkey('esc', e) && hideSettings()}> + + {allowLanguageSelection && ( + <> + Field Settings + + + )} + <> + Global Settings + {themes && ( + <> + ({ value: t, label: t }))} + onChange={onChangeTheme} + /> + + )} + + + + ); +}; + +export default SettingsPane; diff --git a/src/widgets/code/data/languages-raw.yml b/src/widgets/code/data/languages-raw.yml index c2a9b8e2..cbecf76f 100644 --- a/src/widgets/code/data/languages-raw.yml +++ b/src/widgets/code/data/languages-raw.yml @@ -41,18 +41,18 @@ --- 1C Enterprise: type: programming - color: "#814CCC" + color: '#814CCC' extensions: - - ".bsl" - - ".os" + - '.bsl' + - '.os' tm_scope: source.bsl ace_mode: text language_id: 0 ABAP: type: programming - color: "#E8274B" + color: '#E8274B' extensions: - - ".abap" + - '.abap' tm_scope: source.abap ace_mode: abap language_id: 1 @@ -60,17 +60,17 @@ ABNF: type: data ace_mode: text extensions: - - ".abnf" + - '.abnf' tm_scope: source.abnf language_id: 429 AGS Script: type: programming - color: "#B9D9FF" + color: '#B9D9FF' aliases: - - ags + - ags extensions: - - ".asc" - - ".ash" + - '.asc' + - '.ash' tm_scope: source.c++ ace_mode: c_cpp codemirror_mode: clike @@ -78,39 +78,39 @@ AGS Script: language_id: 2 AMPL: type: programming - color: "#E6EFBB" + color: '#E6EFBB' extensions: - - ".ampl" - - ".mod" + - '.ampl' + - '.mod' tm_scope: source.ampl ace_mode: text language_id: 3 ANTLR: type: programming - color: "#9DC3FF" + color: '#9DC3FF' extensions: - - ".g4" + - '.g4' tm_scope: source.antlr ace_mode: text language_id: 4 API Blueprint: type: markup - color: "#2ACCA8" + color: '#2ACCA8' ace_mode: markdown extensions: - - ".apib" + - '.apib' tm_scope: text.html.markdown.source.gfm.apib language_id: 5 APL: type: programming - color: "#5A8164" + color: '#5A8164' extensions: - - ".apl" - - ".dyalog" + - '.apl' + - '.dyalog' interpreters: - - apl - - aplx - - dyalog + - apl + - aplx + - dyalog tm_scope: source.apl ace_mode: text codemirror_mode: apl @@ -119,8 +119,8 @@ APL: ASN.1: type: data extensions: - - ".asn" - - ".asn1" + - '.asn' + - '.asn1' tm_scope: source.asn ace_mode: text codemirror_mode: asn.1 @@ -128,57 +128,57 @@ ASN.1: language_id: 7 ASP: type: programming - color: "#6a40fd" + color: '#6a40fd' tm_scope: text.html.asp aliases: - - aspx - - aspx-vb + - aspx + - aspx-vb extensions: - - ".asp" - - ".asax" - - ".ascx" - - ".ashx" - - ".asmx" - - ".aspx" - - ".axd" + - '.asp' + - '.asax' + - '.ascx' + - '.ashx' + - '.asmx' + - '.aspx' + - '.axd' ace_mode: text codemirror_mode: htmlembedded codemirror_mime_type: application/x-aspx language_id: 8 ATS: type: programming - color: "#1ac620" + color: '#1ac620' aliases: - - ats2 + - ats2 extensions: - - ".dats" - - ".hats" - - ".sats" + - '.dats' + - '.hats' + - '.sats' tm_scope: source.ats ace_mode: ocaml language_id: 9 ActionScript: type: programming tm_scope: source.actionscript.3 - color: "#882B0F" + color: '#882B0F' aliases: - - actionscript 3 - - actionscript3 - - as3 + - actionscript 3 + - actionscript3 + - as3 extensions: - - ".as" + - '.as' ace_mode: actionscript language_id: 10 Ada: type: programming - color: "#02f88c" + color: '#02f88c' extensions: - - ".adb" - - ".ada" - - ".ads" + - '.adb' + - '.ada' + - '.ads' aliases: - - ada95 - - ada2005 + - ada95 + - ada2005 tm_scope: source.ada ace_mode: ada language_id: 11 @@ -186,27 +186,27 @@ Adobe Font Metrics: type: data tm_scope: source.afm extensions: - - ".afm" + - '.afm' aliases: - - acfm - - adobe composite font metrics - - adobe multiple font metrics - - amfm + - acfm + - adobe composite font metrics + - adobe multiple font metrics + - amfm ace_mode: text language_id: 147198098 Agda: type: programming - color: "#315665" + color: '#315665' extensions: - - ".agda" + - '.agda' tm_scope: source.agda ace_mode: text language_id: 12 Alloy: type: programming - color: "#64C800" + color: '#64C800' extensions: - - ".als" + - '.als' tm_scope: source.alloy ace_mode: text language_id: 13 @@ -214,10 +214,10 @@ Alpine Abuild: type: programming group: Shell aliases: - - abuild - - apkbuild + - abuild + - apkbuild filenames: - - APKBUILD + - APKBUILD tm_scope: source.shell ace_mode: sh codemirror_mode: shell @@ -226,21 +226,21 @@ Alpine Abuild: Altium Designer: type: data aliases: - - altium + - altium extensions: - - ".OutJob" - - ".PcbDoc" - - ".PrjPCB" - - ".SchDoc" + - '.OutJob' + - '.PcbDoc' + - '.PrjPCB' + - '.SchDoc' tm_scope: source.ini ace_mode: ini language_id: 187772328 AngelScript: type: programming - color: "#C7D7DC" + color: '#C7D7DC' extensions: - - ".as" - - ".angelscript" + - '.as' + - '.angelscript' tm_scope: source.angelscript ace_mode: text codemirror_mode: clike @@ -250,8 +250,8 @@ Ant Build System: type: data tm_scope: text.xml.ant filenames: - - ant.xml - - build.xml + - ant.xml + - build.xml ace_mode: xml codemirror_mode: xml codemirror_mime_type: application/xml @@ -259,22 +259,22 @@ Ant Build System: ApacheConf: type: data aliases: - - aconf - - apache + - aconf + - apache extensions: - - ".apacheconf" - - ".vhost" + - '.apacheconf' + - '.vhost' filenames: - - ".htaccess" - - apache2.conf - - httpd.conf + - '.htaccess' + - apache2.conf + - httpd.conf tm_scope: source.apache-config ace_mode: apache_conf language_id: 16 Apex: type: programming extensions: - - ".cls" + - '.cls' tm_scope: source.java ace_mode: java codemirror_mode: clike @@ -284,28 +284,28 @@ Apollo Guidance Computer: type: programming group: Assembly extensions: - - ".agc" + - '.agc' tm_scope: source.agc ace_mode: assembly_x86 language_id: 18 AppleScript: type: programming aliases: - - osascript + - osascript extensions: - - ".applescript" - - ".scpt" + - '.applescript' + - '.scpt' interpreters: - - osascript + - osascript tm_scope: source.applescript ace_mode: applescript - color: "#101F1F" + color: '#101F1F' language_id: 19 Arc: type: programming - color: "#aa2afe" + color: '#aa2afe' extensions: - - ".arc" + - '.arc' tm_scope: none ace_mode: text language_id: 20 @@ -314,40 +314,40 @@ AsciiDoc: ace_mode: asciidoc wrap: true extensions: - - ".asciidoc" - - ".adoc" - - ".asc" + - '.asciidoc' + - '.adoc' + - '.asc' tm_scope: text.html.asciidoc language_id: 22 AspectJ: type: programming - color: "#a957b0" + color: '#a957b0' extensions: - - ".aj" + - '.aj' tm_scope: source.aspectj ace_mode: text language_id: 23 Assembly: type: programming - color: "#6E4C13" + color: '#6E4C13' aliases: - - asm - - nasm + - asm + - nasm extensions: - - ".asm" - - ".a51" - - ".inc" - - ".nasm" + - '.asm' + - '.a51' + - '.inc' + - '.nasm' tm_scope: source.assembly ace_mode: assembly_x86 language_id: 24 Asymptote: type: programming - color: "#4a0c0c" + color: '#4a0c0c' extensions: - - ".asy" + - '.asy' interpreters: - - asy + - asy tm_scope: source.c++ ace_mode: c_cpp codemirror_mode: clike @@ -356,75 +356,75 @@ Asymptote: Augeas: type: programming extensions: - - ".aug" + - '.aug' tm_scope: none ace_mode: text language_id: 25 AutoHotkey: type: programming - color: "#6594b9" + color: '#6594b9' aliases: - - ahk + - ahk extensions: - - ".ahk" - - ".ahkl" + - '.ahk' + - '.ahkl' tm_scope: source.ahk ace_mode: autohotkey language_id: 26 AutoIt: type: programming - color: "#1C3552" + color: '#1C3552' aliases: - - au3 - - AutoIt3 - - AutoItScript + - au3 + - AutoIt3 + - AutoItScript extensions: - - ".au3" + - '.au3' tm_scope: source.autoit ace_mode: autohotkey language_id: 27 Awk: type: programming extensions: - - ".awk" - - ".auk" - - ".gawk" - - ".mawk" - - ".nawk" + - '.awk' + - '.auk' + - '.gawk' + - '.mawk' + - '.nawk' interpreters: - - awk - - gawk - - mawk - - nawk + - awk + - gawk + - mawk + - nawk tm_scope: source.awk ace_mode: text language_id: 28 Ballerina: type: programming extensions: - - ".bal" + - '.bal' tm_scope: source.ballerina ace_mode: text - color: "#FF5000" + color: '#FF5000' language_id: 720859680 Batchfile: type: programming aliases: - - bat - - batch - - dosbatch - - winbatch + - bat + - batch + - dosbatch + - winbatch extensions: - - ".bat" - - ".cmd" + - '.bat' + - '.cmd' tm_scope: source.batchfile ace_mode: batchfile - color: "#C1F12E" + color: '#C1F12E' language_id: 29 Befunge: type: programming extensions: - - ".befunge" + - '.befunge' tm_scope: source.befunge ace_mode: text language_id: 30 @@ -432,7 +432,7 @@ BibTeX: type: markup group: TeX extensions: - - ".bib" + - '.bib' tm_scope: text.bibtex ace_mode: tex codemirror_mode: stex @@ -443,69 +443,69 @@ Bison: group: Yacc tm_scope: source.yacc extensions: - - ".bison" + - '.bison' ace_mode: text language_id: 31 BitBake: type: programming tm_scope: none extensions: - - ".bb" + - '.bb' ace_mode: text language_id: 32 Blade: type: markup group: HTML extensions: - - ".blade" - - ".blade.php" + - '.blade' + - '.blade.php' tm_scope: text.html.php.blade ace_mode: text language_id: 33 BlitzBasic: type: programming aliases: - - b3d - - blitz3d - - blitzplus - - bplus + - b3d + - blitz3d + - blitzplus + - bplus extensions: - - ".bb" - - ".decls" + - '.bb' + - '.decls' tm_scope: source.blitzmax ace_mode: text language_id: 34 BlitzMax: type: programming - color: "#cd6400" + color: '#cd6400' extensions: - - ".bmx" + - '.bmx' aliases: - - bmax + - bmax tm_scope: source.blitzmax ace_mode: text language_id: 35 Bluespec: type: programming extensions: - - ".bsv" + - '.bsv' tm_scope: source.bsv ace_mode: verilog language_id: 36 Boo: type: programming - color: "#d4bec1" + color: '#d4bec1' extensions: - - ".boo" + - '.boo' ace_mode: text tm_scope: source.boo language_id: 37 Brainfuck: type: programming - color: "#2F2530" + color: '#2F2530' extensions: - - ".b" - - ".bf" + - '.b' + - '.bf' tm_scope: source.bf ace_mode: text codemirror_mode: brainfuck @@ -514,20 +514,20 @@ Brainfuck: Brightscript: type: programming extensions: - - ".brs" + - '.brs' tm_scope: source.brightscript ace_mode: text language_id: 39 C: type: programming - color: "#555555" + color: '#555555' extensions: - - ".c" - - ".cats" - - ".h" - - ".idc" + - '.c' + - '.cats' + - '.h' + - '.idc' interpreters: - - tcc + - tcc tm_scope: source.c ace_mode: c_cpp codemirror_mode: clike @@ -539,13 +539,13 @@ C#: codemirror_mode: clike codemirror_mime_type: text/x-csharp tm_scope: source.cs - color: "#178600" + color: '#178600' aliases: - - csharp + - csharp extensions: - - ".cs" - - ".cake" - - ".csx" + - '.cs' + - '.cake' + - '.csx' language_id: 42 C++: type: programming @@ -553,32 +553,32 @@ C++: ace_mode: c_cpp codemirror_mode: clike codemirror_mime_type: text/x-c++src - color: "#f34b7d" + color: '#f34b7d' aliases: - - cpp + - cpp extensions: - - ".cpp" - - ".c++" - - ".cc" - - ".cp" - - ".cxx" - - ".h" - - ".h++" - - ".hh" - - ".hpp" - - ".hxx" - - ".inc" - - ".inl" - - ".ino" - - ".ipp" - - ".re" - - ".tcc" - - ".tpp" + - '.cpp' + - '.c++' + - '.cc' + - '.cp' + - '.cxx' + - '.h' + - '.h++' + - '.hh' + - '.hpp' + - '.hxx' + - '.inc' + - '.inl' + - '.ino' + - '.ipp' + - '.re' + - '.tcc' + - '.tpp' language_id: 43 C-ObjDump: type: data extensions: - - ".c-objdump" + - '.c-objdump' tm_scope: objdump.x86asm ace_mode: assembly_x86 language_id: 44 @@ -586,9 +586,9 @@ C2hs Haskell: type: programming group: Haskell aliases: - - c2hs + - c2hs extensions: - - ".chs" + - '.chs' tm_scope: source.haskell ace_mode: haskell codemirror_mode: haskell @@ -597,17 +597,17 @@ C2hs Haskell: CLIPS: type: programming extensions: - - ".clp" + - '.clp' tm_scope: source.clips ace_mode: text language_id: 46 CMake: type: programming extensions: - - ".cmake" - - ".cmake.in" + - '.cmake' + - '.cmake.in' filenames: - - CMakeLists.txt + - CMakeLists.txt tm_scope: source.cmake ace_mode: text codemirror_mode: cmake @@ -616,11 +616,11 @@ CMake: COBOL: type: programming extensions: - - ".cob" - - ".cbl" - - ".ccp" - - ".cobol" - - ".cpy" + - '.cob' + - '.cbl' + - '.ccp' + - '.cobol' + - '.cpy' tm_scope: source.cobol ace_mode: cobol codemirror_mode: cobol @@ -629,7 +629,7 @@ COBOL: COLLADA: type: data extensions: - - ".dae" + - '.dae' tm_scope: text.xml ace_mode: xml codemirror_mode: xml @@ -642,7 +642,7 @@ CSON: codemirror_mode: coffeescript codemirror_mime_type: text/x-coffeescript extensions: - - ".cson" + - '.cson' language_id: 424 CSS: type: markup @@ -650,33 +650,33 @@ CSS: ace_mode: css codemirror_mode: css codemirror_mime_type: text/css - color: "#563d7c" + color: '#563d7c' extensions: - - ".css" + - '.css' language_id: 50 CSV: type: data ace_mode: text tm_scope: none extensions: - - ".csv" + - '.csv' language_id: 51 CWeb: type: programming extensions: - - ".w" + - '.w' tm_scope: none ace_mode: text language_id: 657332628 Cabal Config: type: data aliases: - - Cabal + - Cabal extensions: - - ".cabal" + - '.cabal' filenames: - - cabal.config - - cabal.project + - cabal.config + - cabal.project ace_mode: haskell codemirror_mode: haskell codemirror_mime_type: text/x-haskell @@ -686,47 +686,47 @@ Cap'n Proto: type: programming tm_scope: source.capnp extensions: - - ".capnp" + - '.capnp' ace_mode: text language_id: 52 CartoCSS: type: programming aliases: - - Carto + - Carto extensions: - - ".mss" + - '.mss' ace_mode: text tm_scope: source.css.mss language_id: 53 Ceylon: type: programming - color: "#dfa535" + color: '#dfa535' extensions: - - ".ceylon" + - '.ceylon' tm_scope: source.ceylon ace_mode: text language_id: 54 Chapel: type: programming - color: "#8dc63f" + color: '#8dc63f' aliases: - - chpl + - chpl extensions: - - ".chpl" + - '.chpl' tm_scope: source.chapel ace_mode: text language_id: 55 Charity: type: programming extensions: - - ".ch" + - '.ch' tm_scope: none ace_mode: text language_id: 56 ChucK: type: programming extensions: - - ".ck" + - '.ck' tm_scope: source.java ace_mode: java codemirror_mode: clike @@ -734,34 +734,34 @@ ChucK: language_id: 57 Cirru: type: programming - color: "#ccccff" + color: '#ccccff' tm_scope: source.cirru ace_mode: cirru extensions: - - ".cirru" + - '.cirru' language_id: 58 Clarion: type: programming - color: "#db901e" + color: '#db901e' ace_mode: text extensions: - - ".clw" + - '.clw' tm_scope: source.clarion language_id: 59 Clean: type: programming - color: "#3F85AF" + color: '#3F85AF' extensions: - - ".icl" - - ".dcl" + - '.icl' + - '.dcl' tm_scope: source.clean ace_mode: text language_id: 60 Click: type: programming - color: "#E4E6F3" + color: '#E4E6F3' extensions: - - ".click" + - '.click' tm_scope: source.click ace_mode: text language_id: 61 @@ -771,19 +771,19 @@ Clojure: ace_mode: clojure codemirror_mode: clojure codemirror_mime_type: text/x-clojure - color: "#db5855" + color: '#db5855' extensions: - - ".clj" - - ".boot" - - ".cl2" - - ".cljc" - - ".cljs" - - ".cljs.hl" - - ".cljscm" - - ".cljx" - - ".hic" + - '.clj' + - '.boot' + - '.cl2' + - '.cljc' + - '.cljs' + - '.cljs.hl' + - '.cljscm' + - '.cljx' + - '.hic' filenames: - - riemann.config + - riemann.config language_id: 62 Closure Templates: type: markup @@ -792,9 +792,9 @@ Closure Templates: codemirror_mode: soy codemirror_mime_type: text/x-soy aliases: - - soy + - soy extensions: - - ".soy" + - '.soy' tm_scope: text.html.soy language_id: 357046146 Cloud Firestore Security Rules: @@ -804,18 +804,18 @@ Cloud Firestore Security Rules: codemirror_mime_type: text/css tm_scope: source.firestore filenames: - - firestore.rules + - firestore.rules language_id: 407996372 CoNLL-U: type: data extensions: - - ".conllu" - - ".conll" + - '.conllu' + - '.conll' tm_scope: text.conllu ace_mode: text aliases: - - CoNLL - - CoNLL-X + - CoNLL + - CoNLL-X language_id: 421026389 CoffeeScript: type: programming @@ -823,32 +823,32 @@ CoffeeScript: ace_mode: coffee codemirror_mode: coffeescript codemirror_mime_type: text/x-coffeescript - color: "#244776" + color: '#244776' aliases: - - coffee - - coffee-script + - coffee + - coffee-script extensions: - - ".coffee" - - "._coffee" - - ".cake" - - ".cjsx" - - ".iced" + - '.coffee' + - '._coffee' + - '.cake' + - '.cjsx' + - '.iced' filenames: - - Cakefile + - Cakefile interpreters: - - coffee + - coffee language_id: 63 ColdFusion: type: programming ace_mode: coldfusion - color: "#ed2cd6" + color: '#ed2cd6' aliases: - - cfm - - cfml - - coldfusion html + - cfm + - cfml + - coldfusion html extensions: - - ".cfm" - - ".cfml" + - '.cfm' + - '.cfml' tm_scope: text.html.cfm language_id: 64 ColdFusion CFC: @@ -856,60 +856,60 @@ ColdFusion CFC: group: ColdFusion ace_mode: coldfusion aliases: - - cfc + - cfc extensions: - - ".cfc" + - '.cfc' tm_scope: source.cfscript language_id: 65 Common Lisp: type: programming tm_scope: source.lisp - color: "#3fb68b" + color: '#3fb68b' aliases: - - lisp + - lisp extensions: - - ".lisp" - - ".asd" - - ".cl" - - ".l" - - ".lsp" - - ".ny" - - ".podsl" - - ".sexp" + - '.lisp' + - '.asd' + - '.cl' + - '.l' + - '.lsp' + - '.ny' + - '.podsl' + - '.sexp' interpreters: - - lisp - - sbcl - - ccl - - clisp - - ecl + - lisp + - sbcl + - ccl + - clisp + - ecl ace_mode: lisp codemirror_mode: commonlisp codemirror_mime_type: text/x-common-lisp language_id: 66 Common Workflow Language: aliases: - - cwl + - cwl type: programming ace_mode: yaml codemirror_mode: yaml codemirror_mime_type: text/x-yaml extensions: - - ".cwl" + - '.cwl' interpreters: - - cwl-runner - color: "#B5314C" + - cwl-runner + color: '#B5314C' tm_scope: source.cwl language_id: 988547172 Component Pascal: type: programming - color: "#B0CE4E" + color: '#B0CE4E' extensions: - - ".cp" - - ".cps" + - '.cp' + - '.cps' tm_scope: source.pascal aliases: - - delphi - - objectpascal + - delphi + - objectpascal ace_mode: pascal codemirror_mode: pascal codemirror_mime_type: text/x-pascal @@ -917,94 +917,94 @@ Component Pascal: Cool: type: programming extensions: - - ".cl" + - '.cl' tm_scope: source.cool ace_mode: text language_id: 68 Coq: type: programming extensions: - - ".coq" - - ".v" + - '.coq' + - '.v' tm_scope: source.coq ace_mode: text language_id: 69 Cpp-ObjDump: type: data extensions: - - ".cppobjdump" - - ".c++-objdump" - - ".c++objdump" - - ".cpp-objdump" - - ".cxx-objdump" + - '.cppobjdump' + - '.c++-objdump' + - '.c++objdump' + - '.cpp-objdump' + - '.cxx-objdump' tm_scope: objdump.x86asm aliases: - - c++-objdump + - c++-objdump ace_mode: assembly_x86 language_id: 70 Creole: type: prose wrap: true extensions: - - ".creole" + - '.creole' tm_scope: text.html.creole ace_mode: text language_id: 71 Crystal: type: programming - color: "#000100" + color: '#000100' extensions: - - ".cr" + - '.cr' ace_mode: ruby codemirror_mode: crystal codemirror_mime_type: text/x-crystal tm_scope: source.crystal interpreters: - - crystal + - crystal language_id: 72 Csound: type: programming aliases: - - csound-orc + - csound-orc extensions: - - ".orc" - - ".udo" + - '.orc' + - '.udo' tm_scope: source.csound ace_mode: csound_orchestra language_id: 73 Csound Document: type: programming aliases: - - csound-csd + - csound-csd extensions: - - ".csd" + - '.csd' tm_scope: source.csound-document ace_mode: csound_document language_id: 74 Csound Score: type: programming aliases: - - csound-sco + - csound-sco extensions: - - ".sco" + - '.sco' tm_scope: source.csound-score ace_mode: csound_score language_id: 75 Cuda: type: programming extensions: - - ".cu" - - ".cuh" + - '.cu' + - '.cuh' tm_scope: source.cuda-c++ ace_mode: c_cpp codemirror_mode: clike codemirror_mime_type: text/x-c++src - color: "#3A4E3A" + color: '#3A4E3A' language_id: 77 Cycript: type: programming extensions: - - ".cy" + - '.cy' tm_scope: source.js ace_mode: javascript codemirror_mode: javascript @@ -1014,11 +1014,11 @@ Cython: type: programming group: Python extensions: - - ".pyx" - - ".pxd" - - ".pxi" + - '.pyx' + - '.pxd' + - '.pxi' aliases: - - pyrex + - pyrex tm_scope: source.cython ace_mode: text codemirror_mode: python @@ -1026,10 +1026,10 @@ Cython: language_id: 79 D: type: programming - color: "#ba595e" + color: '#ba595e' extensions: - - ".d" - - ".di" + - '.d' + - '.di' tm_scope: source.d ace_mode: d codemirror_mode: d @@ -1038,45 +1038,45 @@ D: D-ObjDump: type: data extensions: - - ".d-objdump" + - '.d-objdump' tm_scope: objdump.x86asm ace_mode: assembly_x86 language_id: 81 DIGITAL Command Language: type: programming aliases: - - dcl + - dcl extensions: - - ".com" + - '.com' tm_scope: none ace_mode: text language_id: 82 DM: type: programming - color: "#447265" + color: '#447265' extensions: - - ".dm" + - '.dm' aliases: - - byond + - byond tm_scope: source.dm ace_mode: c_cpp language_id: 83 DNS Zone: type: data extensions: - - ".zone" - - ".arpa" + - '.zone' + - '.arpa' tm_scope: text.zone_file ace_mode: text language_id: 84 DTrace: type: programming aliases: - - dtrace-script + - dtrace-script extensions: - - ".d" + - '.d' interpreters: - - dtrace + - dtrace tm_scope: source.c ace_mode: c_cpp codemirror_mode: clike @@ -1085,20 +1085,20 @@ DTrace: Darcs Patch: type: data aliases: - - dpatch + - dpatch extensions: - - ".darcspatch" - - ".dpatch" + - '.darcspatch' + - '.dpatch' tm_scope: none ace_mode: text language_id: 86 Dart: type: programming - color: "#00B4AB" + color: '#00B4AB' extensions: - - ".dart" + - '.dart' interpreters: - - dart + - dart tm_scope: source.dart ace_mode: dart codemirror_mode: dart @@ -1106,17 +1106,17 @@ Dart: language_id: 87 DataWeave: type: programming - color: "#003a52" + color: '#003a52' extensions: - - ".dwl" + - '.dwl' ace_mode: text tm_scope: source.data-weave language_id: 974514097 Dhall: type: programming - color: "#dfafff" + color: '#dfafff' extensions: - - ".dhall" + - '.dhall' tm_scope: source.haskell ace_mode: haskell codemirror_mode: haskell @@ -1125,10 +1125,10 @@ Dhall: Diff: type: data extensions: - - ".diff" - - ".patch" + - '.diff' + - '.patch' aliases: - - udiff + - udiff tm_scope: source.diff ace_mode: diff codemirror_mode: diff @@ -1136,32 +1136,32 @@ Diff: language_id: 88 Dockerfile: type: programming - color: "#384d54" + color: '#384d54' tm_scope: source.dockerfile extensions: - - ".dockerfile" + - '.dockerfile' filenames: - - Dockerfile + - Dockerfile ace_mode: dockerfile codemirror_mode: dockerfile codemirror_mime_type: text/x-dockerfile language_id: 89 Dogescript: type: programming - color: "#cca760" + color: '#cca760' extensions: - - ".djs" + - '.djs' tm_scope: none ace_mode: text language_id: 90 Dylan: type: programming - color: "#6c616e" + color: '#6c616e' extensions: - - ".dylan" - - ".dyl" - - ".intr" - - ".lid" + - '.dylan' + - '.dyl' + - '.intr' + - '.lid' tm_scope: source.dylan ace_mode: text codemirror_mode: dylan @@ -1169,18 +1169,18 @@ Dylan: language_id: 91 E: type: programming - color: "#ccce35" + color: '#ccce35' extensions: - - ".E" + - '.E' interpreters: - - rune + - rune tm_scope: none ace_mode: text language_id: 92 EBNF: type: data extensions: - - ".ebnf" + - '.ebnf' tm_scope: source.ebnf ace_mode: text codemirror_mode: ebnf @@ -1188,10 +1188,10 @@ EBNF: language_id: 430 ECL: type: programming - color: "#8a1267" + color: '#8a1267' extensions: - - ".ecl" - - ".eclxml" + - '.ecl' + - '.eclxml' tm_scope: none ace_mode: text codemirror_mode: ecl @@ -1201,7 +1201,7 @@ ECLiPSe: type: programming group: prolog extensions: - - ".ecl" + - '.ecl' tm_scope: source.prolog.eclipse ace_mode: prolog language_id: 94 @@ -1209,23 +1209,23 @@ EJS: type: markup group: HTML extensions: - - ".ejs" + - '.ejs' tm_scope: text.html.js ace_mode: ejs language_id: 95 EML: type: data extensions: - - ".eml" - - ".mbox" + - '.eml' + - '.mbox' tm_scope: text.eml.basic ace_mode: text language_id: 529653389 EQ: type: programming - color: "#a78649" + color: '#a78649' extensions: - - ".eq" + - '.eq' tm_scope: source.cs ace_mode: csharp codemirror_mode: clike @@ -1234,8 +1234,8 @@ EQ: Eagle: type: data extensions: - - ".sch" - - ".brd" + - '.sch' + - '.brd' tm_scope: text.xml ace_mode: xml codemirror_mode: xml @@ -1249,13 +1249,13 @@ Easybuild: codemirror_mime_type: text/x-python tm_scope: source.python extensions: - - ".eb" + - '.eb' language_id: 342840477 Ecere Projects: type: data group: JavaScript extensions: - - ".epj" + - '.epj' tm_scope: source.json ace_mode: json codemirror_mode: javascript @@ -1265,9 +1265,9 @@ EditorConfig: type: data group: INI filenames: - - ".editorconfig" + - '.editorconfig' aliases: - - editor-config + - editor-config ace_mode: ini codemirror_mode: properties codemirror_mime_type: text/x-properties @@ -1276,7 +1276,7 @@ EditorConfig: Edje Data Collection: type: data extensions: - - ".edc" + - '.edc' tm_scope: source.c++ ace_mode: c_cpp codemirror_mode: clike @@ -1284,9 +1284,9 @@ Edje Data Collection: language_id: 342840478 Eiffel: type: programming - color: "#946d57" + color: '#946d57' extensions: - - ".e" + - '.e' tm_scope: source.eiffel ace_mode: eiffel codemirror_mode: eiffel @@ -1294,22 +1294,22 @@ Eiffel: language_id: 99 Elixir: type: programming - color: "#6e4a7e" + color: '#6e4a7e' extensions: - - ".ex" - - ".exs" + - '.ex' + - '.exs' tm_scope: source.elixir ace_mode: elixir filenames: - - mix.lock + - mix.lock interpreters: - - elixir + - elixir language_id: 100 Elm: type: programming - color: "#60B5CC" + color: '#60B5CC' extensions: - - ".elm" + - '.elm' tm_scope: source.elm ace_mode: elm codemirror_mode: elm @@ -1318,35 +1318,35 @@ Elm: Emacs Lisp: type: programming tm_scope: source.emacs.lisp - color: "#c065db" + color: '#c065db' aliases: - - elisp - - emacs + - elisp + - emacs filenames: - - ".abbrev_defs" - - ".emacs" - - ".emacs.desktop" - - ".gnus" - - ".spacemacs" - - ".viper" - - Cask - - Project.ede - - _emacs - - abbrev_defs + - '.abbrev_defs' + - '.emacs' + - '.emacs.desktop' + - '.gnus' + - '.spacemacs' + - '.viper' + - Cask + - Project.ede + - _emacs + - abbrev_defs extensions: - - ".el" - - ".emacs" - - ".emacs.desktop" + - '.el' + - '.emacs' + - '.emacs.desktop' ace_mode: lisp codemirror_mode: commonlisp codemirror_mime_type: text/x-common-lisp language_id: 102 EmberScript: type: programming - color: "#FFF4F3" + color: '#FFF4F3' extensions: - - ".em" - - ".emberscript" + - '.em' + - '.emberscript' tm_scope: source.coffee ace_mode: coffee codemirror_mode: coffeescript @@ -1354,36 +1354,36 @@ EmberScript: language_id: 103 Erlang: type: programming - color: "#B83998" + color: '#B83998' extensions: - - ".erl" - - ".app.src" - - ".es" - - ".escript" - - ".hrl" - - ".xrl" - - ".yrl" + - '.erl' + - '.app.src' + - '.es' + - '.escript' + - '.hrl' + - '.xrl' + - '.yrl' filenames: - - Emakefile - - rebar.config - - rebar.config.lock - - rebar.lock + - Emakefile + - rebar.config + - rebar.config.lock + - rebar.lock tm_scope: source.erlang ace_mode: erlang codemirror_mode: erlang codemirror_mime_type: text/x-erlang interpreters: - - escript + - escript language_id: 104 F#: type: programming - color: "#b845fc" + color: '#b845fc' aliases: - - fsharp + - fsharp extensions: - - ".fs" - - ".fsi" - - ".fsx" + - '.fs' + - '.fsi' + - '.fsx' tm_scope: source.fsharp ace_mode: text codemirror_mode: mllike @@ -1392,40 +1392,40 @@ F#: F*: fs_name: Fstar type: programming - color: "#572e30" + color: '#572e30' aliases: - - fstar + - fstar extensions: - - ".fst" + - '.fst' tm_scope: source.fstar ace_mode: text language_id: 336943375 FIGlet Font: type: data aliases: - - FIGfont + - FIGfont extensions: - - ".flf" + - '.flf' tm_scope: source.figfont ace_mode: text language_id: 686129783 FLUX: type: programming - color: "#88ccff" + color: '#88ccff' extensions: - - ".fx" - - ".flux" + - '.fx' + - '.flux' tm_scope: none ace_mode: text language_id: 106 Factor: type: programming - color: "#636746" + color: '#636746' extensions: - - ".factor" + - '.factor' filenames: - - ".factor-boot-rc" - - ".factor-rc" + - '.factor-boot-rc' + - '.factor-rc' tm_scope: source.factor ace_mode: text codemirror_mode: factor @@ -1433,27 +1433,27 @@ Factor: language_id: 108 Fancy: type: programming - color: "#7b9db4" + color: '#7b9db4' extensions: - - ".fy" - - ".fancypack" + - '.fy' + - '.fancypack' filenames: - - Fakefile + - Fakefile tm_scope: source.fancy ace_mode: text language_id: 109 Fantom: type: programming - color: "#14253c" + color: '#14253c' extensions: - - ".fan" + - '.fan' tm_scope: source.fan ace_mode: text language_id: 110 Filebench WML: type: programming extensions: - - ".f" + - '.f' tm_scope: none ace_mode: text language_id: 111 @@ -1461,30 +1461,30 @@ Filterscript: type: programming group: RenderScript extensions: - - ".fs" + - '.fs' tm_scope: none ace_mode: text language_id: 112 Formatted: type: data extensions: - - ".for" - - ".eam.fs" + - '.for' + - '.eam.fs' tm_scope: none ace_mode: text language_id: 113 Forth: type: programming - color: "#341708" + color: '#341708' extensions: - - ".fth" - - ".4th" - - ".f" - - ".for" - - ".forth" - - ".fr" - - ".frt" - - ".fs" + - '.fth' + - '.4th' + - '.f' + - '.for' + - '.forth' + - '.fr' + - '.frt' + - '.fs' tm_scope: source.forth ace_mode: forth codemirror_mode: forth @@ -1492,16 +1492,16 @@ Forth: language_id: 114 Fortran: type: programming - color: "#4d41b1" + color: '#4d41b1' extensions: - - ".f90" - - ".f" - - ".f03" - - ".f08" - - ".f77" - - ".f95" - - ".for" - - ".fpp" + - '.f90' + - '.f' + - '.f03' + - '.f08' + - '.f77' + - '.f95' + - '.for' + - '.fpp' tm_scope: source.fortran.modern ace_mode: text codemirror_mode: fortran @@ -1509,63 +1509,63 @@ Fortran: language_id: 107 FreeMarker: type: programming - color: "#0050b2" + color: '#0050b2' aliases: - - ftl + - ftl extensions: - - ".ftl" + - '.ftl' tm_scope: text.html.ftl ace_mode: ftl language_id: 115 Frege: type: programming - color: "#00cafe" + color: '#00cafe' extensions: - - ".fr" + - '.fr' tm_scope: source.haskell ace_mode: haskell language_id: 116 G-code: type: programming - color: "#D08CF2" + color: '#D08CF2' extensions: - - ".g" - - ".cnc" - - ".gco" - - ".gcode" + - '.g' + - '.cnc' + - '.gco' + - '.gcode' tm_scope: source.gcode ace_mode: gcode language_id: 117 GAML: type: programming - color: "#FFC766" + color: '#FFC766' extensions: - - ".gaml" + - '.gaml' tm_scope: none ace_mode: text language_id: 290345951 GAMS: type: programming extensions: - - ".gms" + - '.gms' tm_scope: none ace_mode: text language_id: 118 GAP: type: programming extensions: - - ".g" - - ".gap" - - ".gd" - - ".gi" - - ".tst" + - '.g' + - '.gap' + - '.gd' + - '.gi' + - '.tst' tm_scope: source.gap ace_mode: text language_id: 119 GCC Machine Description: type: programming extensions: - - ".md" + - '.md' tm_scope: source.lisp ace_mode: lisp codemirror_mode: commonlisp @@ -1574,52 +1574,52 @@ GCC Machine Description: GDB: type: programming extensions: - - ".gdb" - - ".gdbinit" + - '.gdb' + - '.gdbinit' tm_scope: source.gdb ace_mode: text language_id: 122 GDScript: type: programming - color: "#355570" + color: '#355570' extensions: - - ".gd" + - '.gd' tm_scope: source.gdscript ace_mode: text language_id: 123 GLSL: type: programming extensions: - - ".glsl" - - ".fp" - - ".frag" - - ".frg" - - ".fs" - - ".fsh" - - ".fshader" - - ".geo" - - ".geom" - - ".glslv" - - ".gshader" - - ".shader" - - ".tesc" - - ".tese" - - ".vert" - - ".vrx" - - ".vsh" - - ".vshader" + - '.glsl' + - '.fp' + - '.frag' + - '.frg' + - '.fs' + - '.fsh' + - '.fshader' + - '.geo' + - '.geom' + - '.glslv' + - '.gshader' + - '.shader' + - '.tesc' + - '.tese' + - '.vert' + - '.vrx' + - '.vsh' + - '.vshader' tm_scope: source.glsl ace_mode: glsl language_id: 124 GN: type: data extensions: - - ".gn" - - ".gni" + - '.gn' + - '.gni' interpreters: - - gn + - gn filenames: - - ".gn" + - '.gn' tm_scope: source.gn ace_mode: python codemirror_mode: python @@ -1627,9 +1627,9 @@ GN: language_id: 302957008 Game Maker Language: type: programming - color: "#71b417" + color: '#71b417' extensions: - - ".gml" + - '.gml' tm_scope: source.c++ ace_mode: c_cpp codemirror_mode: clike @@ -1639,18 +1639,18 @@ Genie: type: programming ace_mode: text extensions: - - ".gs" - color: "#fb855d" + - '.gs' + color: '#fb855d' tm_scope: none language_id: 792408528 Genshi: type: programming extensions: - - ".kid" + - '.kid' tm_scope: text.xml.genshi aliases: - - xml+genshi - - xml+kid + - xml+genshi + - xml+kid ace_mode: xml codemirror_mode: xml codemirror_mime_type: text/xml @@ -1659,7 +1659,7 @@ Gentoo Ebuild: type: programming group: Shell extensions: - - ".ebuild" + - '.ebuild' tm_scope: source.shell ace_mode: sh codemirror_mode: shell @@ -1669,7 +1669,7 @@ Gentoo Eclass: type: programming group: Shell extensions: - - ".eclass" + - '.eclass' tm_scope: source.shell ace_mode: sh codemirror_mode: shell @@ -1678,24 +1678,24 @@ Gentoo Eclass: Gerber Image: type: data aliases: - - rs-274x + - rs-274x extensions: - - ".gbr" - - ".gbl" - - ".gbo" - - ".gbp" - - ".gbs" - - ".gko" - - ".gml" - - ".gpb" - - ".gpt" - - ".gtl" - - ".gto" - - ".gtp" - - ".gts" + - '.gbr' + - '.gbl' + - '.gbo' + - '.gbp' + - '.gbs' + - '.gko' + - '.gml' + - '.gpb' + - '.gpt' + - '.gtl' + - '.gto' + - '.gtp' + - '.gts' interpreters: - - gerbv - - gerbview + - gerbv + - gerbview tm_scope: source.gerber ace_mode: text language_id: 404627610 @@ -1703,30 +1703,30 @@ Gettext Catalog: type: prose searchable: false aliases: - - pot + - pot extensions: - - ".po" - - ".pot" + - '.po' + - '.pot' tm_scope: source.po ace_mode: text language_id: 129 Gherkin: type: programming extensions: - - ".feature" + - '.feature' tm_scope: text.gherkin.feature aliases: - - cucumber + - cucumber ace_mode: text - color: "#5B2063" + color: '#5B2063' language_id: 76 Git Attributes: type: data group: INI aliases: - - gitattributes + - gitattributes filenames: - - ".gitattributes" + - '.gitattributes' tm_scope: source.gitattributes ace_mode: gitignore codemirror_mode: shell @@ -1736,13 +1736,13 @@ Git Config: type: data group: INI aliases: - - gitconfig - - gitmodules + - gitconfig + - gitmodules extensions: - - ".gitconfig" + - '.gitconfig' filenames: - - ".gitconfig" - - ".gitmodules" + - '.gitconfig' + - '.gitmodules' ace_mode: ini codemirror_mode: properties codemirror_mime_type: text/x-properties @@ -1750,9 +1750,9 @@ Git Config: language_id: 807968997 Glyph: type: programming - color: "#c1ac7f" + color: '#c1ac7f' extensions: - - ".glf" + - '.glf' tm_scope: source.tcl ace_mode: tcl codemirror_mode: tcl @@ -1761,31 +1761,31 @@ Glyph: Glyph Bitmap Distribution Format: type: data extensions: - - ".bdf" + - '.bdf' tm_scope: source.bdf ace_mode: text language_id: 997665271 Gnuplot: type: programming - color: "#f0a9f0" + color: '#f0a9f0' extensions: - - ".gp" - - ".gnu" - - ".gnuplot" - - ".plot" - - ".plt" + - '.gp' + - '.gnu' + - '.gnuplot' + - '.plot' + - '.plt' interpreters: - - gnuplot + - gnuplot tm_scope: source.gnuplot ace_mode: text language_id: 131 Go: type: programming - color: "#00ADD8" + color: '#00ADD8' aliases: - - golang + - golang extensions: - - ".go" + - '.go' tm_scope: source.go ace_mode: golang codemirror_mode: go @@ -1793,44 +1793,44 @@ Go: language_id: 132 Golo: type: programming - color: "#88562A" + color: '#88562A' extensions: - - ".golo" + - '.golo' tm_scope: source.golo ace_mode: text language_id: 133 Gosu: type: programming - color: "#82937f" + color: '#82937f' extensions: - - ".gs" - - ".gst" - - ".gsx" - - ".vark" + - '.gs' + - '.gst' + - '.gsx' + - '.vark' tm_scope: source.gosu.2 ace_mode: text language_id: 134 Grace: type: programming extensions: - - ".grace" + - '.grace' tm_scope: source.grace ace_mode: text language_id: 135 Gradle: type: data extensions: - - ".gradle" + - '.gradle' tm_scope: source.groovy.gradle ace_mode: text language_id: 136 Grammatical Framework: type: programming aliases: - - gf + - gf extensions: - - ".gf" - color: "#79aa7a" + - '.gf' + color: '#79aa7a' tm_scope: source.gf ace_mode: haskell codemirror_mode: haskell @@ -1839,16 +1839,16 @@ Grammatical Framework: Graph Modeling Language: type: data extensions: - - ".gml" + - '.gml' tm_scope: none ace_mode: text language_id: 138 GraphQL: type: data extensions: - - ".graphql" - - ".gql" - - ".graphqls" + - '.graphql' + - '.gql' + - '.graphqls' tm_scope: source.graphql ace_mode: text language_id: 139 @@ -1856,8 +1856,8 @@ Graphviz (DOT): type: data tm_scope: source.dot extensions: - - ".dot" - - ".gv" + - '.dot' + - '.gv' ace_mode: text language_id: 140 Groovy: @@ -1866,25 +1866,25 @@ Groovy: ace_mode: groovy codemirror_mode: groovy codemirror_mime_type: text/x-groovy - color: "#e69f56" + color: '#e69f56' extensions: - - ".groovy" - - ".grt" - - ".gtpl" - - ".gvy" + - '.groovy' + - '.grt' + - '.gtpl' + - '.gvy' interpreters: - - groovy + - groovy filenames: - - Jenkinsfile + - Jenkinsfile language_id: 142 Groovy Server Pages: type: programming group: Groovy aliases: - - gsp - - java server page + - gsp + - java server page extensions: - - ".gsp" + - '.gsp' tm_scope: text.html.jsp ace_mode: jsp codemirror_mode: htmlembedded @@ -1893,21 +1893,21 @@ Groovy Server Pages: HAProxy: type: data extensions: - - ".cfg" + - '.cfg' filenames: - - haproxy.cfg + - haproxy.cfg tm_scope: source.haproxy-config ace_mode: text language_id: 366607477 HCL: type: programming extensions: - - ".hcl" - - ".tf" - - ".tfvars" - - ".workflow" + - '.hcl' + - '.tf' + - '.tfvars' + - '.workflow' aliases: - - terraform + - terraform ace_mode: ruby codemirror_mode: ruby codemirror_mime_type: text/x-ruby @@ -1916,11 +1916,11 @@ HCL: HLSL: type: programming extensions: - - ".hlsl" - - ".cginc" - - ".fx" - - ".fxh" - - ".hlsli" + - '.hlsl' + - '.cginc' + - '.fx' + - '.fxh' + - '.hlsli' ace_mode: text tm_scope: source.hlsl language_id: 145 @@ -1930,34 +1930,34 @@ HTML: ace_mode: html codemirror_mode: htmlmixed codemirror_mime_type: text/html - color: "#e34c26" + color: '#e34c26' aliases: - - xhtml + - xhtml extensions: - - ".html" - - ".htm" - - ".html.hl" - - ".inc" - - ".st" - - ".xht" - - ".xhtml" + - '.html' + - '.htm' + - '.html.hl' + - '.inc' + - '.st' + - '.xht' + - '.xhtml' language_id: 146 HTML+Django: type: markup tm_scope: text.html.django group: HTML extensions: - - ".jinja" - - ".jinja2" - - ".mustache" - - ".njk" + - '.jinja' + - '.jinja2' + - '.mustache' + - '.njk' aliases: - - django - - html+django/jinja - - html+jinja - - htmldjango - - njk - - nunjucks + - django + - html+django/jinja + - html+jinja + - htmldjango + - njk + - nunjucks ace_mode: django codemirror_mode: django codemirror_mime_type: text/x-django @@ -1967,9 +1967,9 @@ HTML+ECR: tm_scope: text.html.ecr group: HTML aliases: - - ecr + - ecr extensions: - - ".ecr" + - '.ecr' ace_mode: text codemirror_mode: htmlmixed codemirror_mime_type: text/html @@ -1979,9 +1979,9 @@ HTML+EEX: tm_scope: text.html.elixir group: HTML aliases: - - eex + - eex extensions: - - ".eex" + - '.eex' ace_mode: text codemirror_mode: htmlmixed codemirror_mime_type: text/html @@ -1991,10 +1991,10 @@ HTML+ERB: tm_scope: text.html.erb group: HTML aliases: - - erb + - erb extensions: - - ".erb" - - ".erb.deface" + - '.erb' + - '.erb.deface' ace_mode: text codemirror_mode: htmlembedded codemirror_mime_type: application/x-erb @@ -2004,7 +2004,7 @@ HTML+PHP: tm_scope: text.html.php group: HTML extensions: - - ".phtml" + - '.phtml' ace_mode: php codemirror_mode: php codemirror_mime_type: application/x-httpd-php @@ -2014,10 +2014,10 @@ HTML+Razor: tm_scope: text.html.cshtml group: HTML aliases: - - razor + - razor extensions: - - ".cshtml" - - ".razor" + - '.cshtml' + - '.razor' ace_mode: razor codemirror_mode: htmlmixed codemirror_mime_type: text/html @@ -2025,7 +2025,7 @@ HTML+Razor: HTTP: type: data extensions: - - ".http" + - '.http' tm_scope: source.httpspec ace_mode: text codemirror_mode: http @@ -2035,7 +2035,7 @@ HXML: type: data ace_mode: text extensions: - - ".hxml" + - '.hxml' tm_scope: source.hxml language_id: 786683730 Hack: @@ -2044,18 +2044,18 @@ Hack: codemirror_mode: php codemirror_mime_type: application/x-httpd-php extensions: - - ".hack" - - ".hh" - - ".php" + - '.hack' + - '.hh' + - '.php' tm_scope: source.hack - color: "#878787" + color: '#878787' language_id: 153 Haml: group: HTML type: markup extensions: - - ".haml" - - ".haml.deface" + - '.haml' + - '.haml.deface' tm_scope: text.haml ace_mode: haml codemirror_mode: haml @@ -2065,30 +2065,30 @@ Handlebars: type: markup group: HTML aliases: - - hbs - - htmlbars + - hbs + - htmlbars extensions: - - ".handlebars" - - ".hbs" + - '.handlebars' + - '.hbs' tm_scope: text.html.handlebars ace_mode: handlebars language_id: 155 Harbour: type: programming - color: "#0e60e3" + color: '#0e60e3' extensions: - - ".hb" + - '.hb' tm_scope: source.harbour ace_mode: text language_id: 156 Haskell: type: programming - color: "#5e5086" + color: '#5e5086' extensions: - - ".hs" - - ".hsc" + - '.hs' + - '.hsc' interpreters: - - runhaskell + - runhaskell tm_scope: source.haskell ace_mode: haskell codemirror_mode: haskell @@ -2099,25 +2099,25 @@ Haxe: ace_mode: haxe codemirror_mode: haxe codemirror_mime_type: text/x-haxe - color: "#df7900" + color: '#df7900' extensions: - - ".hx" - - ".hxsl" + - '.hx' + - '.hxsl' tm_scope: source.hx language_id: 158 HiveQL: type: programming extensions: - - ".q" - color: "#dce200" + - '.q' + color: '#dce200' tm_scope: source.hql ace_mode: sql language_id: 931814087 HolyC: type: programming - color: "#ffefaf" + color: '#ffefaf' extensions: - - ".hc" + - '.hc' tm_scope: source.hc ace_mode: c_cpp codemirror_mode: clike @@ -2126,28 +2126,28 @@ HolyC: Hy: type: programming ace_mode: text - color: "#7790B2" + color: '#7790B2' extensions: - - ".hy" + - '.hy' interpreters: - - hy + - hy aliases: - - hylang + - hylang tm_scope: source.hy language_id: 159 HyPhy: type: programming ace_mode: text extensions: - - ".bf" + - '.bf' tm_scope: none language_id: 160 IDL: type: programming - color: "#a3522f" + color: '#a3522f' extensions: - - ".pro" - - ".dlm" + - '.pro' + - '.dlm' tm_scope: source.idl ace_mode: text codemirror_mode: idl @@ -2155,29 +2155,29 @@ IDL: language_id: 161 IGOR Pro: type: programming - color: "#0000cc" + color: '#0000cc' extensions: - - ".ipf" + - '.ipf' aliases: - - igor - - igorpro + - igor + - igorpro tm_scope: source.igor ace_mode: text language_id: 162 INI: type: data extensions: - - ".ini" - - ".cfg" - - ".lektorproject" - - ".prefs" - - ".pro" - - ".properties" + - '.ini' + - '.cfg' + - '.lektorproject' + - '.prefs' + - '.pro' + - '.properties' filenames: - - buildozer.spec + - buildozer.spec tm_scope: source.ini aliases: - - dosini + - dosini ace_mode: ini codemirror_mode: properties codemirror_mime_type: text/x-properties @@ -2185,11 +2185,11 @@ INI: IRC log: type: data aliases: - - irc - - irc logs + - irc + - irc logs extensions: - - ".irclog" - - ".weechatlog" + - '.irclog' + - '.weechatlog' tm_scope: none ace_mode: text codemirror_mode: mirc @@ -2197,10 +2197,10 @@ IRC log: language_id: 164 Idris: type: programming - color: "#b30000" + color: '#b30000' extensions: - - ".idr" - - ".lidr" + - '.idr' + - '.lidr' ace_mode: text tm_scope: source.idris language_id: 165 @@ -2208,27 +2208,27 @@ Ignore List: type: data group: INI aliases: - - ignore - - gitignore - - git-ignore + - ignore + - gitignore + - git-ignore extensions: - - ".gitignore" + - '.gitignore' filenames: - - ".atomignore" - - ".babelignore" - - ".bzrignore" - - ".coffeelintignore" - - ".cvsignore" - - ".dockerignore" - - ".eslintignore" - - ".gitignore" - - ".nodemonignore" - - ".npmignore" - - ".prettierignore" - - ".stylelintignore" - - ".vscodeignore" - - gitignore-global - - gitignore_global + - '.atomignore' + - '.babelignore' + - '.bzrignore' + - '.coffeelintignore' + - '.cvsignore' + - '.dockerignore' + - '.eslintignore' + - '.gitignore' + - '.nodemonignore' + - '.npmignore' + - '.prettierignore' + - '.stylelintignore' + - '.vscodeignore' + - gitignore-global + - gitignore_global ace_mode: gitignore tm_scope: source.gitignore codemirror_mode: shell @@ -2238,46 +2238,46 @@ Inform 7: type: programming wrap: true extensions: - - ".ni" - - ".i7x" + - '.ni' + - '.i7x' tm_scope: source.inform7 aliases: - - i7 - - inform7 + - i7 + - inform7 ace_mode: text language_id: 166 Inno Setup: type: programming extensions: - - ".iss" + - '.iss' tm_scope: none ace_mode: text language_id: 167 Io: type: programming - color: "#a9188d" + color: '#a9188d' extensions: - - ".io" + - '.io' interpreters: - - io + - io tm_scope: source.io ace_mode: io language_id: 168 Ioke: type: programming - color: "#078193" + color: '#078193' extensions: - - ".ik" + - '.ik' interpreters: - - ioke + - ioke tm_scope: source.ioke ace_mode: text language_id: 169 Isabelle: type: programming - color: "#FEFE00" + color: '#FEFE00' extensions: - - ".thy" + - '.thy' tm_scope: source.isabelle.theory ace_mode: text language_id: 170 @@ -2285,17 +2285,17 @@ Isabelle ROOT: type: programming group: Isabelle filenames: - - ROOT + - ROOT tm_scope: source.isabelle.root ace_mode: text language_id: 171 J: type: programming - color: "#9EEDFF" + color: '#9EEDFF' extensions: - - ".ijs" + - '.ijs' interpreters: - - jconsole + - jconsole tm_scope: source.j ace_mode: text language_id: 172 @@ -2303,8 +2303,8 @@ JFlex: type: programming group: Lex extensions: - - ".flex" - - ".jflex" + - '.flex' + - '.jflex' tm_scope: source.jflex ace_mode: text language_id: 173 @@ -2316,30 +2316,30 @@ JSON: codemirror_mime_type: application/json searchable: false extensions: - - ".json" - - ".avsc" - - ".geojson" - - ".gltf" - - ".har" - - ".ice" - - ".JSON-tmLanguage" - - ".jsonl" - - ".mcmeta" - - ".tfstate" - - ".tfstate.backup" - - ".topojson" - - ".webapp" - - ".webmanifest" - - ".yy" - - ".yyp" + - '.json' + - '.avsc' + - '.geojson' + - '.gltf' + - '.har' + - '.ice' + - '.JSON-tmLanguage' + - '.jsonl' + - '.mcmeta' + - '.tfstate' + - '.tfstate.backup' + - '.topojson' + - '.webapp' + - '.webmanifest' + - '.yy' + - '.yyp' filenames: - - ".arcconfig" - - ".htmlhintrc" - - ".tern-config" - - ".tern-project" - - ".watchmanconfig" - - composer.lock - - mcmod.info + - '.arcconfig' + - '.htmlhintrc' + - '.tern-config' + - '.tern-project' + - '.watchmanconfig' + - composer.lock + - mcmod.info language_id: 174 JSON with Comments: type: data @@ -2349,35 +2349,35 @@ JSON with Comments: codemirror_mode: javascript codemirror_mime_type: text/javascript aliases: - - jsonc + - jsonc extensions: - - ".sublime-build" - - ".sublime-commands" - - ".sublime-completions" - - ".sublime-keymap" - - ".sublime-macro" - - ".sublime-menu" - - ".sublime-mousemap" - - ".sublime-project" - - ".sublime-settings" - - ".sublime-theme" - - ".sublime-workspace" - - ".sublime_metrics" - - ".sublime_session" + - '.sublime-build' + - '.sublime-commands' + - '.sublime-completions' + - '.sublime-keymap' + - '.sublime-macro' + - '.sublime-menu' + - '.sublime-mousemap' + - '.sublime-project' + - '.sublime-settings' + - '.sublime-theme' + - '.sublime-workspace' + - '.sublime_metrics' + - '.sublime_session' filenames: - - ".babelrc" - - ".eslintrc.json" - - ".jscsrc" - - ".jshintrc" - - ".jslintrc" - - jsconfig.json - - language-configuration.json - - tsconfig.json + - '.babelrc' + - '.eslintrc.json' + - '.jscsrc' + - '.jshintrc' + - '.jslintrc' + - jsconfig.json + - language-configuration.json + - tsconfig.json language_id: 423 JSON5: type: data extensions: - - ".json5" + - '.json5' tm_scope: source.js ace_mode: javascript codemirror_mode: javascript @@ -2386,27 +2386,27 @@ JSON5: JSONLD: type: data extensions: - - ".jsonld" + - '.jsonld' tm_scope: source.js ace_mode: javascript codemirror_mode: javascript codemirror_mime_type: application/json language_id: 176 JSONiq: - color: "#40d47e" + color: '#40d47e' type: programming ace_mode: jsoniq codemirror_mode: javascript codemirror_mime_type: application/json extensions: - - ".jq" + - '.jq' tm_scope: source.jq language_id: 177 JSX: type: programming group: JavaScript extensions: - - ".jsx" + - '.jsx' tm_scope: source.js.jsx ace_mode: javascript codemirror_mode: jsx @@ -2416,7 +2416,7 @@ Jasmin: type: programming ace_mode: java extensions: - - ".j" + - '.j' tm_scope: source.jasmin language_id: 180 Java: @@ -2425,14 +2425,14 @@ Java: ace_mode: java codemirror_mode: clike codemirror_mime_type: text/x-java - color: "#b07219" + color: '#b07219' extensions: - - ".java" + - '.java' language_id: 181 Java Properties: type: data extensions: - - ".properties" + - '.properties' tm_scope: source.java-properties ace_mode: properties codemirror_mode: properties @@ -2442,9 +2442,9 @@ Java Server Pages: type: programming group: Java aliases: - - jsp + - jsp extensions: - - ".jsp" + - '.jsp' tm_scope: text.html.jsp ace_mode: jsp codemirror_mode: htmlembedded @@ -2456,48 +2456,48 @@ JavaScript: ace_mode: javascript codemirror_mode: javascript codemirror_mime_type: text/javascript - color: "#f1e05a" + color: '#f1e05a' aliases: - - js - - node + - js + - node extensions: - - ".js" - - "._js" - - ".bones" - - ".es" - - ".es6" - - ".frag" - - ".gs" - - ".jake" - - ".jsb" - - ".jscad" - - ".jsfl" - - ".jsm" - - ".jss" - - ".mjs" - - ".njs" - - ".pac" - - ".sjs" - - ".ssjs" - - ".xsjs" - - ".xsjslib" + - '.js' + - '._js' + - '.bones' + - '.es' + - '.es6' + - '.frag' + - '.gs' + - '.jake' + - '.jsb' + - '.jscad' + - '.jsfl' + - '.jsm' + - '.jss' + - '.mjs' + - '.njs' + - '.pac' + - '.sjs' + - '.ssjs' + - '.xsjs' + - '.xsjslib' filenames: - - Jakefile + - Jakefile interpreters: - - chakra - - d8 - - js - - node - - rhino - - v8 - - v8-shell + - chakra + - d8 + - js + - node + - rhino + - v8 + - v8-shell language_id: 183 JavaScript+ERB: type: programming tm_scope: source.js group: JavaScript extensions: - - ".js.erb" + - '.js.erb' ace_mode: javascript codemirror_mode: javascript codemirror_mime_type: application/javascript @@ -2506,7 +2506,7 @@ Jison: type: programming group: Yacc extensions: - - ".jison" + - '.jison' tm_scope: source.jison ace_mode: text language_id: 284531423 @@ -2514,37 +2514,37 @@ Jison Lex: type: programming group: Lex extensions: - - ".jisonlex" + - '.jisonlex' tm_scope: source.jisonlex ace_mode: text language_id: 406395330 Jolie: type: programming extensions: - - ".ol" - - ".iol" + - '.ol' + - '.iol' interpreters: - - jolie - color: "#843179" + - jolie + color: '#843179' ace_mode: text tm_scope: source.jolie language_id: 998078858 Jsonnet: - color: "#0064bd" + color: '#0064bd' type: programming ace_mode: text extensions: - - ".jsonnet" - - ".libsonnet" + - '.jsonnet' + - '.libsonnet' tm_scope: source.jsonnet language_id: 664885656 Julia: type: programming extensions: - - ".jl" + - '.jl' interpreters: - - julia - color: "#a270ba" + - julia + color: '#a270ba' tm_scope: source.julia ace_mode: julia codemirror_mode: julia @@ -2556,32 +2556,32 @@ Jupyter Notebook: codemirror_mode: javascript codemirror_mime_type: application/json tm_scope: source.json - color: "#DA5B0B" + color: '#DA5B0B' extensions: - - ".ipynb" + - '.ipynb' filenames: - - Notebook + - Notebook aliases: - - IPython Notebook + - IPython Notebook language_id: 185 KRL: type: programming - color: "#28430A" + color: '#28430A' extensions: - - ".krl" + - '.krl' tm_scope: none ace_mode: text language_id: 186 KiCad Layout: type: data aliases: - - pcbnew + - pcbnew extensions: - - ".kicad_pcb" - - ".kicad_mod" - - ".kicad_wks" + - '.kicad_pcb' + - '.kicad_mod' + - '.kicad_wks' filenames: - - fp-lib-table + - fp-lib-table tm_scope: source.pcb.sexp ace_mode: lisp codemirror_mode: commonlisp @@ -2590,16 +2590,16 @@ KiCad Layout: KiCad Legacy Layout: type: data extensions: - - ".brd" + - '.brd' tm_scope: source.pcb.board ace_mode: text language_id: 140848857 KiCad Schematic: type: data aliases: - - eeschema schematic + - eeschema schematic extensions: - - ".sch" + - '.sch' tm_scope: source.pcb.schematic ace_mode: text language_id: 622447435 @@ -2609,16 +2609,16 @@ Kit: codemirror_mode: htmlmixed codemirror_mime_type: text/html extensions: - - ".kit" + - '.kit' tm_scope: text.html.basic language_id: 188 Kotlin: type: programming - color: "#F18E33" + color: '#F18E33' extensions: - - ".kt" - - ".ktm" - - ".kts" + - '.kt' + - '.ktm' + - '.kts' tm_scope: source.kotlin ace_mode: text codemirror_mode: clike @@ -2626,9 +2626,9 @@ Kotlin: language_id: 189 LFE: type: programming - color: "#4C3023" + color: '#4C3023' extensions: - - ".lfe" + - '.lfe' tm_scope: source.lisp ace_mode: lisp codemirror_mode: commonlisp @@ -2637,16 +2637,16 @@ LFE: LLVM: type: programming extensions: - - ".ll" + - '.ll' tm_scope: source.llvm ace_mode: text - color: "#185619" + color: '#185619' language_id: 191 LOLCODE: type: programming extensions: - - ".lol" - color: "#cc9900" + - '.lol' + color: '#cc9900' tm_scope: none ace_mode: text language_id: 192 @@ -2655,16 +2655,16 @@ LSL: tm_scope: source.lsl ace_mode: lsl extensions: - - ".lsl" - - ".lslp" + - '.lsl' + - '.lslp' interpreters: - - lsl - color: "#3d9970" + - lsl + color: '#3d9970' language_id: 193 LTspice Symbol: type: data extensions: - - ".asy" + - '.asy' tm_scope: source.ltspice.symbol ace_mode: text codemirror_mode: spreadsheet @@ -2673,7 +2673,7 @@ LTspice Symbol: LabVIEW: type: programming extensions: - - ".lvproj" + - '.lvproj' tm_scope: text.xml ace_mode: xml codemirror_mode: xml @@ -2681,22 +2681,22 @@ LabVIEW: language_id: 194 Lasso: type: programming - color: "#999999" + color: '#999999' extensions: - - ".lasso" - - ".las" - - ".lasso8" - - ".lasso9" + - '.lasso' + - '.las' + - '.lasso8' + - '.lasso9' tm_scope: file.lasso aliases: - - lassoscript + - lassoscript ace_mode: text language_id: 195 Latte: type: markup group: HTML extensions: - - ".latte" + - '.latte' tm_scope: text.html.smarty ace_mode: smarty codemirror_mode: smarty @@ -2705,8 +2705,8 @@ Latte: Lean: type: programming extensions: - - ".lean" - - ".hlean" + - '.lean' + - '.hlean' tm_scope: source.lean ace_mode: text language_id: 197 @@ -2714,7 +2714,7 @@ Less: type: markup group: CSS extensions: - - ".less" + - '.less' tm_scope: source.css.less ace_mode: less codemirror_mode: css @@ -2722,53 +2722,53 @@ Less: language_id: 198 Lex: type: programming - color: "#DBCA00" + color: '#DBCA00' aliases: - - flex + - flex extensions: - - ".l" - - ".lex" + - '.l' + - '.lex' tm_scope: source.lex ace_mode: text language_id: 199 LilyPond: type: programming extensions: - - ".ly" - - ".ily" + - '.ly' + - '.ily' tm_scope: source.lilypond ace_mode: text language_id: 200 Limbo: type: programming extensions: - - ".b" - - ".m" + - '.b' + - '.m' tm_scope: none ace_mode: text language_id: 201 Linker Script: type: data extensions: - - ".ld" - - ".lds" - - ".x" + - '.ld' + - '.lds' + - '.x' filenames: - - ld.script + - ld.script tm_scope: none ace_mode: text language_id: 202 Linux Kernel Module: type: data extensions: - - ".mod" + - '.mod' tm_scope: none ace_mode: text language_id: 203 Liquid: type: markup extensions: - - ".liquid" + - '.liquid' tm_scope: text.html.liquid ace_mode: liquid language_id: 204 @@ -2776,7 +2776,7 @@ Literate Agda: type: programming group: Agda extensions: - - ".lagda" + - '.lagda' tm_scope: none ace_mode: text language_id: 205 @@ -2787,18 +2787,18 @@ Literate CoffeeScript: ace_mode: text wrap: true aliases: - - litcoffee + - litcoffee extensions: - - ".litcoffee" + - '.litcoffee' language_id: 206 Literate Haskell: type: programming group: Haskell aliases: - - lhaskell - - lhs + - lhaskell + - lhs extensions: - - ".lhs" + - '.lhs' tm_scope: text.tex.latex.haskell ace_mode: text codemirror_mode: haskell-literate @@ -2806,15 +2806,15 @@ Literate Haskell: language_id: 207 LiveScript: type: programming - color: "#499886" + color: '#499886' aliases: - - live-script - - ls + - live-script + - ls extensions: - - ".ls" - - "._ls" + - '.ls' + - '._ls' filenames: - - Slakefile + - Slakefile tm_scope: source.livescript ace_mode: livescript codemirror_mode: livescript @@ -2823,17 +2823,17 @@ LiveScript: Logos: type: programming extensions: - - ".xm" - - ".x" - - ".xi" + - '.xm' + - '.x' + - '.xi' ace_mode: text tm_scope: source.logos language_id: 209 Logtalk: type: programming extensions: - - ".lgt" - - ".logtalk" + - '.lgt' + - '.logtalk' tm_scope: source.logtalk ace_mode: text language_id: 210 @@ -2842,17 +2842,17 @@ LookML: ace_mode: yaml codemirror_mode: yaml codemirror_mime_type: text/x-yaml - color: "#652B81" + color: '#652B81' extensions: - - ".lookml" - - ".model.lkml" - - ".view.lkml" + - '.lookml' + - '.model.lkml' + - '.view.lkml' tm_scope: source.yaml language_id: 211 LoomScript: type: programming extensions: - - ".ls" + - '.ls' tm_scope: source.loomscript ace_mode: text language_id: 212 @@ -2862,25 +2862,25 @@ Lua: ace_mode: lua codemirror_mode: lua codemirror_mime_type: text/x-lua - color: "#000080" + color: '#000080' extensions: - - ".lua" - - ".fcgi" - - ".nse" - - ".p8" - - ".pd_lua" - - ".rbxs" - - ".wlua" + - '.lua' + - '.fcgi' + - '.nse' + - '.p8' + - '.pd_lua' + - '.rbxs' + - '.wlua' interpreters: - - lua + - lua language_id: 213 M: type: programming aliases: - - mumps + - mumps extensions: - - ".mumps" - - ".m" + - '.mumps' + - '.m' ace_mode: text codemirror_mode: mumps codemirror_mime_type: text/x-mumps @@ -2889,7 +2889,7 @@ M: M4: type: programming extensions: - - ".m4" + - '.m4' tm_scope: none ace_mode: text language_id: 215 @@ -2897,22 +2897,22 @@ M4Sugar: type: programming group: M4 aliases: - - autoconf + - autoconf extensions: - - ".m4" + - '.m4' filenames: - - configure.ac + - configure.ac tm_scope: none ace_mode: text language_id: 216 MATLAB: type: programming - color: "#e16737" + color: '#e16737' aliases: - - octave + - octave extensions: - - ".matlab" - - ".m" + - '.matlab' + - '.m' tm_scope: source.matlab ace_mode: matlab codemirror_mode: octave @@ -2920,43 +2920,43 @@ MATLAB: language_id: 225 MAXScript: type: programming - color: "#00a6a6" + color: '#00a6a6' extensions: - - ".ms" - - ".mcr" + - '.ms' + - '.mcr' tm_scope: source.maxscript ace_mode: text language_id: 217 MLIR: type: programming extensions: - - ".mlir" + - '.mlir' tm_scope: source.mlir ace_mode: text language_id: 448253929 MQL4: type: programming - color: "#62A8D6" + color: '#62A8D6' extensions: - - ".mq4" - - ".mqh" + - '.mq4' + - '.mqh' tm_scope: source.mql5 ace_mode: c_cpp language_id: 426 MQL5: type: programming - color: "#4A76B8" + color: '#4A76B8' extensions: - - ".mq5" - - ".mqh" + - '.mq5' + - '.mqh' tm_scope: source.mql5 ace_mode: c_cpp language_id: 427 MTML: type: markup - color: "#b7e1f4" + color: '#b7e1f4' extensions: - - ".mtml" + - '.mtml' tm_scope: text.html.basic ace_mode: html codemirror_mode: htmlmixed @@ -2966,8 +2966,8 @@ MUF: type: programming group: Forth extensions: - - ".muf" - - ".m" + - '.muf' + - '.m' tm_scope: none ace_mode: forth codemirror_mode: forth @@ -2975,33 +2975,33 @@ MUF: language_id: 219 Makefile: type: programming - color: "#427819" + color: '#427819' aliases: - - bsdmake - - make - - mf + - bsdmake + - make + - mf extensions: - - ".mak" - - ".d" - - ".make" - - ".mk" - - ".mkfile" + - '.mak' + - '.d' + - '.make' + - '.mk' + - '.mkfile' filenames: - - BSDmakefile - - GNUmakefile - - Kbuild - - Makefile - - Makefile.am - - Makefile.boot - - Makefile.frag - - Makefile.in - - Makefile.inc - - Makefile.wat - - makefile - - makefile.sco - - mkfile + - BSDmakefile + - GNUmakefile + - Kbuild + - Makefile + - Makefile.am + - Makefile.boot + - Makefile.frag + - Makefile.in + - Makefile.inc + - Makefile.wat + - makefile + - makefile.sco + - mkfile interpreters: - - make + - make tm_scope: source.makefile ace_mode: makefile codemirror_mode: cmake @@ -3010,32 +3010,32 @@ Makefile: Mako: type: programming extensions: - - ".mako" - - ".mao" + - '.mako' + - '.mao' tm_scope: text.html.mako ace_mode: text language_id: 221 Markdown: type: prose aliases: - - pandoc + - pandoc ace_mode: markdown codemirror_mode: gfm codemirror_mime_type: text/x-gfm wrap: true extensions: - - ".md" - - ".markdown" - - ".mdown" - - ".mdwn" - - ".mdx" - - ".mkd" - - ".mkdn" - - ".mkdown" - - ".ronn" - - ".workbook" + - '.md' + - '.markdown' + - '.mdown' + - '.mdwn' + - '.mdx' + - '.mkd' + - '.mkdn' + - '.mkdown' + - '.ronn' + - '.workbook' filenames: - - contents.lr + - contents.lr tm_scope: source.gfm language_id: 222 Marko: @@ -3043,35 +3043,35 @@ Marko: type: markup tm_scope: text.marko extensions: - - ".marko" + - '.marko' aliases: - - markojs + - markojs ace_mode: text codemirror_mode: htmlmixed codemirror_mime_type: text/html language_id: 932782397 Mask: type: markup - color: "#f97732" + color: '#f97732' ace_mode: mask extensions: - - ".mask" + - '.mask' tm_scope: source.mask language_id: 223 Mathematica: type: programming extensions: - - ".mathematica" - - ".cdf" - - ".m" - - ".ma" - - ".mt" - - ".nb" - - ".nbp" - - ".wl" - - ".wlt" + - '.mathematica' + - '.cdf' + - '.m' + - '.ma' + - '.mt' + - '.nb' + - '.nbp' + - '.wl' + - '.wlt' aliases: - - mma + - mma tm_scope: source.mathematica ace_mode: text codemirror_mode: mathematica @@ -3081,23 +3081,23 @@ Maven POM: type: data tm_scope: text.xml.pom filenames: - - pom.xml + - pom.xml ace_mode: xml codemirror_mode: xml codemirror_mime_type: text/xml language_id: 226 Max: type: programming - color: "#c4a79c" + color: '#c4a79c' aliases: - - max/msp - - maxmsp + - max/msp + - maxmsp extensions: - - ".maxpat" - - ".maxhelp" - - ".maxproj" - - ".mxt" - - ".pat" + - '.maxpat' + - '.maxhelp' + - '.maxproj' + - '.mxt' + - '.pat' tm_scope: source.json ace_mode: json codemirror_mode: javascript @@ -3107,36 +3107,36 @@ MediaWiki: type: prose wrap: true extensions: - - ".mediawiki" - - ".wiki" + - '.mediawiki' + - '.wiki' tm_scope: text.html.mediawiki ace_mode: text language_id: 228 Mercury: type: programming - color: "#ff2b2b" + color: '#ff2b2b' ace_mode: prolog interpreters: - - mmi + - mmi extensions: - - ".m" - - ".moo" + - '.m' + - '.moo' tm_scope: source.mercury language_id: 229 Meson: type: programming - color: "#007800" + color: '#007800' filenames: - - meson.build - - meson_options.txt + - meson.build + - meson_options.txt tm_scope: source.meson ace_mode: text language_id: 799141244 Metal: type: programming - color: "#8f14e9" + color: '#8f14e9' extensions: - - ".metal" + - '.metal' tm_scope: source.c++ ace_mode: c_cpp codemirror_mode: clike @@ -3146,17 +3146,17 @@ MiniD: type: programming searchable: false extensions: - - ".minid" + - '.minid' tm_scope: none ace_mode: text language_id: 231 Mirah: type: programming - color: "#c7a938" + color: '#c7a938' extensions: - - ".druby" - - ".duby" - - ".mirah" + - '.druby' + - '.duby' + - '.mirah' tm_scope: source.ruby ace_mode: ruby codemirror_mode: ruby @@ -3165,7 +3165,7 @@ Mirah: Modelica: type: programming extensions: - - ".mo" + - '.mo' tm_scope: source.modelica ace_mode: text codemirror_mode: modelica @@ -3174,53 +3174,53 @@ Modelica: Modula-2: type: programming extensions: - - ".mod" + - '.mod' tm_scope: source.modula2 ace_mode: text language_id: 234 Modula-3: type: programming extensions: - - ".i3" - - ".ig" - - ".m3" - - ".mg" - color: "#223388" + - '.i3' + - '.ig' + - '.m3' + - '.mg' + color: '#223388' ace_mode: text tm_scope: source.modula-3 language_id: 564743864 Module Management System: type: programming extensions: - - ".mms" - - ".mmk" + - '.mms' + - '.mmk' filenames: - - descrip.mmk - - descrip.mms + - descrip.mmk + - descrip.mms tm_scope: none ace_mode: text language_id: 235 Monkey: type: programming extensions: - - ".monkey" - - ".monkey2" + - '.monkey' + - '.monkey2' ace_mode: text tm_scope: source.monkey language_id: 236 Moocode: type: programming extensions: - - ".moo" + - '.moo' tm_scope: none ace_mode: text language_id: 237 MoonScript: type: programming extensions: - - ".moon" + - '.moon' interpreters: - - moon + - moon tm_scope: source.moonscript ace_mode: text language_id: 238 @@ -3228,37 +3228,37 @@ Motorola 68K Assembly: type: programming group: Assembly extensions: - - ".X68" + - '.X68' tm_scope: source.m68k ace_mode: assembly_x86 language_id: 477582706 Myghty: type: programming extensions: - - ".myt" + - '.myt' tm_scope: none ace_mode: text language_id: 239 NCL: type: programming - color: "#28431f" + color: '#28431f' extensions: - - ".ncl" + - '.ncl' tm_scope: source.ncl ace_mode: text language_id: 240 NL: type: data extensions: - - ".nl" + - '.nl' tm_scope: none ace_mode: text language_id: 241 NSIS: type: programming extensions: - - ".nsi" - - ".nsh" + - '.nsi' + - '.nsh' tm_scope: source.nsis ace_mode: text codemirror_mode: nsis @@ -3267,43 +3267,43 @@ NSIS: Nearley: type: programming ace_mode: text - color: "#990000" + color: '#990000' extensions: - - ".ne" - - ".nearley" + - '.ne' + - '.nearley' tm_scope: source.ne language_id: 521429430 Nemerle: type: programming - color: "#3d3c6e" + color: '#3d3c6e' extensions: - - ".n" + - '.n' tm_scope: source.nemerle ace_mode: text language_id: 243 NetLinx: type: programming - color: "#0aa0ff" + color: '#0aa0ff' extensions: - - ".axs" - - ".axi" + - '.axs' + - '.axi' tm_scope: source.netlinx ace_mode: text language_id: 244 NetLinx+ERB: type: programming - color: "#747faa" + color: '#747faa' extensions: - - ".axs.erb" - - ".axi.erb" + - '.axs.erb' + - '.axi.erb' tm_scope: source.netlinx.erb ace_mode: text language_id: 245 NetLogo: type: programming - color: "#ff6375" + color: '#ff6375' extensions: - - ".nlogo" + - '.nlogo' tm_scope: source.lisp ace_mode: lisp codemirror_mode: commonlisp @@ -3311,13 +3311,13 @@ NetLogo: language_id: 246 NewLisp: type: programming - color: "#87AED7" + color: '#87AED7' extensions: - - ".nl" - - ".lisp" - - ".lsp" + - '.nl' + - '.lisp' + - '.lsp' interpreters: - - newlisp + - newlisp tm_scope: source.lisp ace_mode: lisp codemirror_mode: commonlisp @@ -3327,39 +3327,39 @@ Nextflow: type: programming ace_mode: groovy tm_scope: source.nextflow - color: "#3ac486" + color: '#3ac486' extensions: - - ".nf" + - '.nf' filenames: - - nextflow.config + - nextflow.config interpreters: - - nextflow + - nextflow language_id: 506780613 Nginx: type: data extensions: - - ".nginxconf" - - ".vhost" + - '.nginxconf' + - '.vhost' filenames: - - nginx.conf + - nginx.conf tm_scope: source.nginx aliases: - - nginx configuration file + - nginx configuration file ace_mode: text codemirror_mode: nginx codemirror_mime_type: text/x-nginx-conf language_id: 248 Nim: type: programming - color: "#37775b" + color: '#37775b' extensions: - - ".nim" - - ".nim.cfg" - - ".nimble" - - ".nimrod" - - ".nims" + - '.nim' + - '.nim.cfg' + - '.nimble' + - '.nimrod' + - '.nims' filenames: - - nim.cfg + - nim.cfg ace_mode: text tm_scope: source.nim language_id: 249 @@ -3367,50 +3367,50 @@ Ninja: type: data tm_scope: source.ninja extensions: - - ".ninja" + - '.ninja' ace_mode: text language_id: 250 Nit: type: programming - color: "#009917" + color: '#009917' extensions: - - ".nit" + - '.nit' tm_scope: source.nit ace_mode: text language_id: 251 Nix: type: programming - color: "#7e7eff" + color: '#7e7eff' extensions: - - ".nix" + - '.nix' aliases: - - nixos + - nixos tm_scope: source.nix ace_mode: nix language_id: 252 Nu: type: programming - color: "#c9df40" + color: '#c9df40' aliases: - - nush + - nush extensions: - - ".nu" + - '.nu' filenames: - - Nukefile + - Nukefile tm_scope: source.nu ace_mode: scheme codemirror_mode: scheme codemirror_mime_type: text/x-scheme interpreters: - - nush + - nush language_id: 253 NumPy: type: programming group: Python extensions: - - ".numpy" - - ".numpyw" - - ".numsc" + - '.numpy' + - '.numpyw' + - '.numsc' tm_scope: none ace_mode: text codemirror_mode: python @@ -3421,47 +3421,47 @@ OCaml: ace_mode: ocaml codemirror_mode: mllike codemirror_mime_type: text/x-ocaml - color: "#3be133" + color: '#3be133' extensions: - - ".ml" - - ".eliom" - - ".eliomi" - - ".ml4" - - ".mli" - - ".mll" - - ".mly" + - '.ml' + - '.eliom' + - '.eliomi' + - '.ml4' + - '.mli' + - '.mll' + - '.mly' interpreters: - - ocaml - - ocamlrun - - ocamlscript + - ocaml + - ocamlrun + - ocamlscript tm_scope: source.ocaml language_id: 255 ObjDump: type: data extensions: - - ".objdump" + - '.objdump' tm_scope: objdump.x86asm ace_mode: assembly_x86 language_id: 256 ObjectScript: type: programming extensions: - - ".cls" + - '.cls' language_id: 202735509 tm_scope: source.objectscript - color: "#424893" + color: '#424893' ace_mode: text Objective-C: type: programming tm_scope: source.objc - color: "#438eff" + color: '#438eff' aliases: - - obj-c - - objc - - objectivec + - obj-c + - objc + - objectivec extensions: - - ".m" - - ".h" + - '.m' + - '.h' ace_mode: objectivec codemirror_mode: clike codemirror_mime_type: text/x-objectivec @@ -3469,50 +3469,50 @@ Objective-C: Objective-C++: type: programming tm_scope: source.objc++ - color: "#6866fb" + color: '#6866fb' aliases: - - obj-c++ - - objc++ - - objectivec++ + - obj-c++ + - objc++ + - objectivec++ extensions: - - ".mm" + - '.mm' ace_mode: objectivec codemirror_mode: clike codemirror_mime_type: text/x-objectivec language_id: 258 Objective-J: type: programming - color: "#ff0c5a" + color: '#ff0c5a' aliases: - - obj-j - - objectivej - - objj + - obj-j + - objectivej + - objj extensions: - - ".j" - - ".sj" + - '.j' + - '.sj' tm_scope: source.js.objj ace_mode: text language_id: 259 Omgrofl: type: programming extensions: - - ".omgrofl" - color: "#cabbff" + - '.omgrofl' + color: '#cabbff' tm_scope: none ace_mode: text language_id: 260 Opa: type: programming extensions: - - ".opa" + - '.opa' tm_scope: source.opa ace_mode: text language_id: 261 Opal: type: programming - color: "#f7ede0" + color: '#f7ede0' extensions: - - ".opal" + - '.opal' tm_scope: source.opal ace_mode: text language_id: 262 @@ -3520,8 +3520,8 @@ OpenCL: type: programming group: C extensions: - - ".cl" - - ".opencl" + - '.cl' + - '.opencl' tm_scope: source.c ace_mode: c_cpp codemirror_mode: clike @@ -3530,13 +3530,13 @@ OpenCL: OpenEdge ABL: type: programming aliases: - - progress - - openedge - - abl + - progress + - openedge + - abl extensions: - - ".p" - - ".cls" - - ".w" + - '.p' + - '.cls' + - '.w' tm_scope: source.abl ace_mode: text language_id: 264 @@ -3544,9 +3544,9 @@ OpenRC runscript: type: programming group: Shell aliases: - - openrc + - openrc interpreters: - - openrc-run + - openrc-run tm_scope: source.shell ace_mode: sh codemirror_mode: shell @@ -3555,23 +3555,23 @@ OpenRC runscript: OpenSCAD: type: programming extensions: - - ".scad" + - '.scad' tm_scope: source.scad ace_mode: scad language_id: 266 OpenStep Property List: type: data extensions: - - ".plist" + - '.plist' tm_scope: source.plist ace_mode: text language_id: 598917541 OpenType Feature File: type: data aliases: - - AFDKO + - AFDKO extensions: - - ".fea" + - '.fea' tm_scope: source.opentype ace_mode: text language_id: 374317347 @@ -3579,32 +3579,32 @@ Org: type: prose wrap: true extensions: - - ".org" + - '.org' tm_scope: none ace_mode: text language_id: 267 Ox: type: programming extensions: - - ".ox" - - ".oxh" - - ".oxo" + - '.ox' + - '.oxh' + - '.oxo' tm_scope: source.ox ace_mode: text language_id: 268 Oxygene: type: programming - color: "#cdd0e3" + color: '#cdd0e3' extensions: - - ".oxygene" + - '.oxygene' tm_scope: none ace_mode: text language_id: 269 Oz: type: programming - color: "#fab738" + color: '#fab738' extensions: - - ".oz" + - '.oz' tm_scope: source.oz ace_mode: text codemirror_mode: oz @@ -3612,9 +3612,9 @@ Oz: language_id: 270 P4: type: programming - color: "#7055b5" + color: '#7055b5' extensions: - - ".p4" + - '.p4' tm_scope: source.p4 ace_mode: text language_id: 348895984 @@ -3624,27 +3624,27 @@ PHP: ace_mode: php codemirror_mode: php codemirror_mime_type: application/x-httpd-php - color: "#4F5D95" + color: '#4F5D95' extensions: - - ".php" - - ".aw" - - ".ctp" - - ".fcgi" - - ".inc" - - ".php3" - - ".php4" - - ".php5" - - ".phps" - - ".phpt" + - '.php' + - '.aw' + - '.ctp' + - '.fcgi' + - '.inc' + - '.php3' + - '.php4' + - '.php5' + - '.phps' + - '.phpt' filenames: - - ".php" - - ".php_cs" - - ".php_cs.dist" - - Phakefile + - '.php' + - '.php_cs' + - '.php_cs.dist' + - Phakefile interpreters: - - php + - php aliases: - - inc + - inc language_id: 272 PLSQL: type: programming @@ -3652,24 +3652,24 @@ PLSQL: codemirror_mode: sql codemirror_mime_type: text/x-plsql tm_scope: none - color: "#dad8d8" + color: '#dad8d8' extensions: - - ".pls" - - ".bdy" - - ".ddl" - - ".fnc" - - ".pck" - - ".pkb" - - ".pks" - - ".plb" - - ".plsql" - - ".prc" - - ".spc" - - ".sql" - - ".tpb" - - ".tps" - - ".trg" - - ".vw" + - '.pls' + - '.bdy' + - '.ddl' + - '.fnc' + - '.pck' + - '.pkb' + - '.pks' + - '.plb' + - '.plsql' + - '.prc' + - '.spc' + - '.sql' + - '.tpb' + - '.tps' + - '.trg' + - '.vw' language_id: 273 PLpgSQL: type: programming @@ -3678,41 +3678,41 @@ PLpgSQL: codemirror_mime_type: text/x-sql tm_scope: source.sql extensions: - - ".pgsql" - - ".sql" + - '.pgsql' + - '.sql' language_id: 274 POV-Ray SDL: type: programming aliases: - - pov-ray - - povray + - pov-ray + - povray extensions: - - ".pov" - - ".inc" + - '.pov' + - '.inc' tm_scope: source.pov-ray sdl ace_mode: text language_id: 275 Pan: type: programming - color: "#cc0000" + color: '#cc0000' extensions: - - ".pan" + - '.pan' tm_scope: source.pan ace_mode: text language_id: 276 Papyrus: type: programming - color: "#6600cc" + color: '#6600cc' extensions: - - ".psc" + - '.psc' tm_scope: source.papyrus.skyrim ace_mode: text language_id: 277 Parrot: type: programming - color: "#f3ca0a" + color: '#f3ca0a' extensions: - - ".parrot" + - '.parrot' tm_scope: none ace_mode: text language_id: 278 @@ -3720,11 +3720,11 @@ Parrot Assembly: group: Parrot type: programming aliases: - - pasm + - pasm extensions: - - ".pasm" + - '.pasm' interpreters: - - parrot + - parrot tm_scope: none ace_mode: text language_id: 279 @@ -3733,26 +3733,26 @@ Parrot Internal Representation: tm_scope: source.parrot.pir type: programming aliases: - - pir + - pir extensions: - - ".pir" + - '.pir' interpreters: - - parrot + - parrot ace_mode: text language_id: 280 Pascal: type: programming - color: "#E3F171" + color: '#E3F171' extensions: - - ".pas" - - ".dfm" - - ".dpr" - - ".inc" - - ".lpr" - - ".pascal" - - ".pp" + - '.pas' + - '.dfm' + - '.dpr' + - '.inc' + - '.lpr' + - '.pascal' + - '.pp' interpreters: - - instantfpc + - instantfpc tm_scope: source.pascal ace_mode: pascal codemirror_mode: pascal @@ -3760,19 +3760,19 @@ Pascal: language_id: 281 Pawn: type: programming - color: "#dbb284" + color: '#dbb284' extensions: - - ".pwn" - - ".inc" - - ".sma" + - '.pwn' + - '.inc' + - '.sma' tm_scope: source.pawn ace_mode: text language_id: 271 Pep8: type: programming - color: "#C76F5B" + color: '#C76F5B' extensions: - - ".pep" + - '.pep' ace_mode: text tm_scope: source.pep8 language_id: 840372442 @@ -3782,48 +3782,48 @@ Perl: ace_mode: perl codemirror_mode: perl codemirror_mime_type: text/x-perl - color: "#0298c3" + color: '#0298c3' extensions: - - ".pl" - - ".al" - - ".cgi" - - ".fcgi" - - ".perl" - - ".ph" - - ".plx" - - ".pm" - - ".psgi" - - ".t" + - '.pl' + - '.al' + - '.cgi' + - '.fcgi' + - '.perl' + - '.ph' + - '.plx' + - '.pm' + - '.psgi' + - '.t' filenames: - - Makefile.PL - - Rexfile - - ack - - cpanfile + - Makefile.PL + - Rexfile + - ack + - cpanfile interpreters: - - cperl - - perl + - cperl + - perl aliases: - - cperl + - cperl language_id: 282 Perl 6: type: programming - color: "#0000fb" + color: '#0000fb' extensions: - - ".6pl" - - ".6pm" - - ".nqp" - - ".p6" - - ".p6l" - - ".p6m" - - ".pl" - - ".pl6" - - ".pm" - - ".pm6" - - ".t" + - '.6pl' + - '.6pm' + - '.nqp' + - '.p6' + - '.p6l' + - '.p6m' + - '.pl' + - '.pl6' + - '.pm' + - '.pm6' + - '.t' interpreters: - - perl6 + - perl6 aliases: - - perl6 + - perl6 tm_scope: source.perl6fe ace_mode: perl codemirror_mode: perl @@ -3834,8 +3834,8 @@ Pic: group: Roff tm_scope: source.pic extensions: - - ".pic" - - ".chem" + - '.pic' + - '.chem' ace_mode: text codemirror_mode: troff codemirror_mime_type: text/troff @@ -3843,36 +3843,36 @@ Pic: Pickle: type: data extensions: - - ".pkl" + - '.pkl' tm_scope: none ace_mode: text language_id: 284 PicoLisp: type: programming extensions: - - ".l" + - '.l' interpreters: - - picolisp - - pil + - picolisp + - pil tm_scope: source.lisp ace_mode: lisp language_id: 285 PigLatin: type: programming - color: "#fcd7de" + color: '#fcd7de' extensions: - - ".pig" + - '.pig' tm_scope: source.pig_latin ace_mode: text language_id: 286 Pike: type: programming - color: "#005390" + color: '#005390' extensions: - - ".pike" - - ".pmod" + - '.pike' + - '.pmod' interpreters: - - pike + - pike tm_scope: source.pike ace_mode: text language_id: 287 @@ -3883,9 +3883,9 @@ Pod: codemirror_mime_type: text/x-perl wrap: true extensions: - - ".pod" + - '.pod' interpreters: - - perl + - perl tm_scope: none language_id: 288 Pod 6: @@ -3894,23 +3894,23 @@ Pod 6: tm_scope: source.perl6fe wrap: true extensions: - - ".pod" - - ".pod6" + - '.pod' + - '.pod6' interpreters: - - perl6 + - perl6 language_id: 155357471 PogoScript: type: programming - color: "#d80074" + color: '#d80074' extensions: - - ".pogo" + - '.pogo' tm_scope: source.pogoscript ace_mode: text language_id: 289 Pony: type: programming extensions: - - ".pony" + - '.pony' tm_scope: source.pony ace_mode: text language_id: 290 @@ -3919,86 +3919,86 @@ PostCSS: tm_scope: source.postcss group: CSS extensions: - - ".pcss" + - '.pcss' ace_mode: text language_id: 262764437 PostScript: type: markup - color: "#da291c" + color: '#da291c' extensions: - - ".ps" - - ".eps" - - ".pfa" + - '.ps' + - '.eps' + - '.pfa' tm_scope: source.postscript aliases: - - postscr + - postscr ace_mode: text language_id: 291 PowerBuilder: type: programming - color: "#8f0f8d" + color: '#8f0f8d' extensions: - - ".pbt" - - ".sra" - - ".sru" - - ".srw" + - '.pbt' + - '.sra' + - '.sru' + - '.srw' tm_scope: none ace_mode: text language_id: 292 PowerShell: type: programming - color: "#012456" + color: '#012456' tm_scope: source.powershell ace_mode: powershell codemirror_mode: powershell codemirror_mime_type: application/x-powershell aliases: - - posh - - pwsh + - posh + - pwsh extensions: - - ".ps1" - - ".psd1" - - ".psm1" + - '.ps1' + - '.psd1' + - '.psm1' interpreters: - - pwsh + - pwsh language_id: 293 Processing: type: programming - color: "#0096D8" + color: '#0096D8' extensions: - - ".pde" + - '.pde' tm_scope: source.processing ace_mode: text language_id: 294 Prolog: type: programming - color: "#74283c" + color: '#74283c' extensions: - - ".pl" - - ".pro" - - ".prolog" - - ".yap" + - '.pl' + - '.pro' + - '.prolog' + - '.yap' interpreters: - - swipl - - yap + - swipl + - yap tm_scope: source.prolog ace_mode: prolog language_id: 295 Propeller Spin: type: programming - color: "#7fa2a7" + color: '#7fa2a7' extensions: - - ".spin" + - '.spin' tm_scope: source.spin ace_mode: text language_id: 296 Protocol Buffer: type: data aliases: - - protobuf - - Protocol Buffers + - protobuf + - Protocol Buffers extensions: - - ".proto" + - '.proto' tm_scope: source.protobuf ace_mode: protobuf codemirror_mode: protobuf @@ -4007,8 +4007,8 @@ Protocol Buffer: Public Key: type: data extensions: - - ".asc" - - ".pub" + - '.asc' + - '.pub' tm_scope: none ace_mode: text codemirror_mode: asciiarmor @@ -4018,8 +4018,8 @@ Pug: group: HTML type: markup extensions: - - ".jade" - - ".pug" + - '.jade' + - '.pug' tm_scope: text.jade ace_mode: jade codemirror_mode: pug @@ -4027,11 +4027,11 @@ Pug: language_id: 179 Puppet: type: programming - color: "#302B6D" + color: '#302B6D' extensions: - - ".pp" + - '.pp' filenames: - - Modulefile + - Modulefile ace_mode: text codemirror_mode: puppet codemirror_mime_type: text/x-puppet @@ -4040,24 +4040,24 @@ Puppet: Pure Data: type: data extensions: - - ".pd" + - '.pd' tm_scope: none ace_mode: text language_id: 300 PureBasic: type: programming - color: "#5a6986" + color: '#5a6986' extensions: - - ".pb" - - ".pbi" + - '.pb' + - '.pbi' tm_scope: none ace_mode: text language_id: 301 PureScript: type: programming - color: "#1D222D" + color: '#1D222D' extensions: - - ".purs" + - '.purs' tm_scope: source.purescript ace_mode: haskell codemirror_mode: haskell @@ -4069,51 +4069,51 @@ Python: ace_mode: python codemirror_mode: python codemirror_mime_type: text/x-python - color: "#3572A5" + color: '#3572A5' extensions: - - ".py" - - ".bzl" - - ".cgi" - - ".fcgi" - - ".gyp" - - ".gypi" - - ".lmi" - - ".py3" - - ".pyde" - - ".pyi" - - ".pyp" - - ".pyt" - - ".pyw" - - ".rpy" - - ".spec" - - ".tac" - - ".wsgi" - - ".xpy" + - '.py' + - '.bzl' + - '.cgi' + - '.fcgi' + - '.gyp' + - '.gypi' + - '.lmi' + - '.py3' + - '.pyde' + - '.pyi' + - '.pyp' + - '.pyt' + - '.pyw' + - '.rpy' + - '.spec' + - '.tac' + - '.wsgi' + - '.xpy' filenames: - - ".gclient" - - BUCK - - BUILD - - BUILD.bazel - - DEPS - - SConscript - - SConstruct - - Snakefile - - WORKSPACE - - wscript + - '.gclient' + - BUCK + - BUILD + - BUILD.bazel + - DEPS + - SConscript + - SConstruct + - Snakefile + - WORKSPACE + - wscript interpreters: - - python - - python2 - - python3 + - python + - python2 + - python3 aliases: - - rusthon - - python3 + - rusthon + - python3 language_id: 303 Python console: type: programming group: Python searchable: false aliases: - - pycon + - pycon tm_scope: text.python.console ace_mode: text language_id: 428 @@ -4122,54 +4122,54 @@ Python traceback: group: Python searchable: false extensions: - - ".pytb" + - '.pytb' tm_scope: text.python.traceback ace_mode: text language_id: 304 QML: type: programming - color: "#44a51c" + color: '#44a51c' extensions: - - ".qml" - - ".qbs" + - '.qml' + - '.qbs' tm_scope: source.qml ace_mode: text language_id: 305 QMake: type: programming extensions: - - ".pro" - - ".pri" + - '.pro' + - '.pri' interpreters: - - qmake + - qmake tm_scope: source.qmake ace_mode: text language_id: 306 Quake: type: programming filenames: - - m3makefile - - m3overrides - color: "#882233" + - m3makefile + - m3overrides + color: '#882233' ace_mode: text tm_scope: source.quake language_id: 375265331 R: type: programming - color: "#198CE7" + color: '#198CE7' aliases: - - R - - Rscript - - splus + - R + - Rscript + - splus extensions: - - ".r" - - ".rd" - - ".rsx" + - '.r' + - '.rd' + - '.rsx' filenames: - - ".Rprofile" - - expr-dist + - '.Rprofile' + - expr-dist interpreters: - - Rscript + - Rscript tm_scope: source.r ace_mode: r codemirror_mode: r @@ -4181,41 +4181,41 @@ RAML: codemirror_mode: yaml codemirror_mime_type: text/x-yaml tm_scope: source.yaml - color: "#77d9fb" + color: '#77d9fb' extensions: - - ".raml" + - '.raml' language_id: 308 RDoc: type: prose ace_mode: rdoc wrap: true extensions: - - ".rdoc" + - '.rdoc' tm_scope: text.rdoc language_id: 309 REALbasic: type: programming extensions: - - ".rbbas" - - ".rbfrm" - - ".rbmnu" - - ".rbres" - - ".rbtbar" - - ".rbuistate" + - '.rbbas' + - '.rbfrm' + - '.rbmnu' + - '.rbres' + - '.rbtbar' + - '.rbuistate' tm_scope: source.vbnet ace_mode: text language_id: 310 REXX: type: programming aliases: - - arexx + - arexx extensions: - - ".rexx" - - ".pprx" - - ".rex" + - '.rexx' + - '.pprx' + - '.rex' interpreters: - - regina - - rexx + - regina + - rexx tm_scope: source.rexx ace_mode: text language_id: 311 @@ -4223,10 +4223,10 @@ RHTML: type: markup group: HTML extensions: - - ".rhtml" + - '.rhtml' tm_scope: text.html.erb aliases: - - html+ruby + - html+ruby ace_mode: rhtml codemirror_mode: htmlembedded codemirror_mime_type: application/x-erb @@ -4238,78 +4238,78 @@ RMarkdown: codemirror_mode: gfm codemirror_mime_type: text/x-gfm extensions: - - ".rmd" + - '.rmd' tm_scope: source.gfm language_id: 313 RPC: type: programming aliases: - - rpcgen - - oncrpc - - xdr + - rpcgen + - oncrpc + - xdr ace_mode: c_cpp extensions: - - ".x" + - '.x' tm_scope: source.c language_id: 1031374237 RPM Spec: type: data tm_scope: source.rpm-spec extensions: - - ".spec" + - '.spec' aliases: - - specfile + - specfile ace_mode: text codemirror_mode: rpm codemirror_mime_type: text/x-rpm-spec language_id: 314 RUNOFF: type: markup - color: "#665a4e" + color: '#665a4e' extensions: - - ".rnh" - - ".rno" + - '.rnh' + - '.rno' tm_scope: text.runoff ace_mode: text language_id: 315 Racket: type: programming - color: "#3c5caa" + color: '#3c5caa' extensions: - - ".rkt" - - ".rktd" - - ".rktl" - - ".scrbl" + - '.rkt' + - '.rktd' + - '.rktl' + - '.scrbl' interpreters: - - racket + - racket tm_scope: source.racket ace_mode: lisp language_id: 316 Ragel: type: programming - color: "#9d5200" + color: '#9d5200' extensions: - - ".rl" + - '.rl' aliases: - - ragel-rb - - ragel-ruby + - ragel-rb + - ragel-ruby tm_scope: none ace_mode: text language_id: 317 Rascal: type: programming - color: "#fffaa0" + color: '#fffaa0' extensions: - - ".rsc" + - '.rsc' tm_scope: source.rascal ace_mode: text language_id: 173616037 Raw token data: type: data aliases: - - raw + - raw extensions: - - ".raw" + - '.raw' tm_scope: none ace_mode: text language_id: 318 @@ -4320,141 +4320,141 @@ Reason: codemirror_mode: rust codemirror_mime_type: text/x-rustsrc extensions: - - ".re" - - ".rei" + - '.re' + - '.rei' interpreters: - - ocaml + - ocaml tm_scope: source.reason language_id: 869538413 Rebol: type: programming - color: "#358a5b" + color: '#358a5b' extensions: - - ".reb" - - ".r" - - ".r2" - - ".r3" - - ".rebol" + - '.reb' + - '.r' + - '.r2' + - '.r3' + - '.rebol' ace_mode: text tm_scope: source.rebol language_id: 319 Red: type: programming - color: "#f50000" + color: '#f50000' extensions: - - ".red" - - ".reds" + - '.red' + - '.reds' aliases: - - red/system + - red/system tm_scope: source.red ace_mode: text language_id: 320 Redcode: type: programming extensions: - - ".cw" + - '.cw' tm_scope: none ace_mode: text language_id: 321 Regular Expression: type: data extensions: - - ".regexp" - - ".regex" + - '.regexp' + - '.regex' aliases: - - regexp - - regex + - regexp + - regex ace_mode: text tm_scope: source.regexp language_id: 363378884 Ren'Py: type: programming aliases: - - renpy - color: "#ff7f7f" + - renpy + color: '#ff7f7f' extensions: - - ".rpy" + - '.rpy' tm_scope: source.renpy ace_mode: python language_id: 322 RenderScript: type: programming extensions: - - ".rs" - - ".rsh" + - '.rs' + - '.rsh' tm_scope: none ace_mode: text language_id: 323 Rich Text Format: type: markup extensions: - - ".rtf" + - '.rtf' tm_scope: text.rtf ace_mode: text language_id: 51601661 Ring: type: programming - color: "#2D54CB" + color: '#2D54CB' extensions: - - ".ring" + - '.ring' tm_scope: source.ring ace_mode: text language_id: 431 RobotFramework: type: programming extensions: - - ".robot" + - '.robot' tm_scope: text.robot ace_mode: text language_id: 324 Roff: type: markup - color: "#ecdebe" + color: '#ecdebe' extensions: - - ".roff" - - ".1" - - ".1in" - - ".1m" - - ".1x" - - ".2" - - ".3" - - ".3in" - - ".3m" - - ".3p" - - ".3pm" - - ".3qt" - - ".3x" - - ".4" - - ".5" - - ".6" - - ".7" - - ".8" - - ".9" - - ".l" - - ".man" - - ".mdoc" - - ".me" - - ".ms" - - ".n" - - ".nr" - - ".rno" - - ".tmac" + - '.roff' + - '.1' + - '.1in' + - '.1m' + - '.1x' + - '.2' + - '.3' + - '.3in' + - '.3m' + - '.3p' + - '.3pm' + - '.3qt' + - '.3x' + - '.4' + - '.5' + - '.6' + - '.7' + - '.8' + - '.9' + - '.l' + - '.man' + - '.mdoc' + - '.me' + - '.ms' + - '.n' + - '.nr' + - '.rno' + - '.tmac' filenames: - - eqnrc - - mmn - - mmt - - troffrc - - troffrc-end + - eqnrc + - mmn + - mmt + - troffrc + - troffrc-end tm_scope: text.roff aliases: - - groff - - man - - manpage - - man page - - man-page - - mdoc - - nroff - - troff + - groff + - man + - manpage + - man page + - man-page + - mdoc + - nroff + - troff ace_mode: text codemirror_mode: troff codemirror_mime_type: text/troff @@ -4463,26 +4463,26 @@ Roff Manpage: type: markup group: Roff extensions: - - ".1" - - ".1in" - - ".1m" - - ".1x" - - ".2" - - ".3" - - ".3in" - - ".3m" - - ".3p" - - ".3pm" - - ".3qt" - - ".3x" - - ".4" - - ".5" - - ".6" - - ".7" - - ".8" - - ".9" - - ".man" - - ".mdoc" + - '.1' + - '.1in' + - '.1m' + - '.1x' + - '.2' + - '.3' + - '.3in' + - '.3m' + - '.3p' + - '.3pm' + - '.3qt' + - '.3x' + - '.4' + - '.5' + - '.6' + - '.7' + - '.8' + - '.9' + - '.man' + - '.mdoc' tm_scope: text.roff ace_mode: text codemirror_mode: troff @@ -4493,9 +4493,9 @@ Rouge: ace_mode: clojure codemirror_mode: clojure codemirror_mime_type: text/x-clojure - color: "#cc0088" + color: '#cc0088' extensions: - - ".rg" + - '.rg' tm_scope: source.clojure language_id: 325 Ruby: @@ -4504,70 +4504,70 @@ Ruby: ace_mode: ruby codemirror_mode: ruby codemirror_mime_type: text/x-ruby - color: "#701516" + color: '#701516' aliases: - - jruby - - macruby - - rake - - rb - - rbx + - jruby + - macruby + - rake + - rb + - rbx extensions: - - ".rb" - - ".builder" - - ".eye" - - ".fcgi" - - ".gemspec" - - ".god" - - ".jbuilder" - - ".mspec" - - ".pluginspec" - - ".podspec" - - ".rabl" - - ".rake" - - ".rbuild" - - ".rbw" - - ".rbx" - - ".ru" - - ".ruby" - - ".spec" - - ".thor" - - ".watchr" + - '.rb' + - '.builder' + - '.eye' + - '.fcgi' + - '.gemspec' + - '.god' + - '.jbuilder' + - '.mspec' + - '.pluginspec' + - '.podspec' + - '.rabl' + - '.rake' + - '.rbuild' + - '.rbw' + - '.rbx' + - '.ru' + - '.ruby' + - '.spec' + - '.thor' + - '.watchr' interpreters: - - ruby - - macruby - - rake - - jruby - - rbx + - ruby + - macruby + - rake + - jruby + - rbx filenames: - - ".irbrc" - - ".pryrc" - - Appraisals - - Berksfile - - Brewfile - - Buildfile - - Capfile - - Dangerfile - - Deliverfile - - Fastfile - - Gemfile - - Gemfile.lock - - Guardfile - - Jarfile - - Mavenfile - - Podfile - - Puppetfile - - Rakefile - - Snapfile - - Thorfile - - Vagrantfile - - buildfile + - '.irbrc' + - '.pryrc' + - Appraisals + - Berksfile + - Brewfile + - Buildfile + - Capfile + - Dangerfile + - Deliverfile + - Fastfile + - Gemfile + - Gemfile.lock + - Guardfile + - Jarfile + - Mavenfile + - Podfile + - Puppetfile + - Rakefile + - Snapfile + - Thorfile + - Vagrantfile + - buildfile language_id: 326 Rust: type: programming - color: "#dea584" + color: '#dea584' extensions: - - ".rs" - - ".rs.in" + - '.rs' + - '.rs.in' tm_scope: source.rust ace_mode: rust codemirror_mode: rust @@ -4575,9 +4575,9 @@ Rust: language_id: 327 SAS: type: programming - color: "#B34936" + color: '#B34936' extensions: - - ".sas" + - '.sas' tm_scope: source.sas ace_mode: text codemirror_mode: sas @@ -4591,24 +4591,24 @@ SCSS: codemirror_mode: css codemirror_mime_type: text/x-scss extensions: - - ".scss" + - '.scss' language_id: 329 SMT: type: programming extensions: - - ".smt2" - - ".smt" + - '.smt2' + - '.smt' interpreters: - - boolector - - cvc4 - - mathsat5 - - opensmt - - smtinterpol - - smt-rat - - stp - - verit - - yices2 - - z3 + - boolector + - cvc4 + - mathsat5 + - opensmt + - smtinterpol + - smt-rat + - stp + - verit + - yices2 + - z3 tm_scope: source.smt ace_mode: text language_id: 330 @@ -4619,15 +4619,15 @@ SPARQL: codemirror_mode: sparql codemirror_mime_type: application/sparql-query extensions: - - ".sparql" - - ".rq" + - '.sparql' + - '.rq' language_id: 331 SQF: type: programming - color: "#3F3F3F" + color: '#3F3F3F' extensions: - - ".sqf" - - ".hqf" + - '.sqf' + - '.hqf' tm_scope: source.sqf ace_mode: text language_id: 332 @@ -4638,15 +4638,15 @@ SQL: codemirror_mode: sql codemirror_mime_type: text/x-sql extensions: - - ".sql" - - ".cql" - - ".ddl" - - ".inc" - - ".mysql" - - ".prc" - - ".tab" - - ".udf" - - ".viw" + - '.sql' + - '.cql' + - '.ddl' + - '.inc' + - '.mysql' + - '.prc' + - '.tab' + - '.udf' + - '.viw' language_id: 333 SQLPL: type: programming @@ -4655,29 +4655,29 @@ SQLPL: codemirror_mime_type: text/x-sql tm_scope: source.sql extensions: - - ".sql" - - ".db2" + - '.sql' + - '.db2' language_id: 334 SRecode Template: type: markup - color: "#348a34" + color: '#348a34' tm_scope: source.lisp ace_mode: lisp codemirror_mode: commonlisp codemirror_mime_type: text/x-common-lisp extensions: - - ".srt" + - '.srt' language_id: 335 SSH Config: type: data group: INI filenames: - - ssh-config - - ssh_config - - sshconfig - - sshconfig.snip - - sshd-config - - sshd_config + - ssh-config + - ssh_config + - sshconfig + - sshconfig.snip + - sshd-config + - sshd_config ace_mode: text tm_scope: source.ssh-config language_id: 554920715 @@ -4685,14 +4685,14 @@ STON: type: data group: Smalltalk extensions: - - ".ston" + - '.ston' tm_scope: source.smalltalk ace_mode: text language_id: 336 SVG: type: data extensions: - - ".svg" + - '.svg' tm_scope: text.xml.svg ace_mode: xml codemirror_mode: xml @@ -4702,8 +4702,8 @@ Sage: type: programming group: Python extensions: - - ".sage" - - ".sagews" + - '.sage' + - '.sagews' tm_scope: source.python ace_mode: python codemirror_mode: python @@ -4711,12 +4711,12 @@ Sage: language_id: 338 SaltStack: type: programming - color: "#646464" + color: '#646464' aliases: - - saltstate - - salt + - saltstate + - salt extensions: - - ".sls" + - '.sls' tm_scope: source.yaml.salt ace_mode: yaml codemirror_mode: yaml @@ -4727,7 +4727,7 @@ Sass: tm_scope: source.sass group: CSS extensions: - - ".sass" + - '.sass' ace_mode: sass codemirror_mode: sass codemirror_mime_type: text/x-sass @@ -4738,41 +4738,41 @@ Scala: ace_mode: scala codemirror_mode: clike codemirror_mime_type: text/x-scala - color: "#c22d40" + color: '#c22d40' extensions: - - ".scala" - - ".kojo" - - ".sbt" - - ".sc" + - '.scala' + - '.kojo' + - '.sbt' + - '.sc' interpreters: - - scala + - scala language_id: 341 Scaml: group: HTML type: markup extensions: - - ".scaml" + - '.scaml' tm_scope: source.scaml ace_mode: text language_id: 342 Scheme: type: programming - color: "#1e4aec" + color: '#1e4aec' extensions: - - ".scm" - - ".sch" - - ".sld" - - ".sls" - - ".sps" - - ".ss" + - '.scm' + - '.sch' + - '.sld' + - '.sls' + - '.sps' + - '.ss' interpreters: - - scheme - - guile - - bigloo - - chicken - - csi - - gosh - - r6rs + - scheme + - guile + - bigloo + - chicken + - csi + - gosh + - r6rs tm_scope: source.scheme ace_mode: scheme codemirror_mode: scheme @@ -4781,87 +4781,87 @@ Scheme: Scilab: type: programming extensions: - - ".sci" - - ".sce" - - ".tst" + - '.sci' + - '.sce' + - '.tst' tm_scope: source.scilab ace_mode: text language_id: 344 Self: type: programming - color: "#0579aa" + color: '#0579aa' extensions: - - ".self" + - '.self' tm_scope: none ace_mode: text language_id: 345 ShaderLab: type: programming extensions: - - ".shader" + - '.shader' ace_mode: text tm_scope: source.shaderlab language_id: 664257356 Shell: type: programming - color: "#89e051" + color: '#89e051' aliases: - - sh - - shell-script - - bash - - zsh + - sh + - shell-script + - bash + - zsh extensions: - - ".sh" - - ".bash" - - ".bats" - - ".cgi" - - ".command" - - ".fcgi" - - ".ksh" - - ".sh.in" - - ".tmux" - - ".tool" - - ".zsh" + - '.sh' + - '.bash' + - '.bats' + - '.cgi' + - '.command' + - '.fcgi' + - '.ksh' + - '.sh.in' + - '.tmux' + - '.tool' + - '.zsh' filenames: - - ".bash_aliases" - - ".bash_history" - - ".bash_logout" - - ".bash_profile" - - ".bashrc" - - ".cshrc" - - ".login" - - ".profile" - - ".zlogin" - - ".zlogout" - - ".zprofile" - - ".zshenv" - - ".zshrc" - - 9fs - - PKGBUILD - - bash_aliases - - bash_logout - - bash_profile - - bashrc - - cshrc - - gradlew - - login - - man - - profile - - zlogin - - zlogout - - zprofile - - zshenv - - zshrc + - '.bash_aliases' + - '.bash_history' + - '.bash_logout' + - '.bash_profile' + - '.bashrc' + - '.cshrc' + - '.login' + - '.profile' + - '.zlogin' + - '.zlogout' + - '.zprofile' + - '.zshenv' + - '.zshrc' + - 9fs + - PKGBUILD + - bash_aliases + - bash_logout + - bash_profile + - bashrc + - cshrc + - gradlew + - login + - man + - profile + - zlogin + - zlogout + - zprofile + - zshenv + - zshrc interpreters: - - ash - - bash - - dash - - ksh - - mksh - - pdksh - - rc - - sh - - zsh + - ash + - bash + - dash + - ksh + - mksh + - pdksh + - rc + - sh + - zsh tm_scope: source.shell ace_mode: sh codemirror_mode: shell @@ -4870,10 +4870,10 @@ Shell: ShellSession: type: programming extensions: - - ".sh-session" + - '.sh-session' aliases: - - bash session - - console + - bash session + - console tm_scope: text.shell-session ace_mode: sh codemirror_mode: shell @@ -4881,33 +4881,33 @@ ShellSession: language_id: 347 Shen: type: programming - color: "#120F14" + color: '#120F14' extensions: - - ".shen" + - '.shen' tm_scope: source.shen ace_mode: text language_id: 348 Slash: type: programming - color: "#007eff" + color: '#007eff' extensions: - - ".sl" + - '.sl' tm_scope: text.html.slash ace_mode: text language_id: 349 Slice: type: programming - color: "#003fa2" + color: '#003fa2' tm_scope: source.slice ace_mode: text extensions: - - ".ice" + - '.ice' language_id: 894641667 Slim: group: HTML type: markup extensions: - - ".slim" + - '.slim' tm_scope: text.slim ace_mode: text codemirror_mode: slim @@ -4916,28 +4916,28 @@ Slim: SmPL: type: programming extensions: - - ".cocci" + - '.cocci' aliases: - - coccinelle + - coccinelle ace_mode: text tm_scope: source.smpl - color: "#c94949" + color: '#c94949' language_id: 164123055 Smali: type: programming extensions: - - ".smali" + - '.smali' ace_mode: text tm_scope: source.smali language_id: 351 Smalltalk: type: programming - color: "#596706" + color: '#596706' extensions: - - ".st" - - ".cs" + - '.st' + - '.cs' aliases: - - squeak + - squeak tm_scope: source.smalltalk ace_mode: text codemirror_mode: smalltalk @@ -4946,7 +4946,7 @@ Smalltalk: Smarty: type: programming extensions: - - ".tpl" + - '.tpl' ace_mode: smarty codemirror_mode: smarty codemirror_mime_type: text/x-smarty @@ -4954,33 +4954,33 @@ Smarty: language_id: 353 Solidity: type: programming - color: "#AA6746" + color: '#AA6746' ace_mode: text tm_scope: source.solidity language_id: 237469032 SourcePawn: type: programming - color: "#5c7611" + color: '#5c7611' aliases: - - sourcemod + - sourcemod extensions: - - ".sp" - - ".inc" + - '.sp' + - '.inc' tm_scope: source.sourcepawn ace_mode: text language_id: 354 Spline Font Database: type: data extensions: - - ".sfd" + - '.sfd' tm_scope: text.sfd ace_mode: yaml language_id: 767169629 Squirrel: type: programming - color: "#800000" + color: '#800000' extensions: - - ".nut" + - '.nut' tm_scope: source.c++ ace_mode: c_cpp codemirror_mode: clike @@ -4988,22 +4988,22 @@ Squirrel: language_id: 355 Stan: type: programming - color: "#b2011d" + color: '#b2011d' extensions: - - ".stan" + - '.stan' ace_mode: text tm_scope: source.stan language_id: 356 Standard ML: type: programming - color: "#dc566d" + color: '#dc566d' aliases: - - sml + - sml extensions: - - ".ML" - - ".fun" - - ".sig" - - ".sml" + - '.ML' + - '.fun' + - '.sig' + - '.sml' tm_scope: source.ml ace_mode: text codemirror_mode: mllike @@ -5012,13 +5012,13 @@ Standard ML: Stata: type: programming extensions: - - ".do" - - ".ado" - - ".doh" - - ".ihlp" - - ".mata" - - ".matah" - - ".sthlp" + - '.do' + - '.ado' + - '.doh' + - '.ihlp' + - '.mata' + - '.matah' + - '.sthlp' tm_scope: source.stata ace_mode: text language_id: 358 @@ -5026,14 +5026,14 @@ Stylus: type: markup group: CSS extensions: - - ".styl" + - '.styl' tm_scope: source.stylus ace_mode: stylus language_id: 359 SubRip Text: type: data extensions: - - ".srt" + - '.srt' ace_mode: text tm_scope: text.srt language_id: 360 @@ -5042,18 +5042,18 @@ SugarSS: tm_scope: source.css.postcss.sugarss group: CSS extensions: - - ".sss" + - '.sss' ace_mode: text language_id: 826404698 SuperCollider: type: programming - color: "#46390b" + color: '#46390b' extensions: - - ".sc" - - ".scd" + - '.sc' + - '.scd' interpreters: - - sclang - - scsynth + - sclang + - scsynth tm_scope: source.supercollider ace_mode: text language_id: 361 @@ -5065,13 +5065,13 @@ Svelte: codemirror_mode: htmlmixed codemirror_mime_type: text/html extensions: - - ".svelte" + - '.svelte' language_id: 928734530 Swift: type: programming - color: "#ffac45" + color: '#ffac45' extensions: - - ".swift" + - '.swift' tm_scope: source.swift ace_mode: text codemirror_mode: swift @@ -5079,11 +5079,11 @@ Swift: language_id: 362 SystemVerilog: type: programming - color: "#DAE1C2" + color: '#DAE1C2' extensions: - - ".sv" - - ".svh" - - ".vh" + - '.sv' + - '.svh' + - '.vh' tm_scope: source.systemverilog ace_mode: verilog codemirror_mode: verilog @@ -5092,28 +5092,28 @@ SystemVerilog: TI Program: type: programming ace_mode: text - color: "#A0AA87" + color: '#A0AA87' extensions: - - ".8xp" - - ".8xk" - - ".8xk.txt" - - ".8xp.txt" + - '.8xp' + - '.8xk' + - '.8xk.txt' + - '.8xp.txt' language_id: 422 tm_scope: none TLA: type: programming extensions: - - ".tla" + - '.tla' tm_scope: source.tla ace_mode: text language_id: 364 TOML: type: data extensions: - - ".toml" + - '.toml' filenames: - - Cargo.lock - - Gopkg.lock + - Cargo.lock + - Gopkg.lock tm_scope: source.toml ace_mode: toml codemirror_mode: toml @@ -5122,7 +5122,7 @@ TOML: TSQL: type: programming extensions: - - ".sql" + - '.sql' ace_mode: sql tm_scope: source.tsql language_id: 918334941 @@ -5130,7 +5130,7 @@ TSX: type: programming group: TypeScript extensions: - - ".tsx" + - '.tsx' tm_scope: source.tsx ace_mode: javascript codemirror_mode: jsx @@ -5139,23 +5139,23 @@ TSX: TXL: type: programming extensions: - - ".txl" + - '.txl' tm_scope: source.txl ace_mode: text language_id: 366 Tcl: type: programming - color: "#e4cc98" + color: '#e4cc98' extensions: - - ".tcl" - - ".adp" - - ".tm" + - '.tcl' + - '.adp' + - '.tm' filenames: - - owh - - starfield + - owh + - starfield interpreters: - - tclsh - - wish + - tclsh + - wish tm_scope: source.tcl ace_mode: tcl codemirror_mode: tcl @@ -5165,8 +5165,8 @@ Tcsh: type: programming group: Shell extensions: - - ".tcsh" - - ".csh" + - '.tcsh' + - '.csh' tm_scope: source.shell ace_mode: sh codemirror_mode: shell @@ -5174,93 +5174,93 @@ Tcsh: language_id: 368 TeX: type: markup - color: "#3D6117" + color: '#3D6117' ace_mode: tex codemirror_mode: stex codemirror_mime_type: text/x-stex tm_scope: text.tex.latex wrap: true aliases: - - latex + - latex extensions: - - ".tex" - - ".aux" - - ".bbx" - - ".cbx" - - ".cls" - - ".dtx" - - ".ins" - - ".lbx" - - ".ltx" - - ".mkii" - - ".mkiv" - - ".mkvi" - - ".sty" - - ".toc" + - '.tex' + - '.aux' + - '.bbx' + - '.cbx' + - '.cls' + - '.dtx' + - '.ins' + - '.lbx' + - '.ltx' + - '.mkii' + - '.mkiv' + - '.mkvi' + - '.sty' + - '.toc' language_id: 369 Tea: type: markup extensions: - - ".tea" + - '.tea' tm_scope: source.tea ace_mode: text language_id: 370 Terra: type: programming extensions: - - ".t" - color: "#00004c" + - '.t' + color: '#00004c' tm_scope: source.terra ace_mode: lua codemirror_mode: lua codemirror_mime_type: text/x-lua interpreters: - - lua + - lua language_id: 371 Texinfo: type: prose wrap: true extensions: - - ".texinfo" - - ".texi" - - ".txi" + - '.texinfo' + - '.texi' + - '.txi' ace_mode: text tm_scope: text.texinfo interpreters: - - makeinfo + - makeinfo language_id: 988020015 Text: type: prose wrap: true aliases: - - fundamental + - fundamental extensions: - - ".txt" - - ".fr" - - ".nb" - - ".ncl" - - ".no" + - '.txt' + - '.fr' + - '.nb' + - '.ncl' + - '.no' filenames: - - COPYING - - COPYING.regex - - COPYRIGHT.regex - - FONTLOG - - INSTALL - - INSTALL.mysql - - LICENSE - - LICENSE.mysql - - NEWS - - README.1ST - - README.me - - README.mysql - - click.me - - delete.me - - go.mod - - go.sum - - keep.me - - read.me - - readme.1st - - test.me + - COPYING + - COPYING.regex + - COPYRIGHT.regex + - FONTLOG + - INSTALL + - INSTALL.mysql + - LICENSE + - LICENSE.mysql + - NEWS + - README.1ST + - README.me + - README.mysql + - click.me + - delete.me + - go.mod + - go.sum + - keep.me + - read.me + - readme.1st + - test.me tm_scope: none ace_mode: text language_id: 372 @@ -5271,29 +5271,29 @@ Textile: codemirror_mime_type: text/x-textile wrap: true extensions: - - ".textile" + - '.textile' tm_scope: none language_id: 373 Thrift: type: programming tm_scope: source.thrift extensions: - - ".thrift" + - '.thrift' ace_mode: text language_id: 374 Turing: type: programming - color: "#cf142b" + color: '#cf142b' extensions: - - ".t" - - ".tu" + - '.t' + - '.tu' tm_scope: source.turing ace_mode: text language_id: 375 Turtle: type: data extensions: - - ".ttl" + - '.ttl' tm_scope: source.turtle ace_mode: text codemirror_mode: turtle @@ -5303,7 +5303,7 @@ Twig: type: markup group: HTML extensions: - - ".twig" + - '.twig' tm_scope: text.html.twig ace_mode: twig codemirror_mode: twig @@ -5312,22 +5312,22 @@ Twig: Type Language: type: data aliases: - - tl + - tl extensions: - - ".tl" + - '.tl' tm_scope: source.tl ace_mode: text language_id: 632765617 TypeScript: type: programming - color: "#2b7489" + color: '#2b7489' aliases: - - ts + - ts interpreters: - - deno - - ts-node + - deno + - ts-node extensions: - - ".ts" + - '.ts' tm_scope: source.ts ace_mode: typescript codemirror_mode: javascript @@ -5340,7 +5340,7 @@ Unified Parallel C: codemirror_mode: clike codemirror_mime_type: text/x-csrc extensions: - - ".upc" + - '.upc' tm_scope: source.c language_id: 379 Unity3D Asset: @@ -5349,27 +5349,27 @@ Unity3D Asset: codemirror_mode: yaml codemirror_mime_type: text/x-yaml extensions: - - ".anim" - - ".asset" - - ".mat" - - ".meta" - - ".prefab" - - ".unity" + - '.anim' + - '.asset' + - '.mat' + - '.meta' + - '.prefab' + - '.unity' tm_scope: source.yaml language_id: 380 Unix Assembly: type: programming group: Assembly extensions: - - ".s" - - ".ms" + - '.s' + - '.ms' tm_scope: source.x86 ace_mode: assembly_x86 language_id: 120 Uno: type: programming extensions: - - ".uno" + - '.uno' ace_mode: csharp codemirror_mode: clike codemirror_mime_type: text/x-csharp @@ -5377,9 +5377,9 @@ Uno: language_id: 381 UnrealScript: type: programming - color: "#a54c4d" + color: '#a54c4d' extensions: - - ".uc" + - '.uc' tm_scope: source.java ace_mode: java codemirror_mode: clike @@ -5388,21 +5388,21 @@ UnrealScript: UrWeb: type: programming aliases: - - Ur/Web - - Ur + - Ur/Web + - Ur extensions: - - ".ur" - - ".urs" + - '.ur' + - '.urs' tm_scope: source.ur ace_mode: text language_id: 383 V: type: programming - color: "#5d87bd" + color: '#5d87bd' aliases: - - vlang + - vlang extensions: - - ".v" + - '.v' tm_scope: source.v ace_mode: golang codemirror_mode: go @@ -5410,24 +5410,24 @@ V: language_id: 603371597 VCL: type: programming - color: "#148AA8" + color: '#148AA8' extensions: - - ".vcl" + - '.vcl' tm_scope: source.varnish.vcl ace_mode: text language_id: 384 VHDL: type: programming - color: "#adb2cb" + color: '#adb2cb' extensions: - - ".vhdl" - - ".vhd" - - ".vhf" - - ".vhi" - - ".vho" - - ".vhs" - - ".vht" - - ".vhw" + - '.vhdl' + - '.vhd' + - '.vhf' + - '.vhi' + - '.vho' + - '.vhs' + - '.vht' + - '.vhw' tm_scope: source.vhdl ace_mode: vhdl codemirror_mode: vhdl @@ -5435,19 +5435,19 @@ VHDL: language_id: 385 Vala: type: programming - color: "#fbe5cd" + color: '#fbe5cd' extensions: - - ".vala" - - ".vapi" + - '.vala' + - '.vapi' tm_scope: source.vala ace_mode: vala language_id: 386 Verilog: type: programming - color: "#b2b7f8" + color: '#b2b7f8' extensions: - - ".v" - - ".veo" + - '.v' + - '.veo' tm_scope: source.verilog ace_mode: verilog codemirror_mode: verilog @@ -5455,51 +5455,51 @@ Verilog: language_id: 387 Vim script: type: programming - color: "#199f4b" + color: '#199f4b' tm_scope: source.viml aliases: - - vim - - viml - - nvim + - vim + - viml + - nvim extensions: - - ".vim" - - ".vba" - - ".vmb" + - '.vim' + - '.vba' + - '.vmb' filenames: - - ".gvimrc" - - ".nvimrc" - - ".vimrc" - - _vimrc - - gvimrc - - nvimrc - - vimrc + - '.gvimrc' + - '.nvimrc' + - '.vimrc' + - _vimrc + - gvimrc + - nvimrc + - vimrc ace_mode: text language_id: 388 Visual Basic: type: programming - color: "#945db7" + color: '#945db7' extensions: - - ".vb" - - ".bas" - - ".cls" - - ".frm" - - ".frx" - - ".vba" - - ".vbhtml" - - ".vbs" + - '.vb' + - '.bas' + - '.cls' + - '.frm' + - '.frx' + - '.vba' + - '.vbhtml' + - '.vbs' tm_scope: source.vbnet aliases: - - vb.net - - vbnet + - vb.net + - vbnet ace_mode: text codemirror_mode: vb codemirror_mime_type: text/x-vb language_id: 389 Volt: type: programming - color: "#1F1F1F" + color: '#1F1F1F' extensions: - - ".volt" + - '.volt' tm_scope: source.d ace_mode: d codemirror_mode: d @@ -5507,42 +5507,42 @@ Volt: language_id: 390 Vue: type: markup - color: "#2c3e50" + color: '#2c3e50' extensions: - - ".vue" + - '.vue' tm_scope: text.html.vue ace_mode: html language_id: 391 Wavefront Material: type: data extensions: - - ".mtl" + - '.mtl' tm_scope: source.wavefront.mtl ace_mode: text language_id: 392 Wavefront Object: type: data extensions: - - ".obj" + - '.obj' tm_scope: source.wavefront.obj ace_mode: text language_id: 393 Web Ontology Language: type: data extensions: - - ".owl" + - '.owl' tm_scope: text.xml ace_mode: xml language_id: 394 WebAssembly: type: programming - color: "#04133b" + color: '#04133b' extensions: - - ".wast" - - ".wat" + - '.wast' + - '.wat' aliases: - - wast - - wasm + - wast + - wasm tm_scope: source.webassembly ace_mode: lisp codemirror_mode: commonlisp @@ -5551,7 +5551,7 @@ WebAssembly: WebIDL: type: programming extensions: - - ".webidl" + - '.webidl' tm_scope: source.webidl ace_mode: text codemirror_mode: webidl @@ -5561,14 +5561,14 @@ WebVTT: type: data wrap: true extensions: - - ".vtt" + - '.vtt' tm_scope: source.vtt ace_mode: text language_id: 658679714 Windows Registry Entries: type: data extensions: - - ".reg" + - '.reg' tm_scope: source.reg ace_mode: ini codemirror_mode: properties @@ -5576,16 +5576,16 @@ Windows Registry Entries: language_id: 969674868 Wollok: type: programming - color: "#a23738" + color: '#a23738' extensions: - - ".wlk" + - '.wlk' ace_mode: text tm_scope: source.wollok language_id: 632745969 World of Warcraft Addon Data: type: data extensions: - - ".toc" + - '.toc' tm_scope: source.toc ace_mode: text language_id: 396 @@ -5593,9 +5593,9 @@ X BitMap: type: data group: C aliases: - - xbm + - xbm extensions: - - ".xbm" + - '.xbm' ace_mode: c_cpp tm_scope: source.c codemirror_mode: clike @@ -5604,10 +5604,10 @@ X BitMap: X Font Directory Index: type: data filenames: - - encodings.dir - - fonts.alias - - fonts.dir - - fonts.scale + - encodings.dir + - fonts.alias + - fonts.dir + - fonts.scale tm_scope: source.fontdir ace_mode: text language_id: 208700028 @@ -5615,10 +5615,10 @@ X PixMap: type: data group: C aliases: - - xpm + - xpm extensions: - - ".xpm" - - ".pm" + - '.xpm' + - '.pm' ace_mode: c_cpp tm_scope: source.c codemirror_mode: clike @@ -5627,18 +5627,18 @@ X PixMap: X10: type: programming aliases: - - xten + - xten ace_mode: text extensions: - - ".x10" - color: "#4B6BEF" + - '.x10' + color: '#4B6BEF' tm_scope: source.x10 language_id: 397 XC: type: programming - color: "#99DA07" + color: '#99DA07' extensions: - - ".xc" + - '.xc' tm_scope: source.xc ace_mode: c_cpp codemirror_mode: clike @@ -5647,9 +5647,9 @@ XC: XCompose: type: data filenames: - - ".XCompose" - - XCompose - - xcompose + - '.XCompose' + - XCompose + - xcompose tm_scope: config.xcompose ace_mode: text language_id: 225167241 @@ -5660,129 +5660,129 @@ XML: codemirror_mode: xml codemirror_mime_type: text/xml aliases: - - rss - - xsd - - wsdl + - rss + - xsd + - wsdl extensions: - - ".xml" - - ".adml" - - ".admx" - - ".ant" - - ".axml" - - ".builds" - - ".ccproj" - - ".ccxml" - - ".clixml" - - ".cproject" - - ".cscfg" - - ".csdef" - - ".csl" - - ".csproj" - - ".ct" - - ".depproj" - - ".dita" - - ".ditamap" - - ".ditaval" - - ".dll.config" - - ".dotsettings" - - ".filters" - - ".fsproj" - - ".fxml" - - ".glade" - - ".gml" - - ".gmx" - - ".grxml" - - ".iml" - - ".ivy" - - ".jelly" - - ".jsproj" - - ".kml" - - ".launch" - - ".mdpolicy" - - ".mjml" - - ".mm" - - ".mod" - - ".mxml" - - ".natvis" - - ".ncl" - - ".ndproj" - - ".nproj" - - ".nuspec" - - ".odd" - - ".osm" - - ".pkgproj" - - ".pluginspec" - - ".proj" - - ".props" - - ".ps1xml" - - ".psc1" - - ".pt" - - ".rdf" - - ".resx" - - ".rss" - - ".sch" - - ".scxml" - - ".sfproj" - - ".shproj" - - ".srdf" - - ".storyboard" - - ".sublime-snippet" - - ".targets" - - ".tml" - - ".ts" - - ".tsx" - - ".ui" - - ".urdf" - - ".ux" - - ".vbproj" - - ".vcxproj" - - ".vsixmanifest" - - ".vssettings" - - ".vstemplate" - - ".vxml" - - ".wixproj" - - ".workflow" - - ".wsdl" - - ".wsf" - - ".wxi" - - ".wxl" - - ".wxs" - - ".x3d" - - ".xacro" - - ".xaml" - - ".xib" - - ".xlf" - - ".xliff" - - ".xmi" - - ".xml.dist" - - ".xproj" - - ".xsd" - - ".xspec" - - ".xul" - - ".zcml" + - '.xml' + - '.adml' + - '.admx' + - '.ant' + - '.axml' + - '.builds' + - '.ccproj' + - '.ccxml' + - '.clixml' + - '.cproject' + - '.cscfg' + - '.csdef' + - '.csl' + - '.csproj' + - '.ct' + - '.depproj' + - '.dita' + - '.ditamap' + - '.ditaval' + - '.dll.config' + - '.dotsettings' + - '.filters' + - '.fsproj' + - '.fxml' + - '.glade' + - '.gml' + - '.gmx' + - '.grxml' + - '.iml' + - '.ivy' + - '.jelly' + - '.jsproj' + - '.kml' + - '.launch' + - '.mdpolicy' + - '.mjml' + - '.mm' + - '.mod' + - '.mxml' + - '.natvis' + - '.ncl' + - '.ndproj' + - '.nproj' + - '.nuspec' + - '.odd' + - '.osm' + - '.pkgproj' + - '.pluginspec' + - '.proj' + - '.props' + - '.ps1xml' + - '.psc1' + - '.pt' + - '.rdf' + - '.resx' + - '.rss' + - '.sch' + - '.scxml' + - '.sfproj' + - '.shproj' + - '.srdf' + - '.storyboard' + - '.sublime-snippet' + - '.targets' + - '.tml' + - '.ts' + - '.tsx' + - '.ui' + - '.urdf' + - '.ux' + - '.vbproj' + - '.vcxproj' + - '.vsixmanifest' + - '.vssettings' + - '.vstemplate' + - '.vxml' + - '.wixproj' + - '.workflow' + - '.wsdl' + - '.wsf' + - '.wxi' + - '.wxl' + - '.wxs' + - '.x3d' + - '.xacro' + - '.xaml' + - '.xib' + - '.xlf' + - '.xliff' + - '.xmi' + - '.xml.dist' + - '.xproj' + - '.xsd' + - '.xspec' + - '.xul' + - '.zcml' filenames: - - ".classpath" - - ".cproject" - - ".project" - - App.config - - NuGet.config - - Settings.StyleCop - - Web.Debug.config - - Web.Release.config - - Web.config - - packages.config + - '.classpath' + - '.cproject' + - '.project' + - App.config + - NuGet.config + - Settings.StyleCop + - Web.Debug.config + - Web.Release.config + - Web.config + - packages.config language_id: 399 XML Property List: type: data group: XML extensions: - - ".plist" - - ".stTheme" - - ".tmCommand" - - ".tmLanguage" - - ".tmPreferences" - - ".tmSnippet" - - ".tmTheme" + - '.plist' + - '.stTheme' + - '.tmCommand' + - '.tmLanguage' + - '.tmPreferences' + - '.tmSnippet' + - '.tmTheme' tm_scope: text.xml.plist ace_mode: xml codemirror_mode: xml @@ -5791,8 +5791,8 @@ XML Property List: XPages: type: data extensions: - - ".xsp-config" - - ".xsp.metadata" + - '.xsp-config' + - '.xsp.metadata' tm_scope: text.xml ace_mode: xml codemirror_mode: xml @@ -5801,8 +5801,8 @@ XPages: XProc: type: programming extensions: - - ".xpl" - - ".xproc" + - '.xpl' + - '.xproc' tm_scope: text.xml ace_mode: xml codemirror_mode: xml @@ -5810,13 +5810,13 @@ XProc: language_id: 401 XQuery: type: programming - color: "#5232e7" + color: '#5232e7' extensions: - - ".xquery" - - ".xq" - - ".xql" - - ".xqm" - - ".xqy" + - '.xquery' + - '.xq' + - '.xql' + - '.xqm' + - '.xqy' ace_mode: xquery codemirror_mode: xquery codemirror_mime_type: application/xquery @@ -5825,7 +5825,7 @@ XQuery: XS: type: programming extensions: - - ".xs" + - '.xs' tm_scope: source.c ace_mode: c_cpp codemirror_mode: clike @@ -5834,32 +5834,32 @@ XS: XSLT: type: programming aliases: - - xsl + - xsl extensions: - - ".xslt" - - ".xsl" + - '.xslt' + - '.xsl' tm_scope: text.xml.xsl ace_mode: xml codemirror_mode: xml codemirror_mime_type: text/xml - color: "#EB8CEB" + color: '#EB8CEB' language_id: 404 Xojo: type: programming extensions: - - ".xojo_code" - - ".xojo_menu" - - ".xojo_report" - - ".xojo_script" - - ".xojo_toolbar" - - ".xojo_window" + - '.xojo_code' + - '.xojo_menu' + - '.xojo_report' + - '.xojo_script' + - '.xojo_toolbar' + - '.xojo_window' tm_scope: source.xojo ace_mode: text language_id: 405 Xtend: type: programming extensions: - - ".xtend" + - '.xtend' tm_scope: source.xtend ace_mode: text language_id: 406 @@ -5867,22 +5867,22 @@ YAML: type: data tm_scope: source.yaml aliases: - - yml + - yml extensions: - - ".yml" - - ".mir" - - ".reek" - - ".rviz" - - ".sublime-syntax" - - ".syntax" - - ".yaml" - - ".yaml-tmlanguage" - - ".yml.mysql" + - '.yml' + - '.mir' + - '.reek' + - '.rviz' + - '.sublime-syntax' + - '.syntax' + - '.yaml' + - '.yaml-tmlanguage' + - '.yml.mysql' filenames: - - ".clang-format" - - ".clang-tidy" - - ".gemrc" - - glide.lock + - '.clang-format' + - '.clang-tidy' + - '.gemrc' + - glide.lock ace_mode: yaml codemirror_mode: yaml codemirror_mime_type: text/x-yaml @@ -5890,115 +5890,115 @@ YAML: YANG: type: data extensions: - - ".yang" + - '.yang' tm_scope: source.yang ace_mode: text language_id: 408 YARA: type: programming - color: "#220000" + color: '#220000' ace_mode: text extensions: - - ".yar" - - ".yara" + - '.yar' + - '.yara' tm_scope: source.yara language_id: 805122868 YASnippet: type: markup aliases: - - snippet - - yas - color: "#32AB90" + - snippet + - yas + color: '#32AB90' extensions: - - ".yasnippet" + - '.yasnippet' tm_scope: source.yasnippet ace_mode: text language_id: 378760102 Yacc: type: programming extensions: - - ".y" - - ".yacc" - - ".yy" + - '.y' + - '.yacc' + - '.yy' tm_scope: source.yacc ace_mode: text - color: "#4B6C4B" + color: '#4B6C4B' language_id: 409 ZAP: type: programming - color: "#0d665e" + color: '#0d665e' extensions: - - ".zap" - - ".xzap" + - '.zap' + - '.xzap' tm_scope: source.zap ace_mode: text language_id: 952972794 ZIL: type: programming - color: "#dc75e5" + color: '#dc75e5' extensions: - - ".zil" - - ".mud" + - '.zil' + - '.mud' tm_scope: source.zil ace_mode: text language_id: 973483626 Zeek: type: programming aliases: - - bro + - bro extensions: - - ".zeek" - - ".bro" + - '.zeek' + - '.bro' tm_scope: source.zeek ace_mode: text language_id: 40 ZenScript: type: programming - color: "#00BCD1" + color: '#00BCD1' extensions: - - ".zs" + - '.zs' tm_scope: source.zenscript ace_mode: text language_id: 494938890 Zephir: type: programming - color: "#118f9e" + color: '#118f9e' extensions: - - ".zep" + - '.zep' tm_scope: source.php.zephir ace_mode: php language_id: 410 Zig: type: programming - color: "#ec915c" + color: '#ec915c' extensions: - - ".zig" + - '.zig' tm_scope: source.zig ace_mode: text language_id: 646424281 Zimpl: type: programming extensions: - - ".zimpl" - - ".zmpl" - - ".zpl" + - '.zimpl' + - '.zmpl' + - '.zpl' tm_scope: none ace_mode: text language_id: 411 desktop: type: data extensions: - - ".desktop" - - ".desktop.in" + - '.desktop' + - '.desktop.in' tm_scope: source.desktop ace_mode: text language_id: 412 eC: type: programming - color: "#913960" + color: '#913960' extensions: - - ".ec" - - ".eh" + - '.ec' + - '.eh' tm_scope: source.c.ec ace_mode: text language_id: 413 @@ -6008,31 +6008,31 @@ edn: codemirror_mode: clojure codemirror_mime_type: text/x-clojure extensions: - - ".edn" + - '.edn' tm_scope: source.clojure language_id: 414 fish: type: programming group: Shell interpreters: - - fish + - fish extensions: - - ".fish" + - '.fish' tm_scope: source.fish ace_mode: text language_id: 415 mcfunction: type: programming - color: "#E22837" + color: '#E22837' extensions: - - ".mcfunction" + - '.mcfunction' tm_scope: source.mcfunction ace_mode: text language_id: 462488745 mupad: type: programming extensions: - - ".mu" + - '.mu' tm_scope: source.mupad ace_mode: text language_id: 416 @@ -6040,47 +6040,47 @@ nanorc: type: data group: INI extensions: - - ".nanorc" + - '.nanorc' filenames: - - ".nanorc" - - nanorc + - '.nanorc' + - nanorc tm_scope: source.nanorc ace_mode: text language_id: 775996197 nesC: type: programming - color: "#94B0C7" + color: '#94B0C7' extensions: - - ".nc" + - '.nc' ace_mode: text tm_scope: source.nesc language_id: 417 ooc: type: programming - color: "#b0b77e" + color: '#b0b77e' extensions: - - ".ooc" + - '.ooc' tm_scope: source.ooc ace_mode: text language_id: 418 q: type: programming extensions: - - ".q" + - '.q' tm_scope: source.q ace_mode: text - color: "#0040cd" + color: '#0040cd' language_id: 970539067 reStructuredText: type: prose wrap: true aliases: - - rst + - rst extensions: - - ".rst" - - ".rest" - - ".rest.txt" - - ".rst.txt" + - '.rst' + - '.rest' + - '.rest.txt' + - '.rst.txt' tm_scope: text.restructuredtext ace_mode: text codemirror_mode: rst @@ -6088,22 +6088,22 @@ reStructuredText: language_id: 419 sed: type: programming - color: "#64b970" + color: '#64b970' extensions: - - ".sed" + - '.sed' interpreters: - - gsed - - minised - - sed - - ssed + - gsed + - minised + - sed + - ssed ace_mode: text tm_scope: source.sed language_id: 847830017 wdl: type: programming - color: "#42f1f4" + color: '#42f1f4' extensions: - - ".wdl" + - '.wdl' tm_scope: source.wdl ace_mode: text language_id: 374521672 @@ -6112,22 +6112,22 @@ wisp: ace_mode: clojure codemirror_mode: clojure codemirror_mime_type: text/x-clojure - color: "#7582D1" + color: '#7582D1' extensions: - - ".wisp" + - '.wisp' tm_scope: source.clojure language_id: 420 xBase: type: programming - color: "#403a40" + color: '#403a40' aliases: - - advpl - - clipper - - foxpro + - advpl + - clipper + - foxpro extensions: - - ".prg" - - ".ch" - - ".prw" + - '.prg' + - '.ch' + - '.prw' tm_scope: source.harbour ace_mode: text language_id: 421 diff --git a/src/widgets/code/data/languages.json b/src/widgets/code/data/languages.json deleted file mode 100644 index 671490e3..00000000 --- a/src/widgets/code/data/languages.json +++ /dev/null @@ -1 +0,0 @@ -[{"label":"AGS Script","identifiers":["ags","asc","ash"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-c++src"},{"label":"APL","identifiers":["apl","dyalog"],"codemirror_mode":"apl","codemirror_mime_type":"text/apl"},{"label":"ASN.1","identifiers":["asn"],"codemirror_mode":"asn.1","codemirror_mime_type":"text/x-ttcn-asn"},{"label":"ASP","identifiers":["asp","aspx","asax","ascx","ashx","asmx","axd"],"codemirror_mode":"htmlembedded","codemirror_mime_type":"application/x-aspx"},{"label":"Alpine Abuild","identifiers":["abuild","apkbuild"],"codemirror_mode":"shell","codemirror_mime_type":"text/x-sh"},{"label":"AngelScript","identifiers":["angelscript","as"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-c++src"},{"label":"Ant Build System","identifiers":[],"codemirror_mode":"xml","codemirror_mime_type":"application/xml"},{"label":"Apex","identifiers":["apex","cls"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-java"},{"label":"Asymptote","identifiers":["asymptote","asy"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-kotlin"},{"label":"BibTeX","identifiers":["bibtex","bib"],"codemirror_mode":"stex","codemirror_mime_type":"text/x-stex"},{"label":"Brainfuck","identifiers":["brainfuck","b","bf"],"codemirror_mode":"brainfuck","codemirror_mime_type":"text/x-brainfuck"},{"label":"C","identifiers":["c","cats","h","idc"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-csrc"},{"label":"C#","identifiers":["csharp","cs","cake","csx"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-csharp"},{"label":"C++","identifiers":["cpp","cc","cp","cxx","h","hh","hpp","hxx","inc","inl","ino","ipp","re","tcc","tpp"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-c++src"},{"label":"C2hs Haskell","identifiers":["chs"],"codemirror_mode":"haskell","codemirror_mime_type":"text/x-haskell"},{"label":"CMake","identifiers":["cmake"],"codemirror_mode":"cmake","codemirror_mime_type":"text/x-cmake"},{"label":"COBOL","identifiers":["cobol","cob","cbl","ccp","cpy"],"codemirror_mode":"cobol","codemirror_mime_type":"text/x-cobol"},{"label":"COLLADA","identifiers":["collada","dae"],"codemirror_mode":"xml","codemirror_mime_type":"text/xml"},{"label":"CSON","identifiers":["cson"],"codemirror_mode":"coffeescript","codemirror_mime_type":"text/x-coffeescript"},{"label":"CSS","identifiers":["css"],"codemirror_mode":"css","codemirror_mime_type":"text/css"},{"label":"Cabal Config","identifiers":["Cabal","cabal"],"codemirror_mode":"haskell","codemirror_mime_type":"text/x-haskell"},{"label":"ChucK","identifiers":["chuck","ck"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-java"},{"label":"Clojure","identifiers":["clojure","clj","boot","cljc","cljs","cljscm","cljx","hic"],"codemirror_mode":"clojure","codemirror_mime_type":"text/x-clojure"},{"label":"Closure Templates","identifiers":["soy"],"codemirror_mode":"soy","codemirror_mime_type":"text/x-soy"},{"label":"Cloud Firestore Security Rules","identifiers":[],"codemirror_mode":"css","codemirror_mime_type":"text/css"},{"label":"CoffeeScript","identifiers":["coffeescript","coffee","cake","cjsx","iced"],"codemirror_mode":"coffeescript","codemirror_mime_type":"text/x-coffeescript"},{"label":"Common Lisp","identifiers":["lisp","asd","cl","l","lsp","ny","podsl","sexp"],"codemirror_mode":"commonlisp","codemirror_mime_type":"text/x-common-lisp"},{"label":"Common Workflow Language","identifiers":["cwl"],"codemirror_mode":"yaml","codemirror_mime_type":"text/x-yaml"},{"label":"Component Pascal","identifiers":["delphi","objectpascal","cp","cps"],"codemirror_mode":"pascal","codemirror_mime_type":"text/x-pascal"},{"label":"Crystal","identifiers":["crystal","cr"],"codemirror_mode":"crystal","codemirror_mime_type":"text/x-crystal"},{"label":"Cuda","identifiers":["cuda","cu","cuh"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-c++src"},{"label":"Cycript","identifiers":["cycript","cy"],"codemirror_mode":"javascript","codemirror_mime_type":"text/javascript"},{"label":"Cython","identifiers":["cython","pyrex","pyx","pxd","pxi"],"codemirror_mode":"python","codemirror_mime_type":"text/x-cython"},{"label":"D","identifiers":["d","di"],"codemirror_mode":"d","codemirror_mime_type":"text/x-d"},{"label":"DTrace","identifiers":["dtrace","d"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-csrc"},{"label":"Dart","identifiers":["dart"],"codemirror_mode":"dart","codemirror_mime_type":"application/dart"},{"label":"Dhall","identifiers":["dhall"],"codemirror_mode":"haskell","codemirror_mime_type":"text/x-haskell"},{"label":"Diff","identifiers":["diff","udiff","patch"],"codemirror_mode":"diff","codemirror_mime_type":"text/x-diff"},{"label":"Dockerfile","identifiers":["dockerfile"],"codemirror_mode":"dockerfile","codemirror_mime_type":"text/x-dockerfile"},{"label":"Dylan","identifiers":["dylan","dyl","intr","lid"],"codemirror_mode":"dylan","codemirror_mime_type":"text/x-dylan"},{"label":"EBNF","identifiers":["ebnf"],"codemirror_mode":"ebnf","codemirror_mime_type":"text/x-ebnf"},{"label":"ECL","identifiers":["ecl","eclxml"],"codemirror_mode":"ecl","codemirror_mime_type":"text/x-ecl"},{"label":"EQ","identifiers":["eq"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-csharp"},{"label":"Eagle","identifiers":["eagle","sch","brd"],"codemirror_mode":"xml","codemirror_mime_type":"text/xml"},{"label":"Easybuild","identifiers":["easybuild","eb"],"codemirror_mode":"python","codemirror_mime_type":"text/x-python"},{"label":"Ecere Projects","identifiers":["epj"],"codemirror_mode":"javascript","codemirror_mime_type":"application/json"},{"label":"EditorConfig","identifiers":["editorconfig"],"codemirror_mode":"properties","codemirror_mime_type":"text/x-properties"},{"label":"Edje Data Collection","identifiers":["edc"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-c++src"},{"label":"Eiffel","identifiers":["eiffel","e"],"codemirror_mode":"eiffel","codemirror_mime_type":"text/x-eiffel"},{"label":"Elm","identifiers":["elm"],"codemirror_mode":"elm","codemirror_mime_type":"text/x-elm"},{"label":"Emacs Lisp","identifiers":["elisp","emacs","el"],"codemirror_mode":"commonlisp","codemirror_mime_type":"text/x-common-lisp"},{"label":"EmberScript","identifiers":["emberscript","em"],"codemirror_mode":"coffeescript","codemirror_mime_type":"text/x-coffeescript"},{"label":"Erlang","identifiers":["erlang","erl","es","escript","hrl","xrl","yrl"],"codemirror_mode":"erlang","codemirror_mime_type":"text/x-erlang"},{"label":"F#","identifiers":["fsharp","fs","fsi","fsx"],"codemirror_mode":"mllike","codemirror_mime_type":"text/x-fsharp"},{"label":"Factor","identifiers":["factor"],"codemirror_mode":"factor","codemirror_mime_type":"text/x-factor"},{"label":"Forth","identifiers":["forth","fth","f","for","fr","frt","fs"],"codemirror_mode":"forth","codemirror_mime_type":"text/x-forth"},{"label":"Fortran","identifiers":["fortran","f","for","fpp"],"codemirror_mode":"fortran","codemirror_mime_type":"text/x-fortran"},{"label":"GCC Machine Description","identifiers":["md"],"codemirror_mode":"commonlisp","codemirror_mime_type":"text/x-common-lisp"},{"label":"GN","identifiers":["gn","gni"],"codemirror_mode":"python","codemirror_mime_type":"text/x-python"},{"label":"Game Maker Language","identifiers":["gml"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-c++src"},{"label":"Genshi","identifiers":["genshi","kid"],"codemirror_mode":"xml","codemirror_mime_type":"text/xml"},{"label":"Gentoo Ebuild","identifiers":["ebuild"],"codemirror_mode":"shell","codemirror_mime_type":"text/x-sh"},{"label":"Gentoo Eclass","identifiers":["eclass"],"codemirror_mode":"shell","codemirror_mime_type":"text/x-sh"},{"label":"Git Attributes","identifiers":["gitattributes"],"codemirror_mode":"shell","codemirror_mime_type":"text/x-sh"},{"label":"Git Config","identifiers":["gitconfig","gitmodules"],"codemirror_mode":"properties","codemirror_mime_type":"text/x-properties"},{"label":"Glyph","identifiers":["glyph","glf"],"codemirror_mode":"tcl","codemirror_mime_type":"text/x-tcl"},{"label":"Go","identifiers":["go","golang"],"codemirror_mode":"go","codemirror_mime_type":"text/x-go"},{"label":"Grammatical Framework","identifiers":["gf"],"codemirror_mode":"haskell","codemirror_mime_type":"text/x-haskell"},{"label":"Groovy","identifiers":["groovy","grt","gtpl","gvy"],"codemirror_mode":"groovy","codemirror_mime_type":"text/x-groovy"},{"label":"Groovy Server Pages","identifiers":["gsp"],"codemirror_mode":"htmlembedded","codemirror_mime_type":"application/x-jsp"},{"label":"HCL","identifiers":["hcl","terraform","tf","tfvars","workflow"],"codemirror_mode":"ruby","codemirror_mime_type":"text/x-ruby"},{"label":"HTML","identifiers":["html","xhtml","htm","inc","st","xht"],"codemirror_mode":"htmlmixed","codemirror_mime_type":"text/html"},{"label":"HTML+Django","identifiers":["django","htmldjango","njk","nunjucks","jinja","mustache"],"codemirror_mode":"django","codemirror_mime_type":"text/x-django"},{"label":"HTML+ECR","identifiers":["ecr"],"codemirror_mode":"htmlmixed","codemirror_mime_type":"text/html"},{"label":"HTML+EEX","identifiers":["eex"],"codemirror_mode":"htmlmixed","codemirror_mime_type":"text/html"},{"label":"HTML+ERB","identifiers":["erb"],"codemirror_mode":"htmlembedded","codemirror_mime_type":"application/x-erb"},{"label":"HTML+PHP","identifiers":["phtml"],"codemirror_mode":"php","codemirror_mime_type":"application/x-httpd-php"},{"label":"HTML+Razor","identifiers":["razor","cshtml"],"codemirror_mode":"htmlmixed","codemirror_mime_type":"text/html"},{"label":"HTTP","identifiers":["http"],"codemirror_mode":"http","codemirror_mime_type":"message/http"},{"label":"Hack","identifiers":["hack","hh","php"],"codemirror_mode":"php","codemirror_mime_type":"application/x-httpd-php"},{"label":"Haml","identifiers":["haml"],"codemirror_mode":"haml","codemirror_mime_type":"text/x-haml"},{"label":"Haskell","identifiers":["haskell","hs","hsc"],"codemirror_mode":"haskell","codemirror_mime_type":"text/x-haskell"},{"label":"Haxe","identifiers":["haxe","hx","hxsl"],"codemirror_mode":"haxe","codemirror_mime_type":"text/x-haxe"},{"label":"HolyC","identifiers":["holyc","hc"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-csrc"},{"label":"IDL","identifiers":["idl","pro","dlm"],"codemirror_mode":"idl","codemirror_mime_type":"text/x-idl"},{"label":"INI","identifiers":["ini","dosini","cfg","lektorproject","prefs","pro","properties"],"codemirror_mode":"properties","codemirror_mime_type":"text/x-properties"},{"label":"IRC log","identifiers":["irc","irclog","weechatlog"],"codemirror_mode":"mirc","codemirror_mime_type":"text/mirc"},{"label":"Ignore List","identifiers":["ignore","gitignore"],"codemirror_mode":"shell","codemirror_mime_type":"text/x-sh"},{"label":"JSON","identifiers":["json","avsc","geojson","gltf","har","ice","jsonl","mcmeta","tfstate","topojson","webapp","webmanifest","yy","yyp"],"codemirror_mode":"javascript","codemirror_mime_type":"application/json"},{"label":"JSON with Comments","identifiers":["jsonc"],"codemirror_mode":"javascript","codemirror_mime_type":"text/javascript"},{"label":"JSON5","identifiers":[],"codemirror_mode":"javascript","codemirror_mime_type":"application/json"},{"label":"JSONLD","identifiers":["jsonld"],"codemirror_mode":"javascript","codemirror_mime_type":"application/json"},{"label":"JSONiq","identifiers":["jsoniq","jq"],"codemirror_mode":"javascript","codemirror_mime_type":"application/json"},{"label":"JSX","identifiers":["jsx"],"codemirror_mode":"jsx","codemirror_mime_type":"text/jsx"},{"label":"Java","identifiers":["java"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-java"},{"label":"Java Properties","identifiers":["properties"],"codemirror_mode":"properties","codemirror_mime_type":"text/x-properties"},{"label":"Java Server Pages","identifiers":["jsp"],"codemirror_mode":"htmlembedded","codemirror_mime_type":"application/x-jsp"},{"label":"JavaScript","identifiers":["javascript","js","node","bones","es","frag","gs","jake","jsb","jscad","jsfl","jsm","jss","mjs","njs","pac","sjs","ssjs","xsjs","xsjslib"],"codemirror_mode":"javascript","codemirror_mime_type":"text/javascript"},{"label":"JavaScript+ERB","identifiers":[],"codemirror_mode":"javascript","codemirror_mime_type":"application/javascript"},{"label":"Julia","identifiers":["julia","jl"],"codemirror_mode":"julia","codemirror_mime_type":"text/x-julia"},{"label":"Jupyter Notebook","identifiers":["ipynb"],"codemirror_mode":"javascript","codemirror_mime_type":"application/json"},{"label":"KiCad Layout","identifiers":["pcbnew"],"codemirror_mode":"commonlisp","codemirror_mime_type":"text/x-common-lisp"},{"label":"Kit","identifiers":["kit"],"codemirror_mode":"htmlmixed","codemirror_mime_type":"text/html"},{"label":"Kotlin","identifiers":["kotlin","kt","ktm","kts"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-kotlin"},{"label":"LFE","identifiers":["lfe"],"codemirror_mode":"commonlisp","codemirror_mime_type":"text/x-common-lisp"},{"label":"LTspice Symbol","identifiers":["asy"],"codemirror_mode":"spreadsheet","codemirror_mime_type":"text/x-spreadsheet"},{"label":"LabVIEW","identifiers":["labview","lvproj"],"codemirror_mode":"xml","codemirror_mime_type":"text/xml"},{"label":"Latte","identifiers":["latte"],"codemirror_mode":"smarty","codemirror_mime_type":"text/x-smarty"},{"label":"Less","identifiers":["less"],"codemirror_mode":"css","codemirror_mime_type":"text/css"},{"label":"Literate Haskell","identifiers":["lhaskell","lhs"],"codemirror_mode":"haskell-literate","codemirror_mime_type":"text/x-literate-haskell"},{"label":"LiveScript","identifiers":["livescript","ls"],"codemirror_mode":"livescript","codemirror_mime_type":"text/x-livescript"},{"label":"LookML","identifiers":["lookml"],"codemirror_mode":"yaml","codemirror_mime_type":"text/x-yaml"},{"label":"Lua","identifiers":["lua","fcgi","nse","rbxs","wlua"],"codemirror_mode":"lua","codemirror_mime_type":"text/x-lua"},{"label":"M","identifiers":["m","mumps"],"codemirror_mode":"mumps","codemirror_mime_type":"text/x-mumps"},{"label":"MATLAB","identifiers":["matlab","octave","m"],"codemirror_mode":"octave","codemirror_mime_type":"text/x-octave"},{"label":"MTML","identifiers":["mtml"],"codemirror_mode":"htmlmixed","codemirror_mime_type":"text/html"},{"label":"MUF","identifiers":["muf","m"],"codemirror_mode":"forth","codemirror_mime_type":"text/x-forth"},{"label":"Makefile","identifiers":["makefile","bsdmake","make","mf","mak","d","mk","mkfile"],"codemirror_mode":"cmake","codemirror_mime_type":"text/x-cmake"},{"label":"Markdown","identifiers":["markdown","pandoc","md","mdown","mdwn","mdx","mkd","mkdn","mkdown","ronn","workbook"],"codemirror_mode":"gfm","codemirror_mime_type":"text/x-gfm"},{"label":"Marko","identifiers":["marko","markojs"],"codemirror_mode":"htmlmixed","codemirror_mime_type":"text/html"},{"label":"Mathematica","identifiers":["mathematica","mma","cdf","m","ma","mt","nb","nbp","wl","wlt"],"codemirror_mode":"mathematica","codemirror_mime_type":"text/x-mathematica"},{"label":"Maven POM","identifiers":[],"codemirror_mode":"xml","codemirror_mime_type":"text/xml"},{"label":"Max","identifiers":["max","maxmsp","maxpat","maxhelp","maxproj","mxt","pat"],"codemirror_mode":"javascript","codemirror_mime_type":"application/json"},{"label":"Metal","identifiers":["metal"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-c++src"},{"label":"Mirah","identifiers":["mirah","druby","duby"],"codemirror_mode":"ruby","codemirror_mime_type":"text/x-ruby"},{"label":"Modelica","identifiers":["modelica","mo"],"codemirror_mode":"modelica","codemirror_mime_type":"text/x-modelica"},{"label":"NSIS","identifiers":["nsis","nsi","nsh"],"codemirror_mode":"nsis","codemirror_mime_type":"text/x-nsis"},{"label":"NetLogo","identifiers":["netlogo","nlogo"],"codemirror_mode":"commonlisp","codemirror_mime_type":"text/x-common-lisp"},{"label":"NewLisp","identifiers":["newlisp","nl","lisp","lsp"],"codemirror_mode":"commonlisp","codemirror_mime_type":"text/x-common-lisp"},{"label":"Nginx","identifiers":["nginx","nginxconf","vhost"],"codemirror_mode":"nginx","codemirror_mime_type":"text/x-nginx-conf"},{"label":"Nu","identifiers":["nu","nush"],"codemirror_mode":"scheme","codemirror_mime_type":"text/x-scheme"},{"label":"NumPy","identifiers":["numpy","numpyw","numsc"],"codemirror_mode":"python","codemirror_mime_type":"text/x-python"},{"label":"OCaml","identifiers":["ocaml","ml","eliom","eliomi","mli","mll","mly"],"codemirror_mode":"mllike","codemirror_mime_type":"text/x-ocaml"},{"label":"Objective-C","identifiers":["objc","objectivec","m","h"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-objectivec"},{"label":"Objective-C++","identifiers":["mm"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-objectivec"},{"label":"OpenCL","identifiers":["opencl","cl"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-csrc"},{"label":"OpenRC runscript","identifiers":["openrc"],"codemirror_mode":"shell","codemirror_mime_type":"text/x-sh"},{"label":"Oz","identifiers":["oz"],"codemirror_mode":"oz","codemirror_mime_type":"text/x-oz"},{"label":"PHP","identifiers":["php","inc","aw","ctp","fcgi","phps","phpt"],"codemirror_mode":"php","codemirror_mime_type":"application/x-httpd-php"},{"label":"PLSQL","identifiers":["plsql","pls","bdy","ddl","fnc","pck","pkb","pks","plb","prc","spc","sql","tpb","tps","trg","vw"],"codemirror_mode":"sql","codemirror_mime_type":"text/x-plsql"},{"label":"PLpgSQL","identifiers":["plpgsql","pgsql","sql"],"codemirror_mode":"sql","codemirror_mime_type":"text/x-sql"},{"label":"Pascal","identifiers":["pascal","pas","dfm","dpr","inc","lpr","pp"],"codemirror_mode":"pascal","codemirror_mime_type":"text/x-pascal"},{"label":"Perl","identifiers":["perl","cperl","pl","al","cgi","fcgi","ph","plx","pm","psgi","t"],"codemirror_mode":"perl","codemirror_mime_type":"text/x-perl"},{"label":"Perl 6","identifiers":["nqp","pl","pm","t"],"codemirror_mode":"perl","codemirror_mime_type":"text/x-perl"},{"label":"Pic","identifiers":["pic","chem"],"codemirror_mode":"troff","codemirror_mime_type":"text/troff"},{"label":"Pod","identifiers":["pod"],"codemirror_mode":"perl","codemirror_mime_type":"text/x-perl"},{"label":"PowerShell","identifiers":["powershell","posh","pwsh"],"codemirror_mode":"powershell","codemirror_mime_type":"application/x-powershell"},{"label":"Protocol Buffer","identifiers":["protobuf","proto"],"codemirror_mode":"protobuf","codemirror_mime_type":"text/x-protobuf"},{"label":"Public Key","identifiers":["asc","pub"],"codemirror_mode":"asciiarmor","codemirror_mime_type":"application/pgp"},{"label":"Pug","identifiers":["pug","jade"],"codemirror_mode":"pug","codemirror_mime_type":"text/x-pug"},{"label":"Puppet","identifiers":["puppet","pp"],"codemirror_mode":"puppet","codemirror_mime_type":"text/x-puppet"},{"label":"PureScript","identifiers":["purescript","purs"],"codemirror_mode":"haskell","codemirror_mime_type":"text/x-haskell"},{"label":"Python","identifiers":["python","rusthon","py","bzl","cgi","fcgi","gyp","gypi","lmi","pyde","pyi","pyp","pyt","pyw","rpy","spec","tac","wsgi","xpy"],"codemirror_mode":"python","codemirror_mime_type":"text/x-python"},{"label":"R","identifiers":["r","R","Rscript","splus","rd","rsx"],"codemirror_mode":"r","codemirror_mime_type":"text/x-rsrc"},{"label":"RAML","identifiers":["raml"],"codemirror_mode":"yaml","codemirror_mime_type":"text/x-yaml"},{"label":"RHTML","identifiers":["rhtml"],"codemirror_mode":"htmlembedded","codemirror_mime_type":"application/x-erb"},{"label":"RMarkdown","identifiers":["rmarkdown","rmd"],"codemirror_mode":"gfm","codemirror_mime_type":"text/x-gfm"},{"label":"RPM Spec","identifiers":["specfile","spec"],"codemirror_mode":"rpm","codemirror_mime_type":"text/x-rpm-spec"},{"label":"Reason","identifiers":["reason","re","rei"],"codemirror_mode":"rust","codemirror_mime_type":"text/x-rustsrc"},{"label":"Roff","identifiers":["roff","groff","man","manpage","mdoc","nroff","troff","l","me","ms","n","nr","rno","tmac"],"codemirror_mode":"troff","codemirror_mime_type":"text/troff"},{"label":"Roff Manpage","identifiers":["man","mdoc"],"codemirror_mode":"troff","codemirror_mime_type":"text/troff"},{"label":"Rouge","identifiers":["rouge","rg"],"codemirror_mode":"clojure","codemirror_mime_type":"text/x-clojure"},{"label":"Ruby","identifiers":["ruby","jruby","macruby","rake","rb","rbx","builder","eye","fcgi","gemspec","god","jbuilder","mspec","pluginspec","podspec","rabl","rbuild","rbw","ru","spec","thor","watchr"],"codemirror_mode":"ruby","codemirror_mime_type":"text/x-ruby"},{"label":"Rust","identifiers":["rust","rs"],"codemirror_mode":"rust","codemirror_mime_type":"text/x-rustsrc"},{"label":"SAS","identifiers":["sas"],"codemirror_mode":"sas","codemirror_mime_type":"text/x-sas"},{"label":"SCSS","identifiers":["scss"],"codemirror_mode":"css","codemirror_mime_type":"text/x-scss"},{"label":"SPARQL","identifiers":["sparql","rq"],"codemirror_mode":"sparql","codemirror_mime_type":"application/sparql-query"},{"label":"SQL","identifiers":["sql","cql","ddl","inc","mysql","prc","tab","udf","viw"],"codemirror_mode":"sql","codemirror_mime_type":"text/x-sql"},{"label":"SQLPL","identifiers":["sqlpl","sql"],"codemirror_mode":"sql","codemirror_mime_type":"text/x-sql"},{"label":"SRecode Template","identifiers":["srt"],"codemirror_mode":"commonlisp","codemirror_mime_type":"text/x-common-lisp"},{"label":"SVG","identifiers":["svg"],"codemirror_mode":"xml","codemirror_mime_type":"text/xml"},{"label":"Sage","identifiers":["sage","sagews"],"codemirror_mode":"python","codemirror_mime_type":"text/x-python"},{"label":"SaltStack","identifiers":["saltstack","saltstate","salt","sls"],"codemirror_mode":"yaml","codemirror_mime_type":"text/x-yaml"},{"label":"Sass","identifiers":["sass"],"codemirror_mode":"sass","codemirror_mime_type":"text/x-sass"},{"label":"Scala","identifiers":["scala","kojo","sbt","sc"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-scala"},{"label":"Scheme","identifiers":["scheme","scm","sch","sld","sls","sps","ss"],"codemirror_mode":"scheme","codemirror_mime_type":"text/x-scheme"},{"label":"Shell","identifiers":["shell","sh","bash","zsh","bats","cgi","command","fcgi","ksh","tmux","tool"],"codemirror_mode":"shell","codemirror_mime_type":"text/x-sh"},{"label":"ShellSession","identifiers":["shellsession","console"],"codemirror_mode":"shell","codemirror_mime_type":"text/x-sh"},{"label":"Slim","identifiers":["slim"],"codemirror_mode":"slim","codemirror_mime_type":"text/x-slim"},{"label":"Smalltalk","identifiers":["smalltalk","squeak","st","cs"],"codemirror_mode":"smalltalk","codemirror_mime_type":"text/x-stsrc"},{"label":"Smarty","identifiers":["smarty","tpl"],"codemirror_mode":"smarty","codemirror_mime_type":"text/x-smarty"},{"label":"Squirrel","identifiers":["squirrel","nut"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-c++src"},{"label":"Standard ML","identifiers":["sml","ML","fun","sig"],"codemirror_mode":"mllike","codemirror_mime_type":"text/x-ocaml"},{"label":"Svelte","identifiers":["svelte"],"codemirror_mode":"htmlmixed","codemirror_mime_type":"text/html"},{"label":"Swift","identifiers":["swift"],"codemirror_mode":"swift","codemirror_mime_type":"text/x-swift"},{"label":"SystemVerilog","identifiers":["systemverilog","sv","svh","vh"],"codemirror_mode":"verilog","codemirror_mime_type":"text/x-systemverilog"},{"label":"TOML","identifiers":["toml"],"codemirror_mode":"toml","codemirror_mime_type":"text/x-toml"},{"label":"TSX","identifiers":["tsx"],"codemirror_mode":"jsx","codemirror_mime_type":"text/jsx"},{"label":"Tcl","identifiers":["tcl","adp","tm"],"codemirror_mode":"tcl","codemirror_mime_type":"text/x-tcl"},{"label":"Tcsh","identifiers":["tcsh","csh"],"codemirror_mode":"shell","codemirror_mime_type":"text/x-sh"},{"label":"TeX","identifiers":["tex","latex","aux","bbx","cbx","cls","dtx","ins","lbx","ltx","mkii","mkiv","mkvi","sty","toc"],"codemirror_mode":"stex","codemirror_mime_type":"text/x-stex"},{"label":"Terra","identifiers":["terra","t"],"codemirror_mode":"lua","codemirror_mime_type":"text/x-lua"},{"label":"Textile","identifiers":["textile"],"codemirror_mode":"textile","codemirror_mime_type":"text/x-textile"},{"label":"Turtle","identifiers":["turtle","ttl"],"codemirror_mode":"turtle","codemirror_mime_type":"text/turtle"},{"label":"Twig","identifiers":["twig"],"codemirror_mode":"twig","codemirror_mime_type":"text/x-twig"},{"label":"TypeScript","identifiers":["typescript","ts"],"codemirror_mode":"javascript","codemirror_mime_type":"application/typescript"},{"label":"Unified Parallel C","identifiers":["upc"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-csrc"},{"label":"Unity3D Asset","identifiers":["anim","asset","mat","meta","prefab","unity"],"codemirror_mode":"yaml","codemirror_mime_type":"text/x-yaml"},{"label":"Uno","identifiers":["uno"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-csharp"},{"label":"UnrealScript","identifiers":["unrealscript","uc"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-java"},{"label":"V","identifiers":["v","vlang"],"codemirror_mode":"go","codemirror_mime_type":"text/x-go"},{"label":"VHDL","identifiers":["vhdl","vhd","vhf","vhi","vho","vhs","vht","vhw"],"codemirror_mode":"vhdl","codemirror_mime_type":"text/x-vhdl"},{"label":"Verilog","identifiers":["verilog","v","veo"],"codemirror_mode":"verilog","codemirror_mime_type":"text/x-verilog"},{"label":"Visual Basic","identifiers":["vbnet","vb","bas","cls","frm","frx","vba","vbhtml","vbs"],"codemirror_mode":"vb","codemirror_mime_type":"text/x-vb"},{"label":"Volt","identifiers":["volt"],"codemirror_mode":"d","codemirror_mime_type":"text/x-d"},{"label":"WebAssembly","identifiers":["webassembly","wast","wasm","wat"],"codemirror_mode":"commonlisp","codemirror_mime_type":"text/x-common-lisp"},{"label":"WebIDL","identifiers":["webidl"],"codemirror_mode":"webidl","codemirror_mime_type":"text/x-webidl"},{"label":"Windows Registry Entries","identifiers":["reg"],"codemirror_mode":"properties","codemirror_mime_type":"text/x-properties"},{"label":"X BitMap","identifiers":["xbm"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-csrc"},{"label":"X PixMap","identifiers":["xpm","pm"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-csrc"},{"label":"XC","identifiers":["xc"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-csrc"},{"label":"XML","identifiers":["xml","rss","xsd","wsdl","adml","admx","ant","axml","builds","ccproj","ccxml","clixml","cproject","cscfg","csdef","csl","csproj","ct","depproj","dita","ditamap","ditaval","dotsettings","filters","fsproj","fxml","glade","gml","gmx","grxml","iml","ivy","jelly","jsproj","kml","launch","mdpolicy","mjml","mm","mod","mxml","natvis","ncl","ndproj","nproj","nuspec","odd","osm","pkgproj","pluginspec","proj","props","pt","rdf","resx","sch","scxml","sfproj","shproj","srdf","storyboard","targets","tml","ts","tsx","ui","urdf","ux","vbproj","vcxproj","vsixmanifest","vssettings","vstemplate","vxml","wixproj","workflow","wsf","wxi","wxl","wxs","xacro","xaml","xib","xlf","xliff","xmi","xproj","xspec","xul","zcml"],"codemirror_mode":"xml","codemirror_mime_type":"text/xml"},{"label":"XML Property List","identifiers":["plist","stTheme","tmCommand","tmLanguage","tmPreferences","tmSnippet","tmTheme"],"codemirror_mode":"xml","codemirror_mime_type":"text/xml"},{"label":"XPages","identifiers":["xpages"],"codemirror_mode":"xml","codemirror_mime_type":"text/xml"},{"label":"XProc","identifiers":["xproc","xpl"],"codemirror_mode":"xml","codemirror_mime_type":"text/xml"},{"label":"XQuery","identifiers":["xquery","xq","xql","xqm","xqy"],"codemirror_mode":"xquery","codemirror_mime_type":"application/xquery"},{"label":"XS","identifiers":["xs"],"codemirror_mode":"clike","codemirror_mime_type":"text/x-csrc"},{"label":"XSLT","identifiers":["xslt","xsl"],"codemirror_mode":"xml","codemirror_mime_type":"text/xml"},{"label":"YAML","identifiers":["yaml","yml","mir","reek","rviz","syntax"],"codemirror_mode":"yaml","codemirror_mime_type":"text/x-yaml"},{"label":"edn","identifiers":["edn"],"codemirror_mode":"clojure","codemirror_mime_type":"text/x-clojure"},{"label":"reStructuredText","identifiers":["restructuredtext","rst","rest"],"codemirror_mode":"rst","codemirror_mime_type":"text/x-rst"},{"label":"wisp","identifiers":["wisp"],"codemirror_mode":"clojure","codemirror_mime_type":"text/x-clojure"}] diff --git a/src/widgets/code/data/languages.ts b/src/widgets/code/data/languages.ts new file mode 100644 index 00000000..9f0c2938 --- /dev/null +++ b/src/widgets/code/data/languages.ts @@ -0,0 +1,1604 @@ +import type { ProcessedCodeLanguage } from '../../../interface'; + +const languages: ProcessedCodeLanguage[] = [ + { + label: 'AGS Script', + identifiers: ['ags', 'asc', 'ash'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-c++src', + }, + { + label: 'APL', + identifiers: ['apl', 'dyalog'], + codemirror_mode: 'apl', + codemirror_mime_type: 'text/apl', + }, + { + label: 'ASN.1', + identifiers: ['asn'], + codemirror_mode: 'asn.1', + codemirror_mime_type: 'text/x-ttcn-asn', + }, + { + label: 'ASP', + identifiers: ['asp', 'aspx', 'asax', 'ascx', 'ashx', 'asmx', 'axd'], + codemirror_mode: 'htmlembedded', + codemirror_mime_type: 'application/x-aspx', + }, + { + label: 'Alpine Abuild', + identifiers: ['abuild', 'apkbuild'], + codemirror_mode: 'shell', + codemirror_mime_type: 'text/x-sh', + }, + { + label: 'AngelScript', + identifiers: ['angelscript', 'as'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-c++src', + }, + { + label: 'Ant Build System', + identifiers: [], + codemirror_mode: 'xml', + codemirror_mime_type: 'application/xml', + }, + { + label: 'Apex', + identifiers: ['apex', 'cls'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-java', + }, + { + label: 'Asymptote', + identifiers: ['asymptote', 'asy'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-kotlin', + }, + { + label: 'BibTeX', + identifiers: ['bibtex', 'bib'], + codemirror_mode: 'stex', + codemirror_mime_type: 'text/x-stex', + }, + { + label: 'Brainfuck', + identifiers: ['brainfuck', 'b', 'bf'], + codemirror_mode: 'brainfuck', + codemirror_mime_type: 'text/x-brainfuck', + }, + { + label: 'C', + identifiers: ['c', 'cats', 'h', 'idc'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-csrc', + }, + { + label: 'C#', + identifiers: ['csharp', 'cs', 'cake', 'csx'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-csharp', + }, + { + label: 'C++', + identifiers: [ + 'cpp', + 'cc', + 'cp', + 'cxx', + 'h', + 'hh', + 'hpp', + 'hxx', + 'inc', + 'inl', + 'ino', + 'ipp', + 're', + 'tcc', + 'tpp', + ], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-c++src', + }, + { + label: 'C2hs Haskell', + identifiers: ['chs'], + codemirror_mode: 'haskell', + codemirror_mime_type: 'text/x-haskell', + }, + { + label: 'CMake', + identifiers: ['cmake'], + codemirror_mode: 'cmake', + codemirror_mime_type: 'text/x-cmake', + }, + { + label: 'COBOL', + identifiers: ['cobol', 'cob', 'cbl', 'ccp', 'cpy'], + codemirror_mode: 'cobol', + codemirror_mime_type: 'text/x-cobol', + }, + { + label: 'COLLADA', + identifiers: ['collada', 'dae'], + codemirror_mode: 'xml', + codemirror_mime_type: 'text/xml', + }, + { + label: 'CSON', + identifiers: ['cson'], + codemirror_mode: 'coffeescript', + codemirror_mime_type: 'text/x-coffeescript', + }, + { + label: 'CSS', + identifiers: ['css'], + codemirror_mode: 'css', + codemirror_mime_type: 'text/css', + }, + { + label: 'Cabal Config', + identifiers: ['Cabal', 'cabal'], + codemirror_mode: 'haskell', + codemirror_mime_type: 'text/x-haskell', + }, + { + label: 'ChucK', + identifiers: ['chuck', 'ck'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-java', + }, + { + label: 'Clojure', + identifiers: ['clojure', 'clj', 'boot', 'cljc', 'cljs', 'cljscm', 'cljx', 'hic'], + codemirror_mode: 'clojure', + codemirror_mime_type: 'text/x-clojure', + }, + { + label: 'Closure Templates', + identifiers: ['soy'], + codemirror_mode: 'soy', + codemirror_mime_type: 'text/x-soy', + }, + { + label: 'Cloud Firestore Security Rules', + identifiers: [], + codemirror_mode: 'css', + codemirror_mime_type: 'text/css', + }, + { + label: 'CoffeeScript', + identifiers: ['coffeescript', 'coffee', 'cake', 'cjsx', 'iced'], + codemirror_mode: 'coffeescript', + codemirror_mime_type: 'text/x-coffeescript', + }, + { + label: 'Common Lisp', + identifiers: ['lisp', 'asd', 'cl', 'l', 'lsp', 'ny', 'podsl', 'sexp'], + codemirror_mode: 'commonlisp', + codemirror_mime_type: 'text/x-common-lisp', + }, + { + label: 'Common Workflow Language', + identifiers: ['cwl'], + codemirror_mode: 'yaml', + codemirror_mime_type: 'text/x-yaml', + }, + { + label: 'Component Pascal', + identifiers: ['delphi', 'objectpascal', 'cp', 'cps'], + codemirror_mode: 'pascal', + codemirror_mime_type: 'text/x-pascal', + }, + { + label: 'Crystal', + identifiers: ['crystal', 'cr'], + codemirror_mode: 'crystal', + codemirror_mime_type: 'text/x-crystal', + }, + { + label: 'Cuda', + identifiers: ['cuda', 'cu', 'cuh'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-c++src', + }, + { + label: 'Cycript', + identifiers: ['cycript', 'cy'], + codemirror_mode: 'javascript', + codemirror_mime_type: 'text/javascript', + }, + { + label: 'Cython', + identifiers: ['cython', 'pyrex', 'pyx', 'pxd', 'pxi'], + codemirror_mode: 'python', + codemirror_mime_type: 'text/x-cython', + }, + { + label: 'D', + identifiers: ['d', 'di'], + codemirror_mode: 'd', + codemirror_mime_type: 'text/x-d', + }, + { + label: 'DTrace', + identifiers: ['dtrace', 'd'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-csrc', + }, + { + label: 'Dart', + identifiers: ['dart'], + codemirror_mode: 'dart', + codemirror_mime_type: 'application/dart', + }, + { + label: 'Dhall', + identifiers: ['dhall'], + codemirror_mode: 'haskell', + codemirror_mime_type: 'text/x-haskell', + }, + { + label: 'Diff', + identifiers: ['diff', 'udiff', 'patch'], + codemirror_mode: 'diff', + codemirror_mime_type: 'text/x-diff', + }, + { + label: 'Dockerfile', + identifiers: ['dockerfile'], + codemirror_mode: 'dockerfile', + codemirror_mime_type: 'text/x-dockerfile', + }, + { + label: 'Dylan', + identifiers: ['dylan', 'dyl', 'intr', 'lid'], + codemirror_mode: 'dylan', + codemirror_mime_type: 'text/x-dylan', + }, + { + label: 'EBNF', + identifiers: ['ebnf'], + codemirror_mode: 'ebnf', + codemirror_mime_type: 'text/x-ebnf', + }, + { + label: 'ECL', + identifiers: ['ecl', 'eclxml'], + codemirror_mode: 'ecl', + codemirror_mime_type: 'text/x-ecl', + }, + { + label: 'EQ', + identifiers: ['eq'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-csharp', + }, + { + label: 'Eagle', + identifiers: ['eagle', 'sch', 'brd'], + codemirror_mode: 'xml', + codemirror_mime_type: 'text/xml', + }, + { + label: 'Easybuild', + identifiers: ['easybuild', 'eb'], + codemirror_mode: 'python', + codemirror_mime_type: 'text/x-python', + }, + { + label: 'Ecere Projects', + identifiers: ['epj'], + codemirror_mode: 'javascript', + codemirror_mime_type: 'application/json', + }, + { + label: 'EditorConfig', + identifiers: ['editorconfig'], + codemirror_mode: 'properties', + codemirror_mime_type: 'text/x-properties', + }, + { + label: 'Edje Data Collection', + identifiers: ['edc'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-c++src', + }, + { + label: 'Eiffel', + identifiers: ['eiffel', 'e'], + codemirror_mode: 'eiffel', + codemirror_mime_type: 'text/x-eiffel', + }, + { + label: 'Elm', + identifiers: ['elm'], + codemirror_mode: 'elm', + codemirror_mime_type: 'text/x-elm', + }, + { + label: 'Emacs Lisp', + identifiers: ['elisp', 'emacs', 'el'], + codemirror_mode: 'commonlisp', + codemirror_mime_type: 'text/x-common-lisp', + }, + { + label: 'EmberScript', + identifiers: ['emberscript', 'em'], + codemirror_mode: 'coffeescript', + codemirror_mime_type: 'text/x-coffeescript', + }, + { + label: 'Erlang', + identifiers: ['erlang', 'erl', 'es', 'escript', 'hrl', 'xrl', 'yrl'], + codemirror_mode: 'erlang', + codemirror_mime_type: 'text/x-erlang', + }, + { + label: 'F#', + identifiers: ['fsharp', 'fs', 'fsi', 'fsx'], + codemirror_mode: 'mllike', + codemirror_mime_type: 'text/x-fsharp', + }, + { + label: 'Factor', + identifiers: ['factor'], + codemirror_mode: 'factor', + codemirror_mime_type: 'text/x-factor', + }, + { + label: 'Forth', + identifiers: ['forth', 'fth', 'f', 'for', 'fr', 'frt', 'fs'], + codemirror_mode: 'forth', + codemirror_mime_type: 'text/x-forth', + }, + { + label: 'Fortran', + identifiers: ['fortran', 'f', 'for', 'fpp'], + codemirror_mode: 'fortran', + codemirror_mime_type: 'text/x-fortran', + }, + { + label: 'GCC Machine Description', + identifiers: ['md'], + codemirror_mode: 'commonlisp', + codemirror_mime_type: 'text/x-common-lisp', + }, + { + label: 'GN', + identifiers: ['gn', 'gni'], + codemirror_mode: 'python', + codemirror_mime_type: 'text/x-python', + }, + { + label: 'Game Maker Language', + identifiers: ['gml'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-c++src', + }, + { + label: 'Genshi', + identifiers: ['genshi', 'kid'], + codemirror_mode: 'xml', + codemirror_mime_type: 'text/xml', + }, + { + label: 'Gentoo Ebuild', + identifiers: ['ebuild'], + codemirror_mode: 'shell', + codemirror_mime_type: 'text/x-sh', + }, + { + label: 'Gentoo Eclass', + identifiers: ['eclass'], + codemirror_mode: 'shell', + codemirror_mime_type: 'text/x-sh', + }, + { + label: 'Git Attributes', + identifiers: ['gitattributes'], + codemirror_mode: 'shell', + codemirror_mime_type: 'text/x-sh', + }, + { + label: 'Git Config', + identifiers: ['gitconfig', 'gitmodules'], + codemirror_mode: 'properties', + codemirror_mime_type: 'text/x-properties', + }, + { + label: 'Glyph', + identifiers: ['glyph', 'glf'], + codemirror_mode: 'tcl', + codemirror_mime_type: 'text/x-tcl', + }, + { + label: 'Go', + identifiers: ['go', 'golang'], + codemirror_mode: 'go', + codemirror_mime_type: 'text/x-go', + }, + { + label: 'Grammatical Framework', + identifiers: ['gf'], + codemirror_mode: 'haskell', + codemirror_mime_type: 'text/x-haskell', + }, + { + label: 'Groovy', + identifiers: ['groovy', 'grt', 'gtpl', 'gvy'], + codemirror_mode: 'groovy', + codemirror_mime_type: 'text/x-groovy', + }, + { + label: 'Groovy Server Pages', + identifiers: ['gsp'], + codemirror_mode: 'htmlembedded', + codemirror_mime_type: 'application/x-jsp', + }, + { + label: 'HCL', + identifiers: ['hcl', 'terraform', 'tf', 'tfvars', 'workflow'], + codemirror_mode: 'ruby', + codemirror_mime_type: 'text/x-ruby', + }, + { + label: 'HTML', + identifiers: ['html', 'xhtml', 'htm', 'inc', 'st', 'xht'], + codemirror_mode: 'htmlmixed', + codemirror_mime_type: 'text/html', + }, + { + label: 'HTML+Django', + identifiers: ['django', 'htmldjango', 'njk', 'nunjucks', 'jinja', 'mustache'], + codemirror_mode: 'django', + codemirror_mime_type: 'text/x-django', + }, + { + label: 'HTML+ECR', + identifiers: ['ecr'], + codemirror_mode: 'htmlmixed', + codemirror_mime_type: 'text/html', + }, + { + label: 'HTML+EEX', + identifiers: ['eex'], + codemirror_mode: 'htmlmixed', + codemirror_mime_type: 'text/html', + }, + { + label: 'HTML+ERB', + identifiers: ['erb'], + codemirror_mode: 'htmlembedded', + codemirror_mime_type: 'application/x-erb', + }, + { + label: 'HTML+PHP', + identifiers: ['phtml'], + codemirror_mode: 'php', + codemirror_mime_type: 'application/x-httpd-php', + }, + { + label: 'HTML+Razor', + identifiers: ['razor', 'cshtml'], + codemirror_mode: 'htmlmixed', + codemirror_mime_type: 'text/html', + }, + { + label: 'HTTP', + identifiers: ['http'], + codemirror_mode: 'http', + codemirror_mime_type: 'message/http', + }, + { + label: 'Hack', + identifiers: ['hack', 'hh', 'php'], + codemirror_mode: 'php', + codemirror_mime_type: 'application/x-httpd-php', + }, + { + label: 'Haml', + identifiers: ['haml'], + codemirror_mode: 'haml', + codemirror_mime_type: 'text/x-haml', + }, + { + label: 'Haskell', + identifiers: ['haskell', 'hs', 'hsc'], + codemirror_mode: 'haskell', + codemirror_mime_type: 'text/x-haskell', + }, + { + label: 'Haxe', + identifiers: ['haxe', 'hx', 'hxsl'], + codemirror_mode: 'haxe', + codemirror_mime_type: 'text/x-haxe', + }, + { + label: 'HolyC', + identifiers: ['holyc', 'hc'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-csrc', + }, + { + label: 'IDL', + identifiers: ['idl', 'pro', 'dlm'], + codemirror_mode: 'idl', + codemirror_mime_type: 'text/x-idl', + }, + { + label: 'INI', + identifiers: ['ini', 'dosini', 'cfg', 'lektorproject', 'prefs', 'pro', 'properties'], + codemirror_mode: 'properties', + codemirror_mime_type: 'text/x-properties', + }, + { + label: 'IRC log', + identifiers: ['irc', 'irclog', 'weechatlog'], + codemirror_mode: 'mirc', + codemirror_mime_type: 'text/mirc', + }, + { + label: 'Ignore List', + identifiers: ['ignore', 'gitignore'], + codemirror_mode: 'shell', + codemirror_mime_type: 'text/x-sh', + }, + { + label: 'JSON', + identifiers: [ + 'json', + 'avsc', + 'geojson', + 'gltf', + 'har', + 'ice', + 'jsonl', + 'mcmeta', + 'tfstate', + 'topojson', + 'webapp', + 'webmanifest', + 'yy', + 'yyp', + ], + codemirror_mode: 'javascript', + codemirror_mime_type: 'application/json', + }, + { + label: 'JSON with Comments', + identifiers: ['jsonc'], + codemirror_mode: 'javascript', + codemirror_mime_type: 'text/javascript', + }, + { + label: 'JSON5', + identifiers: [], + codemirror_mode: 'javascript', + codemirror_mime_type: 'application/json', + }, + { + label: 'JSONLD', + identifiers: ['jsonld'], + codemirror_mode: 'javascript', + codemirror_mime_type: 'application/json', + }, + { + label: 'JSONiq', + identifiers: ['jsoniq', 'jq'], + codemirror_mode: 'javascript', + codemirror_mime_type: 'application/json', + }, + { + label: 'JSX', + identifiers: ['jsx'], + codemirror_mode: 'jsx', + codemirror_mime_type: 'text/jsx', + }, + { + label: 'Java', + identifiers: ['java'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-java', + }, + { + label: 'Java Properties', + identifiers: ['properties'], + codemirror_mode: 'properties', + codemirror_mime_type: 'text/x-properties', + }, + { + label: 'Java Server Pages', + identifiers: ['jsp'], + codemirror_mode: 'htmlembedded', + codemirror_mime_type: 'application/x-jsp', + }, + { + label: 'JavaScript', + identifiers: [ + 'javascript', + 'js', + 'node', + 'bones', + 'es', + 'frag', + 'gs', + 'jake', + 'jsb', + 'jscad', + 'jsfl', + 'jsm', + 'jss', + 'mjs', + 'njs', + 'pac', + 'sjs', + 'ssjs', + 'xsjs', + 'xsjslib', + ], + codemirror_mode: 'javascript', + codemirror_mime_type: 'text/javascript', + }, + { + label: 'JavaScript+ERB', + identifiers: [], + codemirror_mode: 'javascript', + codemirror_mime_type: 'application/javascript', + }, + { + label: 'Julia', + identifiers: ['julia', 'jl'], + codemirror_mode: 'julia', + codemirror_mime_type: 'text/x-julia', + }, + { + label: 'Jupyter Notebook', + identifiers: ['ipynb'], + codemirror_mode: 'javascript', + codemirror_mime_type: 'application/json', + }, + { + label: 'KiCad Layout', + identifiers: ['pcbnew'], + codemirror_mode: 'commonlisp', + codemirror_mime_type: 'text/x-common-lisp', + }, + { + label: 'Kit', + identifiers: ['kit'], + codemirror_mode: 'htmlmixed', + codemirror_mime_type: 'text/html', + }, + { + label: 'Kotlin', + identifiers: ['kotlin', 'kt', 'ktm', 'kts'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-kotlin', + }, + { + label: 'LFE', + identifiers: ['lfe'], + codemirror_mode: 'commonlisp', + codemirror_mime_type: 'text/x-common-lisp', + }, + { + label: 'LTspice Symbol', + identifiers: ['asy'], + codemirror_mode: 'spreadsheet', + codemirror_mime_type: 'text/x-spreadsheet', + }, + { + label: 'LabVIEW', + identifiers: ['labview', 'lvproj'], + codemirror_mode: 'xml', + codemirror_mime_type: 'text/xml', + }, + { + label: 'Latte', + identifiers: ['latte'], + codemirror_mode: 'smarty', + codemirror_mime_type: 'text/x-smarty', + }, + { + label: 'Less', + identifiers: ['less'], + codemirror_mode: 'css', + codemirror_mime_type: 'text/css', + }, + { + label: 'Literate Haskell', + identifiers: ['lhaskell', 'lhs'], + codemirror_mode: 'haskell-literate', + codemirror_mime_type: 'text/x-literate-haskell', + }, + { + label: 'LiveScript', + identifiers: ['livescript', 'ls'], + codemirror_mode: 'livescript', + codemirror_mime_type: 'text/x-livescript', + }, + { + label: 'LookML', + identifiers: ['lookml'], + codemirror_mode: 'yaml', + codemirror_mime_type: 'text/x-yaml', + }, + { + label: 'Lua', + identifiers: ['lua', 'fcgi', 'nse', 'rbxs', 'wlua'], + codemirror_mode: 'lua', + codemirror_mime_type: 'text/x-lua', + }, + { + label: 'M', + identifiers: ['m', 'mumps'], + codemirror_mode: 'mumps', + codemirror_mime_type: 'text/x-mumps', + }, + { + label: 'MATLAB', + identifiers: ['matlab', 'octave', 'm'], + codemirror_mode: 'octave', + codemirror_mime_type: 'text/x-octave', + }, + { + label: 'MTML', + identifiers: ['mtml'], + codemirror_mode: 'htmlmixed', + codemirror_mime_type: 'text/html', + }, + { + label: 'MUF', + identifiers: ['muf', 'm'], + codemirror_mode: 'forth', + codemirror_mime_type: 'text/x-forth', + }, + { + label: 'Makefile', + identifiers: ['makefile', 'bsdmake', 'make', 'mf', 'mak', 'd', 'mk', 'mkfile'], + codemirror_mode: 'cmake', + codemirror_mime_type: 'text/x-cmake', + }, + { + label: 'Markdown', + identifiers: [ + 'markdown', + 'pandoc', + 'md', + 'mdown', + 'mdwn', + 'mdx', + 'mkd', + 'mkdn', + 'mkdown', + 'ronn', + 'workbook', + ], + codemirror_mode: 'gfm', + codemirror_mime_type: 'text/x-gfm', + }, + { + label: 'Marko', + identifiers: ['marko', 'markojs'], + codemirror_mode: 'htmlmixed', + codemirror_mime_type: 'text/html', + }, + { + label: 'Mathematica', + identifiers: ['mathematica', 'mma', 'cdf', 'm', 'ma', 'mt', 'nb', 'nbp', 'wl', 'wlt'], + codemirror_mode: 'mathematica', + codemirror_mime_type: 'text/x-mathematica', + }, + { + label: 'Maven POM', + identifiers: [], + codemirror_mode: 'xml', + codemirror_mime_type: 'text/xml', + }, + { + label: 'Max', + identifiers: ['max', 'maxmsp', 'maxpat', 'maxhelp', 'maxproj', 'mxt', 'pat'], + codemirror_mode: 'javascript', + codemirror_mime_type: 'application/json', + }, + { + label: 'Metal', + identifiers: ['metal'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-c++src', + }, + { + label: 'Mirah', + identifiers: ['mirah', 'druby', 'duby'], + codemirror_mode: 'ruby', + codemirror_mime_type: 'text/x-ruby', + }, + { + label: 'Modelica', + identifiers: ['modelica', 'mo'], + codemirror_mode: 'modelica', + codemirror_mime_type: 'text/x-modelica', + }, + { + label: 'NSIS', + identifiers: ['nsis', 'nsi', 'nsh'], + codemirror_mode: 'nsis', + codemirror_mime_type: 'text/x-nsis', + }, + { + label: 'NetLogo', + identifiers: ['netlogo', 'nlogo'], + codemirror_mode: 'commonlisp', + codemirror_mime_type: 'text/x-common-lisp', + }, + { + label: 'NewLisp', + identifiers: ['newlisp', 'nl', 'lisp', 'lsp'], + codemirror_mode: 'commonlisp', + codemirror_mime_type: 'text/x-common-lisp', + }, + { + label: 'Nginx', + identifiers: ['nginx', 'nginxconf', 'vhost'], + codemirror_mode: 'nginx', + codemirror_mime_type: 'text/x-nginx-conf', + }, + { + label: 'Nu', + identifiers: ['nu', 'nush'], + codemirror_mode: 'scheme', + codemirror_mime_type: 'text/x-scheme', + }, + { + label: 'NumPy', + identifiers: ['numpy', 'numpyw', 'numsc'], + codemirror_mode: 'python', + codemirror_mime_type: 'text/x-python', + }, + { + label: 'OCaml', + identifiers: ['ocaml', 'ml', 'eliom', 'eliomi', 'mli', 'mll', 'mly'], + codemirror_mode: 'mllike', + codemirror_mime_type: 'text/x-ocaml', + }, + { + label: 'Objective-C', + identifiers: ['objc', 'objectivec', 'm', 'h'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-objectivec', + }, + { + label: 'Objective-C++', + identifiers: ['mm'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-objectivec', + }, + { + label: 'OpenCL', + identifiers: ['opencl', 'cl'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-csrc', + }, + { + label: 'OpenRC runscript', + identifiers: ['openrc'], + codemirror_mode: 'shell', + codemirror_mime_type: 'text/x-sh', + }, + { + label: 'Oz', + identifiers: ['oz'], + codemirror_mode: 'oz', + codemirror_mime_type: 'text/x-oz', + }, + { + label: 'PHP', + identifiers: ['php', 'inc', 'aw', 'ctp', 'fcgi', 'phps', 'phpt'], + codemirror_mode: 'php', + codemirror_mime_type: 'application/x-httpd-php', + }, + { + label: 'PLSQL', + identifiers: [ + 'plsql', + 'pls', + 'bdy', + 'ddl', + 'fnc', + 'pck', + 'pkb', + 'pks', + 'plb', + 'prc', + 'spc', + 'sql', + 'tpb', + 'tps', + 'trg', + 'vw', + ], + codemirror_mode: 'sql', + codemirror_mime_type: 'text/x-plsql', + }, + { + label: 'PLpgSQL', + identifiers: ['plpgsql', 'pgsql', 'sql'], + codemirror_mode: 'sql', + codemirror_mime_type: 'text/x-sql', + }, + { + label: 'Pascal', + identifiers: ['pascal', 'pas', 'dfm', 'dpr', 'inc', 'lpr', 'pp'], + codemirror_mode: 'pascal', + codemirror_mime_type: 'text/x-pascal', + }, + { + label: 'Perl', + identifiers: ['perl', 'cperl', 'pl', 'al', 'cgi', 'fcgi', 'ph', 'plx', 'pm', 'psgi', 't'], + codemirror_mode: 'perl', + codemirror_mime_type: 'text/x-perl', + }, + { + label: 'Perl 6', + identifiers: ['nqp', 'pl', 'pm', 't'], + codemirror_mode: 'perl', + codemirror_mime_type: 'text/x-perl', + }, + { + label: 'Pic', + identifiers: ['pic', 'chem'], + codemirror_mode: 'troff', + codemirror_mime_type: 'text/troff', + }, + { + label: 'Pod', + identifiers: ['pod'], + codemirror_mode: 'perl', + codemirror_mime_type: 'text/x-perl', + }, + { + label: 'PowerShell', + identifiers: ['powershell', 'posh', 'pwsh'], + codemirror_mode: 'powershell', + codemirror_mime_type: 'application/x-powershell', + }, + { + label: 'Protocol Buffer', + identifiers: ['protobuf', 'proto'], + codemirror_mode: 'protobuf', + codemirror_mime_type: 'text/x-protobuf', + }, + { + label: 'Public Key', + identifiers: ['asc', 'pub'], + codemirror_mode: 'asciiarmor', + codemirror_mime_type: 'application/pgp', + }, + { + label: 'Pug', + identifiers: ['pug', 'jade'], + codemirror_mode: 'pug', + codemirror_mime_type: 'text/x-pug', + }, + { + label: 'Puppet', + identifiers: ['puppet', 'pp'], + codemirror_mode: 'puppet', + codemirror_mime_type: 'text/x-puppet', + }, + { + label: 'PureScript', + identifiers: ['purescript', 'purs'], + codemirror_mode: 'haskell', + codemirror_mime_type: 'text/x-haskell', + }, + { + label: 'Python', + identifiers: [ + 'python', + 'rusthon', + 'py', + 'bzl', + 'cgi', + 'fcgi', + 'gyp', + 'gypi', + 'lmi', + 'pyde', + 'pyi', + 'pyp', + 'pyt', + 'pyw', + 'rpy', + 'spec', + 'tac', + 'wsgi', + 'xpy', + ], + codemirror_mode: 'python', + codemirror_mime_type: 'text/x-python', + }, + { + label: 'R', + identifiers: ['r', 'R', 'Rscript', 'splus', 'rd', 'rsx'], + codemirror_mode: 'r', + codemirror_mime_type: 'text/x-rsrc', + }, + { + label: 'RAML', + identifiers: ['raml'], + codemirror_mode: 'yaml', + codemirror_mime_type: 'text/x-yaml', + }, + { + label: 'RHTML', + identifiers: ['rhtml'], + codemirror_mode: 'htmlembedded', + codemirror_mime_type: 'application/x-erb', + }, + { + label: 'RMarkdown', + identifiers: ['rmarkdown', 'rmd'], + codemirror_mode: 'gfm', + codemirror_mime_type: 'text/x-gfm', + }, + { + label: 'RPM Spec', + identifiers: ['specfile', 'spec'], + codemirror_mode: 'rpm', + codemirror_mime_type: 'text/x-rpm-spec', + }, + { + label: 'Reason', + identifiers: ['reason', 're', 'rei'], + codemirror_mode: 'rust', + codemirror_mime_type: 'text/x-rustsrc', + }, + { + label: 'Roff', + identifiers: [ + 'roff', + 'groff', + 'man', + 'manpage', + 'mdoc', + 'nroff', + 'troff', + 'l', + 'me', + 'ms', + 'n', + 'nr', + 'rno', + 'tmac', + ], + codemirror_mode: 'troff', + codemirror_mime_type: 'text/troff', + }, + { + label: 'Roff Manpage', + identifiers: ['man', 'mdoc'], + codemirror_mode: 'troff', + codemirror_mime_type: 'text/troff', + }, + { + label: 'Rouge', + identifiers: ['rouge', 'rg'], + codemirror_mode: 'clojure', + codemirror_mime_type: 'text/x-clojure', + }, + { + label: 'Ruby', + identifiers: [ + 'ruby', + 'jruby', + 'macruby', + 'rake', + 'rb', + 'rbx', + 'builder', + 'eye', + 'fcgi', + 'gemspec', + 'god', + 'jbuilder', + 'mspec', + 'pluginspec', + 'podspec', + 'rabl', + 'rbuild', + 'rbw', + 'ru', + 'spec', + 'thor', + 'watchr', + ], + codemirror_mode: 'ruby', + codemirror_mime_type: 'text/x-ruby', + }, + { + label: 'Rust', + identifiers: ['rust', 'rs'], + codemirror_mode: 'rust', + codemirror_mime_type: 'text/x-rustsrc', + }, + { + label: 'SAS', + identifiers: ['sas'], + codemirror_mode: 'sas', + codemirror_mime_type: 'text/x-sas', + }, + { + label: 'SCSS', + identifiers: ['scss'], + codemirror_mode: 'css', + codemirror_mime_type: 'text/x-scss', + }, + { + label: 'SPARQL', + identifiers: ['sparql', 'rq'], + codemirror_mode: 'sparql', + codemirror_mime_type: 'application/sparql-query', + }, + { + label: 'SQL', + identifiers: ['sql', 'cql', 'ddl', 'inc', 'mysql', 'prc', 'tab', 'udf', 'viw'], + codemirror_mode: 'sql', + codemirror_mime_type: 'text/x-sql', + }, + { + label: 'SQLPL', + identifiers: ['sqlpl', 'sql'], + codemirror_mode: 'sql', + codemirror_mime_type: 'text/x-sql', + }, + { + label: 'SRecode Template', + identifiers: ['srt'], + codemirror_mode: 'commonlisp', + codemirror_mime_type: 'text/x-common-lisp', + }, + { + label: 'SVG', + identifiers: ['svg'], + codemirror_mode: 'xml', + codemirror_mime_type: 'text/xml', + }, + { + label: 'Sage', + identifiers: ['sage', 'sagews'], + codemirror_mode: 'python', + codemirror_mime_type: 'text/x-python', + }, + { + label: 'SaltStack', + identifiers: ['saltstack', 'saltstate', 'salt', 'sls'], + codemirror_mode: 'yaml', + codemirror_mime_type: 'text/x-yaml', + }, + { + label: 'Sass', + identifiers: ['sass'], + codemirror_mode: 'sass', + codemirror_mime_type: 'text/x-sass', + }, + { + label: 'Scala', + identifiers: ['scala', 'kojo', 'sbt', 'sc'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-scala', + }, + { + label: 'Scheme', + identifiers: ['scheme', 'scm', 'sch', 'sld', 'sls', 'sps', 'ss'], + codemirror_mode: 'scheme', + codemirror_mime_type: 'text/x-scheme', + }, + { + label: 'Shell', + identifiers: [ + 'shell', + 'sh', + 'bash', + 'zsh', + 'bats', + 'cgi', + 'command', + 'fcgi', + 'ksh', + 'tmux', + 'tool', + ], + codemirror_mode: 'shell', + codemirror_mime_type: 'text/x-sh', + }, + { + label: 'ShellSession', + identifiers: ['shellsession', 'console'], + codemirror_mode: 'shell', + codemirror_mime_type: 'text/x-sh', + }, + { + label: 'Slim', + identifiers: ['slim'], + codemirror_mode: 'slim', + codemirror_mime_type: 'text/x-slim', + }, + { + label: 'Smalltalk', + identifiers: ['smalltalk', 'squeak', 'st', 'cs'], + codemirror_mode: 'smalltalk', + codemirror_mime_type: 'text/x-stsrc', + }, + { + label: 'Smarty', + identifiers: ['smarty', 'tpl'], + codemirror_mode: 'smarty', + codemirror_mime_type: 'text/x-smarty', + }, + { + label: 'Squirrel', + identifiers: ['squirrel', 'nut'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-c++src', + }, + { + label: 'Standard ML', + identifiers: ['sml', 'ML', 'fun', 'sig'], + codemirror_mode: 'mllike', + codemirror_mime_type: 'text/x-ocaml', + }, + { + label: 'Svelte', + identifiers: ['svelte'], + codemirror_mode: 'htmlmixed', + codemirror_mime_type: 'text/html', + }, + { + label: 'Swift', + identifiers: ['swift'], + codemirror_mode: 'swift', + codemirror_mime_type: 'text/x-swift', + }, + { + label: 'SystemVerilog', + identifiers: ['systemverilog', 'sv', 'svh', 'vh'], + codemirror_mode: 'verilog', + codemirror_mime_type: 'text/x-systemverilog', + }, + { + label: 'TOML', + identifiers: ['toml'], + codemirror_mode: 'toml', + codemirror_mime_type: 'text/x-toml', + }, + { + label: 'TSX', + identifiers: ['tsx'], + codemirror_mode: 'jsx', + codemirror_mime_type: 'text/jsx', + }, + { + label: 'Tcl', + identifiers: ['tcl', 'adp', 'tm'], + codemirror_mode: 'tcl', + codemirror_mime_type: 'text/x-tcl', + }, + { + label: 'Tcsh', + identifiers: ['tcsh', 'csh'], + codemirror_mode: 'shell', + codemirror_mime_type: 'text/x-sh', + }, + { + label: 'TeX', + identifiers: [ + 'tex', + 'latex', + 'aux', + 'bbx', + 'cbx', + 'cls', + 'dtx', + 'ins', + 'lbx', + 'ltx', + 'mkii', + 'mkiv', + 'mkvi', + 'sty', + 'toc', + ], + codemirror_mode: 'stex', + codemirror_mime_type: 'text/x-stex', + }, + { + label: 'Terra', + identifiers: ['terra', 't'], + codemirror_mode: 'lua', + codemirror_mime_type: 'text/x-lua', + }, + { + label: 'Textile', + identifiers: ['textile'], + codemirror_mode: 'textile', + codemirror_mime_type: 'text/x-textile', + }, + { + label: 'Turtle', + identifiers: ['turtle', 'ttl'], + codemirror_mode: 'turtle', + codemirror_mime_type: 'text/turtle', + }, + { + label: 'Twig', + identifiers: ['twig'], + codemirror_mode: 'twig', + codemirror_mime_type: 'text/x-twig', + }, + { + label: 'TypeScript', + identifiers: ['typescript', 'ts'], + codemirror_mode: 'javascript', + codemirror_mime_type: 'application/typescript', + }, + { + label: 'Unified Parallel C', + identifiers: ['upc'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-csrc', + }, + { + label: 'Unity3D Asset', + identifiers: ['anim', 'asset', 'mat', 'meta', 'prefab', 'unity'], + codemirror_mode: 'yaml', + codemirror_mime_type: 'text/x-yaml', + }, + { + label: 'Uno', + identifiers: ['uno'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-csharp', + }, + { + label: 'UnrealScript', + identifiers: ['unrealscript', 'uc'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-java', + }, + { + label: 'V', + identifiers: ['v', 'vlang'], + codemirror_mode: 'go', + codemirror_mime_type: 'text/x-go', + }, + { + label: 'VHDL', + identifiers: ['vhdl', 'vhd', 'vhf', 'vhi', 'vho', 'vhs', 'vht', 'vhw'], + codemirror_mode: 'vhdl', + codemirror_mime_type: 'text/x-vhdl', + }, + { + label: 'Verilog', + identifiers: ['verilog', 'v', 'veo'], + codemirror_mode: 'verilog', + codemirror_mime_type: 'text/x-verilog', + }, + { + label: 'Visual Basic', + identifiers: ['vbnet', 'vb', 'bas', 'cls', 'frm', 'frx', 'vba', 'vbhtml', 'vbs'], + codemirror_mode: 'vb', + codemirror_mime_type: 'text/x-vb', + }, + { + label: 'Volt', + identifiers: ['volt'], + codemirror_mode: 'd', + codemirror_mime_type: 'text/x-d', + }, + { + label: 'WebAssembly', + identifiers: ['webassembly', 'wast', 'wasm', 'wat'], + codemirror_mode: 'commonlisp', + codemirror_mime_type: 'text/x-common-lisp', + }, + { + label: 'WebIDL', + identifiers: ['webidl'], + codemirror_mode: 'webidl', + codemirror_mime_type: 'text/x-webidl', + }, + { + label: 'Windows Registry Entries', + identifiers: ['reg'], + codemirror_mode: 'properties', + codemirror_mime_type: 'text/x-properties', + }, + { + label: 'X BitMap', + identifiers: ['xbm'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-csrc', + }, + { + label: 'X PixMap', + identifiers: ['xpm', 'pm'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-csrc', + }, + { + label: 'XC', + identifiers: ['xc'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-csrc', + }, + { + label: 'XML', + identifiers: [ + 'xml', + 'rss', + 'xsd', + 'wsdl', + 'adml', + 'admx', + 'ant', + 'axml', + 'builds', + 'ccproj', + 'ccxml', + 'clixml', + 'cproject', + 'cscfg', + 'csdef', + 'csl', + 'csproj', + 'ct', + 'depproj', + 'dita', + 'ditamap', + 'ditaval', + 'dotsettings', + 'filters', + 'fsproj', + 'fxml', + 'glade', + 'gml', + 'gmx', + 'grxml', + 'iml', + 'ivy', + 'jelly', + 'jsproj', + 'kml', + 'launch', + 'mdpolicy', + 'mjml', + 'mm', + 'mod', + 'mxml', + 'natvis', + 'ncl', + 'ndproj', + 'nproj', + 'nuspec', + 'odd', + 'osm', + 'pkgproj', + 'pluginspec', + 'proj', + 'props', + 'pt', + 'rdf', + 'resx', + 'sch', + 'scxml', + 'sfproj', + 'shproj', + 'srdf', + 'storyboard', + 'targets', + 'tml', + 'ts', + 'tsx', + 'ui', + 'urdf', + 'ux', + 'vbproj', + 'vcxproj', + 'vsixmanifest', + 'vssettings', + 'vstemplate', + 'vxml', + 'wixproj', + 'workflow', + 'wsf', + 'wxi', + 'wxl', + 'wxs', + 'xacro', + 'xaml', + 'xib', + 'xlf', + 'xliff', + 'xmi', + 'xproj', + 'xspec', + 'xul', + 'zcml', + ], + codemirror_mode: 'xml', + codemirror_mime_type: 'text/xml', + }, + { + label: 'XML Property List', + identifiers: [ + 'plist', + 'stTheme', + 'tmCommand', + 'tmLanguage', + 'tmPreferences', + 'tmSnippet', + 'tmTheme', + ], + codemirror_mode: 'xml', + codemirror_mime_type: 'text/xml', + }, + { + label: 'XPages', + identifiers: ['xpages'], + codemirror_mode: 'xml', + codemirror_mime_type: 'text/xml', + }, + { + label: 'XProc', + identifiers: ['xproc', 'xpl'], + codemirror_mode: 'xml', + codemirror_mime_type: 'text/xml', + }, + { + label: 'XQuery', + identifiers: ['xquery', 'xq', 'xql', 'xqm', 'xqy'], + codemirror_mode: 'xquery', + codemirror_mime_type: 'application/xquery', + }, + { + label: 'XS', + identifiers: ['xs'], + codemirror_mode: 'clike', + codemirror_mime_type: 'text/x-csrc', + }, + { + label: 'XSLT', + identifiers: ['xslt', 'xsl'], + codemirror_mode: 'xml', + codemirror_mime_type: 'text/xml', + }, + { + label: 'YAML', + identifiers: ['yaml', 'yml', 'mir', 'reek', 'rviz', 'syntax'], + codemirror_mode: 'yaml', + codemirror_mime_type: 'text/x-yaml', + }, + { + label: 'edn', + identifiers: ['edn'], + codemirror_mode: 'clojure', + codemirror_mime_type: 'text/x-clojure', + }, + { + label: 'reStructuredText', + identifiers: ['restructuredtext', 'rst', 'rest'], + codemirror_mode: 'rst', + codemirror_mime_type: 'text/x-rst', + }, + { + label: 'wisp', + identifiers: ['wisp'], + codemirror_mode: 'clojure', + codemirror_mime_type: 'text/x-clojure', + }, +]; + +export default languages; diff --git a/src/widgets/code/index.js b/src/widgets/code/index.js deleted file mode 100644 index fe9daf7c..00000000 --- a/src/widgets/code/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import controlComponent from './CodeControl'; -import previewComponent from './CodePreview'; -import schema from './schema'; - -function Widget(opts = {}) { - return { - name: 'code', - controlComponent, - previewComponent, - schema, - allowMapValue: true, - codeMirrorConfig: {}, - ...opts, - }; -} - -export const StaticCmsWidgetCode = { Widget, controlComponent, previewComponent }; -export default StaticCmsWidgetCode; diff --git a/src/widgets/code/index.ts b/src/widgets/code/index.ts new file mode 100644 index 00000000..9e21f30a --- /dev/null +++ b/src/widgets/code/index.ts @@ -0,0 +1,19 @@ +import controlComponent from './CodeControl'; +import previewComponent from './CodePreview'; +import schema from './schema'; + +import type { CodeField, WidgetParam } from '../../interface'; + +const CodeWidget = (): WidgetParam => { + return { + name: 'code', + controlComponent, + previewComponent, + options: { + schema, + allowMapValue: true, + }, + }; +}; + +export default CodeWidget; diff --git a/src/widgets/code/languageSelectStyles.js b/src/widgets/code/languageSelectStyles.ts similarity index 56% rename from src/widgets/code/languageSelectStyles.js rename to src/widgets/code/languageSelectStyles.ts index ae3ef3a0..48b4b22c 100644 --- a/src/widgets/code/languageSelectStyles.js +++ b/src/widgets/code/languageSelectStyles.ts @@ -1,32 +1,35 @@ -import { reactSelectStyles, borders } from '../../ui'; +import { reactSelectStyles, borders } from '../../components/UI/styles'; + +import type { CSSProperties } from 'react'; +import type { OptionStyleState } from '../../components/UI/styles'; const languageSelectStyles = { ...reactSelectStyles, - container: provided => ({ + container: (provided: CSSProperties) => ({ ...reactSelectStyles.container(provided), 'margin-top': '2px', }), - control: provided => ({ + control: (provided: CSSProperties) => ({ ...reactSelectStyles.control(provided), border: borders.textField, padding: 0, fontSize: '13px', minHeight: 'auto', }), - dropdownIndicator: provided => ({ + dropdownIndicator: (provided: CSSProperties) => ({ ...reactSelectStyles.dropdownIndicator(provided), padding: '4px', }), - option: (provided, state) => ({ + option: (provided: CSSProperties, state: OptionStyleState) => ({ ...reactSelectStyles.option(provided, state), padding: 0, paddingLeft: '8px', }), - menu: provided => ({ + menu: (provided: CSSProperties) => ({ ...reactSelectStyles.menu(provided), margin: '2px 0', }), - menuList: provided => ({ + menuList: (provided: CSSProperties) => ({ ...provided, 'max-height': '200px', }), diff --git a/src/widgets/code/schema.js b/src/widgets/code/schema.ts similarity index 100% rename from src/widgets/code/schema.js rename to src/widgets/code/schema.ts diff --git a/src/widgets/code/scripts/process-languages.js b/src/widgets/code/scripts/process-languages.ts similarity index 52% rename from src/widgets/code/scripts/process-languages.js rename to src/widgets/code/scripts/process-languages.ts index 63f78682..0c5cb470 100644 --- a/src/widgets/code/scripts/process-languages.js +++ b/src/widgets/code/scripts/process-languages.ts @@ -1,23 +1,40 @@ -const fs = require('fs-extra'); -const path = require('path'); -const yaml = require('js-yaml'); -const uniq = require('lodash/uniq'); +import fs from 'fs-extra'; +import path from 'path'; +import yaml from 'js-yaml'; +import uniq from 'lodash/uniq'; + +import type { ProcessedCodeLanguage } from '../../../interface'; const rawDataPath = '../data/languages-raw.yml'; -const outputPath = '../data/languages.json'; +const outputPath = '../data/languages.ts'; + +interface CodeLanguage { + extensions: string[]; + aliases: string[]; + codemirror_mode: string; + codemirror_mime_type: string; +} async function fetchData() { const filePath = path.resolve(__dirname, rawDataPath); - const fileContent = await fs.readFile(filePath); - return yaml.load(fileContent); + const fileContent = await fs.readFile(filePath, 'utf-8'); + return yaml.load(fileContent) as Record; } -function outputData(data) { +function outputData(data: ProcessedCodeLanguage[]) { const filePath = path.resolve(__dirname, outputPath); - return fs.writeJson(filePath, data); + return fs.writeFile( + filePath, + `import type { ProcessedCodeLanguage } from '../../../interface'; + +const languages: ProcessedCodeLanguage[] = ${JSON.stringify(data, null, 2)}; + +export default languages; +`, + ); } -function transform(data) { +function transform(data: Record) { return Object.entries(data).reduce((acc, [label, lang]) => { const { extensions = [], aliases = [], codemirror_mode, codemirror_mime_type } = lang; if (codemirror_mode) { @@ -33,7 +50,7 @@ function transform(data) { acc.push({ label, identifiers, codemirror_mode, codemirror_mime_type }); } return acc; - }, []); + }, [] as ProcessedCodeLanguage[]); } async function process() { diff --git a/src/widgets/colorstring/ColorControl.js b/src/widgets/colorstring/ColorControl.js deleted file mode 100644 index d18f24c9..00000000 --- a/src/widgets/colorstring/ColorControl.js +++ /dev/null @@ -1,172 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; -import ChromePicker from 'react-color'; -import validateColor from 'validate-color'; - -import { zIndex } from '../../ui'; - -function ClearIcon() { - return ( - - ); -} - -const ClearButton = styled.div` - position: absolute; - right: 6px; - z-index: ${zIndex.zIndex1000}; - padding: 8px; - margin-top: 11px; -`; - -const ClearButtonWrapper = styled.div` - position: relative; - width: 100%; -`; - -// color swatch background with checkerboard to display behind transparent colors -const ColorSwatchBackground = styled.div` - position: absolute; - z-index: ${zIndex.zIndex1}; - background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg=='); - height: 38px; - width: 48px; - margin-top: 10px; - margin-left: 10px; - border-radius: 5px; -`; - -const ColorSwatch = styled.div` - position: absolute; - z-index: ${zIndex.zIndex2}; - background: ${props => props.background}; - cursor: pointer; - height: 38px; - width: 48px; - margin-top: 10px; - margin-left: 10px; - border-radius: 5px; - border: 2px solid rgb(223, 223, 227); - text-align: center; - font-size: 27px; - line-height: 1; - padding-top: 4px; - user-select: none; - color: ${props => props.color}; -`; - -const ColorPickerContainer = styled.div` - position: absolute; - z-index: ${zIndex.zIndex1000}; - margin-top: 48px; - margin-left: 12px; -`; - -// fullscreen div to close color picker when clicking outside of picker -const ClickOutsideDiv = styled.div` - position: fixed; - top: 0; - right: 0; - bottom: 0; - left: 0; -`; - -export default class ColorControl extends React.Component { - static propTypes = { - onChange: PropTypes.func.isRequired, - forID: PropTypes.string, - value: PropTypes.node, - classNameWrapper: PropTypes.string.isRequired, - setActiveStyle: PropTypes.func.isRequired, - setInactiveStyle: PropTypes.func.isRequired, - }; - - static defaultProps = { - value: '', - }; - - state = { - showColorPicker: false, - }; - // show/hide color picker - handleClick = () => { - this.setState({ showColorPicker: !this.state.showColorPicker }); - }; - handleClear = () => { - this.props.onChange(''); - }; - handleClose = () => { - this.setState({ showColorPicker: false }); - }; - handleChange = color => { - const formattedColor = - color.rgb.a < 1 - ? `rgba(${color.rgb.r}, ${color.rgb.g}, ${color.rgb.b}, ${color.rgb.a})` - : color.hex; - this.props.onChange(formattedColor); - }; - render() { - const { forID, value, field, onChange, classNameWrapper, setActiveStyle, setInactiveStyle } = - this.props; - - const allowInput = field.get('allowInput', false); - - // clear button is not displayed if allowInput: true - const showClearButton = !allowInput && value; - - return ( - <> - {' '} - {showClearButton && ( - - - - - - )} - - - ? - - {this.state.showColorPicker && ( - - - - - )} - onChange(e.target.value)} - onFocus={setActiveStyle} - onBlur={setInactiveStyle} - style={{ - paddingLeft: '75px', - paddingRight: '70px', - color: !allowInput && '#bbb', - }} - // make readonly and open color picker on click if set to allowInput: false - onClick={!allowInput ? this.handleClick : undefined} - readOnly={!allowInput} - /> - - ); - } -} diff --git a/src/widgets/colorstring/ColorControl.tsx b/src/widgets/colorstring/ColorControl.tsx new file mode 100644 index 00000000..9db5528d --- /dev/null +++ b/src/widgets/colorstring/ColorControl.tsx @@ -0,0 +1,227 @@ +import CloseIcon from '@mui/icons-material/Close'; +import IconButton from '@mui/material/IconButton'; +import InputAdornment from '@mui/material/InputAdornment'; +import { styled } from '@mui/material/styles'; +import TextField from '@mui/material/TextField'; +import React, { useCallback, useState } from 'react'; +import { ChromePicker } from 'react-color'; +import validateColor from 'validate-color'; + +import ObjectWidgetTopBar from '../../components/UI/ObjectWidgetTopBar'; +import Outline from '../../components/UI/Outline'; +import { zIndex } from '../../components/UI/styles'; +import { transientOptions } from '../../lib'; + +import type { ChangeEvent, MouseEvent } from 'react'; +import type { ColorResult } from 'react-color'; +import type { ColorField, WidgetControlProps } from '../../interface'; + +const StyledColorControlWrapper = styled('div')` + display: flex; + flex-direction: column; + position: relative; + width: 100%; +`; + +interface StyledColorControlContentProps { + $collapsed: boolean; +} + +const StyledColorControlContent = styled( + 'div', + transientOptions, +)( + ({ $collapsed }) => ` + display: flex; + ${ + $collapsed + ? ` + visibility: hidden; + height: 0; + width: 0; + ` + : ` + padding: 16px; + ` + } + `, +); + +// color swatch background with checkerboard to display behind transparent colors +const ColorSwatchBackground = styled('div')` + position: absolute; + z-index: ${zIndex.zIndex1}; + background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg=='); + height: 38px; + width: 48px; + margin-top: 10px; + margin-left: 10px; + border-radius: 5px; +`; + +interface ColorSwatchProps { + $background: string; + $color: string; +} + +const ColorSwatch = styled( + 'div', + transientOptions, +)( + ({ $background, $color }) => ` + position: absolute; + z-index: ${zIndex.zIndex2}; + background: ${$background}; + cursor: pointer; + height: 38px; + width: 48px; + margin-top: 10px; + margin-left: 10px; + border-radius: 5px; + border: 2px solid rgb(223, 223, 227); + text-align: center; + font-size: 27px; + line-height: 1; + padding-top: 4px; + user-select: none; + color: ${$color}; + `, +); + +const ColorPickerContainer = styled('div')` + position: absolute; + z-index: ${zIndex.zIndex1000}; + margin-top: 48px; + margin-left: 12px; +`; + +// fullscreen div to close color picker when clicking outside of picker +const ClickOutsideDiv = styled('div')` + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; +`; + +const ColorControl = ({ + field, + onChange, + value, + hasErrors, + t, +}: WidgetControlProps) => { + const [collapsed, setCollapsed] = useState(false); + + const handleCollapseToggle = useCallback(() => { + setCollapsed(!collapsed); + }, [collapsed]); + + const [showColorPicker, setShowColorPicker] = useState(false); + const [internalValue, setInternalValue] = useState(value ?? ''); + + // show/hide color picker + const handleClick = useCallback(() => { + setShowColorPicker(!showColorPicker); + }, [showColorPicker]); + + const handleClear = useCallback( + (event: MouseEvent) => { + event.stopPropagation(); + setInternalValue(''); + onChange(''); + }, + [onChange], + ); + + const handleClose = useCallback(() => { + setShowColorPicker(false); + }, []); + + const handlePickerChange = useCallback( + (color: ColorResult) => { + const formattedColor = + (color.rgb?.a ?? 1) < 1 + ? `rgba(${color.rgb.r}, ${color.rgb.g}, ${color.rgb.b}, ${color.rgb.a})` + : color.hex; + setInternalValue(formattedColor); + onChange(formattedColor); + }, + [onChange], + ); + + const handleInputChange = useCallback( + (event: ChangeEvent) => { + setInternalValue(event.target.value); + onChange(event.target.value); + }, + [onChange], + ); + + const allowInput = field.allow_input ?? false; + + // clear button is not displayed if allow_input: true + const showClearButton = !allowInput && internalValue; + + return ( + + + + + + ? + + {showColorPicker && ( + + + + + )} + + + + + + ) : undefined, + }} + /> + + + + ); +}; + +export default ColorControl; diff --git a/src/widgets/colorstring/ColorPreview.js b/src/widgets/colorstring/ColorPreview.js deleted file mode 100644 index 2df3e474..00000000 --- a/src/widgets/colorstring/ColorPreview.js +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import { WidgetPreviewContainer } from '../../ui'; - -function ColorPreview({ value }) { - return {value}; -} - -ColorPreview.propTypes = { - value: PropTypes.node, -}; - -export default ColorPreview; diff --git a/src/widgets/colorstring/ColorPreview.tsx b/src/widgets/colorstring/ColorPreview.tsx new file mode 100644 index 00000000..5f0bcf33 --- /dev/null +++ b/src/widgets/colorstring/ColorPreview.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer'; + +import type { ColorField, WidgetPreviewProps } from '../../interface'; + +const ColorPreview = ({ value }: WidgetPreviewProps) => { + return {value}; +}; + +export default ColorPreview; diff --git a/src/widgets/colorstring/index.js b/src/widgets/colorstring/index.js deleted file mode 100644 index ef4b645a..00000000 --- a/src/widgets/colorstring/index.js +++ /dev/null @@ -1,14 +0,0 @@ -import controlComponent from './ColorControl'; -import previewComponent from './ColorPreview'; - -function Widget(opts = {}) { - return { - name: 'color', - controlComponent, - previewComponent, - ...opts, - }; -} - -export const StaticCmsWidgetColorString = { Widget, controlComponent, previewComponent }; -export default StaticCmsWidgetColorString; diff --git a/src/widgets/colorstring/index.ts b/src/widgets/colorstring/index.ts new file mode 100644 index 00000000..e3cc7b42 --- /dev/null +++ b/src/widgets/colorstring/index.ts @@ -0,0 +1,14 @@ +import controlComponent from './ColorControl'; +import previewComponent from './ColorPreview'; + +import type { ColorField, WidgetParam } from '../../interface'; + +const ColorWidget = (): WidgetParam => { + return { + name: 'color', + controlComponent, + previewComponent, + }; +}; + +export default ColorWidget; diff --git a/src/widgets/datetime/DateTimeControl.js b/src/widgets/datetime/DateTimeControl.js deleted file mode 100644 index 4709251d..00000000 --- a/src/widgets/datetime/DateTimeControl.js +++ /dev/null @@ -1,172 +0,0 @@ -/** @jsx jsx */ -import { css, jsx } from '@emotion/react'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React from 'react'; -import DateTime from 'react-datetime'; -import reactDateTimeStyles from 'react-datetime/css/react-datetime.css'; - -import alert from '../../components/UI/Alert'; -import { buttons } from '../../ui'; - -function NowButton({ t, handleChange }) { - return ( -
    - -
    - ); -} - -export default class DateTimeControl extends React.Component { - static propTypes = { - field: PropTypes.object.isRequired, - forID: PropTypes.string, - onChange: PropTypes.func.isRequired, - classNameWrapper: PropTypes.string.isRequired, - setActiveStyle: PropTypes.func.isRequired, - setInactiveStyle: PropTypes.func.isRequired, - value: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), - }; - - getFormats() { - const { field } = this.props; - const format = field.get('format'); - - // dateFormat and timeFormat are strictly for modifying - // input field with the date/time pickers - const dateFormat = field.get('date_format'); - // show time-picker? false hides it, true shows it using default format - let timeFormat = field.get('time_format'); - if (typeof timeFormat === 'undefined') { - timeFormat = true; - } - - return { - format, - dateFormat, - timeFormat, - }; - } - - getDefaultValue() { - const { field } = this.props; - const defaultValue = field.get('default'); - return defaultValue; - } - - getPickerUtc() { - const { field } = this.props; - const pickerUtc = field.get('picker_utc'); - return pickerUtc; - } - - formats = this.getFormats(); - defaultValue = this.getDefaultValue(); - pickerUtc = this.getPickerUtc(); - - componentDidMount() { - const { value } = this.props; - - /** - * Set the current date as default value if no value is provided and default is absent. An - * empty default string means the value is intentionally blank. - */ - if (value === undefined) { - setTimeout(() => { - this.handleChange(this.defaultValue === undefined ? new Date() : this.defaultValue); - }, 0); - } - } - - // Date is valid if datetime is a moment or Date object otherwise it's a string. - // Handle the empty case, if the user wants to empty the field. - isValidDate = datetime => - moment.isMoment(datetime) || datetime instanceof Date || datetime === ''; - - handleChange = datetime => { - /** - * Set the date only if it is valid. - */ - if (!this.isValidDate(datetime)) { - return; - } - - const { onChange } = this.props; - const { format } = this.formats; - - /** - * Produce a formatted string only if a format is set in the config. - * Otherwise produce a date object. - */ - if (format) { - const formattedValue = datetime ? moment(datetime).format(format) : ''; - onChange(formattedValue); - } else { - const value = moment.isMoment(datetime) ? datetime.toDate() : datetime; - onChange(value); - } - }; - - onClose = datetime => { - const { setInactiveStyle } = this.props; - - if (!this.isValidDate(datetime)) { - const parsedDate = moment(datetime); - - if (parsedDate.isValid()) { - this.handleChange(datetime); - } else { - alert({ - title: 'editor.editorWidgets.datetime.invalidDateTitle', - body: 'editor.editorWidgets.datetime.invalidDateBody', - }); - } - } - - setInactiveStyle(); - }; - - render() { - const { forID, value, classNameWrapper, setActiveStyle, t, isDisabled } = this.props; - const { format, dateFormat, timeFormat } = this.formats; - - return ( -
    - - {!isDisabled && this.handleChange(v)} />} -
    - ); - } -} diff --git a/src/widgets/datetime/DateTimeControl.tsx b/src/widgets/datetime/DateTimeControl.tsx new file mode 100644 index 00000000..7eab1af6 --- /dev/null +++ b/src/widgets/datetime/DateTimeControl.tsx @@ -0,0 +1,268 @@ +import TodayIcon from '@mui/icons-material/Today'; +import Button from '@mui/material/Button'; +import { styled } from '@mui/material/styles'; +import TextField from '@mui/material/TextField'; +import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { MobileDatePicker } from '@mui/x-date-pickers/MobileDatePicker'; +import { MobileDateTimePicker } from '@mui/x-date-pickers/MobileDateTimePicker'; +import { TimePicker } from '@mui/x-date-pickers/TimePicker'; +import formatDate from 'date-fns/format'; +import formatISO from 'date-fns/formatISO'; +import parse from 'date-fns/parse'; +import parseISO from 'date-fns/parseISO'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import type { MouseEvent } from 'react'; +import type { DateTimeField, TranslatedProps, WidgetControlProps } from '../../interface'; + +const StyledNowButton = styled('div')` + width: fit-content; +`; + +interface NowButtonProps { + handleChange: (value: Date) => void; + disabled: boolean; +} + +function NowButton({ t, handleChange, disabled }: TranslatedProps) { + const handleClick = useCallback( + (event: MouseEvent) => { + event.stopPropagation(); + handleChange(new Date()); + }, + [handleChange], + ); + + return ( + + + + ); +} + +const DateTimeControl = ({ + field, + label, + value, + t, + isDisabled, + onChange, + hasErrors, +}: WidgetControlProps) => { + const [internalValue, setInternalValue] = useState(value ?? ''); + + const { format, dateFormat, timeFormat } = useMemo(() => { + const format = field.format; + + // dateFormat and timeFormat are strictly for modifying input field with the date/time pickers + const dateFormat: string | boolean = field.date_format ?? false; + // show time-picker? false hides it, true shows it using default format + let timeFormat: string | boolean = field.time_format ?? false; + if (typeof timeFormat === 'undefined') { + timeFormat = true; + } + + return { + format, + dateFormat, + timeFormat, + }; + }, [field.date_format, field.format, field.time_format]); + + const dateValue = useMemo( + () => (format ? parse(internalValue, format, new Date()) : parseISO(internalValue)), + [format, internalValue], + ); + + const timezoneOffset = useMemo(() => dateValue.getTimezoneOffset() * 60000, [dateValue]); + + const utcDate = useMemo(() => { + const dateTime = new Date(dateValue); + const utcFromLocal = new Date(dateTime.getTime() + timezoneOffset); + return utcFromLocal; + }, [dateValue, timezoneOffset]); + + const localToUTC = useCallback( + (dateTime: Date) => { + const utcFromLocal = new Date(dateTime.getTime() - timezoneOffset); + return utcFromLocal; + }, + [timezoneOffset], + ); + + const defaultValue = useMemo(() => { + const today = field.picker_utc ? localToUTC(new Date()) : new Date(); + return field.default === undefined + ? format + ? formatDate(today, format) + : formatISO(today) + : field.default; + }, [field.default, field.picker_utc, format, localToUTC]); + + const handleChange = useCallback( + (datetime: Date | null) => { + if (datetime === null) { + setInternalValue(defaultValue); + onChange(defaultValue); + return; + } + + const adjustedValue = field.picker_utc ? localToUTC(datetime) : datetime; + + let formattedValue: string; + if (format) { + formattedValue = formatDate(adjustedValue, format); + } else { + formattedValue = formatISO(adjustedValue); + } + setInternalValue(formattedValue); + onChange(formattedValue); + }, + [defaultValue, field.picker_utc, format, localToUTC, onChange], + ); + + useEffect(() => { + /** + * Set the current date as default value if no value is provided and default is absent. An + * empty default string means the value is intentionally blank. + */ + if (internalValue === undefined) { + setTimeout(() => { + setInternalValue(defaultValue); + onChange(defaultValue); + }, 0); + } + }, [defaultValue, handleChange, internalValue, onChange]); + + const dateTimePicker = useMemo(() => { + if (dateFormat && !timeFormat) { + return ( + ( + handleChange(v)} + disabled={isDisabled} + /> + ), + }} + /> + )} + /> + ); + } + + if (!dateFormat && timeFormat) { + return ( + ( + handleChange(v)} + disabled={isDisabled} + /> + ), + }} + /> + )} + /> + ); + } + + let inputFormat = 'MMM d, yyyy H:mm'; + if (dateFormat || timeFormat) { + const formatParts: string[] = []; + if (typeof dateFormat === 'string') { + formatParts.push(dateFormat); + } + + if (typeof timeFormat === 'string') { + formatParts.push(timeFormat); + } + + inputFormat = formatParts.join(' '); + } + + return ( + ( + handleChange(v)} + disabled={isDisabled} + /> + ), + }} + /> + )} + /> + ); + }, [ + dateFormat, + dateValue, + field.picker_utc, + handleChange, + hasErrors, + isDisabled, + label, + t, + timeFormat, + utcDate, + ]); + + return ( + + {dateTimePicker} + + ); +}; + +export default DateTimeControl; diff --git a/src/widgets/datetime/DateTimePreview.js b/src/widgets/datetime/DateTimePreview.js deleted file mode 100644 index 9d51dc97..00000000 --- a/src/widgets/datetime/DateTimePreview.js +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import { WidgetPreviewContainer } from '../../ui'; - -function DatePreview({ value }) { - return {value ? value.toString() : null}; -} - -DatePreview.propTypes = { - value: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), -}; - -export default DatePreview; diff --git a/src/widgets/datetime/DateTimePreview.tsx b/src/widgets/datetime/DateTimePreview.tsx new file mode 100644 index 00000000..5821ddca --- /dev/null +++ b/src/widgets/datetime/DateTimePreview.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer'; + +import type { DateTimeField, WidgetPreviewProps } from '../../interface'; + +function DatePreview({ value }: WidgetPreviewProps) { + return {value ? value.toString() : null}; +} + +export default DatePreview; diff --git a/src/widgets/datetime/index.js b/src/widgets/datetime/index.tsx similarity index 51% rename from src/widgets/datetime/index.js rename to src/widgets/datetime/index.tsx index 3dfa9471..684f8725 100644 --- a/src/widgets/datetime/index.js +++ b/src/widgets/datetime/index.tsx @@ -2,15 +2,17 @@ import controlComponent from './DateTimeControl'; import previewComponent from './DateTimePreview'; import schema from './schema'; -function Widget(opts = {}) { +import type { DateTimeField, WidgetParam } from '../../interface'; + +const DateTimeWidget = (): WidgetParam => { return { name: 'datetime', controlComponent, previewComponent, - schema, - ...opts, + options: { + schema, + }, }; -} +}; -export const StaticCmsWidgetDatetime = { Widget, controlComponent, previewComponent }; -export default StaticCmsWidgetDatetime; +export default DateTimeWidget; diff --git a/src/widgets/datetime/schema.js b/src/widgets/datetime/schema.ts similarity index 100% rename from src/widgets/datetime/schema.js rename to src/widgets/datetime/schema.ts diff --git a/src/widgets/file/FilePreview.js b/src/widgets/file/FilePreview.js deleted file mode 100644 index c79ac106..00000000 --- a/src/widgets/file/FilePreview.js +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; -import { List } from 'immutable'; - -import { WidgetPreviewContainer } from '../../ui'; - -const FileLink = styled(({ href, path }) => ( - - {path} - -))` - display: block; -`; - -function FileLinkList({ values, getAsset, field }) { - return ( -
    - {values.map(value => ( - - ))} -
    - ); -} - -function FileContent(props) { - const { value, getAsset, field } = props; - if (Array.isArray(value) || List.isList(value)) { - return ; - } - return ; -} - -function FilePreview(props) { - return ( - - {props.value ? : null} - - ); -} - -FilePreview.propTypes = { - getAsset: PropTypes.func.isRequired, - value: PropTypes.node, -}; - -export default FilePreview; diff --git a/src/widgets/file/FilePreview.tsx b/src/widgets/file/FilePreview.tsx new file mode 100644 index 00000000..99463095 --- /dev/null +++ b/src/widgets/file/FilePreview.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { styled } from '@mui/material/styles'; + +import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer'; + +import type { FileOrImageField, WidgetPreviewProps, GetAssetFunction } from '../../interface'; + +interface FileLinkProps { + href: string; + path: string; +} + +const FileLink = styled(({ href, path }: FileLinkProps) => ( + + {path} + +))` + display: block; +`; + +interface FileLinkListProps { + values: string[]; + getAsset: GetAssetFunction; + field: FileOrImageField; +} + +function FileLinkList({ values, getAsset, field }: FileLinkListProps) { + return ( +
    + {values.map(value => ( + + ))} +
    + ); +} + +function FileContent({ + value, + getAsset, + field, +}: WidgetPreviewProps) { + if (!value) { + return null; + } + + if (Array.isArray(value)) { + return ; + } + + return ; +} + +function FilePreview(props: WidgetPreviewProps) { + return ( + + {props.value ? : null} + + ); +} + +export default FilePreview; diff --git a/src/widgets/file/index.js b/src/widgets/file/index.js deleted file mode 100644 index eb3db17b..00000000 --- a/src/widgets/file/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import withFileControl from './withFileControl'; -import previewComponent from './FilePreview'; -import schema from './schema'; - -const controlComponent = withFileControl(); - -function Widget(opts = {}) { - return { - name: 'file', - controlComponent, - previewComponent, - schema, - ...opts, - }; -} - -export const StaticCmsWidgetFile = { Widget, controlComponent, previewComponent, withFileControl }; -export default StaticCmsWidgetFile; diff --git a/src/widgets/file/index.ts b/src/widgets/file/index.ts new file mode 100644 index 00000000..0540c94b --- /dev/null +++ b/src/widgets/file/index.ts @@ -0,0 +1,21 @@ +import withFileControl, { getValidValue } from './withFileControl'; +import previewComponent from './FilePreview'; +import schema from './schema'; + +import type { FileOrImageField, WidgetParam } from '../../interface'; + +const controlComponent = withFileControl(); + +const FileWidget = (): WidgetParam => { + return { + name: 'file', + controlComponent, + previewComponent, + options: { + schema, + getValidValue, + }, + }; +}; + +export default FileWidget; diff --git a/src/widgets/file/schema.js b/src/widgets/file/schema.ts similarity index 100% rename from src/widgets/file/schema.js rename to src/widgets/file/schema.ts diff --git a/src/widgets/file/withFileControl.js b/src/widgets/file/withFileControl.js deleted file mode 100644 index 63135b7d..00000000 --- a/src/widgets/file/withFileControl.js +++ /dev/null @@ -1,430 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import styled from '@emotion/styled'; -import { css } from '@emotion/react'; -import { Map, List } from 'immutable'; -import { once } from 'lodash'; -import uuid from 'uuid/v4'; -import { oneLine } from 'common-tags'; -import { SortableContainer, SortableElement } from 'react-sortable-hoc'; -import { arrayMoveImmutable as arrayMove } from 'array-move'; - -import { - lengths, - components, - buttons, - borders, - effects, - shadows, - IconButton, -} from '../../ui'; -import { basename } from '../../lib/util'; - -const MAX_DISPLAY_LENGTH = 50; - -const ImageWrapper = styled.div` - flex-basis: 155px; - width: 155px; - height: 100px; - margin-right: 20px; - margin-bottom: 20px; - border: ${borders.textField}; - border-radius: ${lengths.borderRadius}; - overflow: hidden; - ${effects.checkerboard}; - ${shadows.inset}; - cursor: ${props => (props.sortable ? 'pointer' : 'auto')}; -`; - -const SortableImageButtonsWrapper = styled.div` - display: flex; - justify-content: center; - column-gap: 10px; - margin-right: 20px; - margin-top: -10px; - margin-bottom: 10px; -`; - -const StyledImage = styled.img` - width: 100%; - height: 100%; - object-fit: contain; -`; - -function Image(props) { - return ; -} - -function SortableImageButtons({ onRemove, onReplace }) { - return ( - - - - - ); -} - -const SortableImage = SortableElement(({ itemValue, getAsset, field, onRemove, onReplace }) => { - return ( -
    - - - - -
    - ); -}); - -const SortableMultiImageWrapper = SortableContainer( - ({ items, getAsset, field, onRemoveOne, onReplaceOne }) => { - return ( -
    - {items.map((itemValue, index) => ( - - ))} -
    - ); - }, -); - -const FileLink = styled.a` - margin-bottom: 20px; - font-weight: normal; - color: inherit; - - &:hover, - &:active, - &:focus { - text-decoration: underline; - } -`; - -const FileLinks = styled.div` - margin-bottom: 12px; -`; - -const FileLinkList = styled.ul` - list-style-type: none; -`; - -const FileWidgetButton = styled.button` - ${buttons.button}; - ${components.badge}; - margin-bottom: 12px; -`; - -const FileWidgetButtonRemove = styled.button` - ${buttons.button}; - ${components.badgeDanger}; -`; - -function isMultiple(value) { - return Array.isArray(value) || List.isList(value); -} - -function sizeOfValue(value) { - if (Array.isArray(value)) { - return value.length; - } - - if (List.isList(value)) { - return value.size; - } - - return value ? 1 : 0; -} - -function valueListToArray(value) { - return List.isList(value) ? value.toArray() : value; -} - -const warnDeprecatedOptions = once(field => - console.warn(oneLine` - Static CMS config: ${field.get('name')} field: property "options" has been deprecated for the - ${field.get('widget')} widget and will be removed in the next major release. Rather than - \`field.options.media_library\`, apply media library options for this widget under - \`field.media_library\`. -`), -); - -export default function withFileControl({ forImage } = {}) { - return class FileControl extends React.Component { - static propTypes = { - field: PropTypes.object.isRequired, - getAsset: PropTypes.func.isRequired, - mediaPaths: ImmutablePropTypes.map.isRequired, - onAddAsset: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onRemoveInsertedMedia: PropTypes.func.isRequired, - onOpenMediaLibrary: PropTypes.func.isRequired, - onClearMediaControl: PropTypes.func.isRequired, - onRemoveMediaControl: PropTypes.func.isRequired, - classNameWrapper: PropTypes.string.isRequired, - value: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.arrayOf(PropTypes.string), - ImmutablePropTypes.listOf(PropTypes.string), - ]), - t: PropTypes.func.isRequired, - }; - - static defaultProps = { - value: '', - }; - - constructor(props) { - super(props); - this.controlID = uuid(); - } - - shouldComponentUpdate(nextProps) { - /** - * Always update if the value or getAsset changes. - */ - if (this.props.value !== nextProps.value || this.props.getAsset !== nextProps.getAsset) { - return true; - } - - /** - * If there is a media path for this control in the state object, and that - * path is different than the value in `nextProps`, update. - */ - const mediaPath = nextProps.mediaPaths.get(this.controlID); - if (mediaPath && nextProps.value !== mediaPath) { - return true; - } - - return false; - } - - componentDidUpdate() { - const { mediaPaths, value, onRemoveInsertedMedia, onChange } = this.props; - const mediaPath = mediaPaths.get(this.controlID); - if (mediaPath && mediaPath !== value) { - onChange(mediaPath); - } else if (mediaPath && mediaPath === value) { - onRemoveInsertedMedia(this.controlID); - } - } - - componentWillUnmount() { - this.props.onRemoveMediaControl(this.controlID); - } - - handleChange = e => { - const { field, onOpenMediaLibrary, value } = this.props; - e.preventDefault(); - const mediaLibraryFieldOptions = this.getMediaLibraryFieldOptions(); - - return onOpenMediaLibrary({ - controlID: this.controlID, - forImage, - privateUpload: field.get('private'), - value: valueListToArray(value), - allowMultiple: !!mediaLibraryFieldOptions.get('allow_multiple', true), - config: mediaLibraryFieldOptions.get('config'), - field, - }); - }; - - handleUrl = subject => e => { - e.preventDefault(); - - const url = window.prompt(this.props.t(`editor.editorWidgets.${subject}.promptUrl`)); - - return this.props.onChange(url); - }; - - handleRemove = e => { - e.preventDefault(); - this.props.onClearMediaControl(this.controlID); - return this.props.onChange(''); - }; - - onRemoveOne = index => () => { - const { value } = this.props; - value.splice(index, 1); - return this.props.onChange(sizeOfValue(value) > 0 ? [...value] : null); - }; - - onReplaceOne = index => () => { - const { field, onOpenMediaLibrary, value } = this.props; - const mediaLibraryFieldOptions = this.getMediaLibraryFieldOptions(); - - return onOpenMediaLibrary({ - controlID: this.controlID, - forImage, - privateUpload: field.get('private'), - value: valueListToArray(value), - replaceIndex: index, - allowMultiple: false, - config: mediaLibraryFieldOptions.get('config'), - field, - }); - }; - - getMediaLibraryFieldOptions = () => { - const { field } = this.props; - - if (field.hasIn(['options', 'media_library'])) { - warnDeprecatedOptions(field); - return field.getIn(['options', 'media_library'], Map()); - } - - return field.get('media_library', Map()); - }; - - allowsMultiple = () => { - const mediaLibraryFieldOptions = this.getMediaLibraryFieldOptions(); - return ( - mediaLibraryFieldOptions.get('config', false) && - mediaLibraryFieldOptions.get('config').get('multiple', false) - ); - }; - - onSortEnd = ({ oldIndex, newIndex }) => { - const { value } = this.props; - const newValue = arrayMove(value, oldIndex, newIndex); - return this.props.onChange(newValue); - }; - - getValidateValue = () => { - const { value } = this.props; - if (value) { - return isMultiple(value) ? value.map(v => basename(v)) : basename(value); - } - - return value; - }; - - renderFileLink = value => { - const size = MAX_DISPLAY_LENGTH; - if (!value || value.length <= size) { - return value; - } - const text = `${value.slice(0, size / 2)}\u2026${value.slice(-(size / 2) + 1)}`; - return ( - - {text} - - ); - }; - - renderFileLinks = () => { - const { value } = this.props; - - if (isMultiple(value)) { - return ( - - - {value.map(val => ( -
  • {this.renderFileLink(val)}
  • - ))} -
    -
    - ); - } - return {this.renderFileLink(value)}; - }; - - renderImages = () => { - const { getAsset, value, field } = this.props; - - if (isMultiple(value)) { - return ( - - ); - } - - const src = getAsset(value, field); - return ( - - - - ); - }; - - renderSelection = subject => { - const { t, field } = this.props; - const allowsMultiple = this.allowsMultiple(); - return ( -
    - {forImage ? this.renderImages() : null} -
    - {forImage ? null : this.renderFileLinks()} - - {t( - `editor.editorWidgets.${subject}.${ - this.allowsMultiple() ? 'addMore' : 'chooseDifferent' - }`, - )} - - {field.get('choose_url', true) && !this.allowsMultiple() ? ( - - {t(`editor.editorWidgets.${subject}.replaceUrl`)} - - ) : null} - - {t(`editor.editorWidgets.${subject}.remove${allowsMultiple ? 'All' : ''}`)} - -
    -
    - ); - }; - - renderNoSelection = subject => { - const { t, field } = this.props; - return ( - <> - - {t(`editor.editorWidgets.${subject}.choose${this.allowsMultiple() ? 'Multiple' : ''}`)} - - {field.get('choose_url', true) ? ( - - {t(`editor.editorWidgets.${subject}.chooseUrl`)} - - ) : null} - - ); - }; - - render() { - const { value, classNameWrapper } = this.props; - const subject = forImage ? 'image' : 'file'; - - return ( -
    - {value ? this.renderSelection(subject) : this.renderNoSelection(subject)} -
    - ); - } - }; -} diff --git a/src/widgets/file/withFileControl.tsx b/src/widgets/file/withFileControl.tsx new file mode 100644 index 00000000..c1a0c537 --- /dev/null +++ b/src/widgets/file/withFileControl.tsx @@ -0,0 +1,500 @@ +import CloseIcon from '@mui/icons-material/Close'; +import PhotoIcon from '@mui/icons-material/Photo'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import { styled } from '@mui/material/styles'; +import { arrayMoveImmutable as arrayMove } from 'array-move'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { SortableContainer, SortableElement } from 'react-sortable-hoc'; +import uuid from 'uuid/v4'; + +import ObjectWidgetTopBar from '../../components/UI/ObjectWidgetTopBar'; +import Outline from '../../components/UI/Outline'; +import { borders, effects, lengths, shadows } from '../../components/UI/styles'; +import { basename, transientOptions } from '../../lib/util'; + +import type { MouseEvent, MouseEventHandler } from 'react'; +import type { FileOrImageField, GetAssetFunction, WidgetControlProps } from '../../interface'; + +const MAX_DISPLAY_LENGTH = 50; + +const StyledFileControlWrapper = styled('div')` + display: flex; + flex-direction: column; + position: relative; + width: 100%; +`; + +interface StyledFileControlContentProps { + $collapsed: boolean; +} + +const StyledFileControlContent = styled( + 'div', + transientOptions, +)( + ({ $collapsed }) => ` + display: flex; + flex-direction: column; + gap: 16px; + ${ + $collapsed + ? ` + visibility: hidden; + height: 0; + width: 0; + ` + : ` + padding: 16px; + ` + } + `, +); + +const StyledSelection = styled('div')` + display: flex; + flex-direction: column; +`; + +const StyledButtonWrapper = styled('div')` + display: flex; + gap: 16px; +`; + +interface ImageWrapperProps { + $sortable?: boolean; +} + +const ImageWrapper = styled( + 'div', + transientOptions, +)( + ({ $sortable }) => ` + flex-basis: 155px; + width: 155px; + height: 100px; + margin-right: 20px; + margin-bottom: 20px; + border: ${borders.textField}; + border-radius: ${lengths.borderRadius}; + overflow: hidden; + ${effects.checkerboard}; + ${shadows.inset}; + cursor: ${$sortable ? 'pointer' : 'auto'}; + `, +); + +const SortableImageButtonsWrapper = styled('div')` + display: flex; + justify-content: center; + column-gap: 10px; + margin-right: 20px; + margin-top: -10px; + margin-bottom: 10px; +`; + +const StyledImage = styled('img')` + width: 100%; + height: 100%; + object-fit: contain; +`; + +interface ImageProps { + src: string; +} + +const Image = ({ src }: ImageProps) => { + return ; +}; + +interface SortableImageButtonsProps { + onRemove: MouseEventHandler; + onReplace: MouseEventHandler; +} + +const SortableImageButtons = ({ onRemove, onReplace }: SortableImageButtonsProps) => { + return ( + + + + + + + + + ); +}; + +interface SortableImageProps { + itemValue: string; + getAsset: GetAssetFunction; + field: FileOrImageField; + onRemove: MouseEventHandler; + onReplace: MouseEventHandler; +} + +const SortableImage = SortableElement( + ({ itemValue, getAsset, field, onRemove, onReplace }: SortableImageProps) => { + return ( +
    + + + + +
    + ); + }, +); + +const StyledSortableMultiImageWrapper = styled('div')` + display: flex; + flex-wrap: wrap; +`; + +interface SortableMultiImageWrapperProps { + items: string[]; + getAsset: GetAssetFunction; + field: FileOrImageField; + onRemoveOne: (index: number) => MouseEventHandler; + onReplaceOne: (index: number) => MouseEventHandler; +} + +const SortableMultiImageWrapper = SortableContainer( + ({ items, getAsset, field, onRemoveOne, onReplaceOne }: SortableMultiImageWrapperProps) => { + return ( + + {items.map((itemValue, index) => ( + + ))} + + ); + }, +); + +const FileLink = styled('a')` + margin-bottom: 20px; + font-weight: normal; + color: inherit; + + &:hover, + &:active, + &:focus { + text-decoration: underline; + } +`; + +const FileLinks = styled('div')` + margin-bottom: 12px; +`; + +const FileLinkList = styled('ul')` + list-style-type: none; +`; + +function isMultiple(value: string | string[] | null | undefined): value is string[] { + return Array.isArray(value); +} + +export function getValidValue(value: string | string[] | null | undefined) { + if (value) { + return isMultiple(value) ? value.map(v => basename(v)) : basename(value); + } + + return value; +} + +function sizeOfValue(value: string | string[] | null | undefined) { + if (Array.isArray(value)) { + return value.length; + } + + return value ? 1 : 0; +} + +interface WithImageOptions { + forImage?: boolean; +} + +export default function withFileControl({ forImage = false }: WithImageOptions = {}) { + const FileControl = ({ + path, + value, + field, + onChange, + openMediaLibrary, + clearMediaControl, + removeInsertedMedia, + removeMediaControl, + getAsset, + mediaPaths, + hasErrors, + t, + }: WidgetControlProps) => { + const controlID = useMemo(() => uuid(), []); + const [collapsed, setCollapsed] = useState(false); + + const handleCollapseToggle = useCallback(() => { + setCollapsed(!collapsed); + }, [collapsed]); + + useEffect(() => { + const mediaPath = mediaPaths[controlID]; + if (mediaPath && mediaPath !== value) { + onChange(mediaPath); + } else if (mediaPath && mediaPath === value) { + removeInsertedMedia(controlID); + } + }, [controlID, field, mediaPaths, onChange, removeInsertedMedia, path, value]); + + useEffect(() => { + return () => { + removeMediaControl(controlID); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const mediaLibraryFieldOptions = useMemo(() => { + return field.media_library ?? {}; + }, [field.media_library]); + + const config = useMemo( + () => ('config' in mediaLibraryFieldOptions ? mediaLibraryFieldOptions.config : undefined), + [mediaLibraryFieldOptions], + ); + + const allowsMultiple = useMemo(() => { + return config?.multiple ?? false; + }, [config?.multiple]); + + const chooseUrl = useMemo( + () => + 'choose_url' in mediaLibraryFieldOptions && (mediaLibraryFieldOptions.choose_url ?? true), + [mediaLibraryFieldOptions], + ); + + const handleChange = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + return openMediaLibrary({ + controlID, + forImage, + privateUpload: field.private, + value: value ?? '', + allowMultiple: + 'allow_multiple' in mediaLibraryFieldOptions + ? mediaLibraryFieldOptions.allow_multiple ?? true + : true, + config, + field, + }); + }, + [config, controlID, field, mediaLibraryFieldOptions, openMediaLibrary, value], + ); + + const handleUrl = useCallback( + (subject: 'image' | 'file') => (e: MouseEvent) => { + e.preventDefault(); + + const url = window.prompt(t(`editor.editorWidgets.${subject}.promptUrl`)); + + return onChange(url); + }, + [onChange, t], + ); + + const handleRemove = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + clearMediaControl(controlID); + return onChange(''); + }, + [controlID, onChange, clearMediaControl], + ); + + const onRemoveOne = useCallback( + (index: number) => () => { + if (Array.isArray(value)) { + value.splice(index, 1); + return onChange(sizeOfValue(value) > 0 ? [...value] : null); + } + }, + [onChange, value], + ); + + const onReplaceOne = useCallback( + (index: number) => () => { + return openMediaLibrary({ + controlID, + forImage, + privateUpload: field.private, + value: value ?? '', + replaceIndex: index, + allowMultiple: false, + config, + field, + }); + }, + [config, controlID, field, openMediaLibrary, value], + ); + + const onSortEnd = useCallback( + ({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => { + if (Array.isArray(value)) { + const newValue = arrayMove(value, oldIndex, newIndex); + return onChange(newValue); + } + }, + [onChange, value], + ); + + const renderFileLink = useCallback((value: string | undefined | null) => { + const size = MAX_DISPLAY_LENGTH; + if (!value || value.length <= size) { + return value; + } + const text = `${value.slice(0, size / 2)}\u2026${value.slice(-(size / 2) + 1)}`; + return ( + + {text} + + ); + }, []); + + const renderFileLinks = useCallback(() => { + if (isMultiple(value)) { + return ( + + + {value.map(val => ( +
  • {renderFileLink(val)}
  • + ))} +
    +
    + ); + } + + return {renderFileLink(value)}; + }, [renderFileLink, value]); + + const renderImages = useCallback(() => { + if (!value) { + return null; + } + + if (isMultiple(value)) { + return ( + + ); + } + + const src = getAsset(value, field)?.toString() ?? ''; + return ( + + + + ); + }, [field, getAsset, onRemoveOne, onReplaceOne, onSortEnd, value]); + + const content = useMemo(() => { + const subject = forImage ? 'image' : 'file'; + + if (!value) { + return ( + + + {chooseUrl ? ( + + ) : null} + + ); + } + + return ( + + {forImage ? renderImages() : renderFileLinks()} + + + {chooseUrl && !allowsMultiple ? ( + + ) : null} + + + + ); + }, [ + allowsMultiple, + chooseUrl, + handleChange, + handleRemove, + handleUrl, + renderFileLinks, + renderImages, + t, + value, + ]); + + return ( + + + {content} + + + ); + }; + + FileControl.displayName = 'FileControl'; + + return FileControl; +} diff --git a/src/widgets/image/ImagePreview.js b/src/widgets/image/ImagePreview.js deleted file mode 100644 index 9e8e7965..00000000 --- a/src/widgets/image/ImagePreview.js +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; -import { List } from 'immutable'; - -import { WidgetPreviewContainer } from '../../ui'; - -const StyledImage = styled(({ src }) => )` - display: block; - max-width: 100%; - height: auto; -`; - -function StyledImageAsset({ getAsset, value, field }) { - return ; -} - -function ImagePreviewContent(props) { - const { value, getAsset, field } = props; - if (Array.isArray(value) || List.isList(value)) { - return value.map(val => ( - - )); - } - return ; -} - -function ImagePreview(props) { - return ( - - {props.value ? : null} - - ); -} - -ImagePreview.propTypes = { - getAsset: PropTypes.func.isRequired, - value: PropTypes.node, -}; - -export default ImagePreview; diff --git a/src/widgets/image/ImagePreview.tsx b/src/widgets/image/ImagePreview.tsx new file mode 100644 index 00000000..55304455 --- /dev/null +++ b/src/widgets/image/ImagePreview.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { styled } from '@mui/material/styles'; + +import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer'; + +import type { FileOrImageField, WidgetPreviewProps, GetAssetFunction } from '../../interface'; + +interface StyledImageProps { + src: string; +} + +const StyledImage = styled(({ src }: StyledImageProps) => ( + +))` + display: block; + max-width: 100%; + height: auto; +`; + +interface StyledImageAsset { + getAsset: GetAssetFunction; + value: string; + field: FileOrImageField; +} + +function StyledImageAsset({ getAsset, value, field }: StyledImageAsset) { + return ; +} + +function ImagePreviewContent({ + value, + getAsset, + field, +}: WidgetPreviewProps) { + if (!value) { + return null; + } + + if (Array.isArray(value)) { + return ( + <> + {value.map(val => ( + + ))} + + ); + } + + return ; +} + +function ImagePreview(props: WidgetPreviewProps) { + return ( + + {props.value ? : null} + + ); +} + +export default ImagePreview; diff --git a/src/widgets/image/index.js b/src/widgets/image/index.js deleted file mode 100644 index 3f38bbd7..00000000 --- a/src/widgets/image/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import StaticCmsWidgetFile from '../file'; -import previewComponent from './ImagePreview'; -import schema from './schema'; - -const controlComponent = StaticCmsWidgetFile.withFileControl({ forImage: true }); - -function Widget(opts = {}) { - return { - name: 'image', - controlComponent, - previewComponent, - schema, - ...opts, - }; -} - -export const StaticCmsWidgetImage = { Widget, controlComponent, previewComponent }; -export default StaticCmsWidgetImage; diff --git a/src/widgets/image/index.ts b/src/widgets/image/index.ts new file mode 100644 index 00000000..9746f46e --- /dev/null +++ b/src/widgets/image/index.ts @@ -0,0 +1,21 @@ +import withFileControl, { getValidValue } from '../file/withFileControl'; +import previewComponent from './ImagePreview'; +import schema from './schema'; + +import type { FileOrImageField, WidgetParam } from '../../interface'; + +const controlComponent = withFileControl({ forImage: true }); + +function ImageWidget(): WidgetParam { + return { + name: 'image', + controlComponent, + previewComponent, + options: { + schema, + getValidValue, + }, + }; +} + +export default ImageWidget; diff --git a/src/widgets/image/schema.js b/src/widgets/image/schema.ts similarity index 100% rename from src/widgets/image/schema.js rename to src/widgets/image/schema.ts diff --git a/src/widgets/index.tsx b/src/widgets/index.tsx index 0eb79659..23c1c16a 100644 --- a/src/widgets/index.tsx +++ b/src/widgets/index.tsx @@ -1,33 +1,15 @@ -import BooleanWidget from './boolean'; -import CodeWidget from './code'; -import ColorStringWidget from './colorstring'; -import DateTimeWidget from './datetime'; -import FileWidget from './file'; -import ImageWidget from './image'; -import ListWidget from './list'; -import MapWidget from './map'; -import MarkdownWidget from './markdown'; -import NumberWidget from './number'; -import ObjectWidget from './object'; -import RelationWidget from './relation'; -import SelectWidget from './select'; -import StringWidget from './string'; -import TextWidget from './text'; - -export { - BooleanWidget, - CodeWidget, - ColorStringWidget, - DateTimeWidget, - FileWidget, - ImageWidget, - ListWidget, - MapWidget, - MarkdownWidget, - NumberWidget, - ObjectWidget, - RelationWidget, - SelectWidget, - StringWidget, - TextWidget, -}; +export { default as BooleanWidget } from './boolean'; +export { default as CodeWidget } from './code'; +export { default as ColorStringWidget } from './colorstring'; +export { default as DateTimeWidget } from './datetime'; +export { default as FileWidget } from './file'; +export { default as ImageWidget } from './image'; +export { default as ListWidget } from './list'; +export { default as MapWidget } from './map'; +export { default as MarkdownWidget } from './markdown'; +export { default as NumberWidget } from './number'; +export { default as ObjectWidget } from './object'; +export { default as RelationWidget } from './relation'; +export { default as SelectWidget } from './select'; +export { default as StringWidget } from './string'; +export { default as TextWidget } from './text'; diff --git a/src/widgets/list/ListControl.js b/src/widgets/list/ListControl.js deleted file mode 100644 index effb902e..00000000 --- a/src/widgets/list/ListControl.js +++ /dev/null @@ -1,680 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import styled from '@emotion/styled'; -import { css, ClassNames } from '@emotion/react'; -import { List, Map, fromJS } from 'immutable'; -import { partial, isEmpty, uniqueId } from 'lodash'; -import uuid from 'uuid/v4'; -import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc'; - -import { - ListItemTopBar, - ObjectWidgetTopBar, - colors, - lengths, - FieldLabel, -} from '../../ui'; -import { stringTemplate, validations } from '../../lib/widgets'; -import StaticCmsWidgetObject from '../object'; -import { - TYPES_KEY, - getTypedFieldForValue, - resolveFieldKeyType, - getErrorMessageForTypedFieldAndValue, -} from './typedListHelpers'; - -const ObjectControl = StaticCmsWidgetObject.controlComponent; - -const ListItem = styled.div(); - -const SortableListItem = SortableElement(ListItem); - -const StyledListItemTopBar = styled(ListItemTopBar)` - background-color: ${colors.textFieldBorder}; -`; - -const NestedObjectLabel = styled.div` - display: ${props => (props.collapsed ? 'block' : 'none')}; - border-top: 0; - color: ${props => (props.error ? colors.errorText : 'inherit')}; - background-color: ${colors.textFieldBorder}; - padding: 6px 13px; - border-radius: 0 0 ${lengths.borderRadius} ${lengths.borderRadius}; -`; - -const styleStrings = { - collapsedObjectControl: ` - display: none; - `, - objectWidgetTopBarContainer: ` - padding: ${lengths.objectWidgetTopBarContainerPadding}; - `, -}; - -const styles = { - listControlItem: css` - margin-top: 18px; - - &:last-of-type { - margin-bottom: 14px; - } - `, - -}; - -const SortableList = SortableContainer(({ items, renderItem }) => { - return
    {items.map(renderItem)}
    ; -}); - -const valueTypes = { - SINGLE: 'SINGLE', - MULTIPLE: 'MULTIPLE', - MIXED: 'MIXED', -}; - -function handleSummary(summary, entry, label, item) { - const data = stringTemplate.addFileTemplateFields( - entry.get('path'), - item.set('fields.label', label), - ); - return stringTemplate.compileStringTemplate(summary, null, '', data); -} - -function validateItem(field, item) { - if (!Map.isMap(item)) { - console.warn( - `'${field.get('name')}' field item value value should be a map but is a '${typeof item}'`, - ); - return false; - } - - return true; -} -function LabelComponent({ field, isActive, hasErrors, uniqueFieldId, isFieldOptional, t }) { - const label = `${field.get('label', field.get('name'))}`; - return ( - - {label} {`${isFieldOptional ? ` (${t('editor.editorControl.field.optional')})` : ''}`} - - ); -} - -export default class ListControl extends React.Component { - validations = []; - - static propTypes = { - metadata: ImmutablePropTypes.map, - onChange: PropTypes.func.isRequired, - onChangeObject: PropTypes.func.isRequired, - onValidateObject: PropTypes.func.isRequired, - validate: PropTypes.func.isRequired, - value: ImmutablePropTypes.list, - field: PropTypes.object, - forID: PropTypes.string, - controlRef: PropTypes.func, - mediaPaths: ImmutablePropTypes.map.isRequired, - getAsset: PropTypes.func.isRequired, - onOpenMediaLibrary: PropTypes.func.isRequired, - onAddAsset: PropTypes.func.isRequired, - onRemoveInsertedMedia: PropTypes.func.isRequired, - classNameWrapper: PropTypes.string.isRequired, - setActiveStyle: PropTypes.func.isRequired, - setInactiveStyle: PropTypes.func.isRequired, - editorControl: PropTypes.elementType.isRequired, - resolveWidget: PropTypes.func.isRequired, - clearFieldErrors: PropTypes.func.isRequired, - fieldsErrors: ImmutablePropTypes.map.isRequired, - entry: ImmutablePropTypes.map.isRequired, - t: PropTypes.func.isRequired, - }; - - static defaultProps = { - value: List(), - parentIds: [], - }; - - constructor(props) { - super(props); - const { field, value } = props; - const listCollapsed = field.get('collapsed', true); - const itemsCollapsed = (value && Array(value.size).fill(listCollapsed)) || []; - const keys = (value && Array.from({ length: value.size }, () => uuid())) || []; - - this.state = { - listCollapsed, - itemsCollapsed, - value: this.valueToString(value), - keys, - }; - } - - valueToString = value => { - let stringValue; - if (List.isList(value) || Array.isArray(value)) { - stringValue = value.join(','); - } else { - console.warn( - `Expected List value to be an array but received '${value}' with type of '${typeof value}'. Please check the value provided to the '${this.props.field.get( - 'name', - )}' field`, - ); - stringValue = String(value); - } - return stringValue.replace(/,([^\s]|$)/g, ', $1'); - }; - - getValueType = () => { - const { field } = this.props; - if (field.get('fields')) { - return valueTypes.MULTIPLE; - } else if (field.get('field')) { - return valueTypes.SINGLE; - } else if (field.get(TYPES_KEY)) { - return valueTypes.MIXED; - } else { - return null; - } - }; - - uniqueFieldId = uniqueId(`${this.props.field.get('name')}-field-`); - /** - * Always update so that each nested widget has the option to update. This is - * required because ControlHOC provides a default `shouldComponentUpdate` - * which only updates if the value changes, but every widget must be allowed - * to override this. - */ - shouldComponentUpdate() { - return true; - } - - handleChange = e => { - const { onChange } = this.props; - const oldValue = this.state.value; - const newValue = e.target.value.trim(); - const listValue = newValue ? newValue.split(',') : []; - if (newValue.match(/,$/) && oldValue.match(/, $/)) { - listValue.pop(); - } - - const parsedValue = this.valueToString(listValue); - this.setState({ value: parsedValue }); - onChange(List(listValue.map(val => val.trim()))); - }; - - handleFocus = () => { - this.props.setActiveStyle(); - }; - - handleBlur = e => { - const listValue = e.target.value - .split(',') - .map(el => el.trim()) - .filter(el => el); - this.setState({ value: this.valueToString(listValue) }); - this.props.setInactiveStyle(); - }; - - handleAdd = e => { - e.preventDefault(); - const { field } = this.props; - const parsedValue = - this.getValueType() === valueTypes.SINGLE - ? this.singleDefault() - : fromJS(this.multipleDefault(field.get('fields'))); - this.addItem(parsedValue); - }; - - singleDefault = () => { - return this.props.field.getIn(['field', 'default'], null); - }; - - multipleDefault = fields => { - return this.getFieldsDefault(fields); - }; - - handleAddType = (type, typeKey) => { - const parsedValue = fromJS(this.mixedDefault(typeKey, type)); - this.addItem(parsedValue); - }; - - mixedDefault = (typeKey, type) => { - const selectedType = this.props.field.get(TYPES_KEY).find(f => f.get('name') === type); - const fields = selectedType.get('fields') || [selectedType.get('field')]; - - return this.getFieldsDefault(fields, { [typeKey]: type }); - }; - - getFieldsDefault = (fields, initialValue = {}) => { - return fields.reduce((acc, item) => { - const subfields = item.get('field') || item.get('fields'); - const object = item.get('widget') == 'object'; - const name = item.get('name'); - const defaultValue = item.get('default', null); - - if (List.isList(subfields) && object) { - const subDefaultValue = this.getFieldsDefault(subfields); - !isEmpty(subDefaultValue) && (acc[name] = subDefaultValue); - return acc; - } - - if (Map.isMap(subfields) && object) { - const subDefaultValue = this.getFieldsDefault([subfields]); - !isEmpty(subDefaultValue) && (acc[name] = subDefaultValue); - return acc; - } - - if (defaultValue !== null) { - acc[name] = defaultValue; - } - - return acc; - }, initialValue); - }; - - addItem = parsedValue => { - const { value, onChange, field } = this.props; - const addToTop = field.get('add_to_top', false); - - const itemKey = uuid(); - this.setState({ - itemsCollapsed: addToTop - ? [false, ...this.state.itemsCollapsed] - : [...this.state.itemsCollapsed, false], - keys: addToTop ? [itemKey, ...this.state.keys] : [...this.state.keys, itemKey], - }); - - const listValue = value || List(); - if (addToTop) { - onChange(listValue.unshift(parsedValue)); - } else { - onChange(listValue.push(parsedValue)); - } - }; - - processControlRef = ref => { - if (!ref) return; - const { - validate, - props: { validationKey: key }, - } = ref; - this.validations.push({ key, validate }); - }; - - validate = () => { - if (this.getValueType()) { - this.validations.forEach(item => { - item.validate(); - }); - } else { - this.props.validate(); - } - this.props.onValidateObject(this.props.forID, this.validateSize()); - }; - - validateSize = () => { - const { field, value, t } = this.props; - const min = field.get('min'); - const max = field.get('max'); - const required = field.get('required', true); - - if (!required && !value?.size) { - return []; - } - - const error = validations.validateMinMax( - t, - field.get('label', field.get('name')), - value, - min, - max, - ); - - return error ? [error] : []; - }; - - /** - * In case the `onChangeObject` function is frozen by a child widget implementation, - * e.g. when debounced, always get the latest object value instead of using - * `this.props.value` directly. - */ - getObjectValue = idx => this.props.value.get(idx) || Map(); - - handleChangeFor(index) { - return (f, newValue, newMetadata) => { - const { value, metadata, onChange, field } = this.props; - const collectionName = field.get('name'); - const listFieldObjectWidget = field.getIn(['field', 'widget']) === 'object'; - const withNameKey = - this.getValueType() !== valueTypes.SINGLE || - (this.getValueType() === valueTypes.SINGLE && listFieldObjectWidget); - const newObjectValue = withNameKey - ? this.getObjectValue(index).set(f.get('name'), newValue) - : newValue; - const parsedMetadata = { - [collectionName]: Object.assign(metadata ? metadata.toJS() : {}, newMetadata || {}), - }; - onChange(value.set(index, newObjectValue), parsedMetadata); - }; - } - - handleRemove = (index, event) => { - event.preventDefault(); - const { itemsCollapsed } = this.state; - const { value, metadata, onChange, field, clearFieldErrors } = this.props; - const collectionName = field.get('name'); - const isSingleField = this.getValueType() === valueTypes.SINGLE; - - const metadataRemovePath = isSingleField ? value.get(index) : value.get(index).valueSeq(); - const parsedMetadata = - metadata && !metadata.isEmpty() - ? { [collectionName]: metadata.removeIn(metadataRemovePath) } - : metadata; - - itemsCollapsed.splice(index, 1); - // clear validations - this.validations = []; - - this.setState({ - itemsCollapsed: [...itemsCollapsed], - keys: Array.from({ length: value.size - 1 }, () => uuid()), - }); - - onChange(value.remove(index), parsedMetadata); - clearFieldErrors(); - }; - - handleItemCollapseToggle = (index, event) => { - event.preventDefault(); - const { itemsCollapsed } = this.state; - const newItemsCollapsed = itemsCollapsed.map((collapsed, itemIndex) => { - if (index === itemIndex) { - return !collapsed; - } - return collapsed; - }); - this.setState({ - itemsCollapsed: newItemsCollapsed, - }); - }; - - handleCollapseAllToggle = e => { - e.preventDefault(); - const { value, field } = this.props; - const { itemsCollapsed, listCollapsed } = this.state; - const minimizeCollapsedItems = field.get('minimize_collapsed', false); - const listCollapsedByDefault = field.get('collapsed', true); - const allItemsCollapsed = itemsCollapsed.every(val => val === true); - - if (minimizeCollapsedItems) { - let updatedItemsCollapsed = itemsCollapsed; - // Only allow collapsing all items in this mode but not opening all at once - if (!listCollapsed || !listCollapsedByDefault) { - updatedItemsCollapsed = Array(value.size).fill(!listCollapsed); - } - this.setState({ listCollapsed: !listCollapsed, itemsCollapsed: updatedItemsCollapsed }); - } else { - this.setState({ itemsCollapsed: Array(value.size).fill(!allItemsCollapsed) }); - } - }; - - objectLabel(item) { - const { field, entry } = this.props; - const valueType = this.getValueType(); - switch (valueType) { - case valueTypes.MIXED: { - if (!validateItem(field, item)) { - return; - } - const itemType = getTypedFieldForValue(field, item); - const label = itemType.get('label', itemType.get('name')); - // each type can have its own summary, but default to the list summary if exists - const summary = itemType.get('summary', field.get('summary')); - const labelReturn = summary ? handleSummary(summary, entry, label, item) : label; - return labelReturn; - } - case valueTypes.SINGLE: { - const singleField = field.get('field'); - const label = singleField.get('label', singleField.get('name')); - const summary = field.get('summary'); - const data = fromJS({ [singleField.get('name')]: item }); - const labelReturn = summary ? handleSummary(summary, entry, label, data) : label; - return labelReturn; - } - case valueTypes.MULTIPLE: { - if (!validateItem(field, item)) { - return; - } - const multiFields = field.get('fields'); - const labelField = multiFields && multiFields.first(); - const value = item.get(labelField.get('name')); - const summary = field.get('summary'); - const labelReturn = summary ? handleSummary(summary, entry, value, item) : value; - return (labelReturn || `No ${labelField.get('name')}`).toString(); - } - } - return ''; - } - - onSortEnd = ({ oldIndex, newIndex }) => { - const { value, clearFieldErrors } = this.props; - const { itemsCollapsed, keys } = this.state; - - // Update value - const item = value.get(oldIndex); - const newValue = value.delete(oldIndex).insert(newIndex, item); - this.props.onChange(newValue); - - // Update collapsing - const collapsed = itemsCollapsed[oldIndex]; - itemsCollapsed.splice(oldIndex, 1); - const updatedItemsCollapsed = [...itemsCollapsed]; - updatedItemsCollapsed.splice(newIndex, 0, collapsed); - - // Reset item to ensure updated state - const updatedKeys = keys.map((key, keyIndex) => { - if (keyIndex === oldIndex || keyIndex === newIndex) { - return uuid(); - } - return key; - }); - this.setState({ itemsCollapsed: updatedItemsCollapsed, keys: updatedKeys }); - - //clear error fields and remove old validations - clearFieldErrors(); - this.validations = this.validations.filter(item => updatedKeys.includes(item.key)); - }; - - hasError = index => { - const { fieldsErrors } = this.props; - if (fieldsErrors && fieldsErrors.size > 0) { - return Object.values(fieldsErrors.toJS()).some(arr => - arr.some(err => err.parentIds && err.parentIds.includes(this.state.keys[index])), - ); - } - }; - - // eslint-disable-next-line react/display-name - renderItem = (item, index) => { - const { - classNameWrapper, - editorControl, - onValidateObject, - metadata, - clearFieldErrors, - fieldsErrors, - controlRef, - resolveWidget, - parentIds, - forID, - t, - } = this.props; - - const { itemsCollapsed, keys } = this.state; - const collapsed = itemsCollapsed[index]; - const key = keys[index]; - let field = this.props.field; - const hasError = this.hasError(index); - const isVariableTypesList = this.getValueType() === valueTypes.MIXED; - if (isVariableTypesList) { - field = getTypedFieldForValue(field, item); - if (!field) { - return this.renderErroneousTypedItem(index, item); - } - } - return ( - - {isVariableTypesList && ( - - )} - - {/* - {this.objectLabel(item)} - */} - - {({ css, cx }) => ( - - )} - - - ); - }; - - renderErroneousTypedItem(index, item) { - const field = this.props.field; - const errorMessage = getErrorMessageForTypedFieldAndValue(field, item); - const key = `item-${index}`; - return ( - - - - {errorMessage} - - - ); - } - - renderListControl() { - const { value, forID, field, classNameWrapper, t } = this.props; - const { itemsCollapsed, listCollapsed } = this.state; - const items = value || List(); - const label = field.get('label', field.get('name')); - const labelSingular = field.get('label_singular') || field.get('label', field.get('name')); - const listLabel = items.size === 1 ? labelSingular.toLowerCase() : label.toLowerCase(); - const minimizeCollapsedItems = field.get('minimize_collapsed', false); - const allItemsCollapsed = itemsCollapsed.every(val => val === true); - const selfCollapsed = allItemsCollapsed && (listCollapsed || !minimizeCollapsedItems); - - return ( - - {({ cx, css }) => ( -
    - this.handleAddType(type, resolveFieldKeyType(field))} - heading={`${items.size} ${listLabel}`} - label={labelSingular.toLowerCase()} - onCollapseToggle={this.handleCollapseAllToggle} - collapsed={selfCollapsed} - t={t} - /> - {(!selfCollapsed || !minimizeCollapsedItems) && ( - - )} -
    - )} -
    - ); - } - - renderInput() { - const { forID, classNameWrapper } = this.props; - const { value } = this.state; - - return ( - - ); - } - - render() { - if (this.getValueType() !== null) { - return this.renderListControl(); - } else { - return this.renderInput(); - } - } -} diff --git a/src/widgets/list/ListControl.tsx b/src/widgets/list/ListControl.tsx new file mode 100644 index 00000000..0269ef5c --- /dev/null +++ b/src/widgets/list/ListControl.tsx @@ -0,0 +1,300 @@ +import { styled } from '@mui/material/styles'; +import { arrayMoveImmutable } from 'array-move'; +import isEmpty from 'lodash/isEmpty'; +import React, { useCallback, useMemo, useState } from 'react'; +import { SortableContainer } from 'react-sortable-hoc'; +import uuid from 'uuid'; + +import FieldLabel from '../../components/UI/FieldLabel'; +import ObjectWidgetTopBar from '../../components/UI/ObjectWidgetTopBar'; +import Outline from '../../components/UI/Outline'; +import transientOptions from '../../lib/util/transientOptions'; +import ListItem from './ListItem'; +import { resolveFieldKeyType, TYPES_KEY } from './typedListHelpers'; + +import type { MouseEvent } from 'react'; +import type { + Field, + ListField, + ObjectValue, + ValueOrNestedValue, + WidgetControlProps, +} from '../../interface'; + +const StyledListWrapper = styled('div')` + position: relative; + width: 100%; +`; + +interface StyledSortableListProps { + $collapsed: boolean; +} + +const StyledSortableList = styled( + 'div', + transientOptions, +)( + ({ $collapsed }) => ` + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + ${ + $collapsed + ? ` + visibility: hidden; + height: 0; + width: 0; + ` + : ` + padding: 16px; + ` + } + `, +); + +interface SortableListProps { + items: ObjectValue[]; + collapsed: boolean; + renderItem: (item: ObjectValue, index: number) => JSX.Element; +} + +const SortableList = SortableContainer( + ({ items, collapsed, renderItem }: SortableListProps) => { + return {items.map(renderItem)}; + }, +); + +export enum ListValueType { + MULTIPLE, + MIXED, +} + +function getFieldsDefault(fields: Field[], initialValue: ObjectValue = {}): ObjectValue { + return fields.reduce((acc, item) => { + const subfields = 'fields' in item && item.fields; + const name = item.name; + const defaultValue: ValueOrNestedValue | null = + 'default' in item && item.default ? item.default : null; + + if (Array.isArray(subfields)) { + const subDefaultValue = getFieldsDefault(subfields); + if (!isEmpty(subDefaultValue)) { + acc[name] = subDefaultValue; + } + return acc; + } else if (typeof subfields === 'object') { + const subDefaultValue = getFieldsDefault([subfields]); + !isEmpty(subDefaultValue) && (acc[name] = subDefaultValue); + return acc; + } + + if (defaultValue !== null) { + acc[name] = defaultValue; + } + + return acc; + }, initialValue); +} + +const ListControl = ({ + clearFieldErrors, + entry, + field, + fieldsErrors, + submitted, + isFieldDuplicate, + isFieldHidden, + locale, + onChange, + path, + t, + value, + i18n, + hasErrors, +}: WidgetControlProps) => { + const internalValue = useMemo(() => value ?? [], [value]); + const [collapsed, setCollapsed] = useState(field.collapsed ?? true); + const [keys, setKeys] = useState(Array.from({ length: internalValue.length }, () => uuid())); + + const valueType = useMemo(() => { + if ('fields' in field) { + return ListValueType.MULTIPLE; + } else if ('types' in field) { + return ListValueType.MIXED; + } else { + return null; + } + }, [field]); + + const multipleDefault = useCallback((fields: Field[]) => { + return getFieldsDefault(fields); + }, []); + + const mixedDefault = useCallback( + (typeKey: string, type: string): ObjectValue => { + const selectedType = 'types' in field && field.types?.find(f => f.name === type); + if (!selectedType) { + return {}; + } + + return getFieldsDefault(selectedType.fields ?? [], { [typeKey]: type }); + }, + [field], + ); + + const addItem = useCallback( + (parsedValue: ObjectValue) => { + const addToTop = field.add_to_top ?? false; + + const newKeys = [...keys]; + const newValue = [...internalValue]; + if (addToTop) { + newKeys.unshift(uuid()); + newValue.unshift(parsedValue); + } else { + newKeys.push(uuid()); + newValue.push(parsedValue); + } + setKeys(newKeys); + onChange(newValue); + setCollapsed(false); + }, + [field.add_to_top, onChange, internalValue, keys], + ); + + const handleAdd = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + addItem('fields' in field && field.fields ? multipleDefault(field.fields) : {}); + }, + [addItem, field, multipleDefault], + ); + + const handleAddType = useCallback( + (type: string, typeKey: string) => { + const parsedValue = mixedDefault(typeKey, type); + addItem(parsedValue); + }, + [addItem, mixedDefault], + ); + + const handleRemove = useCallback( + (index: number, event: MouseEvent) => { + event.preventDefault(); + + const newKeys = [...keys]; + const newValue = [...internalValue]; + + newKeys.splice(index, 1); + newValue.splice(index, 1); + + setKeys(newKeys); + onChange(newValue); + }, + [onChange, internalValue, keys], + ); + + const handleCollapseAllToggle = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + setCollapsed(!collapsed); + }, + [collapsed], + ); + + const onSortEnd = useCallback( + ({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => { + // Update value + setKeys(arrayMoveImmutable(keys, oldIndex, newIndex)); + onChange(arrayMoveImmutable(internalValue, oldIndex, newIndex)); + }, + [onChange, internalValue, keys], + ); + + const renderItem = useCallback( + (item: ObjectValue, index: number) => { + const key = keys[index]; + if (valueType === null) { + return
    ; + } + + return ( + } + i18n={i18n} + /> + ); + }, + [ + keys, + valueType, + handleRemove, + clearFieldErrors, + entry, + field, + fieldsErrors, + submitted, + isFieldDuplicate, + isFieldHidden, + locale, + path, + i18n, + ], + ); + + if (valueType === null) { + return null; + } + + const label = field.label ?? field.name; + const labelSingular = field.label_singular ? field.label_singular : field.label ?? field.name; + const listLabel = internalValue.length === 1 ? labelSingular.toLowerCase() : label.toLowerCase(); + + return ( + + {label} + handleAddType(type, resolveFieldKeyType(field))} + heading={`${internalValue.length} ${listLabel}`} + label={labelSingular.toLowerCase()} + onCollapseToggle={handleCollapseAllToggle} + collapsed={collapsed} + hasError={hasErrors} + t={t} + /> + {internalValue.length > 0 ? ( + + ) : null} + + + ); +}; + +export default ListControl; diff --git a/src/widgets/list/ListItem.tsx b/src/widgets/list/ListItem.tsx new file mode 100644 index 00000000..7948b098 --- /dev/null +++ b/src/widgets/list/ListItem.tsx @@ -0,0 +1,225 @@ +import { styled } from '@mui/material/styles'; +import partial from 'lodash/partial'; +import React, { useCallback, useMemo, useState } from 'react'; +import { SortableElement, SortableHandle } from 'react-sortable-hoc'; + +import EditorControl from '../../components/Editor/EditorControlPane/EditorControl'; +import ListItemTopBar from '../../components/UI/ListItemTopBar'; +import Outline from '../../components/UI/Outline'; +import { colors } from '../../components/UI/styles'; +import { transientOptions } from '../../lib'; +import { addFileTemplateFields, compileStringTemplate } from '../../lib/widgets/stringTemplate'; +import { ListValueType } from './ListControl'; +import { getTypedFieldForValue } from './typedListHelpers'; + +import type { MouseEvent } from 'react'; +import type { + Entry, + EntryData, + ListField, + ObjectField, + ObjectValue, + WidgetControlProps, +} from '../../interface'; + +const StyledListItem = styled('div')` + position: relative; +`; + +const SortableStyledListItem = SortableElement<{ children: JSX.Element }>(StyledListItem); + +const StyledListItemTopBar = styled(ListItemTopBar)` + background-color: ${colors.textFieldBorder}; +`; + +interface StyledObjectFieldWrapperProps { + $collapsed: boolean; +} + +const StyledObjectFieldWrapper = styled( + 'div', + transientOptions, +)( + ({ $collapsed }) => ` + display: flex; + flex-direction: column; + gap: 16px; + ${ + $collapsed + ? ` + visibility: hidden; + height: 0; + width: 0; + ` + : '' + } + `, +); + +function handleSummary(summary: string, entry: Entry, label: string, item: ObjectValue) { + const labeledItem: EntryData = { + ...item, + fields: { + label, + }, + }; + const data = addFileTemplateFields(entry.path, labeledItem); + return compileStringTemplate(summary, null, '', data); +} + +function validateItem(field: ListField, item: ObjectValue) { + if (!(typeof item === 'object')) { + console.warn( + `'${field.name}' field item value value should be an object but is a '${typeof item}'`, + ); + return false; + } + + return true; +} + +interface ListItemProps + extends Pick< + WidgetControlProps, + | 'clearFieldErrors' + | 'entry' + | 'field' + | 'fieldsErrors' + | 'submitted' + | 'isFieldDuplicate' + | 'isFieldHidden' + | 'locale' + | 'path' + | 'value' + | 'i18n' + > { + valueType: ListValueType; + index: number; + handleRemove: (index: number, event: MouseEvent) => void; +} + +const ListItem = ({ + index, + clearFieldErrors, + entry, + field, + fieldsErrors, + submitted, + isFieldDuplicate, + isFieldHidden, + locale, + path, + valueType, + handleRemove, + value, + i18n, +}: ListItemProps) => { + const [objectLabel, objectField] = useMemo((): [string, ListField | ObjectField] => { + const childObjectField: ObjectField = { + name: `${index}`, + label: field.label, + summary: field.summary, + widget: 'object', + fields: [], + }; + + const base = field.label ?? field.name; + if (valueType === null) { + return [base, childObjectField]; + } + + const objectValue = value ?? {}; + + switch (valueType) { + case ListValueType.MIXED: { + if (!validateItem(field, objectValue)) { + return [base, childObjectField]; + } + + const itemType = getTypedFieldForValue(field, objectValue, index); + if (!itemType) { + return [base, childObjectField]; + } + + const label = itemType.label ?? itemType.name; + // each type can have its own summary, but default to the list summary if exists + const summary = ('summary' in itemType && itemType.summary) ?? field.summary; + const labelReturn = summary + ? `${label} - ${handleSummary(summary, entry, label, objectValue)}` + : label; + return [labelReturn, itemType]; + } + case ListValueType.MULTIPLE: { + childObjectField.fields = field.fields ?? []; + + if (!validateItem(field, objectValue)) { + return [base, childObjectField]; + } + + const multiFields = field.fields; + const labelField = multiFields && multiFields[0]; + if (!labelField) { + return [base, childObjectField]; + } + + const labelFieldValue = objectValue[labelField.name]; + + const summary = field.summary; + const labelReturn = summary + ? handleSummary(summary, entry, String(labelFieldValue), objectValue) + : labelFieldValue; + return [(labelReturn || `No ${labelField.name}`).toString(), childObjectField]; + } + } + }, [entry, field, index, value, valueType]); + + const [collapsed, setCollapsed] = useState(false); + const handleCollapseToggle = useCallback( + (event: MouseEvent) => { + event.stopPropagation(); + setCollapsed(!collapsed); + }, + [collapsed], + ); + + const isDuplicate = isFieldDuplicate && isFieldDuplicate(field); + const isHidden = isFieldHidden && isFieldHidden(field); + + return ( + + <> + + + + + + + + ); +}; + +export default ListItem; diff --git a/src/widgets/list/index.js b/src/widgets/list/index.js deleted file mode 100644 index 44f0decc..00000000 --- a/src/widgets/list/index.js +++ /dev/null @@ -1,18 +0,0 @@ -import StaticCmsWidgetObject from '../object'; -import controlComponent from './ListControl'; -import schema from './schema'; - -const previewComponent = StaticCmsWidgetObject.previewComponent; - -function Widget(opts = {}) { - return { - name: 'list', - controlComponent, - previewComponent, - schema, - ...opts, - }; -} - -export const StaticCmsWidgetList = { Widget, controlComponent, previewComponent }; -export default StaticCmsWidgetList; diff --git a/src/widgets/list/index.ts b/src/widgets/list/index.ts new file mode 100644 index 00000000..3900337c --- /dev/null +++ b/src/widgets/list/index.ts @@ -0,0 +1,18 @@ +import ObjectPreview from '../object/ObjectPreview'; +import controlComponent from './ListControl'; +import schema from './schema'; + +import type { ListField, WidgetParam, ObjectValue } from '../../interface'; + +const ListWidget = (): WidgetParam => { + return { + name: 'list', + controlComponent, + previewComponent: ObjectPreview, + options: { + schema, + }, + }; +}; + +export default ListWidget; diff --git a/src/widgets/list/schema.js b/src/widgets/list/schema.ts similarity index 100% rename from src/widgets/list/schema.js rename to src/widgets/list/schema.ts diff --git a/src/widgets/list/typedListHelpers.js b/src/widgets/list/typedListHelpers.js deleted file mode 100644 index b617a055..00000000 --- a/src/widgets/list/typedListHelpers.js +++ /dev/null @@ -1,35 +0,0 @@ -export const TYPES_KEY = 'types'; -export const TYPE_KEY = 'typeKey'; -export const DEFAULT_TYPE_KEY = 'type'; - -export function getTypedFieldForValue(field, value) { - const typeKey = resolveFieldKeyType(field); - const types = field.get(TYPES_KEY); - const valueType = value.get(typeKey); - return types.find(type => type.get('name') === valueType); -} - -export function resolveFunctionForTypedField(field) { - const typeKey = resolveFieldKeyType(field); - const types = field.get(TYPES_KEY); - return value => { - const valueType = value.get(typeKey); - return types.find(type => type.get('name') === valueType); - }; -} - -export function resolveFieldKeyType(field) { - return field.get(TYPE_KEY, DEFAULT_TYPE_KEY); -} - -export function getErrorMessageForTypedFieldAndValue(field, value) { - const keyType = resolveFieldKeyType(field); - const type = value.get(keyType); - let errorMessage; - if (!type) { - errorMessage = `Error: item has no '${keyType}' property`; - } else { - errorMessage = `Error: item has illegal '${keyType}' property: '${type}'`; - } - return errorMessage; -} diff --git a/src/widgets/list/typedListHelpers.ts b/src/widgets/list/typedListHelpers.ts new file mode 100644 index 00000000..2e780662 --- /dev/null +++ b/src/widgets/list/typedListHelpers.ts @@ -0,0 +1,52 @@ +import type { ListField, ObjectField, ObjectValue } from '../../interface'; + +export const TYPES_KEY = 'types'; +export const TYPE_KEY = 'typeKey'; +export const DEFAULT_TYPE_KEY = 'type'; + +export function getTypedFieldForValue( + field: ListField, + value: ObjectValue | undefined | null, + index: number, +): ObjectField | undefined { + const typeKey = resolveFieldKeyType(field); + const types = field[TYPES_KEY] ?? []; + const valueType = value?.[typeKey] ?? {}; + const typeField = types.find(type => type.name === valueType); + if (!typeField) { + return typeField; + } + + return { + ...typeField, + name: `${index}`, + }; +} + +export function resolveFunctionForTypedField(field: ListField) { + const typeKey = resolveFieldKeyType(field); + const types = field[TYPES_KEY] ?? []; + return (value: ObjectValue) => { + const valueType = value[typeKey]; + return types.find(type => type.name === valueType); + }; +} + +export function resolveFieldKeyType(field: ListField) { + return (TYPE_KEY in field && field[TYPE_KEY]) || DEFAULT_TYPE_KEY; +} + +export function getErrorMessageForTypedFieldAndValue( + field: ListField, + value: ObjectValue | undefined | null, +) { + const keyType = resolveFieldKeyType(field); + const type = value?.[keyType] ?? {}; + let errorMessage; + if (!type) { + errorMessage = `Error: item has no '${keyType}' property`; + } else { + errorMessage = `Error: item has illegal '${keyType}' property: '${type}'`; + } + return errorMessage; +} diff --git a/src/widgets/map/MapPreview.js b/src/widgets/map/MapPreview.js deleted file mode 100644 index b76d34c0..00000000 --- a/src/widgets/map/MapPreview.js +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import { WidgetPreviewContainer } from '../../ui'; - -function MapPreview({ value }) { - return {value ? value.toString() : null}; -} - -MapPreview.propTypes = { - value: PropTypes.string, -}; - -export default MapPreview; diff --git a/src/widgets/map/MapPreview.tsx b/src/widgets/map/MapPreview.tsx new file mode 100644 index 00000000..8e9301a2 --- /dev/null +++ b/src/widgets/map/MapPreview.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer'; + +import type { MapField, WidgetPreviewProps } from '../../interface'; + +const MapPreview = ({ value }: WidgetPreviewProps) => { + return {value ? value.toString() : null}; +}; + +export default MapPreview; diff --git a/src/widgets/map/index.js b/src/widgets/map/index.ts similarity index 57% rename from src/widgets/map/index.js rename to src/widgets/map/index.ts index 4b8baf84..c0a9871b 100644 --- a/src/widgets/map/index.js +++ b/src/widgets/map/index.ts @@ -1,18 +1,20 @@ -import withMapControl from './withMapControl'; import previewComponent from './MapPreview'; import schema from './schema'; +import withMapControl from './withMapControl'; + +import type { MapField, WidgetParam } from '../../interface'; const controlComponent = withMapControl(); -function Widget(opts = {}) { +const MapWidget = (): WidgetParam => { return { name: 'map', controlComponent, previewComponent, - schema, - ...opts, + options: { + schema, + }, }; -} +}; -export const StaticCmsWidgetMap = { Widget, controlComponent, previewComponent }; -export default StaticCmsWidgetMap; +export default MapWidget; diff --git a/src/widgets/map/schema.js b/src/widgets/map/schema.ts similarity index 100% rename from src/widgets/map/schema.js rename to src/widgets/map/schema.ts diff --git a/src/widgets/map/withMapControl.js b/src/widgets/map/withMapControl.js deleted file mode 100644 index d91d50b2..00000000 --- a/src/widgets/map/withMapControl.js +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { ClassNames } from '@emotion/react'; -import olStyles from 'ol/ol.css'; -import Map from 'ol/Map.js'; -import View from 'ol/View.js'; -import GeoJSON from 'ol/format/GeoJSON'; -import Draw from 'ol/interaction/Draw.js'; -import TileLayer from 'ol/layer/Tile.js'; -import VectorLayer from 'ol/layer/Vector.js'; -import OSMSource from 'ol/source/OSM.js'; -import VectorSource from 'ol/source/Vector.js'; - -const formatOptions = { - dataProjection: 'EPSG:4326', - featureProjection: 'EPSG:3857', -}; - -function getDefaultFormat() { - return new GeoJSON(formatOptions); -} - -function getDefaultMap(target, featuresLayer) { - return new Map({ - target, - layers: [new TileLayer({ source: new OSMSource() }), featuresLayer], - view: new View({ center: [0, 0], zoom: 2 }), - }); -} - -export default function withMapControl({ getFormat, getMap } = {}) { - return class MapControl extends React.Component { - static propTypes = { - onChange: PropTypes.func.isRequired, - field: PropTypes.object.isRequired, - height: PropTypes.string, - value: PropTypes.node, - }; - - static defaultProps = { - value: '', - height: '400px', - }; - - constructor(props) { - super(props); - this.mapContainer = React.createRef(); - } - - componentDidMount() { - const { field, onChange, value } = this.props; - const format = getFormat ? getFormat(field) : getDefaultFormat(field); - const features = value ? [format.readFeature(value)] : []; - - const featuresSource = new VectorSource({ features, wrapX: false }); - const featuresLayer = new VectorLayer({ source: featuresSource }); - - const target = this.mapContainer.current; - const map = getMap ? getMap(target, featuresLayer) : getDefaultMap(target, featuresLayer); - if (features.length > 0) { - map.getView().fit(featuresSource.getExtent(), { maxZoom: 16, padding: [80, 80, 80, 80] }); - } - - const draw = new Draw({ source: featuresSource, type: field.get('type', 'Point') }); - map.addInteraction(draw); - - const writeOptions = { decimals: field.get('decimals', 7) }; - draw.on('drawend', ({ feature }) => { - featuresSource.clear(); - onChange(format.writeGeometry(feature.getGeometry(), writeOptions)); - }); - } - - render() { - const { height } = this.props; - - return ( - - {({ cx, css }) => ( -
    - )} - - ); - } - }; -} diff --git a/src/widgets/map/withMapControl.tsx b/src/widgets/map/withMapControl.tsx new file mode 100644 index 00000000..ec3d807f --- /dev/null +++ b/src/widgets/map/withMapControl.tsx @@ -0,0 +1,152 @@ +import { css, styled } from '@mui/material/styles'; +import GeoJSON from 'ol/format/GeoJSON'; +import Draw from 'ol/interaction/Draw'; +import TileLayer from 'ol/layer/Tile'; +import VectorLayer from 'ol/layer/Vector'; +import Map from 'ol/Map.js'; +import olStyles from 'ol/ol.css'; +import OSMSource from 'ol/source/OSM'; +import VectorSource from 'ol/source/Vector'; +import View from 'ol/View.js'; +import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react'; + +import ObjectWidgetTopBar from '../../components/UI/ObjectWidgetTopBar'; +import Outline from '../../components/UI/Outline'; +import transientOptions from '../../lib/util/transientOptions'; + +import type { Geometry } from 'ol/geom'; +import type { MapField, WidgetControlProps } from '../../interface'; + +const StyledMapControlWrapper = styled('div')` + display: flex; + flex-direction: column; + position: relative; + width: 100%; +`; + +interface StyledMapControlContentProps { + $collapsed: boolean; + $height: string; +} + +const StyledMapControlContent = styled( + 'div', + transientOptions, +)( + ({ $collapsed, $height }) => ` + display: flex; + postion: relative; + height: ${$height} + ${ + $collapsed + ? ` + visibility: hidden; + height: 0; + width: 0; + ` + : '' + } + `, +); + +const StyledMap = styled('div')` + width: 100%; + position: relative; + ${css` + ${olStyles} + `} +`; + +const formatOptions = { + dataProjection: 'EPSG:4326', + featureProjection: 'EPSG:3857', +}; + +function getDefaultFormat() { + return new GeoJSON(formatOptions); +} + +function getDefaultMap(target: HTMLDivElement, featuresLayer: VectorLayer>) { + return new Map({ + target, + layers: [new TileLayer({ source: new OSMSource() }), featuresLayer], + view: new View({ center: [0, 0], zoom: 2 }), + }); +} + +interface WithMapControlProps { + getFormat?: (field: MapField) => GeoJSON; + getMap?: (target: HTMLDivElement, featuresLayer: VectorLayer>) => Map; +} + +export default function withMapControl({ getFormat, getMap }: WithMapControlProps = {}) { + const MapControl = ({ + path, + value, + field, + onChange, + hasErrors, + label, + t, + }: WidgetControlProps) => { + const [collapsed, setCollapsed] = useState(false); + + const handleCollapseToggle = useCallback(() => { + setCollapsed(!collapsed); + }, [collapsed]); + const { height = '400px' } = field; + + const mapContainer: React.LegacyRef = useMemo(() => React.createRef(), []); + + useLayoutEffect(() => { + const format = getFormat ? getFormat(field) : getDefaultFormat(); + const features = value ? [format.readFeature(value)] : []; + + const featuresSource = new VectorSource({ features, wrapX: false }); + const featuresLayer = new VectorLayer({ source: featuresSource }); + + const target = mapContainer.current; + if (!target) { + return; + } + + const map = getMap ? getMap(target, featuresLayer) : getDefaultMap(target, featuresLayer); + if (features.length > 0) { + map.getView().fit(featuresSource.getExtent(), { maxZoom: 16, padding: [80, 80, 80, 80] }); + } + + const draw = new Draw({ source: featuresSource, type: field.type ?? 'Point' }); + map.addInteraction(draw); + + const writeOptions = { decimals: field.decimals ?? 7 }; + draw.on('drawend', ({ feature }) => { + featuresSource.clear(); + const geometry = feature.getGeometry(); + if (geometry) { + onChange(format.writeGeometry(geometry, writeOptions)); + } + }); + }, [field, mapContainer, onChange, path, value]); + + return ( + + + + + + + + ); + }; + + MapControl.displayName = 'MapControl'; + + return MapControl; +} diff --git a/src/widgets/markdown/MarkdownControl.tsx b/src/widgets/markdown/MarkdownControl.tsx new file mode 100644 index 00000000..a5a3e60e --- /dev/null +++ b/src/widgets/markdown/MarkdownControl.tsx @@ -0,0 +1,149 @@ +import { styled } from '@mui/material/styles'; +import { Editor } from '@toast-ui/react-editor'; +import mime from 'mime-types'; +import React, { useCallback, useMemo, useState } from 'react'; +import uuid from 'uuid'; + +import FieldLabel from '../../components/UI/FieldLabel'; +import Outline from '../../components/UI/Outline'; +import { sanitizeSlug } from '../../lib/urlHelper'; +import { selectMediaFilePath } from '../../lib/util/media.util'; +import { createAssetProxy } from '../../valueObjects/AssetProxy'; + +import type { RefObject } from 'react'; +import type { MarkdownField, WidgetControlProps } from '../../interface'; + +import '@toast-ui/editor/dist/toastui-editor.css'; + +const StyledEditorWrapper = styled('div')` + position: relative; + width: 100%; + + .toastui-editor-main .toastui-editor-md-vertical-style .toastui-editor { + width: 100%; + } + + .toastui-editor-main .toastui-editor-md-splitter { + display: none; + } + + .toastui-editor-md-preview { + display: none; + } + + .toastui-editor-defaultUI { + border: none; + } +`; + +const MarkdownControl = ({ + label, + value, + onChange, + hasErrors, + field, + addAsset, + addDraftEntryMediaFile, + config, + collection, + entry, +}: WidgetControlProps) => { + const [internalValue, setInternalValue] = useState(value ?? ''); + const editorRef = useMemo(() => React.createRef(), []) as RefObject; + const [hasFocus, setHasFocus] = useState(false); + + const handleOnFocus = useCallback(() => { + setHasFocus(true); + }, []); + + const handleOnBlur = useCallback(() => { + setHasFocus(false); + }, []); + + const handleOnChange = useCallback(() => { + const newValue = editorRef.current?.getInstance().getMarkdown() ?? ''; + setInternalValue(newValue); + onChange(newValue); + }, [editorRef, onChange]); + + const handleLabelClick = useCallback(() => { + editorRef.current?.getInstance().focus(); + }, [editorRef]); + + const imageUpload = useCallback( + (blob: Blob | File, callback: (url: string, text?: string) => void) => { + let file: File; + if (blob instanceof Blob) { + blob.type; + file = new File([blob], `${uuid()}.${mime.extension(blob.type)}`); + } else { + file = blob; + } + const fileName = sanitizeSlug(file.name.toLowerCase(), config.slug); + const path = selectMediaFilePath(config, collection, entry, fileName, field); + const blobUrl = URL.createObjectURL(file); + addAsset( + createAssetProxy({ + url: blobUrl, + file, + path, + field, + }), + ); + addDraftEntryMediaFile({ + name: file.name, + id: file.name, + size: file.size, + displayURL: blobUrl, + path, + draft: true, + url: blobUrl, + file, + field, + }); + console.log(blob); + callback(path); + handleOnChange(); + }, + [addAsset, addDraftEntryMediaFile, collection, config, entry, field, handleOnChange], + ); + + return ( + + + {label} + + + + + ); +}; + +export default MarkdownControl; diff --git a/src/widgets/markdown/MarkdownControl/RawEditor.js b/src/widgets/markdown/MarkdownControl/RawEditor.js deleted file mode 100644 index b7d67511..00000000 --- a/src/widgets/markdown/MarkdownControl/RawEditor.js +++ /dev/null @@ -1,157 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import styled from '@emotion/styled'; -import { ClassNames } from '@emotion/react'; -import { debounce } from 'lodash'; -import { Value } from 'slate'; -import { Editor as Slate, setEventTransfer } from 'slate-react'; -import Plain from 'slate-plain-serializer'; -import isHotkey from 'is-hotkey'; - -import { lengths, fonts } from '../../../ui'; -import { markdownToHtml } from '../serializers'; -import { editorStyleVars, EditorControlBar } from '../styles'; -import Toolbar from './Toolbar'; - -function rawEditorStyles({ minimal }) { - return ` - position: relative; - overflow: hidden; - overflow-x: auto; - min-height: ${minimal ? 'auto' : lengths.richTextEditorMinHeight}; - font-family: ${fonts.mono}; - border-top-left-radius: 0; - border-top-right-radius: 0; - border-top: 0; - margin-top: -${editorStyleVars.stickyDistanceBottom}; -`; -} - -const RawEditorContainer = styled.div` - position: relative; -`; - -export default class RawEditor extends React.Component { - constructor(props) { - super(props); - this.state = { - value: Plain.deserialize(this.props.value || ''), - }; - } - - shouldComponentUpdate(nextProps, nextState) { - return ( - !this.state.value.equals(nextState.value) || - nextProps.value !== Plain.serialize(nextState.value) - ); - } - - componentDidUpdate(prevProps) { - if (prevProps.value !== this.props.value) { - this.setState({ value: Plain.deserialize(this.props.value) }); - } - } - - componentDidMount() { - if (this.props.pendingFocus) { - this.editor.focus(); - this.props.pendingFocus(); - } - } - - handleCopy = (event, editor) => { - const { getAsset, resolveWidget } = this.props; - const markdown = Plain.serialize(Value.create({ document: editor.value.fragment })); - const html = markdownToHtml(markdown, { getAsset, resolveWidget }); - setEventTransfer(event, 'text', markdown); - setEventTransfer(event, 'html', html); - event.preventDefault(); - }; - - handleCut = (event, editor, next) => { - this.handleCopy(event, editor, next); - editor.delete(); - }; - - handlePaste = (event, editor, next) => { - event.preventDefault(); - const data = event.clipboardData; - if (isHotkey('shift', event)) { - return next(); - } - - const value = Plain.deserialize(data.getData('text/plain')); - return editor.insertFragment(value.document); - }; - - handleChange = editor => { - if (!this.state.value.document.equals(editor.value.document)) { - this.handleDocumentChange(editor); - } - this.setState({ value: editor.value }); - }; - - /** - * When the document value changes, serialize from Slate's AST back to plain - * text (which is Markdown) and pass that up as the new value. - */ - handleDocumentChange = debounce(editor => { - const value = Plain.serialize(editor.value); - this.props.onChange(value); - }, 150); - - handleToggleMode = () => { - this.props.onMode('rich_text'); - }; - - processRef = ref => { - this.editor = ref; - }; - - render() { - const { className, field, isShowModeToggle, t } = this.props; - return ( - - - - - - {({ css, cx }) => ( - - )} - - - ); - } -} - -RawEditor.propTypes = { - onChange: PropTypes.func.isRequired, - onMode: PropTypes.func.isRequired, - className: PropTypes.string.isRequired, - value: PropTypes.string, - field: ImmutablePropTypes.map.isRequired, - isShowModeToggle: PropTypes.bool.isRequired, - t: PropTypes.func.isRequired, -}; diff --git a/src/widgets/markdown/MarkdownControl/Toolbar.js b/src/widgets/markdown/MarkdownControl/Toolbar.js deleted file mode 100644 index 0a64ca0d..00000000 --- a/src/widgets/markdown/MarkdownControl/Toolbar.js +++ /dev/null @@ -1,278 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import styled from '@emotion/styled'; -import { css } from '@emotion/react'; -import { List } from 'immutable'; - -import { - Toggle, - Dropdown, - DropdownItem, - DropdownButton, - colors, - transitions, - lengths, -} from '../../../ui'; -import ToolbarButton from './ToolbarButton'; - -const ToolbarContainer = styled.div` - background-color: ${colors.textFieldBorder}; - border-top-right-radius: ${lengths.borderRadius}; - position: relative; - display: flex; - justify-content: space-between; - align-items: center; - padding: 11px 14px; - min-height: 58px; - transition: background-color ${transitions.main}, color ${transitions.main}; - color: ${colors.text}; -`; - -const ToolbarDropdownWrapper = styled.div` - display: inline-block; - position: relative; -`; - -const ToolbarToggle = styled.div` - flex-shrink: 0; - display: flex; - align-items: center; - font-size: 14px; - margin: 0 10px; -`; - -const StyledToggle = ToolbarToggle.withComponent(Toggle); - -const ToolbarToggleLabel = styled.span` - display: inline-block; - text-align: center; - white-space: nowrap; - line-height: 20px; - min-width: ${props => (props.offPosition ? '62px' : '70px')}; - - ${props => - props.isActive && - css` - font-weight: 600; - color: ${colors.active}; - `}; -`; - -export default class Toolbar extends React.Component { - static propTypes = { - buttons: ImmutablePropTypes.list, - editorComponents: ImmutablePropTypes.list, - onToggleMode: PropTypes.func.isRequired, - rawMode: PropTypes.bool, - isShowModeToggle: PropTypes.bool.isRequired, - plugins: ImmutablePropTypes.map, - onSubmit: PropTypes.func, - onAddAsset: PropTypes.func, - getAsset: PropTypes.func, - disabled: PropTypes.bool, - onMarkClick: PropTypes.func, - onBlockClick: PropTypes.func, - onLinkClick: PropTypes.func, - hasMark: PropTypes.func, - hasInline: PropTypes.func, - hasBlock: PropTypes.func, - t: PropTypes.func.isRequired, - }; - - isVisible = button => { - const { buttons } = this.props; - return !List.isList(buttons) || buttons.includes(button); - }; - - handleBlockClick = (event, type) => { - if (event) { - event.preventDefault(); - } - this.props.onBlockClick(type); - }; - - handleMarkClick = (event, type) => { - event.preventDefault(); - this.props.onMarkClick(type); - }; - - render() { - const { - onLinkClick, - onToggleMode, - rawMode, - isShowModeToggle, - plugins, - disabled, - onSubmit, - hasMark = () => {}, - hasInline = () => {}, - hasBlock = () => {}, - hasQuote = () => {}, - hasListItems = () => {}, - editorComponents, - t, - } = this.props; - const isVisible = this.isVisible; - const showEditorComponents = !editorComponents || editorComponents.size >= 1; - - function showPlugin({ id }) { - return editorComponents ? editorComponents.includes(id) : true; - } - - const pluginsList = plugins ? plugins.toList().filter(showPlugin) : List(); - - const headingOptions = { - 'heading-one': t('editor.editorWidgets.headingOptions.headingOne'), - 'heading-two': t('editor.editorWidgets.headingOptions.headingTwo'), - 'heading-three': t('editor.editorWidgets.headingOptions.headingThree'), - 'heading-four': t('editor.editorWidgets.headingOptions.headingFour'), - 'heading-five': t('editor.editorWidgets.headingOptions.headingFive'), - 'heading-six': t('editor.editorWidgets.headingOptions.headingSix'), - }; - - return ( - -
    - {isVisible('bold') && ( - - )} - {isVisible('italic') && ( - - )} - {isVisible('code') && ( - - )} - {isVisible('link') && ( - - )} - {/* Show dropdown if at least one heading is not hidden */} - {Object.keys(headingOptions).some(isVisible) && ( - - ( - - - - )} - > - {!disabled && - Object.keys(headingOptions).map( - (optionKey, idx) => - isVisible(optionKey) && ( - this.handleBlockClick(null, optionKey)} - /> - ), - )} - - - )} - {isVisible('quote') && ( - - )} - {isVisible('bulleted-list') && ( - - )} - {isVisible('numbered-list') && ( - - )} - {showEditorComponents && ( - - ( - - - - )} - > - {pluginsList.map((plugin, idx) => ( - onSubmit(plugin)} /> - ))} - - - )} -
    - {isShowModeToggle && ( - - - {t('editor.editorWidgets.markdown.richText')} - - - - {t('editor.editorWidgets.markdown.markdown')} - - - )} -
    - ); - } -} diff --git a/src/widgets/markdown/MarkdownControl/ToolbarButton.js b/src/widgets/markdown/MarkdownControl/ToolbarButton.js deleted file mode 100644 index f8cc2331..00000000 --- a/src/widgets/markdown/MarkdownControl/ToolbarButton.js +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from '@emotion/styled'; - -import { Icon, buttons } from '../../../ui'; - -const StyledToolbarButton = styled.button` - ${buttons.button}; - display: inline-block; - padding: 6px; - border: none; - background-color: transparent; - font-size: 16px; - color: ${props => (props.isActive ? '#1e2532' : 'inherit')}; - cursor: pointer; - - &:disabled { - cursor: auto; - opacity: 0.5; - } - - ${Icon} { - display: block; - } -`; - -function ToolbarButton({ type, label, icon, onClick, isActive, disabled }) { - return ( - onClick && onClick(e, type)} - title={label} - disabled={disabled} - > - {icon ? : label} - - ); -} - -ToolbarButton.propTypes = { - type: PropTypes.string, - label: PropTypes.string.isRequired, - icon: PropTypes.string, - onClick: PropTypes.func, - isActive: PropTypes.bool, - disabled: PropTypes.bool, -}; - -export default ToolbarButton; diff --git a/src/widgets/markdown/MarkdownControl/VisualEditor.js b/src/widgets/markdown/MarkdownControl/VisualEditor.js deleted file mode 100644 index 2b19d84d..00000000 --- a/src/widgets/markdown/MarkdownControl/VisualEditor.js +++ /dev/null @@ -1,279 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { fromJS } from 'immutable'; -import styled from '@emotion/styled'; -import { css as coreCss, ClassNames } from '@emotion/react'; -import { get, isEmpty, debounce } from 'lodash'; -import { Value, Document, Block, Text } from 'slate'; -import { Editor as Slate } from 'slate-react'; - -import { lengths, fonts, zIndex } from '../../../ui'; -import { editorStyleVars, EditorControlBar } from '../styles'; -import { slateToMarkdown, markdownToSlate } from '../serializers'; -import Toolbar from './Toolbar'; -import { renderBlock, renderInline, renderMark } from './renderers'; -import plugins from './plugins/visual'; -import schema from './schema'; - -function visualEditorStyles({ minimal }) { - return ` - position: relative; - overflow: auto; - font-family: ${fonts.primary}; - min-height: ${minimal ? 'auto' : lengths.richTextEditorMinHeight}; - border-top-left-radius: 0; - border-top-right-radius: 0; - border-top: 0; - margin-top: -${editorStyleVars.stickyDistanceBottom}; - padding: 0; - display: flex; - flex-direction: column; - z-index: ${zIndex.zIndex100}; -`; -} - -const InsertionPoint = styled.div` - flex: 1 1 auto; - cursor: text; -`; - -function createEmptyRawDoc() { - const emptyText = Text.create(''); - const emptyBlock = Block.create({ object: 'block', type: 'paragraph', nodes: [emptyText] }); - return { nodes: [emptyBlock] }; -} - -function createSlateValue(rawValue, { voidCodeBlock, remarkPlugins }) { - const rawDoc = rawValue && markdownToSlate(rawValue, { voidCodeBlock, remarkPlugins }); - const rawDocHasNodes = !isEmpty(get(rawDoc, 'nodes')); - const document = Document.fromJSON(rawDocHasNodes ? rawDoc : createEmptyRawDoc()); - return Value.create({ document }); -} - -export function mergeMediaConfig(editorComponents, field) { - // merge editor media library config to image components - if (editorComponents.has('image')) { - const imageComponent = editorComponents.get('image'); - const fields = imageComponent?.fields; - - if (fields) { - imageComponent.fields = fields.update( - fields.findIndex(f => f.get('widget') === 'image'), - f => { - // merge `media_library` config - if (field.has('media_library')) { - f = f.set( - 'media_library', - field.get('media_library').mergeDeep(f.get('media_library')), - ); - } - // merge 'media_folder' - if (field.has('media_folder') && !f.has('media_folder')) { - f = f.set('media_folder', field.get('media_folder')); - } - // merge 'public_folder' - if (field.has('public_folder') && !f.has('public_folder')) { - f = f.set('public_folder', field.get('public_folder')); - } - return f; - }, - ); - } - } -} - -export default class Editor extends React.Component { - constructor(props) { - super(props); - const editorComponents = props.getEditorComponents(); - this.shortcodeComponents = editorComponents.filter(({ type }) => type === 'shortcode'); - this.codeBlockComponent = fromJS(editorComponents.find(({ type }) => type === 'code-block')); - this.editorComponents = - this.codeBlockComponent || editorComponents.has('code-block') - ? editorComponents - : editorComponents.set('code-block', { label: 'Code Block', type: 'code-block' }); - - this.remarkPlugins = props.getRemarkPlugins(); - - mergeMediaConfig(this.editorComponents, this.props.field); - this.renderBlock = renderBlock({ - classNameWrapper: props.className, - resolveWidget: props.resolveWidget, - codeBlockComponent: this.codeBlockComponent, - }); - this.renderInline = renderInline(); - this.renderMark = renderMark(); - this.schema = schema({ voidCodeBlock: !!this.codeBlockComponent }); - this.plugins = plugins({ - getAsset: props.getAsset, - resolveWidget: props.resolveWidget, - t: props.t, - remarkPlugins: this.remarkPlugins, - }); - this.state = { - value: createSlateValue(this.props.value, { - voidCodeBlock: !!this.codeBlockComponent, - remarkPlugins: this.remarkPlugins, - }), - }; - } - - static propTypes = { - onAddAsset: PropTypes.func.isRequired, - getAsset: PropTypes.func.isRequired, - onChange: PropTypes.func.isRequired, - onMode: PropTypes.func.isRequired, - className: PropTypes.string.isRequired, - value: PropTypes.string, - field: ImmutablePropTypes.map.isRequired, - getEditorComponents: PropTypes.func.isRequired, - getRemarkPlugins: PropTypes.func.isRequired, - isShowModeToggle: PropTypes.bool.isRequired, - t: PropTypes.func.isRequired, - }; - - shouldComponentUpdate(nextProps, nextState) { - if (!this.state.value.equals(nextState.value)) return true; - - const raw = nextState.value.document.toJS(); - const markdown = slateToMarkdown(raw, { - voidCodeBlock: this.codeBlockComponent, - remarkPlugins: this.remarkPlugins, - }); - return nextProps.value !== markdown; - } - - componentDidMount() { - if (this.props.pendingFocus) { - this.editor.focus(); - this.props.pendingFocus(); - } - } - - componentDidUpdate(prevProps) { - if (prevProps.value !== this.props.value) { - this.setState({ - value: createSlateValue(this.props.value, { - voidCodeBlock: !!this.codeBlockComponent, - remarkPlugins: this.remarkPlugins, - }), - }); - } - } - - handleMarkClick = type => { - this.editor.toggleMark(type).focus(); - }; - - handleBlockClick = type => { - this.editor.toggleBlock(type).focus(); - }; - - handleLinkClick = () => { - this.editor.toggleLink(oldUrl => - window.prompt(this.props.t('editor.editorWidgets.markdown.linkPrompt'), oldUrl), - ); - }; - - hasMark = type => this.editor && this.editor.hasMark(type); - hasInline = type => this.editor && this.editor.hasInline(type); - hasBlock = type => this.editor && this.editor.hasBlock(type); - hasQuote = type => this.editor && this.editor.hasQuote(type); - hasListItems = type => this.editor && this.editor.hasListItems(type); - - handleToggleMode = () => { - this.props.onMode('raw'); - }; - - handleInsertShortcode = pluginConfig => { - this.editor.insertShortcode(pluginConfig); - }; - - handleClickBelowDocument = () => { - this.editor.moveToEndOfDocument(); - }; - - handleDocumentChange = debounce(editor => { - const { onChange } = this.props; - const raw = editor.value.document.toJS(); - const markdown = slateToMarkdown(raw, { - voidCodeBlock: this.codeBlockComponent, - remarkPlugins: this.remarkPlugins, - }); - onChange(markdown); - }, 150); - - handleChange = editor => { - if (!this.state.value.document.equals(editor.value.document)) { - this.handleDocumentChange(editor); - } - this.setState({ value: editor.value }); - }; - - processRef = ref => { - this.editor = ref; - }; - - render() { - const { onAddAsset, getAsset, className, field, isShowModeToggle, t, isDisabled } = this.props; - return ( -
    - - - - - {({ css, cx }) => ( -
    - - -
    - )} -
    -
    - ); - } -} diff --git a/src/widgets/markdown/MarkdownControl/components/Shortcode.js b/src/widgets/markdown/MarkdownControl/components/Shortcode.js deleted file mode 100644 index 49b2e868..00000000 --- a/src/widgets/markdown/MarkdownControl/components/Shortcode.js +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint-disable react/prop-types */ -import React from 'react'; -import { css } from '@emotion/react'; -import { Map, fromJS } from 'immutable'; -import { omit } from 'lodash'; - -import { getEditorControl, getEditorComponents } from '../index'; - -export default class Shortcode extends React.Component { - state = { - field: Map(), - }; - - componentDidMount() { - const { node, typeOverload } = this.props; - const plugin = getEditorComponents().get(typeOverload || node.data.get('shortcode')); - const fieldKeys = ['id', 'fromBlock', 'toBlock', 'toPreview', 'pattern', 'icon']; - const field = fromJS(omit(plugin, fieldKeys)); - this.setState({ field }); - } - - render() { - const { editor, node, dataKey = 'shortcodeData' } = this.props; - const { field } = this.state; - const EditorControl = getEditorControl(); - const value = dataKey === false ? node.data : fromJS(node.data.get(dataKey)); - - function handleChange(fieldName, value, metadata) { - const dataValue = dataKey === false ? value : node.data.set('shortcodeData', value); - editor.setNodeByKey(node.key, { data: dataValue || Map(), metadata }); - } - - function handleFocus() { - return editor.moveToRangeOfNode(node); - } - - return ( - !field.isEmpty() && ( -
    - -
    - ) - ); - } -} diff --git a/src/widgets/markdown/MarkdownControl/components/VoidBlock.js b/src/widgets/markdown/MarkdownControl/components/VoidBlock.js deleted file mode 100644 index e92e2c62..00000000 --- a/src/widgets/markdown/MarkdownControl/components/VoidBlock.js +++ /dev/null @@ -1,40 +0,0 @@ -/* eslint-disable react/prop-types */ -import React from 'react'; -import { css } from '@emotion/react'; - -import { zIndex } from '../../../../ui'; - -function InsertionPoint(props) { - return ( -
    - ); -} - -function VoidBlock({ editor, attributes, node, children }) { - function handleClick(event) { - event.stopPropagation(); - } - - return ( -
    - {!editor.canInsertBeforeNode(node) && ( - editor.forceInsertBeforeNode(node)} /> - )} - {children} - {!editor.canInsertAfterNode(node) && ( - editor.forceInsertAfterNode(node)} /> - )} -
    - ); -} - -export default VoidBlock; diff --git a/src/widgets/markdown/MarkdownControl/index.js b/src/widgets/markdown/MarkdownControl/index.js deleted file mode 100644 index d89b1998..00000000 --- a/src/widgets/markdown/MarkdownControl/index.js +++ /dev/null @@ -1,125 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { List, Map } from 'immutable'; - -import RawEditor from './RawEditor'; -import VisualEditor from './VisualEditor'; - -const MODE_STORAGE_KEY = 'cms.md-mode'; - -// TODO: passing the editorControl and components like this is horrible, should -// be handled through Redux and a separate registry store for instances -let editorControl; -// eslint-disable-next-line func-style -let _getEditorComponents = () => Map(); - -export function getEditorControl() { - return editorControl; -} - -export function getEditorComponents() { - return _getEditorComponents(); -} - -export default class MarkdownControl extends React.Component { - static propTypes = { - onChange: PropTypes.func.isRequired, - onAddAsset: PropTypes.func.isRequired, - getAsset: PropTypes.func.isRequired, - classNameWrapper: PropTypes.string.isRequired, - editorControl: PropTypes.elementType.isRequired, - value: PropTypes.string, - field: ImmutablePropTypes.map.isRequired, - getEditorComponents: PropTypes.func, - t: PropTypes.func.isRequired, - }; - - static defaultProps = { - value: '', - }; - - constructor(props) { - super(props); - editorControl = props.editorControl; - const preferredMode = localStorage.getItem(MODE_STORAGE_KEY) ?? 'rich_text'; - - _getEditorComponents = props.getEditorComponents; - this.state = { - mode: - this.getAllowedModes().indexOf(preferredMode) !== -1 - ? preferredMode - : this.getAllowedModes()[0], - pendingFocus: false, - }; - } - - handleMode = mode => { - this.setState({ mode, pendingFocus: true }); - localStorage.setItem(MODE_STORAGE_KEY, mode); - }; - - processRef = ref => (this.ref = ref); - - setFocusReceived = () => { - this.setState({ pendingFocus: false }); - }; - - getAllowedModes = () => this.props.field.get('modes', List(['rich_text', 'raw'])).toArray(); - - render() { - const { - onChange, - onAddAsset, - getAsset, - value, - classNameWrapper, - field, - getEditorComponents, - getRemarkPlugins, - resolveWidget, - t, - isDisabled, - } = this.props; - - const { mode, pendingFocus } = this.state; - const isShowModeToggle = this.getAllowedModes().length > 1; - const visualEditor = ( -
    - -
    - ); - const rawEditor = ( -
    - -
    - ); - return mode === 'rich_text' ? visualEditor : rawEditor; - } -} diff --git a/src/widgets/markdown/MarkdownControl/plugins/BreakToDefaultBlock.js b/src/widgets/markdown/MarkdownControl/plugins/BreakToDefaultBlock.js deleted file mode 100644 index 30a86900..00000000 --- a/src/widgets/markdown/MarkdownControl/plugins/BreakToDefaultBlock.js +++ /dev/null @@ -1,23 +0,0 @@ -import isHotkey from 'is-hotkey'; - -function BreakToDefaultBlock({ defaultType }) { - return { - onKeyDown(event, editor, next) { - const { selection, startBlock } = editor.value; - const isEnter = isHotkey('enter', event); - if (!isEnter) { - return next(); - } - if (selection.isExpanded) { - editor.delete(); - return next(); - } - if (selection.start.isAtEndOfNode(startBlock) && startBlock.type !== defaultType) { - return editor.insertBlock(defaultType); - } - return next(); - }, - }; -} - -export default BreakToDefaultBlock; diff --git a/src/widgets/markdown/MarkdownControl/plugins/CloseBlock.js b/src/widgets/markdown/MarkdownControl/plugins/CloseBlock.js deleted file mode 100644 index 4c2b05a7..00000000 --- a/src/widgets/markdown/MarkdownControl/plugins/CloseBlock.js +++ /dev/null @@ -1,25 +0,0 @@ -import isHotkey from 'is-hotkey'; - -function CloseBlock({ defaultType }) { - return { - onKeyDown(event, editor, next) { - const { selection, startBlock } = editor.value; - const isBackspace = isHotkey('backspace', event); - if (!isBackspace) { - return next(); - } - if (selection.isExpanded) { - return editor.delete(); - } - if (!selection.start.isAtStartOfNode(startBlock) || startBlock.text.length > 0) { - return next(); - } - if (startBlock.type !== defaultType) { - editor.setBlocks(defaultType); - } - return next(); - }, - }; -} - -export default CloseBlock; diff --git a/src/widgets/markdown/MarkdownControl/plugins/CommandsAndQueries.js b/src/widgets/markdown/MarkdownControl/plugins/CommandsAndQueries.js deleted file mode 100644 index e4ca93a3..00000000 --- a/src/widgets/markdown/MarkdownControl/plugins/CommandsAndQueries.js +++ /dev/null @@ -1,156 +0,0 @@ -import { isArray, tail, castArray } from 'lodash'; - -function CommandsAndQueries({ defaultType }) { - return { - queries: { - atStartOf(editor, node) { - const { selection } = editor.value; - return selection.isCollapsed && selection.start.isAtStartOfNode(node); - }, - getAncestor(editor, firstKey, lastKey) { - if (firstKey === lastKey) { - return editor.value.document.getParent(firstKey); - } - return editor.value.document.getCommonAncestor(firstKey, lastKey); - }, - getOffset(editor, node) { - const parent = editor.value.document.getParent(node.key); - return parent.nodes.indexOf(node); - }, - getSelectedChildren(editor, node) { - return node.nodes.filter(child => editor.isSelected(child)); - }, - getCommonAncestor(editor) { - const { startBlock, endBlock, document: doc } = editor.value; - return doc.getCommonAncestor(startBlock.key, endBlock.key); - }, - getClosestType(editor, node, type) { - const types = castArray(type); - return editor.value.document.getClosest(node.key, n => types.includes(n.type)); - }, - getBlockContainer(editor, node) { - const targetTypes = ['bulleted-list', 'numbered-list', 'list-item', 'quote', 'table-cell']; - const { startBlock, selection } = editor.value; - const target = node - ? editor.value.document.getParent(node.key) - : (selection.isCollapsed && startBlock) || editor.getCommonAncestor(); - if (!target) { - return editor.value.document; - } - if (targetTypes.includes(target.type)) { - return target; - } - return editor.getBlockContainer(target); - }, - isSelected(editor, nodes) { - return castArray(nodes).every(node => { - return editor.value.document.isInRange(node.key, editor.value.selection); - }); - }, - isFirstChild(editor, node) { - return editor.value.document.getParent(node.key).nodes.first().key === node.key; - }, - areSiblings(editor, nodes) { - if (!isArray(nodes) || nodes.length < 2) { - return true; - } - const parent = editor.value.document.getParent(nodes[0].key); - return tail(nodes).every(node => { - return editor.value.document.getParent(node.key).key === parent.key; - }); - }, - everyBlock(editor, type) { - return editor.value.blocks.every(block => block.type === type); - }, - hasMark(editor, type) { - return editor.value.activeMarks.some(mark => mark.type === type); - }, - hasBlock(editor, type) { - return editor.value.blocks.some(node => node.type === type); - }, - hasInline(editor, type) { - return editor.value.inlines.some(node => node.type === type); - }, - hasQuote(editor, quoteLabel) { - const { value } = editor; - const { document, blocks } = value; - return blocks.some(node => { - const { key: descendantNodeKey } = node; - /* When focusing a quote block, the actual block that gets the focus is the paragraph block whose parent is a `quote` block. - Hence, we need to get its parent and check if its type is `quote`. This parent will always be defined because every block in the editor - has a Document object as parent by default. - */ - const parent = document.getParent(descendantNodeKey); - return parent.type === quoteLabel; - }); - }, - hasListItems(editor, listType) { - const { value } = editor; - const { document, blocks } = value; - return blocks.some(node => { - const { key: lowestNodeKey } = node; - /* A list block has the following structure: -
      -
    1. -

      Coffee

      -
    2. -
    3. -

      Tea

      -
    4. -
    - */ - const parent = document.getParent(lowestNodeKey); - const grandparent = document.getParent(parent.key); - return parent.type === 'list-item' && grandparent?.type === listType; - }); - }, - }, - commands: { - toggleBlock(editor, type) { - switch (type) { - case 'heading-one': - case 'heading-two': - case 'heading-three': - case 'heading-four': - case 'heading-five': - case 'heading-six': - return editor.setBlocks(editor.everyBlock(type) ? defaultType : type); - case 'quote': - return editor.toggleQuoteBlock(); - case 'numbered-list': - case 'bulleted-list': { - return editor.toggleList(type); - } - } - }, - unwrapBlockChildren(editor, block) { - if (!block || block.object !== 'block') { - throw Error(`Expected block but received ${block}.`); - } - const index = editor.value.document.getPath(block.key).last(); - const parent = editor.value.document.getParent(block.key); - editor.withoutNormalizing(() => { - block.nodes.forEach((node, idx) => { - editor.moveNodeByKey(node.key, parent.key, index + idx); - }); - editor.removeNodeByKey(block.key); - }); - }, - unwrapNodeToDepth(editor, node, depth) { - let currentDepth = 0; - editor.withoutNormalizing(() => { - while (currentDepth < depth) { - editor.unwrapNodeByKey(node.key); - currentDepth += 1; - } - }); - }, - unwrapNodeFromAncestor(editor, node, ancestor) { - const depth = ancestor.getDepth(node.key); - editor.unwrapNodeToDepth(node, depth); - }, - }, - }; -} - -export default CommandsAndQueries; diff --git a/src/widgets/markdown/MarkdownControl/plugins/CopyPasteVisual.js b/src/widgets/markdown/MarkdownControl/plugins/CopyPasteVisual.js deleted file mode 100644 index 07fd943c..00000000 --- a/src/widgets/markdown/MarkdownControl/plugins/CopyPasteVisual.js +++ /dev/null @@ -1,47 +0,0 @@ -import { Document } from 'slate'; -import { setEventTransfer } from 'slate-react'; -import base64 from 'slate-base64-serializer'; -import isHotkey from 'is-hotkey'; - -import { slateToMarkdown, markdownToSlate, htmlToSlate, markdownToHtml } from '../../serializers'; - -function CopyPasteVisual({ getAsset, resolveWidget, remarkPlugins }) { - function handleCopy(event, editor) { - const markdown = slateToMarkdown(editor.value.fragment.toJS(), { remarkPlugins }); - const html = markdownToHtml(markdown, { getAsset, resolveWidget, remarkPlugins }); - setEventTransfer(event, 'text', markdown); - setEventTransfer(event, 'html', html); - setEventTransfer(event, 'fragment', base64.serializeNode(editor.value.fragment)); - event.preventDefault(); - } - - return { - onPaste(event, editor, next) { - const data = event.clipboardData; - if (isHotkey('shift', event)) { - return next(); - } - - if (data.types.includes('application/x-slate-fragment')) { - const fragment = base64.deserializeNode(data.getData('application/x-slate-fragment')); - return editor.insertFragment(fragment); - } - - const html = data.types.includes('text/html') && data.getData('text/html'); - const ast = html - ? htmlToSlate(html) - : markdownToSlate(data.getData('text/plain'), { remarkPlugins }); - const doc = Document.fromJSON(ast); - return editor.insertFragment(doc); - }, - onCopy(event, editor, next) { - handleCopy(event, editor, next); - }, - onCut(event, editor, next) { - handleCopy(event, editor, next); - editor.delete(); - }, - }; -} - -export default CopyPasteVisual; diff --git a/src/widgets/markdown/MarkdownControl/plugins/ForceInsert.js b/src/widgets/markdown/MarkdownControl/plugins/ForceInsert.js deleted file mode 100644 index cc0d003f..00000000 --- a/src/widgets/markdown/MarkdownControl/plugins/ForceInsert.js +++ /dev/null @@ -1,38 +0,0 @@ -function ForceInsert({ defaultType }) { - return { - queries: { - canInsertBeforeNode(editor, node) { - if (!editor.isVoid(node)) { - return true; - } - return !!editor.value.document.getPreviousSibling(node.key); - }, - canInsertAfterNode(editor, node) { - if (!editor.isVoid(node)) { - return true; - } - const nextSibling = editor.value.document.getNextSibling(node.key); - return nextSibling && !editor.isVoid(nextSibling); - }, - }, - commands: { - forceInsertBeforeNode(editor, node) { - const block = { type: defaultType, object: 'block' }; - const parent = editor.value.document.getParent(node.key); - return editor.insertNodeByKey(parent.key, 0, block).moveToStartOfNode(parent).focus(); - }, - forceInsertAfterNode(editor, node) { - return editor.moveToEndOfNode(node).insertBlock(defaultType).focus(); - }, - moveToEndOfDocument(editor) { - const lastBlock = editor.value.document.nodes.last(); - if (editor.isVoid(lastBlock)) { - editor.insertBlock(defaultType); - } - return editor.moveToEndOfNode(lastBlock).focus(); - }, - }, - }; -} - -export default ForceInsert; diff --git a/src/widgets/markdown/MarkdownControl/plugins/Hotkey.js b/src/widgets/markdown/MarkdownControl/plugins/Hotkey.js deleted file mode 100644 index 7ed4d49f..00000000 --- a/src/widgets/markdown/MarkdownControl/plugins/Hotkey.js +++ /dev/null @@ -1,29 +0,0 @@ -import isHotkey from 'is-hotkey'; - -export const HOT_KEY_MAP = { - bold: 'mod+b', - code: 'mod+shift+c', - italic: 'mod+i', - strikethrough: 'mod+shift+s', - 'heading-one': 'mod+1', - 'heading-two': 'mod+2', - 'heading-three': 'mod+3', - 'heading-four': 'mod+4', - 'heading-five': 'mod+5', - 'heading-six': 'mod+6', - link: 'mod+k', -}; - -function Hotkey(key, fn) { - return { - onKeyDown(event, editor, next) { - if (!isHotkey(key, event)) { - return next(); - } - event.preventDefault(); - editor.command(fn); - }, - }; -} - -export default Hotkey; diff --git a/src/widgets/markdown/MarkdownControl/plugins/LineBreak.js b/src/widgets/markdown/MarkdownControl/plugins/LineBreak.js deleted file mode 100644 index 4fa3709f..00000000 --- a/src/widgets/markdown/MarkdownControl/plugins/LineBreak.js +++ /dev/null @@ -1,15 +0,0 @@ -import isHotkey from 'is-hotkey'; - -function LineBreak() { - return { - onKeyDown(event, editor, next) { - const isShiftEnter = isHotkey('shift+enter', event); - if (!isShiftEnter) { - return next(); - } - return editor.insertInline('break').insertText('').moveToStartOfNextText(); - }, - }; -} - -export default LineBreak; diff --git a/src/widgets/markdown/MarkdownControl/plugins/Link.js b/src/widgets/markdown/MarkdownControl/plugins/Link.js deleted file mode 100644 index e88db119..00000000 --- a/src/widgets/markdown/MarkdownControl/plugins/Link.js +++ /dev/null @@ -1,40 +0,0 @@ -function Link({ type }) { - return { - commands: { - toggleLink(editor, getUrl) { - const selection = editor.value.selection; - const isCollapsed = selection && selection.isCollapsed; - - if (editor.hasInline(type)) { - const inlines = editor.value.inlines.toJSON(); - const link = inlines.find(item => item.type === type); - - const url = getUrl(link.data.url); - - if (url) { - // replace the old link - return editor.setInlines({ data: { url } }); - } else { - // remove url if it was removed by the user - return editor.unwrapInline(type); - } - } else { - const url = getUrl(); - if (!url) { - return; - } - - return isCollapsed - ? editor.insertInline({ - type, - data: { url }, - nodes: [{ object: 'text', text: url }], - }) - : editor.wrapInline({ type, data: { url } }).moveToEnd(); - } - }, - }, - }; -} - -export default Link; diff --git a/src/widgets/markdown/MarkdownControl/plugins/List.js b/src/widgets/markdown/MarkdownControl/plugins/List.js deleted file mode 100644 index 20405950..00000000 --- a/src/widgets/markdown/MarkdownControl/plugins/List.js +++ /dev/null @@ -1,371 +0,0 @@ -import { List } from 'immutable'; -import { castArray, throttle, get } from 'lodash'; -import { Range, Block } from 'slate'; -import isHotkey from 'is-hotkey'; - -import { assertType } from './util'; - -function ListPlugin({ defaultType, unorderedListType, orderedListType }) { - const LIST_TYPES = [orderedListType, unorderedListType]; - - function oppositeListType(type) { - switch (type) { - case LIST_TYPES[0]: - return LIST_TYPES[1]; - case LIST_TYPES[1]: - return LIST_TYPES[0]; - } - } - - return { - queries: { - getCurrentListItem(editor) { - const { startBlock, endBlock } = editor.value; - const ancestor = editor.value.document.getCommonAncestor(startBlock.key, endBlock.key); - if (ancestor && ancestor.type === 'list-item') { - return ancestor; - } - return editor.value.document.getClosest(ancestor.key, node => node.type === 'list-item'); - }, - getListOrListItem(editor, { node, ...opts } = {}) { - const listContextNode = editor.getBlockContainer(node); - if (!listContextNode) { - return; - } - if (['bulleted-list', 'numbered-list', 'list-item'].includes(listContextNode.type)) { - return listContextNode; - } - if (opts.force) { - return editor.getListOrListItem({ node: listContextNode, ...opts }); - } - }, - isList(editor, node) { - return node && LIST_TYPES.includes(node.type); - }, - getLowestListItem(editor, list) { - assertType(list, LIST_TYPES); - const lastItem = list.nodes.last(); - const lastItemLastChild = lastItem.nodes.last(); - if (editor.isList(lastItemLastChild)) { - return editor.getLowestListItem(lastItemLastChild); - } - return lastItem; - }, - }, - commands: { - wrapInList(editor, type) { - editor.withoutNormalizing(() => { - editor.wrapBlock(type).wrapBlock('list-item'); - }); - }, - unwrapListItem(editor, node) { - assertType(node, 'list-item'); - editor.withoutNormalizing(() => { - editor.unwrapNodeByKey(node.key).unwrapBlockChildren(node); - }); - }, - indentListItems: throttle(function indentListItem(editor, listItemsArg) { - const listItems = List.isList(listItemsArg) ? listItemsArg : List(castArray(listItemsArg)); - const firstListItem = listItems.first(); - const firstListItemIndex = editor.value.document.getPath(firstListItem.key).last(); - const list = editor.value.document.getParent(firstListItem.key); - - /** - * If the first list item in the list is in the selection, and the list - * previous sibling is a list of the opposite type, we should still indent - * the list items as children of the last item in the previous list, as - * the behavior otherwise for first items is to do nothing on tab, while - * in this case the user would expect indenting via tab to "just work". - */ - if (firstListItemIndex === 0) { - const listPreviousSibling = editor.value.document.getPreviousSibling(list.key); - if (!listPreviousSibling || listPreviousSibling.type !== oppositeListType(list.type)) { - return; - } - editor.withoutNormalizing(() => { - listItems.forEach((listItem, idx) => { - const index = listPreviousSibling.nodes.size + idx; - editor.moveNodeByKey(listItem.key, listPreviousSibling.key, index); - }); - }); - } - - /** - * Wrap all selected list items into a new list item and list, then merge - * the new parent list item into the previous list item in the list. - */ - const newListItem = Block.create('list-item'); - const newList = Block.create(list.type); - editor.withoutNormalizing(() => { - editor - .insertNodeByKey(list.key, firstListItemIndex, newListItem) - .insertNodeByKey(newListItem.key, 0, newList); - - listItems.forEach((listItem, index) => { - editor.moveNodeByKey(listItem.key, newList.key, index); - }); - - editor.mergeNodeByKey(newListItem.key); - }); - }, 100), - unindentListItems: throttle(function unindentListItems(editor, listItemsArg) { - // Ensure that `listItems` are children of a list. - const listItems = List.isList(listItemsArg) ? listItemsArg : List(castArray(listItemsArg)); - const list = editor.value.document.getParent(listItems.first().key); - if (!editor.isList(list)) { - return; - } - - // If the current list isn't nested under a list, we cannot unindent. - const parentListItem = editor.value.document.getParent(list.key); - if (!parentListItem || parentListItem.type !== 'list-item') { - return; - } - - // Check if there are more list items after the items being indented. - const nextSibling = editor.value.document.getNextSibling(listItems.last().key); - - // Unwrap each selected list item into the parent list. - editor.withoutNormalizing(() => { - listItems.forEach(listItem => editor.unwrapNodeToDepth(listItem, 2)); - }); - - // If there were other list items after the selected items, use the last - // of the unindented list items as the new parent of the remaining items - // list. - if (nextSibling) { - const nextSiblingParentListItem = editor.value.document.getNextSibling( - listItems.last().key, - ); - editor.mergeNodeByKey(nextSiblingParentListItem.key); - } - }, 100), - toggleListItemType(editor, listItem) { - assertType(listItem, 'list-item'); - const list = editor.value.document.getParent(listItem.key); - const newListType = oppositeListType(list.type); - editor.withoutNormalizing(() => { - editor.unwrapNodeByKey(listItem.key).wrapBlockByKey(listItem.key, newListType); - }); - }, - toggleList(editor, type) { - if (!LIST_TYPES.includes(type)) { - throw Error(`${type} is not a valid list type, must be one of: ${LIST_TYPES}`); - } - const { startBlock, blocks } = editor.value; - const target = editor.getBlockContainer(); - - switch (get(target, 'type')) { - case 'bulleted-list': - case 'numbered-list': { - const list = target; - if (list.type !== type) { - const newListType = oppositeListType(target.type); - const newList = Block.create(newListType); - editor.withoutNormalizing(() => { - editor.wrapBlock(newList).unwrapNodeByKey(newList.key); - }); - } else { - editor.withoutNormalizing(() => { - list.nodes.forEach(listItem => { - if (editor.isSelected(listItem)) { - editor.unwrapListItem(listItem); - } - }); - }); - } - break; - } - - case 'list-item': { - const listItem = target; - const list = editor.value.document.getParent(listItem.key); - if (!editor.isFirstChild(startBlock)) { - editor.wrapInList(type); - } else if (list.type !== type) { - editor.toggleListItemType(listItem); - } else { - editor.unwrapListItem(listItem); - } - break; - } - - default: { - if (blocks.size > 1) { - const listItems = blocks.map(block => - Block.create({ type: 'list-item', nodes: [block] }), - ); - const listBlock = Block.create({ type, nodes: listItems }); - editor - .delete() - .replaceNodeByKey(startBlock.key, listBlock) - .moveToRangeOfNode(listBlock); - } else { - editor.wrapInList(type); - } - break; - } - } - }, - }, - onKeyDown(event, editor, next) { - // Handle space ('*' + ) or ('-' + ) - if (isHotkey('space', event)) { - if (editor.value.startBlock.text === '*' || editor.value.startBlock.text === '-') { - event.preventDefault(); - return editor.wrapInList('bulleted-list').deleteBackward(1); - } - } - - // Handle Backspace - if (isHotkey('backspace', event) && editor.value.selection.isCollapsed) { - // If beginning block is not of default type, do nothing - if (editor.value.startBlock.type !== defaultType) { - return next(); - } - const listOrListItem = editor.getListOrListItem(); - const isListItem = listOrListItem && listOrListItem.type === 'list-item'; - - // If immediate block is a list item, unwrap it - if (isListItem && editor.value.selection.start.isAtStartOfNode(listOrListItem)) { - const listItem = listOrListItem; - const previousSibling = editor.value.document.getPreviousSibling(listItem.key); - - // If this isn't the first item in the list, merge into previous list item - if (previousSibling && previousSibling.type === 'list-item') { - return editor.mergeNodeByKey(listItem.key); - } - return editor.unwrapListItem(listItem); - } - - return next(); - } - - // Handle Tab - if (isHotkey('tab', event) || isHotkey('shift+tab', event)) { - const isTab = isHotkey('tab', event); - const isShiftTab = !isTab; - event.preventDefault(); - - const listOrListItem = editor.getListOrListItem({ force: true }); - if (!listOrListItem) { - return next(); - } - - if (listOrListItem.type === 'list-item') { - const listItem = listOrListItem; - if (isTab) { - return editor.indentListItems(listItem); - } - if (isShiftTab) { - return editor.unindentListItems(listItem); - } - } else { - const list = listOrListItem; - if (isTab) { - const listItems = editor.getSelectedChildren(list); - return editor.indentListItems(listItems); - } - if (isShiftTab) { - const listItems = editor.getSelectedChildren(list); - return editor.unindentListItems(listItems); - } - } - return next(); - } - - // Handle Enter - if (isHotkey('enter', event)) { - const listOrListItem = editor.getListOrListItem(); - if (!listOrListItem) { - return next(); - } - - if (editor.value.selection.isExpanded) { - editor.delete(); - } - - if (listOrListItem.type === 'list-item') { - const listItem = listOrListItem; - const { - value: { document }, - } = editor; - // If focus is at start of list item, unwrap the entire list item. - if (editor.atStartOf(listItem)) { - /* If the list item in question has a grandparent list, this means it is a child of a nested list. - * Hitting Enter key on an empty nested list item like this should move that list item out of the nested list - * and into the grandparent list. The targeted list item becomes direct child of its grandparent list - * Example - *
      ----- GRANDPARENT LIST - *
    • ------ GRANDPARENT LIST ITEM - *

      foo

      - *
        ----- PARENT LIST - *
      • - *

        bar

        - *
      • - *
      • ------ LIST ITEM - *

        ----- WHERE THE ENTER KEY HAPPENS - *
      • - *
      - *
    • - *
    - */ - const parentList = document.getParent(listItem.key); - const grandparentListItem = document.getParent(parentList.key); - if (grandparentListItem.type === 'list-item') { - const grandparentList = document.getParent(grandparentListItem.key); - const indexOfGrandparentListItem = grandparentList.nodes.findIndex( - node => node.key === grandparentListItem.key, - ); - return editor.moveNodeByKey( - listItem.key, - grandparentList.key, - indexOfGrandparentListItem + 1, - ); - } - return editor.unwrapListItem(listItem); - } - - // If focus is at start of a subsequent block in the list item, move - // everything after the cursor in the current list item to a new list - // item. - if (editor.atStartOf(editor.value.startBlock)) { - const newListItem = Block.create('list-item'); - const range = Range.create(editor.value.selection).moveEndToEndOfNode(listItem); - - return editor.withoutNormalizing(() => { - editor.wrapBlockAtRange(range, newListItem).unwrapNodeByKey(newListItem.key); - }); - } - const list = document.getParent(listItem.key); - if (LIST_TYPES.includes(list.type)) { - const newListItem = Block.create({ - type: 'list-item', - nodes: [Block.create('paragraph')], - }); - // Check if the targeted list item contains a nested list. If it does, insert a new list item in the beginning of that nested list. - const nestedList = listItem.findDescendant(block => LIST_TYPES.includes(block.type)); - if (nestedList) { - return editor.insertNodeByKey(nestedList.key, 0, newListItem).moveForward(1); // Each list item is separated by a \n character. We need to move the cursor past this character so that it'd be on new list item that has just been inserted - } - // Find index of the list item block that receives Enter key - const previousListItemIndex = list.nodes.findIndex(block => block.key === listItem.key); - return editor - .insertNodeByKey(list.key, previousListItemIndex + 1, newListItem) // insert a new list item after the list item above - .moveForward(1); - } - return next(); - } else { - const list = listOrListItem; - if (list.nodes.size === 0) { - return editor.removeNodeByKey(list.key); - } - } - return next(); - } - return next(); - }, - }; -} - -export default ListPlugin; diff --git a/src/widgets/markdown/MarkdownControl/plugins/QuoteBlock.js b/src/widgets/markdown/MarkdownControl/plugins/QuoteBlock.js deleted file mode 100644 index a5a959e1..00000000 --- a/src/widgets/markdown/MarkdownControl/plugins/QuoteBlock.js +++ /dev/null @@ -1,103 +0,0 @@ -import { Block } from 'slate'; -import isHotkey from 'is-hotkey'; - -/** - * TODO: highlight a couple list items and hit the quote button. doesn't work. - */ -function QuoteBlock({ type }) { - return { - commands: { - /** - * Quotes can contain other blocks, even other quotes. If a selection contains quotes, they - * shouldn't be impacted. The selection's immediate parent should be checked - if it's a - * quote, unwrap the quote (as within are only blocks), and if it's not, wrap all selected - * blocks into a quote. Make sure text is wrapped into paragraphs. - */ - toggleQuoteBlock(editor) { - const blockContainer = editor.getBlockContainer(); - if (['bulleted-list', 'numbered-list'].includes(blockContainer.type)) { - const { nodes } = blockContainer; - const allItemsSelected = editor.isSelected([nodes.first(), nodes.last()]); - if (allItemsSelected) { - const nextContainer = editor.getBlockContainer(blockContainer); - if (nextContainer?.type === type) { - editor.unwrapNodeFromAncestor(blockContainer, nextContainer); - } else { - editor.wrapBlockByKey(blockContainer.key, type); - } - } else { - const blockContainerParent = editor.value.document.getParent(blockContainer.key); - editor.withoutNormalizing(() => { - const selectedListItems = nodes.filter(node => editor.isSelected(node)); - const newList = Block.create(blockContainer.type); - editor.unwrapNodeByKey(selectedListItems.first()); - const offset = editor.getOffset(selectedListItems.first()); - editor.insertNodeByKey(blockContainerParent.key, offset + 1, newList); - selectedListItems.forEach(({ key }, idx) => - editor.moveNodeByKey(key, newList.key, idx), - ); - editor.wrapBlockByKey(newList.key, type); - }); - } - return; - } - - const blocks = editor.value.blocks; - const firstBlockKey = blocks.first().key; - const lastBlockKey = blocks.last().key; - const ancestor = editor.getAncestor(firstBlockKey, lastBlockKey); - if (ancestor.type === type) { - editor.unwrapBlockChildren(ancestor); - } else { - editor.wrapBlock(type); - } - }, - }, - onKeyDown(event, editor, next) { - if (!isHotkey('enter', event) && !isHotkey('backspace', event)) { - return next(); - } - const { selection, startBlock, document: doc } = editor.value; - const parent = doc.getParent(startBlock.key); - const isQuote = parent.type === type; - if (!isQuote) { - return next(); - } - if (isHotkey('enter', event)) { - if (selection.isExpanded) { - editor.delete(); - } - - // If the quote is empty, remove it. - if (editor.atStartOf(parent)) { - return editor.unwrapBlockByKey(parent.key); - } - - if (editor.atStartOf(startBlock)) { - const offset = editor.getOffset(startBlock); - return editor - .splitNodeByKey(parent.key, offset) - .unwrapBlockByKey(editor.value.document.getParent(startBlock.key).key); - } - - return next(); - } else if (isHotkey('backspace', event)) { - if (selection.isExpanded) { - editor.delete(); - } - if (!editor.atStartOf(parent)) { - return next(); - } - const previousParentSibling = doc.getPreviousSibling(parent.key); - if (previousParentSibling && previousParentSibling.type === type) { - return editor.mergeNodeByKey(parent.key); - } - - return editor.unwrapNodeByKey(startBlock.key); - } - return next(); - }, - }; -} - -export default QuoteBlock; diff --git a/src/widgets/markdown/MarkdownControl/plugins/SelectAll.js b/src/widgets/markdown/MarkdownControl/plugins/SelectAll.js deleted file mode 100644 index e9762bed..00000000 --- a/src/widgets/markdown/MarkdownControl/plugins/SelectAll.js +++ /dev/null @@ -1,16 +0,0 @@ -import isHotkey from 'is-hotkey'; - -function SelectAll() { - return { - onKeyDown(event, editor, next) { - const isModA = isHotkey('mod+a', event); - if (!isModA) { - return next(); - } - event.preventDefault(); - return editor.moveToRangeOfDocument(); - }, - }; -} - -export default SelectAll; diff --git a/src/widgets/markdown/MarkdownControl/plugins/Shortcode.js b/src/widgets/markdown/MarkdownControl/plugins/Shortcode.js deleted file mode 100644 index a0b9251b..00000000 --- a/src/widgets/markdown/MarkdownControl/plugins/Shortcode.js +++ /dev/null @@ -1,49 +0,0 @@ -import { Text, Block } from 'slate'; - -function createShortcodeBlock(shortcodeConfig) { - // Handle code block component - if (shortcodeConfig.type === 'code-block') { - return Block.create({ type: shortcodeConfig.type, data: { shortcodeNew: true } }); - } - - const nodes = [Text.create('')]; - - // Get default values for plugin fields. - const defaultValues = shortcodeConfig.fields - .toMap() - .mapKeys((_, field) => field.get('name')) - .filter(field => field.has('default')) - .map(field => field.get('default')); - - // Create new shortcode block with default values set. - return Block.create({ - type: 'shortcode', - data: { - shortcode: shortcodeConfig.id, - shortcodeNew: true, - shortcodeData: defaultValues, - }, - nodes, - }); -} - -function Shortcode({ defaultType }) { - return { - commands: { - insertShortcode(editor, shortcodeConfig) { - const block = createShortcodeBlock(shortcodeConfig); - const { focusBlock } = editor.value; - - if (focusBlock.text === '' && focusBlock.type === defaultType) { - editor.replaceNodeByKey(focusBlock.key, block); - } else { - editor.insertBlock(block); - } - - editor.focus(); - }, - }, - }; -} - -export default Shortcode; diff --git a/src/widgets/markdown/MarkdownControl/plugins/util.js b/src/widgets/markdown/MarkdownControl/plugins/util.js deleted file mode 100644 index d012f875..00000000 --- a/src/widgets/markdown/MarkdownControl/plugins/util.js +++ /dev/null @@ -1,11 +0,0 @@ -import { castArray, isArray } from 'lodash'; - -export function assertType(nodes, type) { - const nodesArray = castArray(nodes); - const validate = isArray(type) ? node => type.includes(node.type) : node => type === node.type; - const invalidNode = nodesArray.find(node => !validate(node)); - if (invalidNode) { - throw Error(`Expected node of type "${type}", received "${invalidNode.type}".`); - } - return true; -} diff --git a/src/widgets/markdown/MarkdownControl/plugins/visual.js b/src/widgets/markdown/MarkdownControl/plugins/visual.js deleted file mode 100644 index 0d53945d..00000000 --- a/src/widgets/markdown/MarkdownControl/plugins/visual.js +++ /dev/null @@ -1,59 +0,0 @@ -//import { Text, Inline } from 'slate'; -import isHotkey from 'is-hotkey'; - -import CommandsAndQueries from './CommandsAndQueries'; -import ListPlugin from './List'; -import LineBreak from './LineBreak'; -import BreakToDefaultBlock from './BreakToDefaultBlock'; -import CloseBlock from './CloseBlock'; -import QuoteBlock from './QuoteBlock'; -import SelectAll from './SelectAll'; -import CopyPasteVisual from './CopyPasteVisual'; -import Link from './Link'; -import ForceInsert from './ForceInsert'; -import Shortcode from './Shortcode'; -import { SLATE_DEFAULT_BLOCK_TYPE as defaultType } from '../../types'; -import Hotkey, { HOT_KEY_MAP } from './Hotkey'; - -function plugins({ getAsset, resolveWidget, t, remarkPlugins }) { - return [ - { - onKeyDown(event, editor, next) { - if (isHotkey('mod+j', event)) { - console.info(JSON.stringify(editor.value.document.toJS(), null, 2)); - } - next(); - }, - }, - Hotkey(HOT_KEY_MAP['bold'], e => e.toggleMark('bold')), - Hotkey(HOT_KEY_MAP['code'], e => e.toggleMark('code')), - Hotkey(HOT_KEY_MAP['italic'], e => e.toggleMark('italic')), - Hotkey(HOT_KEY_MAP['strikethrough'], e => e.toggleMark('strikethrough')), - Hotkey(HOT_KEY_MAP['heading-one'], e => e.toggleBlock('heading-one')), - Hotkey(HOT_KEY_MAP['heading-two'], e => e.toggleBlock('heading-two')), - Hotkey(HOT_KEY_MAP['heading-three'], e => e.toggleBlock('heading-three')), - Hotkey(HOT_KEY_MAP['heading-four'], e => e.toggleBlock('heading-four')), - Hotkey(HOT_KEY_MAP['heading-five'], e => e.toggleBlock('heading-five')), - Hotkey(HOT_KEY_MAP['heading-six'], e => e.toggleBlock('heading-six')), - Hotkey(HOT_KEY_MAP['link'], e => - e.toggleLink(() => window.prompt(t('editor.editorWidgets.markdown.linkPrompt'))), - ), - CommandsAndQueries({ defaultType }), - QuoteBlock({ defaultType, type: 'quote' }), - ListPlugin({ - defaultType, - unorderedListType: 'bulleted-list', - orderedListType: 'numbered-list', - }), - Link({ type: 'link' }), - LineBreak(), - BreakToDefaultBlock({ defaultType }), - CloseBlock({ defaultType }), - SelectAll(), - ForceInsert({ defaultType }), - CopyPasteVisual({ getAsset, resolveWidget, remarkPlugins }), - Shortcode({ defaultType }), - ]; -} - -export default plugins; diff --git a/src/widgets/markdown/MarkdownControl/renderers.js b/src/widgets/markdown/MarkdownControl/renderers.js deleted file mode 100644 index 32938097..00000000 --- a/src/widgets/markdown/MarkdownControl/renderers.js +++ /dev/null @@ -1,353 +0,0 @@ -/* eslint-disable react/display-name */ -import React from 'react'; -import { css } from '@emotion/react'; -import styled from '@emotion/styled'; - -import { colors, lengths } from '../../../ui'; -import VoidBlock from './components/VoidBlock'; -import Shortcode from './components/Shortcode'; - -const bottomMargin = '16px'; - -const headerStyles = ` - font-weight: 700; - line-height: 1; -`; - -const StyledH1 = styled.h1` - ${headerStyles}; - font-size: 32px; - margin-top: 16px; -`; - -const StyledH2 = styled.h2` - ${headerStyles}; - font-size: 24px; - margin-top: 12px; -`; - -const StyledH3 = styled.h3` - ${headerStyles}; - font-size: 20px; -`; - -const StyledH4 = styled.h4` - ${headerStyles}; - font-size: 18px; - margin-top: 8px; -`; - -const StyledH5 = styled.h5` - ${headerStyles}; - font-size: 16px; - margin-top: 8px; -`; - -const StyledH6 = StyledH5.withComponent('h6'); - -const StyledP = styled.p` - margin-bottom: ${bottomMargin}; -`; - -const StyledBlockQuote = styled.blockquote` - padding-left: 16px; - border-left: 3px solid ${colors.background}; - margin-left: 0; - margin-right: 0; - margin-bottom: ${bottomMargin}; -`; - -const StyledPre = styled.pre` - margin-bottom: ${bottomMargin}; - white-space: pre-wrap; - - & > code { - display: block; - width: 100%; - overflow-y: auto; - background-color: #000; - color: #ccc; - border-radius: ${lengths.borderRadius}; - padding: 10px; - } -`; - -const StyledCode = styled.code` - background-color: ${colors.background}; - border-radius: ${lengths.borderRadius}; - padding: 0 2px; - font-size: 85%; -`; - -const StyledUl = styled.ul` - margin-bottom: ${bottomMargin}; - padding-left: 30px; -`; - -const StyledOl = StyledUl.withComponent('ol'); - -const StyledLi = styled.li` - & > p:first-child { - margin-top: 8px; - } - - & > p:last-child { - margin-bottom: 8px; - } -`; - -const StyledA = styled.a` - text-decoration: underline; - font-size: inherit; -`; - -const StyledHr = styled.hr` - border: 1px solid; - margin-bottom: 16px; -`; - -const StyledTable = styled.table` - border-collapse: collapse; -`; - -const StyledTd = styled.td` - border: 2px solid black; - padding: 8px; - text-align: left; -`; - -/** - * Slate uses React components to render each type of node that it receives. - * This is the closest thing Slate has to a schema definition. The types are set - * by us when we manually deserialize from Remark's MDAST to Slate's AST. - */ - -/** - * Mark Components - */ -function Bold(props) { - return {props.children}; -} - -function Italic(props) { - return {props.children}; -} - -function Strikethrough(props) { - return {props.children}; -} - -function Code(props) { - return {props.children}; -} - -/** - * Node Components - */ -function Paragraph(props) { - return {props.children}; -} - -function ListItem(props) { - return {props.children}; -} - -function Quote(props) { - return {props.children}; -} - -function CodeBlock(props) { - return ( - - {props.children} - - ); -} - -function HeadingOne(props) { - return {props.children}; -} - -function HeadingTwo(props) { - return {props.children}; -} - -function HeadingThree(props) { - return {props.children}; -} - -function HeadingFour(props) { - return {props.children}; -} - -function HeadingFive(props) { - return {props.children}; -} - -function HeadingSix(props) { - return {props.children}; -} - -function Table(props) { - return ( - - {props.children} - - ); -} - -function TableRow(props) { - return {props.children}; -} - -function TableCell(props) { - return {props.children}; -} - -function ThematicBreak(props) { - return ( - - ); -} - -function Break(props) { - return
    ; -} - -function BulletedList(props) { - return {props.children}; -} - -function NumberedList(props) { - return ( - - {props.children} - - ); -} - -function Link(props) { - const data = props.node.get('data'); - const url = data.get('url'); - const title = data.get('title') || url; - - return ( - - {props.children} - - ); -} - -function Image(props) { - const data = props.node.get('data'); - const marks = data.get('marks'); - const url = data.get('url'); - const title = data.get('title'); - const alt = data.get('alt'); - const image = {alt}; - const result = !marks - ? image - : marks.reduce((acc, mark) => { - return renderMark({ mark, children: acc }); - }, image); - return result; -} - -export function renderMark() { - return props => { - switch (props.mark.type) { - case 'bold': - return ; - case 'italic': - return ; - case 'strikethrough': - return ; - case 'code': - return ; - } - }; -} - -export function renderInline() { - return props => { - switch (props.node.type) { - case 'link': - return ; - case 'image': - return ; - case 'break': - return ; - } - }; -} - -export function renderBlock({ classNameWrapper, codeBlockComponent }) { - return props => { - switch (props.node.type) { - case 'paragraph': - return ; - case 'list-item': - return ; - case 'quote': - return ; - case 'code-block': - if (codeBlockComponent) { - return ( - - - - ); - } - return ; - case 'heading-one': - return ; - case 'heading-two': - return ; - case 'heading-three': - return ; - case 'heading-four': - return ; - case 'heading-five': - return ; - case 'heading-six': - return ; - case 'table': - return ; - case 'table-row': - return ; - case 'table-cell': - return ; - case 'thematic-break': - return ( - - - - ); - case 'bulleted-list': - return ; - case 'numbered-list': - return ; - case 'shortcode': - return ( - - - - ); - } - }; -} diff --git a/src/widgets/markdown/MarkdownControl/schema.js b/src/widgets/markdown/MarkdownControl/schema.js deleted file mode 100644 index ab01f865..00000000 --- a/src/widgets/markdown/MarkdownControl/schema.js +++ /dev/null @@ -1,274 +0,0 @@ -import { Inline, Text } from 'slate'; - -const codeBlock = { - match: [{ object: 'block', type: 'code-block' }], - nodes: [ - { - match: [{ object: 'text' }], - }, - ], - normalize: (editor, error) => { - switch (error.code) { - // Replace break nodes with newlines - case 'child_object_invalid': { - const { child } = error; - if (Inline.isInline(child) && child.type === 'break') { - editor.replaceNodeByKey(child.key, Text.create({ text: '\n' })); - return; - } - } - } - }, -}; - -const codeBlockOverride = { - match: [{ object: 'block', type: 'code-block' }], - isVoid: true, -}; - -function schema({ voidCodeBlock } = {}) { - return { - rules: [ - /** - * Document - */ - { - match: [{ object: 'document' }], - nodes: [ - { - match: [ - { type: 'paragraph' }, - { type: 'heading-one' }, - { type: 'heading-two' }, - { type: 'heading-three' }, - { type: 'heading-four' }, - { type: 'heading-five' }, - { type: 'heading-six' }, - { type: 'quote' }, - { type: 'code-block' }, - { type: 'bulleted-list' }, - { type: 'numbered-list' }, - { type: 'thematic-break' }, - { type: 'table' }, - { type: 'shortcode' }, - ], - min: 1, - }, - ], - normalize: (editor, error) => { - switch (error.code) { - // If no blocks present, insert one. - case 'child_min_invalid': { - const node = { object: 'block', type: 'paragraph' }; - editor.insertNodeByKey(error.node.key, 0, node); - return; - } - } - }, - }, - - /** - * Block Containers - */ - { - match: [ - { object: 'block', type: 'quote' }, - { object: 'block', type: 'list-item' }, - ], - nodes: [ - { - match: [ - { type: 'paragraph' }, - { type: 'heading-one' }, - { type: 'heading-two' }, - { type: 'heading-three' }, - { type: 'heading-four' }, - { type: 'heading-five' }, - { type: 'heading-six' }, - { type: 'quote' }, - { type: 'code-block' }, - { type: 'bulleted-list' }, - { type: 'numbered-list' }, - { type: 'thematic-break' }, - { type: 'table' }, - { type: 'shortcode' }, - ], - }, - ], - }, - - /** - * List Items - */ - { - match: [{ object: 'block', type: 'list-item' }], - parent: [{ type: 'bulleted-list' }, { type: 'numbered-list' }], - }, - - /** - * Blocks - */ - { - match: [ - { object: 'block', type: 'paragraph' }, - { object: 'block', type: 'heading-one' }, - { object: 'block', type: 'heading-two' }, - { object: 'block', type: 'heading-three' }, - { object: 'block', type: 'heading-four' }, - { object: 'block', type: 'heading-five' }, - { object: 'block', type: 'heading-six' }, - { object: 'block', type: 'table-cell' }, - { object: 'inline', type: 'link' }, - ], - nodes: [ - { - match: [{ object: 'text' }, { type: 'link' }, { type: 'image' }, { type: 'break' }], - }, - ], - }, - - /** - * Bulleted List - */ - { - match: [{ object: 'block', type: 'bulleted-list' }], - nodes: [ - { - match: [{ type: 'list-item' }], - min: 1, - }, - ], - next: [ - { type: 'paragraph' }, - { type: 'heading-one' }, - { type: 'heading-two' }, - { type: 'heading-three' }, - { type: 'heading-four' }, - { type: 'heading-five' }, - { type: 'heading-six' }, - { type: 'quote' }, - { type: 'code-block' }, - { type: 'numbered-list' }, - { type: 'thematic-break' }, - { type: 'table' }, - { type: 'shortcode' }, - ], - normalize: (editor, error) => { - switch (error.code) { - // If a list has no list items, remove the list - case 'child_min_invalid': - editor.removeNodeByKey(error.node.key); - return; - - // If two bulleted lists are immediately adjacent, join them - case 'next_sibling_type_invalid': - if (error.next.type === 'bulleted-list') { - editor.mergeNodeByKey(error.next.key); - } - return; - } - }, - }, - - /** - * Numbered List - */ - { - match: [{ object: 'block', type: 'numbered-list' }], - nodes: [ - { - match: [{ type: 'list-item' }], - min: 1, - }, - ], - next: [ - { type: 'paragraph' }, - { type: 'heading-one' }, - { type: 'heading-two' }, - { type: 'heading-three' }, - { type: 'heading-four' }, - { type: 'heading-five' }, - { type: 'heading-six' }, - { type: 'quote' }, - { type: 'code-block' }, - { type: 'bulleted-list' }, - { type: 'thematic-break' }, - { type: 'table' }, - { type: 'shortcode' }, - ], - normalize: (editor, error) => { - switch (error.code) { - // If a list has no list items, remove the list - case 'child_min_invalid': - editor.removeNodeByKey(error.node.key); - return; - - // If two numbered lists are immediately adjacent, join them - case 'next_sibling_type_invalid': { - if (error.next.type === 'numbered-list') { - editor.mergeNodeByKey(error.next.key); - } - return; - } - } - }, - }, - - /** - * Voids - */ - { - match: [ - { object: 'inline', type: 'image' }, - { object: 'inline', type: 'break' }, - { object: 'block', type: 'thematic-break' }, - { object: 'block', type: 'shortcode' }, - ], - isVoid: true, - }, - - /** - * Table - */ - { - match: [{ object: 'block', type: 'table' }], - nodes: [ - { - match: [{ object: 'block', type: 'table-row' }], - }, - ], - }, - - /** - * Table Row - */ - { - match: [{ object: 'block', type: 'table-row' }], - nodes: [ - { - match: [{ object: 'block', type: 'table-cell' }], - }, - ], - }, - - /** - * Marks - */ - { - match: [ - { object: 'mark', type: 'bold' }, - { object: 'mark', type: 'italic' }, - { object: 'mark', type: 'strikethrough' }, - { object: 'mark', type: 'code' }, - ], - }, - - /** - * Overrides - */ - voidCodeBlock ? codeBlockOverride : codeBlock, - ], - }; -} - -export default schema; diff --git a/src/widgets/markdown/MarkdownPreview.js b/src/widgets/markdown/MarkdownPreview.js deleted file mode 100644 index d1c7f8bf..00000000 --- a/src/widgets/markdown/MarkdownPreview.js +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import DOMPurify from 'dompurify'; - -import { WidgetPreviewContainer } from '../../ui'; -import { markdownToHtml } from './serializers'; -class MarkdownPreview extends React.Component { - static propTypes = { - getAsset: PropTypes.func.isRequired, - resolveWidget: PropTypes.func.isRequired, - value: PropTypes.string, - }; - - render() { - const { value, getAsset, resolveWidget, field, getRemarkPlugins } = this.props; - if (value === null) { - return null; - } - - const html = markdownToHtml(value, { getAsset, resolveWidget }, getRemarkPlugins?.()); - const toRender = field?.get('sanitize_preview', false) ? DOMPurify.sanitize(html) : html; - - return ; - } -} - -export default MarkdownPreview; diff --git a/src/widgets/markdown/MarkdownPreview.tsx b/src/widgets/markdown/MarkdownPreview.tsx new file mode 100644 index 00000000..efb83490 --- /dev/null +++ b/src/widgets/markdown/MarkdownPreview.tsx @@ -0,0 +1,28 @@ +import DOMPurify from 'dompurify'; +import React from 'react'; + +import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer'; +import { markdownToHtml } from './serializers'; + +import type { MarkdownField, WidgetPreviewProps } from '../../interface'; + +const MarkdownPreview = ({ + value, + getAsset, + field, + getRemarkPlugins, +}: WidgetPreviewProps) => { + if (!value) { + return null; + } + + const html = markdownToHtml(value, { + getAsset, + remarkPlugins: getRemarkPlugins(), + }); + const toRender = field.sanitize_preview ?? false ? DOMPurify.sanitize(html) : html; + + return ; +}; + +export default MarkdownPreview; diff --git a/src/widgets/markdown/index.js b/src/widgets/markdown/index.ts similarity index 51% rename from src/widgets/markdown/index.js rename to src/widgets/markdown/index.ts index 9f0f963a..fbb1fe46 100644 --- a/src/widgets/markdown/index.js +++ b/src/widgets/markdown/index.ts @@ -2,15 +2,17 @@ import controlComponent from './MarkdownControl'; import previewComponent from './MarkdownPreview'; import schema from './schema'; -function Widget(opts = {}) { +import type { MarkdownField, WidgetParam } from '../../interface'; + +const MarkdownWidget = (): WidgetParam => { return { name: 'markdown', controlComponent, previewComponent, - schema, - ...opts, + options: { + schema, + }, }; -} +}; -export const StaticCmsWidgetMarkdown = { Widget, controlComponent, previewComponent }; -export default StaticCmsWidgetMarkdown; +export default MarkdownWidget; diff --git a/src/widgets/markdown/regexHelper.js b/src/widgets/markdown/regexHelper.ts similarity index 85% rename from src/widgets/markdown/regexHelper.js rename to src/widgets/markdown/regexHelper.ts index 50dd1ec7..af4d30c0 100644 --- a/src/widgets/markdown/regexHelper.js +++ b/src/widgets/markdown/regexHelper.ts @@ -1,10 +1,10 @@ -import { last } from 'lodash'; +import last from 'lodash/last'; /** * Joins an array of regular expressions into a single expression, without * altering the received expressions. */ -export function joinPatternSegments(patterns) { +export function joinPatternSegments(patterns: RegExp[]) { return patterns.map(p => p.source).join(''); } @@ -13,10 +13,16 @@ export function joinPatternSegments(patterns) { * each in a non-capturing group and interposing alternation characters (|) so * that each expression is executed separately. */ -export function combinePatterns(patterns) { +export function combinePatterns(patterns: RegExp[]) { return patterns.map(p => `(?:${p.source})`).join('|'); } +interface PatternMatch { + index: number; + text: string; + match?: boolean; +} + /** * Modify substrings within a string if they match a (global) pattern. Can be * inverted to only modify non-matches. @@ -29,7 +35,12 @@ export function combinePatterns(patterns) { * invertMatchPattern - boolean - if true, non-matching substrings are modified * instead of matching substrings */ -export function replaceWhen(matchPattern, replaceFn, text, invertMatchPattern) { +export function replaceWhen( + matchPattern: RegExp, + replaceFn: (text: string) => string, + text: string, + invertMatchPattern: boolean, +): string { /** * Splits the string into an array of objects with the following shape: * @@ -42,7 +53,7 @@ export function replaceWhen(matchPattern, replaceFn, text, invertMatchPattern) { * Loops through matches via recursion (`RegExp.exec` tracks the loop * internally). */ - function split(exp, text, acc) { + function split(exp: RegExp, text: string, acc: PatternMatch[]): PatternMatch[] { /** * Get the next match starting from the end of the last match or start of * string. @@ -53,7 +64,9 @@ export function replaceWhen(matchPattern, replaceFn, text, invertMatchPattern) { /** * `match` will be null if there are no matches. */ - if (!match) return acc; + if (!match) { + return acc; + } /** * If the match is at the beginning of the input string, normalize to a data @@ -99,7 +112,7 @@ export function replaceWhen(matchPattern, replaceFn, text, invertMatchPattern) { * Factory for converting substrings to data objects and adding to an output * array. */ - function addSubstring(arr, index, text, match = false) { + function addSubstring(arr: PatternMatch[], index: number, text: string, match = false) { arr.push({ index, text, match }); } @@ -113,7 +126,9 @@ export function replaceWhen(matchPattern, replaceFn, text, invertMatchPattern) { * Process the trailing substring after the final match, if one exists. */ const lastEntry = last(acc); - if (!lastEntry) return replaceFn(text); + if (!lastEntry) { + return replaceFn(text); + } const nextIndex = lastEntry.index + lastEntry.text.length; if (text.length > nextIndex) { @@ -121,7 +136,7 @@ export function replaceWhen(matchPattern, replaceFn, text, invertMatchPattern) { } /** - * Map the data objects in the accumulator to their string values, modifying + * Record the data objects in the accumulator to their string values, modifying * matched strings with the replacement function. Modifies non-matches if * `invertMatchPattern` is truthy. */ diff --git a/src/widgets/markdown/schema.js b/src/widgets/markdown/schema.ts similarity index 79% rename from src/widgets/markdown/schema.js rename to src/widgets/markdown/schema.ts index 19fda27d..86ffd377 100644 --- a/src/widgets/markdown/schema.js +++ b/src/widgets/markdown/schema.ts @@ -23,13 +23,5 @@ export default { }, }, editor_components: { type: 'array', items: { type: 'string' } }, - modes: { - type: 'array', - items: { - type: 'string', - enum: ['raw', 'rich_text'], - }, - minItems: 1, - }, }, }; diff --git a/src/widgets/markdown/serializers.ts b/src/widgets/markdown/serializers.ts new file mode 100644 index 00000000..d9368311 --- /dev/null +++ b/src/widgets/markdown/serializers.ts @@ -0,0 +1,40 @@ +import rehypeStringify from 'rehype-stringify'; +import remarkGfm from 'remark-gfm'; +import remarkParse from 'remark-parse'; +import remarkRehype from 'remark-rehype'; +import { unified } from 'unified'; + +// import { getEditorComponents } from '../../../lib/registry'; + +import type { Pluggable } from 'unified'; +import type { GetAssetFunction } from '../../interface'; + +interface MarkdownToHtmlProps { + getAsset: GetAssetFunction; + remarkPlugins?: Pluggable[]; +} + +/** + * Convert Markdown to HTML. + */ +export function markdownToHtml( + markdown: string, + { remarkPlugins = [] }: MarkdownToHtmlProps, +): string { + const html = unified() + .use(remarkParse) + .use(remarkGfm) + // .use(remarkParseShortcodes as any, { plugins: getEditorComponents() }) + .use(remarkPlugins) + // .use(remarkToRehypeShortcodes as any, { plugins: getEditorComponents(), getAsset }) + .use(remarkRehype, { allowDangerousHTML: true }) + .use(rehypeStringify, { + allowDangerousHtml: true, + allowDangerousCharacters: true, + closeSelfClosing: true, + entities: { useNamedReferences: true }, + }) + .processSync(markdown); + + return String(html); +} diff --git a/src/widgets/markdown/serializers/index.js b/src/widgets/markdown/serializers/index.js deleted file mode 100644 index 79c82be7..00000000 --- a/src/widgets/markdown/serializers/index.js +++ /dev/null @@ -1,226 +0,0 @@ -import { trimEnd } from 'lodash'; -import unified from 'unified'; -import u from 'unist-builder'; -import markdownToRemarkPlugin from 'remark-parse'; -import remarkToMarkdownPlugin from 'remark-stringify'; -import remarkToRehype from 'remark-rehype'; -import rehypeToHtml from 'rehype-stringify'; -import htmlToRehype from 'rehype-parse'; -import rehypeToRemark from 'rehype-remark'; - -import remarkToRehypeShortcodes from './remarkRehypeShortcodes'; -import rehypePaperEmoji from './rehypePaperEmoji'; -import remarkAssertParents from './remarkAssertParents'; -import remarkPaddedLinks from './remarkPaddedLinks'; -import remarkWrapHtml from './remarkWrapHtml'; -import remarkToSlate from './remarkSlate'; -import remarkSquashReferences from './remarkSquashReferences'; -import { remarkParseShortcodes, createRemarkShortcodeStringifier } from './remarkShortcodes'; -import remarkEscapeMarkdownEntities from './remarkEscapeMarkdownEntities'; -import remarkStripTrailingBreaks from './remarkStripTrailingBreaks'; -import remarkAllowHtmlEntities from './remarkAllowHtmlEntities'; -import slateToRemark from './slateRemark'; -import { getEditorComponents } from '../MarkdownControl'; - -/** - * This module contains all serializers for the Markdown widget. - * - * The value of a Markdown widget is transformed to various formats during - * editing, and these formats are referenced throughout serializer source - * documentation. Below is brief glossary of the formats used. - * - * - Markdown {string} - * The stringified Markdown value. The value of the field is persisted - * (stored) in this format, and the stringified value is also used when the - * editor is in "raw" Markdown mode. - * - * - MDAST {object} - * Also loosely referred to as "Remark". MDAST stands for MarkDown AST - * (Abstract Syntax Tree), and is an object representation of a Markdown - * document. Underneath, it's a Unist tree with a Markdown-specific schema. - * MDAST syntax is a part of the Unified ecosystem, and powers the Remark - * processor, so Remark plugins may be used. - * - * - HAST {object} - * Also loosely referred to as "Rehype". HAST, similar to MDAST, is an object - * representation of an HTML document. The field value takes this format - * temporarily before the document is stringified to HTML. - * - * - HTML {string} - * The field value is stringifed to HTML for preview purposes - the HTML value - * is never parsed, it is output only. - * - * - Slate Raw AST {object} - * Slate's Raw AST is a very simple and unopinionated object representation of - * a document in a Slate editor. We define our own Markdown-specific schema - * for serialization to/from Slate's Raw AST and MDAST. - */ - -/** - * Deserialize a Markdown string to an MDAST. - */ -export function markdownToRemark(markdown, remarkPlugins) { - const processor = unified() - .use(markdownToRemarkPlugin, { fences: true, commonmark: true }) - .use(markdownToRemarkRemoveTokenizers, { inlineTokenizers: ['url'] }) - .use(remarkParseShortcodes, { plugins: getEditorComponents() }) - .use(remarkAllowHtmlEntities) - .use(remarkSquashReferences) - .use(remarkPlugins); - - /** - * Parse the Markdown string input to an MDAST. - */ - const parsed = processor.parse(markdown); - - /** - * Further transform the MDAST with plugins. - */ - const result = processor.runSync(parsed); - - return result; -} - -/** - * Remove named tokenizers from the parser, effectively deactivating them. - */ -function markdownToRemarkRemoveTokenizers({ inlineTokenizers }) { - inlineTokenizers && - inlineTokenizers.forEach(tokenizer => { - delete this.Parser.prototype.inlineTokenizers[tokenizer]; - }); -} - -/** - * Serialize an MDAST to a Markdown string. - */ -export function remarkToMarkdown(obj, remarkPlugins) { - /** - * Rewrite the remark-stringify text visitor to simply return the text value, - * without encoding or escaping any characters. This means we're completely - * trusting the markdown that we receive. - */ - function remarkAllowAllText() { - const Compiler = this.Compiler; - const visitors = Compiler.prototype.visitors; - visitors.text = node => node.value; - } - - /** - * Provide an empty MDAST if no value is provided. - */ - const mdast = obj || u('root', [u('paragraph', [u('text', '')])]); - - const remarkToMarkdownPluginOpts = { - commonmark: true, - fences: true, - listItemIndent: '1', - - /** - * Use asterisk for everything, it's the most versatile. Eventually using - * other characters should be an option. - */ - bullet: '*', - emphasis: '*', - strong: '*', - rule: '-', - }; - - const processor = unified() - .use({ settings: remarkToMarkdownPluginOpts }) - .use(remarkEscapeMarkdownEntities) - .use(remarkStripTrailingBreaks) - .use(remarkToMarkdownPlugin) - .use(remarkAllowAllText) - .use(createRemarkShortcodeStringifier({ plugins: getEditorComponents() })) - .use(remarkPlugins); - - /** - * Transform the MDAST with plugins. - */ - const processedMdast = processor.runSync(mdast); - - /** - * Serialize the MDAST to markdown. - */ - const markdown = processor.stringify(processedMdast).replace(/\r?/g, ''); - - /** - * Return markdown with trailing whitespace removed. - */ - return trimEnd(markdown); -} - -/** - * Convert Markdown to HTML. - */ -export function markdownToHtml(markdown, { getAsset, resolveWidget, remarkPlugins = [] } = {}) { - const mdast = markdownToRemark(markdown, remarkPlugins); - - const hast = unified() - .use(remarkToRehypeShortcodes, { plugins: getEditorComponents(), getAsset, resolveWidget }) - .use(remarkToRehype, { allowDangerousHTML: true }) - .runSync(mdast); - - const html = unified() - .use(rehypeToHtml, { - allowDangerousHtml: true, - allowDangerousCharacters: true, - closeSelfClosing: true, - entities: { useNamedReferences: true }, - }) - .stringify(hast); - - return html; -} - -/** - * Deserialize an HTML string to Slate's Raw AST. Currently used for HTML - * pastes. - */ -export function htmlToSlate(html) { - const hast = unified().use(htmlToRehype, { fragment: true }).parse(html); - - const mdast = unified() - .use(rehypePaperEmoji) - .use(rehypeToRemark, { minify: false }) - .runSync(hast); - - const slateRaw = unified() - .use(remarkAssertParents) - .use(remarkPaddedLinks) - .use(remarkWrapHtml) - .use(remarkToSlate) - .runSync(mdast); - - return slateRaw; -} - -/** - * Convert Markdown to Slate's Raw AST. - */ -export function markdownToSlate(markdown, { voidCodeBlock, remarkPlugins = [] } = {}) { - const mdast = markdownToRemark(markdown, remarkPlugins); - - const slateRaw = unified() - .use(remarkWrapHtml) - .use(remarkToSlate, { voidCodeBlock }) - .runSync(mdast); - - return slateRaw; -} - -/** - * Convert a Slate Raw AST to Markdown. - * - * Requires shortcode plugins to parse shortcode nodes back to text. - * - * Note that Unified is not utilized for the conversion from Slate's Raw AST to - * MDAST. The conversion is manual because Unified can only operate on Unist - * trees. - */ -export function slateToMarkdown(raw, { voidCodeBlock, remarkPlugins = [] } = {}) { - const mdast = slateToRemark(raw, { voidCodeBlock }); - const markdown = remarkToMarkdown(mdast, remarkPlugins); - return markdown; -} diff --git a/src/widgets/markdown/serializers/rehypePaperEmoji.js b/src/widgets/markdown/serializers/rehypePaperEmoji.js deleted file mode 100644 index cda7bab9..00000000 --- a/src/widgets/markdown/serializers/rehypePaperEmoji.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Dropbox Paper outputs emoji characters as images, and stores the actual - * emoji character in a `data-emoji-ch` attribute on the image. This plugin - * replaces the images with the emoji characters. - */ -export default function rehypePaperEmoji() { - function transform(node) { - if (node.tagName === 'img' && node.properties.dataEmojiCh) { - return { type: 'text', value: node.properties.dataEmojiCh }; - } - node.children = node.children ? node.children.map(transform) : node.children; - return node; - } - - return transform; -} diff --git a/src/widgets/markdown/serializers/remarkAllowHtmlEntities.js b/src/widgets/markdown/serializers/remarkAllowHtmlEntities.js deleted file mode 100644 index 8e48a0f9..00000000 --- a/src/widgets/markdown/serializers/remarkAllowHtmlEntities.js +++ /dev/null @@ -1,58 +0,0 @@ -export default function remarkAllowHtmlEntities() { - this.Parser.prototype.inlineTokenizers.text = text; - - /** - * This is a port of the `remark-parse` text tokenizer, adapted to exclude - * HTML entity decoding. - */ - function text(eat, value, silent) { - var self = this; - var methods; - var tokenizers; - var index; - var length; - var subvalue; - var position; - var tokenizer; - var name; - var min; - - /* istanbul ignore if - never used (yet) */ - if (silent) { - return true; - } - - methods = self.inlineMethods; - length = methods.length; - tokenizers = self.inlineTokenizers; - index = -1; - min = value.length; - - while (++index < length) { - name = methods[index]; - - if (name === 'text' || !tokenizers[name]) { - continue; - } - - tokenizer = tokenizers[name].locator; - - if (!tokenizer) { - eat.file.fail('Missing locator: `' + name + '`'); - } - - position = tokenizer.call(self, value, 1); - - if (position !== -1 && position < min) { - min = position; - } - } - - subvalue = value.slice(0, min); - - eat(subvalue)({ - type: 'text', - value: subvalue, - }); - } -} diff --git a/src/widgets/markdown/serializers/remarkAssertParents.js b/src/widgets/markdown/serializers/remarkAssertParents.js deleted file mode 100644 index 431dd0a5..00000000 --- a/src/widgets/markdown/serializers/remarkAssertParents.js +++ /dev/null @@ -1,83 +0,0 @@ -import { concat, last, nth, isEmpty } from 'lodash'; -import visitParents from 'unist-util-visit-parents'; - -/** - * remarkUnwrapInvalidNest - * - * Some MDAST node types can only be nested within specific node types - for - * example, a paragraph can't be nested within another paragraph, and a heading - * can't be nested in a "strong" type node. This kind of invalid MDAST can be - * generated by rehype-remark from invalid HTML. - * - * This plugin finds instances of invalid nesting, and unwraps the invalidly - * nested nodes as far up the parental line as necessary, splitting parent nodes - * along the way. The resulting node has no invalidly nested nodes, and all - * validly nested nodes retain their ancestry. Nodes that are emptied as a - * result of unnesting nodes are removed from the tree. - */ -export default function remarkUnwrapInvalidNest() { - return transform; - - function transform(tree) { - const invalidNest = findInvalidNest(tree); - - if (!invalidNest) return tree; - - splitTreeAtNest(tree, invalidNest); - - return transform(tree); - } - - /** - * visitParents uses unist-util-visit-parent to check every node in the - * tree while having access to every ancestor of the node. This is ideal - * for determining whether a block node has an ancestor that should not - * contain a block node. Note that it operates in a mutable fashion. - */ - function findInvalidNest(tree) { - /** - * Node types that are considered "blocks". - */ - const blocks = ['paragraph', 'heading', 'code', 'blockquote', 'list', 'table', 'thematicBreak']; - - /** - * Node types that can contain "block" nodes as direct children. We check - */ - const canContainBlocks = ['root', 'blockquote', 'listItem', 'tableCell']; - - let invalidNest; - - visitParents(tree, (node, parents) => { - const parentType = !isEmpty(parents) && last(parents).type; - const isInvalidNest = blocks.includes(node.type) && !canContainBlocks.includes(parentType); - - if (isInvalidNest) { - invalidNest = concat(parents, node); - return false; - } - }); - - return invalidNest; - } - - function splitTreeAtNest(tree, nest) { - const grandparent = nth(nest, -3) || tree; - const parent = nth(nest, -2); - const node = last(nest); - - const splitIndex = grandparent.children.indexOf(parent); - const splitChildren = grandparent.children; - const splitChildIndex = parent.children.indexOf(node); - - const childrenBefore = parent.children.slice(0, splitChildIndex); - const childrenAfter = parent.children.slice(splitChildIndex + 1); - const nodeBefore = !isEmpty(childrenBefore) && { ...parent, children: childrenBefore }; - const nodeAfter = !isEmpty(childrenAfter) && { ...parent, children: childrenAfter }; - - const childrenToInsert = [nodeBefore, node, nodeAfter].filter(val => !isEmpty(val)); - const beforeChildren = splitChildren.slice(0, splitIndex); - const afterChildren = splitChildren.slice(splitIndex + 1); - const newChildren = concat(beforeChildren, childrenToInsert, afterChildren); - grandparent.children = newChildren; - } -} diff --git a/src/widgets/markdown/serializers/remarkEscapeMarkdownEntities.js b/src/widgets/markdown/serializers/remarkEscapeMarkdownEntities.js deleted file mode 100644 index 31a2e119..00000000 --- a/src/widgets/markdown/serializers/remarkEscapeMarkdownEntities.js +++ /dev/null @@ -1,269 +0,0 @@ -import { has, flow, partial, map } from 'lodash'; - -import { joinPatternSegments, combinePatterns, replaceWhen } from '../regexHelper'; - -/** - * Reusable regular expressions segments. - */ -const patternSegments = { - /** - * Matches zero or more HTML attributes followed by the tag close bracket, - * which may be prepended by zero or more spaces. The attributes can use - * single or double quotes and may be prepended by zero or more spaces. - */ - htmlOpeningTagEnd: /(?: *\w+=(?:(?:"[^"]*")|(?:'[^']*')))* *>/, -}; - -/** - * Patterns matching substrings that should not be escaped. Array values must be - * joined before use. - */ -const nonEscapePatterns = { - /** - * HTML Tags - * - * Matches HTML opening tags and any attributes. Does not check for contents - * between tags or closing tags. - */ - htmlTags: [ - /** - * Matches the beginning of an HTML tag, excluding preformatted tag types. - */ - /<(?!pre|style|script)[\w]+/, - - /** - * Matches attributes. - */ - patternSegments.htmlOpeningTagEnd, - ], - - /** - * Preformatted HTML Blocks - * - * Matches HTML blocks with preformatted content. The content of these blocks, - * including the tags and attributes, should not be escaped at all. - */ - preformattedHtmlBlocks: [ - /** - * Matches the names of tags known to have preformatted content. The capture - * group is reused when matching the closing tag. - * - * NOTE: this pattern reuses a capture group, and could break if combined with - * other expressions using capture groups. - */ - /<(pre|style|script)/, - - /** - * Matches attributes. - */ - patternSegments.htmlOpeningTagEnd, - - /** - * Allow zero or more of any character (including line breaks) between the - * tags. Match lazily in case of subsequent blocks. - */ - /(.|[\n\r])*?/, - - /** - * Match closing tag via first capture group. - */ - /<\/\1>/, - ], -}; - -/** - * Escape patterns - * - * Each escape pattern matches a markdown entity and captures up to two - * groups. These patterns must use one of the following formulas: - * - * - Single capture group followed by match content - /(...).../ - * The captured characters should be escaped and the remaining match should - * remain unchanged. - * - * - Two capture groups surrounding matched content - /(...)...(...)/ - * The captured characters in both groups should be escaped and the matched - * characters in between should remain unchanged. - */ -const escapePatterns = [ - /** - * Emphasis/Bold - Asterisk - * - * Match strings surrounded by one or more asterisks on both sides. - */ - /(\*+)[^*]*(\1)/g, - - /** - * Emphasis - Underscore - * - * Match strings surrounded by a single underscore on both sides followed by - * a word boundary. Remark disregards whether a word boundary exists at the - * beginning of an emphasis node. - */ - /(_)[^_]+(_)\b/g, - - /** - * Bold - Underscore - * - * Match strings surrounded by multiple underscores on both sides. Remark - * disregards the absence of word boundaries on either side of a bold node. - */ - /(_{2,})[^_]*(\1)/g, - - /** - * Strikethrough - * - * Match strings surrounded by multiple tildes on both sides. - */ - /(~+)[^~]*(\1)/g, - - /** - * Inline Code - * - * Match strings surrounded by backticks. - */ - /(`+)[^`]*(\1)/g, - - /** - * Links and Images - * - * Match strings surrounded by square brackets, except when the opening - * bracket is followed by a caret. This could be improved to specifically - * match only the exact syntax of each covered entity, but doing so through - * current approach would incur a considerable performance penalty. - */ - /(\[(?!\^)+)[^\]]*]/g, -]; - -/** - * Generate new non-escape expression. The non-escape expression matches - * substrings whose contents should not be processed for escaping. - */ -const joinedNonEscapePatterns = map(nonEscapePatterns, pattern => { - return new RegExp(joinPatternSegments(pattern)); -}); -const nonEscapePattern = combinePatterns(joinedNonEscapePatterns); - -/** - * Create chain of successive escape functions for various markdown entities. - */ -const escapeFunctions = escapePatterns.map(pattern => partial(escapeDelimiters, pattern)); -const escapeAll = flow(escapeFunctions); - -/** - * Executes both the `escapeCommonChars` and `escapeLeadingChars` functions. - */ -function escapeAllChars(text) { - const partiallyEscapedMarkdown = escapeCommonChars(text); - return escapeLeadingChars(partiallyEscapedMarkdown); -} - -/** - * escapeLeadingChars - * - * Handles escaping for characters that must be positioned at the beginning of - * the string, such as headers and list items. - * - * Escapes '#', '*', '-', '>', '=', '|', and sequences of 3+ backticks or 4+ - * spaces when found at the beginning of a string, preceded by zero or more - * whitespace characters. - */ -function escapeLeadingChars(text) { - return text.replace(/^\s*([-#*>=|]| {4,}|`{3,})/, '$`\\$1'); -} - -/** - * escapeCommonChars - * - * Escapes active markdown entities. See escape pattern groups for details on - * which entities are replaced. - */ -function escapeCommonChars(text) { - /** - * Generate new non-escape expression (must happen at execution time because - * we use `RegExp.exec`, which tracks it's own state internally). - */ - const nonEscapeExpression = new RegExp(nonEscapePattern, 'gm'); - - /** - * Use `replaceWhen` to escape markdown entities only within substrings that - * are eligible for escaping. - */ - return replaceWhen(nonEscapeExpression, escapeAll, text, true); -} - -/** - * escapeDelimiters - * - * Executes `String.replace` for a given pattern, but only on the first two - * capture groups. Specifically intended for escaping opening (and optionally - * closing) markdown entities without escaping the content in between. - */ -function escapeDelimiters(pattern, text) { - return text.replace(pattern, (match, start, end) => { - const hasEnd = typeof end === 'string'; - const matchSliceEnd = hasEnd ? match.length - end.length : match.length; - const content = match.slice(start.length, matchSliceEnd); - return `${escape(start)}${content}${hasEnd ? escape(end) : ''}`; - }); -} - -/** - * escape - * - * Simple replacement function for escaping markdown entities. Prepends every - * character in the received string with a backslash. - */ -function escape(delim) { - let result = ''; - for (const char of delim) { - result += `\\${char}`; - } - return result; -} - -/** - * A Remark plugin for escaping markdown entities. - * - * When markdown entities are entered in raw markdown, they don't appear as - * characters in the resulting AST; for example, dashes surrounding a piece of - * text cause the text to be inserted in a special node type, but the asterisks - * themselves aren't present as text. Therefore, we generally don't expect to - * encounter markdown characters in text nodes. - * - * However, the CMS visual editor does not interpret markdown characters, and - * users will expect these characters to be represented literally. In that case, - * we need to escape them, otherwise they'll be interpreted during - * stringification. - */ -export default function remarkEscapeMarkdownEntities() { - function transform(node, index) { - /** - * Shortcode nodes will intentionally inject markdown entities in text node - * children not be escaped. - */ - if (has(node.data, 'shortcode')) return node; - - const children = node.children ? { children: node.children.map(transform) } : {}; - - /** - * Escape characters in text and html nodes only. We store a lot of normal - * text in html nodes to keep Remark from escaping html entities. - */ - if (['text', 'html'].includes(node.type)) { - /** - * Escape all characters if this is the first child node, otherwise only - * common characters. - */ - const value = index === 0 ? escapeAllChars(node.value) : escapeCommonChars(node.value); - return { ...node, value, ...children }; - } - - /** - * Always return nodes with recursively mapped children. - */ - return { ...node, ...children }; - } - - return transform; -} diff --git a/src/widgets/markdown/serializers/remarkImagesToText.js b/src/widgets/markdown/serializers/remarkImagesToText.js deleted file mode 100644 index 6f305a12..00000000 --- a/src/widgets/markdown/serializers/remarkImagesToText.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Images must be parsed as shortcodes for asset proxying. This plugin converts - * MDAST image nodes back to text to allow shortcode pattern matching. Note that - * this transformation only occurs for images that are the sole child of a top - * level paragraph - any other image is left alone and treated as an inline - * image. - */ -export default function remarkImagesToText() { - return transform; - - function transform(node) { - const children = node.children.map(child => { - if ( - child.type === 'paragraph' && - child.children.length === 1 && - child.children[0].type === 'image' - ) { - const { alt, url, title } = child.children[0]; - const value = `![${alt || ''}](${url || ''}${title ? ` "${title}"` : ''})`; - child.children = [{ type: 'text', value }]; - } - return child; - }); - return { ...node, children }; - } -} diff --git a/src/widgets/markdown/serializers/remarkPaddedLinks.js b/src/widgets/markdown/serializers/remarkPaddedLinks.js deleted file mode 100644 index 96ac4a5d..00000000 --- a/src/widgets/markdown/serializers/remarkPaddedLinks.js +++ /dev/null @@ -1,120 +0,0 @@ -import { find, findLast, startsWith, endsWith, trimStart, trimEnd, flatMap } from 'lodash'; -import u from 'unist-builder'; -import toString from 'mdast-util-to-string'; - -/** - * Convert leading and trailing spaces in a link to single spaces outside of the - * link. MDASTs derived from pasted Google Docs HTML require this treatment. - * - * Note that, because we're potentially replacing characters in a link node's - * children with character's in a link node's siblings, we have to operate on a - * parent (link) node and its children at once, rather than just processing - * children one at a time. - */ -export default function remarkPaddedLinks() { - function transform(node) { - /** - * Because we're operating on link nodes and their children at once, we can - * exit if the current node has no children. - */ - if (!node.children) return node; - - /** - * Process a node's children if any of them are links. If a node is a link - * with leading or trailing spaces, we'll get back an array of nodes instead - * of a single node, so we use `flatMap` to keep those nodes as siblings - * with the other children. - * - * If performance improvements are found desirable, we could change this to - * only pass in the link nodes instead of the entire array of children, but - * this seems unlikely to produce a noticeable perf gain. - */ - const hasLinkChild = node.children.some(child => child.type === 'link'); - const processedChildren = hasLinkChild - ? flatMap(node.children, transformChildren) - : node.children; - - /** - * Run all children through the transform recursively. - */ - const children = processedChildren.map(transform); - - return { ...node, children }; - } - - function transformChildren(node) { - if (node.type !== 'link') return node; - - /** - * Get the node's complete string value, check for leading and trailing - * whitespace, and get nodes from each edge where whitespace is found. - */ - const text = toString(node); - const leadingWhitespaceNode = startsWith(text, ' ') && getEdgeTextChild(node); - const trailingWhitespaceNode = endsWith(text, ' ') && getEdgeTextChild(node, true); - - if (!leadingWhitespaceNode && !trailingWhitespaceNode) return node; - - /** - * Trim the edge nodes in place. Unified handles everything in a mutable - * fashion, so it's often simpler to do the same when working with Unified - * ASTs. - */ - if (leadingWhitespaceNode) { - leadingWhitespaceNode.value = trimStart(leadingWhitespaceNode.value); - } - - if (trailingWhitespaceNode) { - trailingWhitespaceNode.value = trimEnd(trailingWhitespaceNode.value); - } - - /** - * Create an array of nodes. The first and last child will either be `false` - * or a text node. We filter out the false values before returning. - */ - const nodes = [ - leadingWhitespaceNode && u('text', ' '), - node, - trailingWhitespaceNode && u('text', ' '), - ]; - - return nodes.filter(val => val); - } - - /** - * Get the first or last non-blank text child of a node, regardless of - * nesting. If `end` is truthy, get the last node, otherwise first. - */ - function getEdgeTextChild(node, end) { - /** - * This was changed from a ternary to a long form if due to issues with istanbul's instrumentation and babel's code - * generation. - * TODO: watch https://github.com/istanbuljs/babel-plugin-istanbul/issues/95 - * when it is resolved then revert to ```const findFn = end ? findLast : find;``` - */ - let findFn; - if (end) { - findFn = findLast; - } else { - findFn = find; - } - - let edgeChildWithValue; - setEdgeChildWithValue(node); - return edgeChildWithValue; - - /** - * searchChildren checks a node and all of it's children deeply to find a - * non-blank text value. When the text node is found, we set it in an outside - * variable, as it may be deep in the tree and therefore wouldn't be returned - * by `find`/`findLast`. - */ - function setEdgeChildWithValue(child) { - if (!edgeChildWithValue && child.value) { - edgeChildWithValue = child; - } - findFn(child.children, setEdgeChildWithValue); - } - } - return transform; -} diff --git a/src/widgets/markdown/serializers/remarkRehypeShortcodes.js b/src/widgets/markdown/serializers/remarkRehypeShortcodes.js deleted file mode 100644 index 67d612aa..00000000 --- a/src/widgets/markdown/serializers/remarkRehypeShortcodes.js +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import { map, has } from 'lodash'; -import { renderToString } from 'react-dom/server'; -import u from 'unist-builder'; - -/** - * This plugin doesn't actually transform Remark (MDAST) nodes to Rehype - * (HAST) nodes, but rather, it prepares an MDAST shortcode node for HAST - * conversion by replacing the shortcode text with stringified HTML for - * previewing the shortcode output. - */ -export default function remarkToRehypeShortcodes({ plugins, getAsset, resolveWidget }) { - return transform; - - function transform(root) { - const transformedChildren = map(root.children, processShortcodes); - return { ...root, children: transformedChildren }; - } - - /** - * Mapping function to transform nodes that contain shortcodes. - */ - function processShortcodes(node) { - /** - * If the node doesn't contain shortcode data, return the original node. - */ - if (!has(node, ['data', 'shortcode'])) return node; - - /** - * Get shortcode data from the node, and retrieve the matching plugin by - * key. - */ - const { shortcode, shortcodeData } = node.data; - const plugin = plugins.get(shortcode); - - /** - * Run the shortcode plugin's `toPreview` method, which will return either - * an HTML string or a React component. If a React component is returned, - * render it to an HTML string. - */ - const value = getPreview(plugin, shortcodeData); - const valueHtml = typeof value === 'string' ? value : renderToString(value); - - /** - * Return a new 'html' type node containing the shortcode preview markup. - */ - const textNode = u('html', valueHtml); - const children = [textNode]; - return { ...node, children }; - } - - /** - * Retrieve the shortcode preview component. - */ - function getPreview(plugin, shortcodeData) { - const { toPreview, widget, fields } = plugin; - if (toPreview) { - return toPreview(shortcodeData, getAsset, fields); - } - const preview = resolveWidget(widget); - return React.createElement(preview.preview, { - value: shortcodeData, - field: plugin, - getAsset, - }); - } -} diff --git a/src/widgets/markdown/serializers/remarkShortcodes.js b/src/widgets/markdown/serializers/remarkShortcodes.js deleted file mode 100644 index 6a31ace3..00000000 --- a/src/widgets/markdown/serializers/remarkShortcodes.js +++ /dev/null @@ -1,106 +0,0 @@ -export function remarkParseShortcodes({ plugins }) { - const Parser = this.Parser; - const tokenizers = Parser.prototype.blockTokenizers; - const methods = Parser.prototype.blockMethods; - - tokenizers.shortcode = createShortcodeTokenizer({ plugins }); - - methods.unshift('shortcode'); -} - -export function getLinesWithOffsets(value) { - const SEPARATOR = '\n\n'; - const splitted = value.split(SEPARATOR); - const trimmedLines = splitted - .reduce( - (acc, line) => { - const { start: previousLineStart, originalLength: previousLineOriginalLength } = - acc[acc.length - 1]; - - return [ - ...acc, - { - line: line.trimEnd(), - start: previousLineStart + previousLineOriginalLength + SEPARATOR.length, - originalLength: line.length, - }, - ]; - }, - [{ start: -SEPARATOR.length, originalLength: 0 }], - ) - .slice(1) - .map(({ line, start }) => ({ line, start })); - return trimmedLines; -} - -function matchFromLines({ trimmedLines, plugin }) { - for (const { line, start } of trimmedLines) { - const match = line.match(plugin.pattern); - if (match) { - match.index += start; - return match; - } - } -} - -function createShortcodeTokenizer({ plugins }) { - return function tokenizeShortcode(eat, value, silent) { - // Plugin patterns may rely on `^` and `$` tokens, even if they don't - // use the multiline flag. To support this, we fall back to searching - // through each line individually, trimming trailing whitespace and - // newlines, if we don't initially match on a pattern. We keep track of - // the starting position of each line so that we can sort correctly - // across the full multiline matches. - const trimmedLines = getLinesWithOffsets(value); - - // Attempt to find a regex match for each plugin's pattern, and then - // select the first by its occurrence in `value`. This ensures we won't - // skip a plugin that occurs later in the plugin registry, but earlier - // in the `value`. - const [{ plugin, match } = {}] = plugins - .toArray() - .map(plugin => ({ - match: value.match(plugin.pattern) || matchFromLines({ trimmedLines, plugin }), - plugin, - })) - .filter(({ match }) => !!match) - .sort((a, b) => a.match.index - b.match.index); - - if (match) { - if (silent) { - return true; - } - - const shortcodeData = plugin.fromBlock(match); - - try { - return eat(match[0])({ - type: 'shortcode', - data: { shortcode: plugin.id, shortcodeData }, - }); - } catch (e) { - console.warn( - `Sent invalid data to remark. Plugin: ${plugin.id}. Value: ${ - match[0] - }. Data: ${JSON.stringify(shortcodeData)}`, - ); - return false; - } - } - }; -} - -export function createRemarkShortcodeStringifier({ plugins }) { - return function remarkStringifyShortcodes() { - const Compiler = this.Compiler; - const visitors = Compiler.prototype.visitors; - - visitors.shortcode = shortcode; - - function shortcode(node) { - const { data } = node; - const plugin = plugins.find(plugin => data.shortcode === plugin.id); - return plugin.toBlock(data.shortcodeData); - } - }; -} diff --git a/src/widgets/markdown/serializers/remarkSlate.js b/src/widgets/markdown/serializers/remarkSlate.js deleted file mode 100644 index 455e11d5..00000000 --- a/src/widgets/markdown/serializers/remarkSlate.js +++ /dev/null @@ -1,446 +0,0 @@ -import { isEmpty, isArray, flatMap, map, flatten, isEqual } from 'lodash'; - -/** - * Map of MDAST node types to Slate node types. - */ -const typeMap = { - root: 'root', - paragraph: 'paragraph', - blockquote: 'quote', - code: 'code-block', - listItem: 'list-item', - table: 'table', - tableRow: 'table-row', - tableCell: 'table-cell', - thematicBreak: 'thematic-break', - link: 'link', - image: 'image', - shortcode: 'shortcode', -}; - -/** - * Map of MDAST node types to Slate mark types. - */ -const markMap = { - strong: 'bold', - emphasis: 'italic', - delete: 'strikethrough', - inlineCode: 'code', -}; - -function isInline(node) { - return node.object === 'inline'; -} - -function isText(node) { - return node.object === 'text'; -} - -function isMarksEqual(node1, node2) { - return isEqual(node1.marks, node2.marks); -} - -export function wrapInlinesWithTexts(children) { - if (children.length <= 0) { - return children; - } - - const insertLocations = []; - let prev = children[0]; - if (isInline(prev)) { - insertLocations.push(0); - } - - for (let i = 1; i < children.length; i++) { - const current = children[i]; - if (isInline(prev) && !isText(current)) { - insertLocations.push(i); - } else if (!isText(prev) && isInline(current)) { - insertLocations.push(i); - } - - prev = current; - } - - if (isInline(prev)) { - insertLocations.push(children.length); - } - - for (let i = 0; i < insertLocations.length; i++) { - children.splice(insertLocations[i] + i, 0, { object: 'text', text: '' }); - } - - return children; -} - -export function mergeAdjacentTexts(children) { - if (children.length <= 0) { - return children; - } - - const mergedChildren = []; - - let isMerging = false; - let current; - - for (let i = 0; i < children.length - 1; i++) { - if (!isMerging) { - current = children[i]; - } - const next = children[i + 1]; - if (isText(current) && isText(next) && isMarksEqual(current, next)) { - isMerging = true; - current = { ...current, text: `${current.text}${next.text}` }; - } else { - mergedChildren.push(current); - isMerging = false; - } - } - - if (isMerging) { - mergedChildren.push(current); - } else { - mergedChildren.push(children[children.length - 1]); - } - - return mergedChildren; -} - -/** - * A Remark plugin for converting an MDAST to Slate Raw AST. Remark plugins - * return a `transformNode` function that receives the MDAST as it's first argument. - */ -export default function remarkToSlate({ voidCodeBlock } = {}) { - return transformNode; - - function transformNode(node) { - /** - * Call `transformNode` recursively on child nodes. - * - * If a node returns a falsey value, filter it out. Some nodes do not - * translate from MDAST to Slate, such as definitions for link/image - * references or footnotes. - */ - let children = - !['strong', 'emphasis', 'delete'].includes(node.type) && - !isEmpty(node.children) && - flatMap(node.children, transformNode).filter(val => val); - - if (Array.isArray(children)) { - // Ensure that inline nodes are surrounded by text nodes to conform to slate schema - children = wrapInlinesWithTexts(children); - // Merge adjacent text nodes with the same marks to conform to slate schema - children = mergeAdjacentTexts(children); - } - - /** - * Run individual nodes through the conversion factory. - */ - const output = convertNode(node, children || undefined); - return output; - } - - /** - * Add nodes to a parent node only if `nodes` is truthy. - */ - function addNodes(parent, nodes) { - return nodes ? { ...parent, nodes } : parent; - } - - /** - * Create a Slate Block node. - */ - function createBlock(type, nodes, props = {}) { - if (!isArray(nodes)) { - props = nodes; - nodes = undefined; - } - - // Ensure block nodes have at least one text child to conform to slate schema - const children = isEmpty(nodes) ? [createText('')] : nodes; - const node = { object: 'block', type, ...props }; - return addNodes(node, children); - } - - /** - * Create a Slate Inline node. - */ - function createInline(type, props = {}, nodes) { - const node = { object: 'inline', type, ...props }; - - // Ensure inline nodes have at least one text child to conform to slate schema - const children = isEmpty(nodes) ? [createText('')] : nodes; - return addNodes(node, children); - } - - /** - * Create a Slate Raw text node. - */ - function createText(node) { - const newNode = { object: 'text' }; - if (typeof node === 'string') { - return { ...newNode, text: node }; - } - const { text, marks } = node; - return { ...newNode, text, marks }; - } - - function processMarkChild(childNode, marks) { - switch (childNode.type) { - /** - * If a text node is a direct child of the current node, it should be - * set aside as a text, and all marks that have been collected in the - * `marks` array should apply to that specific text. - */ - case 'html': - case 'text': - return { ...convertNode(childNode), marks }; - - /** - * MDAST inline code nodes don't have children, just a text value, similar - * to a text node, so it receives the same treatment as a text node, but we - * first add the inline code mark to the marks array. - */ - case 'inlineCode': { - const childMarks = [...marks, { type: markMap[childNode.type] }]; - return { ...convertNode(childNode), marks: childMarks }; - } - - /** - * Process nested style nodes. The recursive results should be pushed into - * the texts array. This way, every MDAST nested text structure becomes a - * flat array of texts that can serve as the value of a single Slate Raw - * text node. - */ - case 'strong': - case 'emphasis': - case 'delete': - return processMarkNode(childNode, marks); - - case 'link': { - const nodes = map(childNode.children, child => processMarkChild(child, marks)); - const result = convertNode(childNode, flatten(nodes)); - return result; - } - - /** - * Remaining nodes simply need mark data added to them, and to then be - * added into the cumulative children array. - */ - default: - return transformNode({ ...childNode, data: { ...childNode.data, marks } }); - } - } - - function processMarkNode(node, parentMarks = []) { - /** - * Add the current node's mark type to the marks collected from parent - * mark nodes, if any. - */ - const markType = markMap[node.type]; - const marks = markType - ? [...parentMarks.filter(({ type }) => type !== markType), { type: markType }] - : parentMarks; - - const children = flatMap(node.children, child => processMarkChild(child, marks)); - - return children; - } - - /** - * Convert a single MDAST node to a Slate Raw node. Uses local node factories - * that mimic the unist-builder function utilized in the slateRemark - * transformer. - */ - function convertNode(node, nodes) { - switch (node.type) { - /** - * General - * - * Convert simple cases that only require a type and children, with no - * additional properties. - */ - case 'root': - case 'paragraph': - case 'blockquote': - case 'tableRow': - case 'tableCell': { - return createBlock(typeMap[node.type], nodes); - } - - /** - * List Items - * - * Markdown list items can be empty, but a list item in the Slate schema - * should at least have an empty paragraph node. - */ - case 'listItem': { - const children = isEmpty(nodes) ? [createBlock('paragraph')] : nodes; - return createBlock(typeMap[node.type], children); - } - - /** - * Shortcodes - * - * Shortcode nodes are represented as "void" blocks in the Slate AST. They - * maintain the same data as MDAST shortcode nodes. Slate void blocks must - * contain a blank text node. - */ - case 'shortcode': { - const nodes = [createText('')]; - const data = { ...node.data }; - return createBlock(typeMap[node.type], nodes, { data }); - } - - case 'text': { - const text = node.value; - return createText(text); - } - - /** - * HTML - * - * HTML nodes contain plain text like text nodes, except they only contain - * HTML. Our serialization results in non-HTML being placed in HTML nodes - * sometimes to ensure that we're never escaping HTML from the rich text - * editor. We do not replace line feeds in HTML because the HTML is raw - * in the rich text editor, so the writer knows they're writing HTML, and - * should expect soft breaks to be visually absent in the rendered HTML. - */ - case 'html': { - return createText(node.value); - } - - /** - * Inline Code - * - * Inline code nodes from an MDAST are represented in our Slate schema as - * text nodes with a "code" mark. We manually create the text containing - * the inline code value and a "code" mark, and place it in an array for use - * as a Slate text node's children array. - */ - case 'inlineCode': { - return createText({ text: node.value, marks: [{ type: 'code' }] }); - } - - /** - * Marks - * - * Marks are typically decorative sub-types that apply to text nodes. In an - * MDAST, marks are nodes that can contain other nodes. This nested - * hierarchy has to be flattened and split into distinct text nodes with - * their own set of marks. - */ - case 'strong': - case 'emphasis': - case 'delete': { - return processMarkNode(node); - } - - /** - * Headings - * - * MDAST headings use a single type with a separate "depth" property to - * indicate the heading level, while the Slate schema uses a separate node - * type for each heading level. Here we get the proper Slate node name based - * on the MDAST node depth. - */ - case 'heading': { - const depthMap = { 1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five', 6: 'six' }; - const slateType = `heading-${depthMap[node.depth]}`; - return createBlock(slateType, nodes); - } - - /** - * Code Blocks - * - * MDAST code blocks are a distinct node type with a simple text value. We - * convert that value into a nested child text node for Slate. If a void - * node is required due to a custom code block handler, the value is - * stored in the "code" data property instead. We also carry over the "lang" - * data property if it's defined. - */ - case 'code': { - const data = { - lang: node.lang, - ...(voidCodeBlock ? { code: node.value } : {}), - }; - const text = createText(voidCodeBlock ? '' : node.value); - const nodes = [text]; - const block = createBlock(typeMap[node.type], nodes, { data }); - return block; - } - - /** - * Lists - * - * MDAST has a single list type and an "ordered" property. We derive that - * information into the Slate schema's distinct list node types. We also - * include the "start" property, which indicates the number an ordered list - * starts at, if defined. - */ - case 'list': { - const slateType = node.ordered ? 'numbered-list' : 'bulleted-list'; - const data = { start: node.start }; - return createBlock(slateType, nodes, { data }); - } - - /** - * Breaks - * - * MDAST soft break nodes represent a trailing double space or trailing - * slash from a Markdown document. In Slate, these are simply transformed to - * line breaks within a text node. - */ - case 'break': { - const { data } = node; - return createInline('break', { data }); - } - - /** - * Thematic Breaks - * - * Thematic breaks are void nodes in the Slate schema. - */ - case 'thematicBreak': { - return createBlock(typeMap[node.type]); - } - - /** - * Links - * - * MDAST stores the link attributes directly on the node, while our Slate - * schema references them in the data object. - */ - case 'link': { - const { title, url, data } = node; - const newData = { ...data, title, url }; - return createInline(typeMap[node.type], { data: newData }, nodes); - } - - /** - * Images - * - * Identical to link nodes except for the lack of child nodes and addition - * of alt attribute data MDAST stores the link attributes directly on the - * node, while our Slate schema references them in the data object. - */ - case 'image': { - const { title, url, alt, data } = node; - const newData = { ...data, title, alt, url }; - return createInline(typeMap[node.type], { data: newData }); - } - - /** - * Tables - * - * Tables are parsed separately because they may include an "align" - * property, which should be passed to the Slate node. - */ - case 'table': { - const data = { align: node.align }; - return createBlock(typeMap[node.type], nodes, { data }); - } - } - } -} diff --git a/src/widgets/markdown/serializers/remarkSquashReferences.js b/src/widgets/markdown/serializers/remarkSquashReferences.js deleted file mode 100644 index 72dd4fb9..00000000 --- a/src/widgets/markdown/serializers/remarkSquashReferences.js +++ /dev/null @@ -1,73 +0,0 @@ -import { without, flatten } from 'lodash'; -import u from 'unist-builder'; -import mdastDefinitions from 'mdast-util-definitions'; - -/** - * Raw markdown may contain image references or link references. Because there - * is no way to maintain these references within the Slate AST, we convert image - * and link references to standard images and links by putting their url's - * inline. The definitions are then removed from the document. - * - * For example, the following markdown: - * - * ``` - * ![alpha][bravo] - * - * [bravo]: http://example.com/example.jpg - * ``` - * - * Yields: - * - * ``` - * ![alpha](http://example.com/example.jpg) - * ``` - * - */ -export default function remarkSquashReferences() { - return getTransform; - - function getTransform(node) { - const getDefinition = mdastDefinitions(node); - return transform.call(null, getDefinition, node); - } - - function transform(getDefinition, node) { - /** - * Bind the `getDefinition` function to `transform` and recursively map all - * nodes. - */ - const boundTransform = transform.bind(null, getDefinition); - const children = node.children ? node.children.map(boundTransform) : node.children; - - /** - * Combine reference and definition nodes into standard image and link - * nodes. - */ - if (['imageReference', 'linkReference'].includes(node.type)) { - const type = node.type === 'imageReference' ? 'image' : 'link'; - const definition = getDefinition(node.identifier); - - if (definition) { - const { title, url } = definition; - return u(type, { title, url, alt: node.alt }, children); - } - - const pre = u('text', node.type === 'imageReference' ? '![' : '['); - const post = u('text', ']'); - const nodes = children || [u('text', node.alt)]; - return [pre, ...nodes, post]; - } - - /** - * Remove definition nodes and filter the resulting null values from the - * filtered children array. - */ - if (node.type === 'definition') { - return null; - } - - const filteredChildren = without(children, null); - - return { ...node, children: flatten(filteredChildren) }; - } -} diff --git a/src/widgets/markdown/serializers/remarkStripTrailingBreaks.js b/src/widgets/markdown/serializers/remarkStripTrailingBreaks.js deleted file mode 100644 index 36933fac..00000000 --- a/src/widgets/markdown/serializers/remarkStripTrailingBreaks.js +++ /dev/null @@ -1,56 +0,0 @@ -import mdastToString from 'mdast-util-to-string'; - -/** - * Removes break nodes that are at the end of a block. - * - * When a trailing double space or backslash is encountered at the end of a - * markdown block, Remark will interpret the character(s) literally, as only - * break entities followed by text qualify as breaks. A manually created MDAST, - * however, may have such entities, and users of visual editors shouldn't see - * these artifacts in resulting markdown. - */ -export default function remarkStripTrailingBreaks() { - function transform(node) { - if (node.children) { - node.children = node.children - .map((child, idx, children) => { - /** - * Only touch break nodes. Convert all subsequent nodes to their text - * value and exclude the break node if no non-whitespace characters - * are found. - */ - if (child.type === 'break') { - const subsequentNodes = children.slice(idx + 1); - - /** - * Create a small MDAST so that mdastToString can process all - * siblings as children of one node rather than making multiple - * calls. - */ - const fragment = { type: 'root', children: subsequentNodes }; - const subsequentText = mdastToString(fragment); - return subsequentText.trim() ? child : null; - } - - /** - * Always return the child if not a break. - */ - return child; - }) - - /** - * Because some break nodes may be excluded, we filter out the resulting - * null values. - */ - .filter(child => child) - - /** - * Recurse through the MDAST by transforming each individual child node. - */ - .map(transform); - } - return node; - } - - return transform; -} diff --git a/src/widgets/markdown/serializers/remarkWrapHtml.js b/src/widgets/markdown/serializers/remarkWrapHtml.js deleted file mode 100644 index 6131faaa..00000000 --- a/src/widgets/markdown/serializers/remarkWrapHtml.js +++ /dev/null @@ -1,20 +0,0 @@ -import u from 'unist-builder'; - -/** - * Ensure that top level 'html' type nodes are wrapped in paragraphs. Html nodes - * are used for text nodes that we don't want Remark or Rehype to parse. - */ -export default function remarkWrapHtml() { - function transform(tree) { - tree.children = tree.children.map(node => { - if (node.type === 'html') { - return u('paragraph', [node]); - } - return node; - }); - - return tree; - } - - return transform; -} diff --git a/src/widgets/markdown/serializers/slateRemark.js b/src/widgets/markdown/serializers/slateRemark.js deleted file mode 100644 index 7fca36a0..00000000 --- a/src/widgets/markdown/serializers/slateRemark.js +++ /dev/null @@ -1,401 +0,0 @@ -import { get, without, last, map, intersection, omit } from 'lodash'; -import u from 'unist-builder'; -import mdastToString from 'mdast-util-to-string'; - -/** - * Map of Slate node types to MDAST/Remark node types. - */ -const typeMap = { - root: 'root', - paragraph: 'paragraph', - 'heading-one': 'heading', - 'heading-two': 'heading', - 'heading-three': 'heading', - 'heading-four': 'heading', - 'heading-five': 'heading', - 'heading-six': 'heading', - quote: 'blockquote', - 'code-block': 'code', - 'numbered-list': 'list', - 'bulleted-list': 'list', - 'list-item': 'listItem', - table: 'table', - 'table-row': 'tableRow', - 'table-cell': 'tableCell', - break: 'break', - 'thematic-break': 'thematicBreak', - link: 'link', - image: 'image', - shortcode: 'shortcode', -}; - -/** - * Map of Slate mark types to MDAST/Remark node types. - */ -const markMap = { - bold: 'strong', - italic: 'emphasis', - strikethrough: 'delete', - code: 'inlineCode', -}; - -const leadingWhitespaceExp = /^\s+\S/; -const trailingWhitespaceExp = /(?!\S)\s+$/; - -export default function slateToRemark(raw, { voidCodeBlock }) { - /** - * The Slate Raw AST generally won't have a top level type, so we set it to - * "root" for clarity. - */ - raw.type = 'root'; - - return transform(raw); - - /** - * The transform function mimics the approach of a Remark plugin for - * conformity with the other serialization functions. This function converts - * Slate nodes to MDAST nodes, and recursively calls itself to process child - * nodes to arbitrary depth. - */ - function transform(node) { - /** - * Combine adjacent text and inline nodes before processing so they can - * share marks. - */ - const hasBlockChildren = node.nodes && node.nodes[0] && node.nodes[0].object === 'block'; - const children = hasBlockChildren - ? node.nodes.map(transform).filter(v => v) - : convertInlineAndTextChildren(node.nodes); - - const output = convertBlockNode(node, children); - return output; - } - - function removeMarkFromNodes(nodes, markType) { - return nodes.map(node => { - switch (node.type) { - case 'link': { - const updatedNodes = removeMarkFromNodes(node.nodes, markType); - return { - ...node, - nodes: updatedNodes, - }; - } - - case 'image': - case 'break': { - const data = omit(node.data, 'marks'); - return { ...node, data }; - } - - default: - return { - ...node, - marks: node.marks.filter(({ type }) => type !== markType), - }; - } - }); - } - - function getNodeMarks(node) { - switch (node.type) { - case 'link': { - // Code marks can't always be condensed together. If all text in a link - // is wrapped in a mark, this function returns that mark and the node - // ends up nested inside of that mark. Code marks sometimes can't do - // that, like when they wrap all of the text content of a link. Here we - // remove code marks before processing so that they stay put. - const nodesWithoutCode = node.nodes.map(n => ({ - ...n, - marks: n.marks ? n.marks.filter(({ type }) => type !== 'code') : n.marks, - })); - const childMarks = map(nodesWithoutCode, getNodeMarks); - return intersection(...childMarks); - } - - case 'break': - case 'image': - return map(get(node, ['data', 'marks']), mark => mark.type); - - default: - return map(node.marks, mark => mark.type); - } - } - - function getSharedMarks(marks, node) { - const nodeMarks = getNodeMarks(node); - const sharedMarks = intersection(marks, nodeMarks); - if (sharedMarks[0] === 'code') { - return nodeMarks.length === 1 ? marks : []; - } - return sharedMarks; - } - - function extractFirstMark(nodes) { - let firstGroupMarks = getNodeMarks(nodes[0]) || []; - - // If code mark is present, but there are other marks, process others first. - // If only the code mark is present, don't allow it to be shared with other - // nodes. - if (firstGroupMarks[0] === 'code' && firstGroupMarks.length > 1) { - firstGroupMarks = [...without('firstGroupMarks', 'code'), 'code']; - } - - let splitIndex = 1; - - if (firstGroupMarks.length > 0) { - while (splitIndex < nodes.length) { - if (nodes[splitIndex]) { - const sharedMarks = getSharedMarks(firstGroupMarks, nodes[splitIndex]); - if (sharedMarks.length > 0) { - firstGroupMarks = sharedMarks; - } else { - break; - } - } - splitIndex += 1; - } - } - - const markType = firstGroupMarks[0]; - const childNodes = nodes.slice(0, splitIndex); - const updatedChildNodes = markType ? removeMarkFromNodes(childNodes, markType) : childNodes; - const remainingNodes = nodes.slice(splitIndex); - - return [markType, updatedChildNodes, remainingNodes]; - } - - /** - * Converts the strings returned from `splitToNamedParts` to Slate nodes. - */ - function splitWhitespace(node, { trailing } = {}) { - if (!node.text) { - return { trimmedNode: node }; - } - const exp = trailing ? trailingWhitespaceExp : leadingWhitespaceExp; - const index = node.text.search(exp); - if (index > -1) { - const substringIndex = trailing ? index : index + 1; - const firstSplit = node.text.slice(0, substringIndex); - const secondSplit = node.text.slice(substringIndex); - const whitespace = trailing ? secondSplit : firstSplit; - const text = trailing ? firstSplit : secondSplit; - return { whitespace, trimmedNode: { ...node, text } }; - } - return { trimmedNode: node }; - } - - function collectCenterNodes(nodes, leadingNode, trailingNode) { - switch (nodes.length) { - case 0: - return []; - case 1: - return [trailingNode]; - case 2: - return [leadingNode, trailingNode]; - default: - return [leadingNode, ...nodes.slice(1, -1), trailingNode]; - } - } - - function normalizeFlankingWhitespace(nodes) { - const { whitespace: leadingWhitespace, trimmedNode: leadingNode } = splitWhitespace(nodes[0]); - const lastNode = nodes.length > 1 ? last(nodes) : leadingNode; - const trailingSplitResult = splitWhitespace(lastNode, { trailing: true }); - const { whitespace: trailingWhitespace, trimmedNode: trailingNode } = trailingSplitResult; - const centerNodes = collectCenterNodes(nodes, leadingNode, trailingNode).filter(val => val); - return { leadingWhitespace, centerNodes, trailingWhitespace }; - } - - function createText(text) { - return text && u('html', text); - } - - function convertInlineAndTextChildren(nodes = []) { - const convertedNodes = []; - let remainingNodes = nodes; - - while (remainingNodes.length > 0) { - const nextNode = remainingNodes[0]; - if (nextNode.object === 'inline' || (nextNode.marks && nextNode.marks.length > 0)) { - const [markType, markNodes, remainder] = extractFirstMark(remainingNodes); - /** - * A node with a code mark will be a text node, and will not be adjacent - * to a sibling code node as the Slate schema requires them to be - * merged. Markdown also requires at least a space between inline code - * nodes. - */ - if (markType === 'code') { - const node = markNodes[0]; - convertedNodes.push(u(markMap[markType], node.data, node.text)); - } else if (!markType && markNodes.length === 1 && markNodes[0].object === 'inline') { - const node = markNodes[0]; - convertedNodes.push(convertInlineNode(node, convertInlineAndTextChildren(node.nodes))); - } else { - const { leadingWhitespace, trailingWhitespace, centerNodes } = - normalizeFlankingWhitespace(markNodes); - const children = convertInlineAndTextChildren(centerNodes); - const markNode = u(markMap[markType], children); - - // Filter out empty marks, otherwise their output literally by - // remark-stringify, eg. an empty bold node becomes "****" - if (mdastToString(markNode) === '') { - remainingNodes = remainder; - continue; - } - - const normalizedNodes = [ - createText(leadingWhitespace), - markNode, - createText(trailingWhitespace), - ].filter(val => val); - convertedNodes.push(...normalizedNodes); - } - remainingNodes = remainder; - } else { - remainingNodes.shift(); - convertedNodes.push(u('html', nextNode.text)); - } - } - - return convertedNodes; - } - - function convertBlockNode(node, children) { - switch (node.type) { - /** - * General - * - * Convert simple cases that only require a type and children, with no - * additional properties. - */ - case 'root': - case 'paragraph': - case 'quote': - case 'list-item': - case 'table': - case 'table-row': - case 'table-cell': { - return u(typeMap[node.type], children); - } - - /** - * Shortcodes - * - * Shortcode nodes only exist in Slate's Raw AST if they were inserted - * via the plugin toolbar in memory, so they should always have - * shortcode data attached. The "shortcode" data property contains the - * name of the registered shortcode plugin, and the "shortcodeData" data - * property contains the data received from the shortcode plugin's - * `fromBlock` method when the shortcode node was created. - * - * Here we create a `shortcode` MDAST node that contains only the shortcode - * data. - */ - case 'shortcode': { - const { data } = node; - return u(typeMap[node.type], { data }); - } - - /** - * Headings - * - * Slate schemas don't usually infer basic type info from data, so each - * level of heading is a separately named type. The MDAST schema just - * has a single "heading" type with the depth stored in a "depth" - * property on the node. Here we derive the depth from the Slate node - * type - e.g., for "heading-two", we need a depth value of "2". - */ - case 'heading-one': - case 'heading-two': - case 'heading-three': - case 'heading-four': - case 'heading-five': - case 'heading-six': { - const depthMap = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 }; - const depthText = node.type.split('-')[1]; - const depth = depthMap[depthText]; - const mdastNode = u(typeMap[node.type], { depth }, children); - if (mdastToString(mdastNode)) { - return mdastNode; - } - return; - } - - /** - * Code Blocks - * - * Code block nodes may have a single text child, or instead be void and - * store their value in `data.code`. They also may have a code language - * stored in the "lang" data property. Here we transfer both the node value - * and the "lang" data property to the new MDAST node, and spread any - * remaining data as `data`. - */ - case 'code-block': { - const { lang, code, ...data } = get(node, 'data', {}); - const value = voidCodeBlock ? code : children[0]?.value; - return u(typeMap[node.type], { lang, data }, value || ''); - } - - /** - * Lists - * - * Our Slate schema has separate node types for ordered and unordered - * lists, but the MDAST spec uses a single type with a boolean "ordered" - * property to indicate whether the list is numbered. The MDAST spec also - * allows for a "start" property to indicate the first number used for an - * ordered list. Here we translate both values to our Slate schema. - */ - case 'numbered-list': - case 'bulleted-list': { - const ordered = node.type === 'numbered-list'; - const props = { ordered, start: get(node.data, 'start') || 1 }; - return u(typeMap[node.type], props, children); - } - - /** - * Thematic Break - * - * Thematic break is a block level break. They cannot have children. - */ - case 'thematic-break': { - return u(typeMap[node.type]); - } - } - } - - function convertInlineNode(node, children) { - switch (node.type) { - /** - * Break - * - * Breaks are phrasing level breaks. They cannot have children. - */ - case 'break': { - return u(typeMap[node.type]); - } - - /** - * Links - * - * The url and title attributes of link nodes are stored in properties on - * the node for both Slate and Remark schemas. - */ - case 'link': { - const { url, title, ...data } = get(node, 'data', {}); - return u(typeMap[node.type], { url, title, data }, children); - } - - /** - * Images - * - * This transformation is almost identical to that of links, except for the - * lack of child nodes and addition of `alt` attribute data. - */ - case 'image': { - const { url, title, alt, ...data } = get(node, 'data', {}); - return u(typeMap[node.type], { url, title, alt, data }); - } - } - } -} diff --git a/src/widgets/markdown/styles.js b/src/widgets/markdown/styles.ts similarity index 83% rename from src/widgets/markdown/styles.js rename to src/widgets/markdown/styles.ts index 9ca94b75..8245ef5b 100644 --- a/src/widgets/markdown/styles.js +++ b/src/widgets/markdown/styles.ts @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; -import { zIndex } from '../../ui'; +import { zIndex } from '../../components/UI/styles'; export const editorStyleVars = { stickyDistanceBottom: '100px', diff --git a/src/widgets/markdown/types.js b/src/widgets/markdown/types.js deleted file mode 100644 index 22a02787..00000000 --- a/src/widgets/markdown/types.js +++ /dev/null @@ -1,3 +0,0 @@ -export const SLATE_DEFAULT_BLOCK_TYPE = 'paragraph'; - -export const SLATE_BLOCK_PARENT_TYPES = ['list-item', 'quote']; diff --git a/src/widgets/markdown/types.ts b/src/widgets/markdown/types.ts new file mode 100644 index 00000000..952a21f6 --- /dev/null +++ b/src/widgets/markdown/types.ts @@ -0,0 +1,4 @@ +export const SLATE_DEFAULT_BLOCK_TYPE = 'paragraph' as const; +export const SLATE_BLOCK_PARENT_TYPES = ['list-item', 'quote'] as const; + +export type SlateBlockParentType = typeof SLATE_BLOCK_PARENT_TYPES[number]; diff --git a/src/widgets/number/NumberControl.js b/src/widgets/number/NumberControl.js deleted file mode 100644 index 6e6ac4cd..00000000 --- a/src/widgets/number/NumberControl.js +++ /dev/null @@ -1,103 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -const ValidationErrorTypes = { - PRESENCE: 'PRESENCE', - PATTERN: 'PATTERN', - RANGE: 'RANGE', - CUSTOM: 'CUSTOM', -}; - -export function validateMinMax(value, min, max, field, t) { - let error; - - switch (true) { - case value !== '' && min !== false && max !== false && (value < min || value > max): - error = { - type: ValidationErrorTypes.RANGE, - message: t('editor.editorControlPane.widget.range', { - fieldLabel: field.get('label', field.get('name')), - minValue: min, - maxValue: max, - }), - }; - break; - case value !== '' && min !== false && value < min: - error = { - type: ValidationErrorTypes.RANGE, - message: t('editor.editorControlPane.widget.min', { - fieldLabel: field.get('label', field.get('name')), - minValue: min, - }), - }; - break; - case value !== '' && max !== false && value > max: - error = { - type: ValidationErrorTypes.RANGE, - message: t('editor.editorControlPane.widget.max', { - fieldLabel: field.get('label', field.get('name')), - maxValue: max, - }), - }; - break; - default: - error = null; - break; - } - - return error; -} - -export default class NumberControl extends React.Component { - static propTypes = { - field: ImmutablePropTypes.map.isRequired, - onChange: PropTypes.func.isRequired, - classNameWrapper: PropTypes.string.isRequired, - setActiveStyle: PropTypes.func.isRequired, - setInactiveStyle: PropTypes.func.isRequired, - value: PropTypes.node, - forID: PropTypes.string, - valueType: PropTypes.string, - step: PropTypes.number, - min: PropTypes.number, - max: PropTypes.number, - t: PropTypes.func.isRequired, - }; - - static defaultProps = { - value: '', - }; - - handleChange = e => { - const valueType = this.props.field.get('value_type'); - const { onChange } = this.props; - const value = valueType === 'float' ? parseFloat(e.target.value) : parseInt(e.target.value, 10); - - if (!isNaN(value)) { - onChange(value); - } else { - onChange(''); - } - }; - - render() { - const { field, value, classNameWrapper, forID, setActiveStyle, setInactiveStyle } = this.props; - const min = field.get('min', ''); - const max = field.get('max', ''); - const step = field.get('step', field.get('value_type') === 'int' ? 1 : ''); - return ( - - ); - } -} diff --git a/src/widgets/number/NumberControl.tsx b/src/widgets/number/NumberControl.tsx new file mode 100644 index 00000000..0060a30d --- /dev/null +++ b/src/widgets/number/NumberControl.tsx @@ -0,0 +1,110 @@ +import TextField from '@mui/material/TextField'; +import React, { useCallback, useState } from 'react'; + +import type { ChangeEvent } from 'react'; +import type { t } from 'react-polyglot'; +import type { FieldError, NumberField, WidgetControlProps } from '../../interface'; + +const ValidationErrorTypes = { + PRESENCE: 'PRESENCE', + PATTERN: 'PATTERN', + RANGE: 'RANGE', + CUSTOM: 'CUSTOM', +}; + +export function validateMinMax( + value: string | number, + min: number | false, + max: number | false, + field: NumberField, + t: t, +): FieldError | false { + let error: FieldError | false; + + switch (true) { + case value !== '' && min !== false && max !== false && (value < min || value > max): + error = { + type: ValidationErrorTypes.RANGE, + message: t('editor.editorControlPane.widget.range', { + fieldLabel: field.label ?? field.name, + minValue: min, + maxValue: max, + }), + }; + break; + case value !== '' && min !== false && value < min: + error = { + type: ValidationErrorTypes.RANGE, + message: t('editor.editorControlPane.widget.min', { + fieldLabel: field.label ?? field.name, + minValue: min, + }), + }; + break; + case value !== '' && max !== false && value > max: + error = { + type: ValidationErrorTypes.RANGE, + message: t('editor.editorControlPane.widget.max', { + fieldLabel: field.label ?? field.name, + maxValue: max, + }), + }; + break; + default: + error = false; + break; + } + + return error; +} + +const NumberControl = ({ + label, + field, + value, + onChange, + hasErrors, +}: WidgetControlProps) => { + const [internalValue, setInternalValue] = useState(value ?? ''); + + const handleChange = useCallback( + (e: ChangeEvent) => { + const valueType = field.value_type; + let newValue: string | number = + valueType === 'float' ? parseFloat(e.target.value) : parseInt(e.target.value, 10); + + if (isNaN(newValue)) { + newValue = ''; + } + onChange(newValue); + setInternalValue(newValue); + }, + [field, onChange], + ); + + const min = field.min ?? ''; + const max = field.max ?? ''; + const step = field.step ?? (field.value_type === 'int' ? 1 : ''); + return ( + + ); +}; + +export default NumberControl; diff --git a/src/widgets/number/NumberPreview.js b/src/widgets/number/NumberPreview.js deleted file mode 100644 index 1e7df36e..00000000 --- a/src/widgets/number/NumberPreview.js +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import { WidgetPreviewContainer } from '../../ui'; - -function NumberPreview({ value }) { - return {value}; -} - -NumberPreview.propTypes = { - value: PropTypes.node, -}; - -export default NumberPreview; diff --git a/src/widgets/number/NumberPreview.tsx b/src/widgets/number/NumberPreview.tsx new file mode 100644 index 00000000..bf73cb68 --- /dev/null +++ b/src/widgets/number/NumberPreview.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer'; + +import type { NumberField, WidgetPreviewProps } from '../../interface'; + +function NumberPreview({ value }: WidgetPreviewProps) { + return {value}; +} + +export default NumberPreview; diff --git a/src/widgets/number/index.js b/src/widgets/number/index.js deleted file mode 100644 index 2d1005a9..00000000 --- a/src/widgets/number/index.js +++ /dev/null @@ -1,29 +0,0 @@ -import controlComponent, { validateMinMax } from './NumberControl'; -import previewComponent from './NumberPreview'; -import schema from './schema'; - -function Widget(opts = {}) { - return { - name: 'number', - controlComponent, - previewComponent, - validator: ({ field, value, t }) => { - const hasPattern = !!field.get('pattern', false); - const min = field.get('min', false); - const max = field.get('max', false); - - // Pattern overrides min/max logic always: - if (hasPattern) { - return true; - } - - const error = validateMinMax(value, min, max, field, t); - return error ? { error } : true; - }, - schema, - ...opts, - }; -} - -export const StaticCmsWidgetNumber = { Widget, controlComponent, previewComponent }; -export default StaticCmsWidgetNumber; diff --git a/src/widgets/number/index.ts b/src/widgets/number/index.ts new file mode 100644 index 00000000..c54f90b7 --- /dev/null +++ b/src/widgets/number/index.ts @@ -0,0 +1,32 @@ +import controlComponent, { validateMinMax } from './NumberControl'; +import previewComponent from './NumberPreview'; +import schema from './schema'; + +import type { NumberField, WidgetParam } from '../../interface'; + +const NumberWidget = (): WidgetParam => { + return { + name: 'number', + controlComponent, + previewComponent, + options: { + validator: ({ field, value, t }) => { + // Pattern overrides min/max logic always: + const hasPattern = !!field.pattern ?? false; + + if (hasPattern || !value) { + return false; + } + + const min = field.min ?? false; + const max = field.max ?? false; + + const error = validateMinMax(value, min, max, field, t); + return error ?? false; + }, + schema, + }, + }; +}; + +export default NumberWidget; diff --git a/src/widgets/number/schema.js b/src/widgets/number/schema.ts similarity index 100% rename from src/widgets/number/schema.js rename to src/widgets/number/schema.ts diff --git a/src/widgets/object/ObjectControl.js b/src/widgets/object/ObjectControl.js deleted file mode 100644 index 3461920b..00000000 --- a/src/widgets/object/ObjectControl.js +++ /dev/null @@ -1,201 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { ClassNames } from '@emotion/react'; -import styled from '@emotion/styled'; -import { List, Map } from 'immutable'; - -import { colors, lengths, ObjectWidgetTopBar } from '../../ui'; -import { stringTemplate } from '../../lib/widgets'; - -const styleStrings = { - nestedObjectControl: ` - padding: 6px 14px 0; - border-top: 0; - border-top-left-radius: 0; - border-top-right-radius: 0; - `, - objectWidgetTopBarContainer: ` - padding: ${lengths.objectWidgetTopBarContainerPadding}; - `, - collapsedObjectControl: ` - display: none; - `, -}; - -const StyledFieldsBox = styled.div` - padding-bottom: 14px; -`; - -export default class ObjectControl extends React.Component { - componentValidate = {}; - - static propTypes = { - onChangeObject: PropTypes.func.isRequired, - onValidateObject: PropTypes.func.isRequired, - value: PropTypes.oneOfType([PropTypes.node, PropTypes.object, PropTypes.bool]), - field: PropTypes.object, - forID: PropTypes.string, - classNameWrapper: PropTypes.string.isRequired, - forList: PropTypes.bool, - controlRef: PropTypes.func, - editorControl: PropTypes.elementType.isRequired, - resolveWidget: PropTypes.func.isRequired, - clearFieldErrors: PropTypes.func.isRequired, - fieldsErrors: ImmutablePropTypes.map.isRequired, - hasError: PropTypes.bool, - t: PropTypes.func.isRequired, - locale: PropTypes.string, - }; - - static defaultProps = { - value: Map(), - }; - - constructor(props) { - super(props); - this.state = { - collapsed: props.field.get('collapsed', false), - }; - } - - /* - * Always update so that each nested widget has the option to update. This is - * required because ControlHOC provides a default `shouldComponentUpdate` - * which only updates if the value changes, but every widget must be allowed - * to override this. - */ - shouldComponentUpdate() { - return true; - } - - validate = () => { - const { field } = this.props; - let fields = field.get('field') || field.get('fields'); - fields = List.isList(fields) ? fields : List([fields]); - fields.forEach(field => { - if (field.get('widget') === 'hidden') return; - this.componentValidate[field.get('name')](); - }); - }; - - controlFor(field, key) { - const { - value, - onChangeObject, - onValidateObject, - clearFieldErrors, - metadata, - fieldsErrors, - editorControl: EditorControl, - controlRef, - parentIds, - isFieldDuplicate, - isFieldHidden, - locale, - } = this.props; - - if (field.get('widget') === 'hidden') { - return null; - } - const fieldName = field.get('name'); - const fieldValue = value && Map.isMap(value) ? value.get(fieldName) : value; - - const isDuplicate = isFieldDuplicate && isFieldDuplicate(field); - const isHidden = isFieldHidden && isFieldHidden(field); - - return ( - - ); - } - - handleCollapseToggle = () => { - this.setState({ collapsed: !this.state.collapsed }); - }; - - renderFields = (multiFields, singleField) => { - if (multiFields) { - return multiFields.map((f, idx) => this.controlFor(f, idx)); - } - return this.controlFor(singleField); - }; - - objectLabel = () => { - const { value, field } = this.props; - const label = field.get('label', field.get('name')); - const summary = field.get('summary'); - return summary ? stringTemplate.compileStringTemplate(summary, null, '', value) : label; - }; - - render() { - const { field, forID, classNameWrapper, forList, hasError, t } = this.props; - const collapsed = forList ? this.props.collapsed : this.state.collapsed; - const multiFields = field.get('fields'); - const singleField = field.get('field'); - - if (multiFields || singleField) { - return ( - - {({ css, cx }) => ( -
    - {forList ? null : ( - - )} - - {this.renderFields(multiFields, singleField)} - -
    - )} -
    - ); - } - - return

    No field(s) defined for this widget

    ; - } -} diff --git a/src/widgets/object/ObjectControl.tsx b/src/widgets/object/ObjectControl.tsx new file mode 100644 index 00000000..b4a52db1 --- /dev/null +++ b/src/widgets/object/ObjectControl.tsx @@ -0,0 +1,149 @@ +import { styled } from '@mui/material/styles'; +import React, { useCallback, useMemo, useState } from 'react'; + +import EditorControl from '../../components/Editor/EditorControlPane/EditorControl'; +import ObjectWidgetTopBar from '../../components/UI/ObjectWidgetTopBar'; +import Outline from '../../components/UI/Outline'; +import { transientOptions } from '../../lib'; +import { compileStringTemplate } from '../../lib/widgets/stringTemplate'; + +import type { ListField, ObjectField, ObjectValue, WidgetControlProps } from '../../interface'; + +const StyledObjectControlWrapper = styled('div')` + position: relative; + background: white; + width: 100%; +`; + +interface StyledFieldsBoxProps { + $collapsed: boolean; +} + +const StyledFieldsBox = styled( + 'div', + transientOptions, +)( + ({ $collapsed }) => ` + display: flex; + flex-direction: column; + gap: 16px; + width: 100%; + ${ + $collapsed + ? ` + visibility: hidden; + height: 0; + width: 0; + ` + : ` + padding: 16px; + ` + } + `, +); + +const StyledNoFieldsMessage = styled('div')` + display: flex; + padding: 16px; + width: 100%; +`; + +const ObjectControl = ({ + clearFieldErrors, + field, + fieldsErrors, + submitted, + forList, + isFieldDuplicate, + isFieldHidden, + locale, + path, + t, + i18n, + hasErrors, + value = {}, +}: WidgetControlProps) => { + const [collapsed, setCollapsed] = useState(false); + + const handleCollapseToggle = useCallback(() => { + setCollapsed(!collapsed); + }, [collapsed]); + + const objectLabel = useMemo(() => { + const label = field.label ?? field.name; + const summary = field.summary; + return summary ? `${label} - ${compileStringTemplate(summary, null, '', value)}` : label; + }, [field.label, field.name, field.summary, value]); + + const multiFields = useMemo(() => field.fields, [field.fields]); + + const renderedField = useMemo(() => { + return ( + multiFields?.map((field, index) => { + const fieldName = field.name; + const fieldValue = value && value[fieldName]; + + const isDuplicate = isFieldDuplicate && isFieldDuplicate(field); + const isHidden = isFieldHidden && isFieldHidden(field); + + return ( + + ); + }) ?? null + ); + }, [ + clearFieldErrors, + fieldsErrors, + i18n, + isFieldDuplicate, + isFieldHidden, + locale, + multiFields, + path, + submitted, + value, + ]); + + if (multiFields) { + return ( + + {forList ? null : ( + + )} + + {renderedField} + + {forList ? null : } + + ); + } + + return ( + + No field(s) defined for this widget + + ); +}; + +export default ObjectControl; diff --git a/src/widgets/object/ObjectPreview.js b/src/widgets/object/ObjectPreview.js deleted file mode 100644 index 8703c84a..00000000 --- a/src/widgets/object/ObjectPreview.js +++ /dev/null @@ -1,18 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import { WidgetPreviewContainer } from '../../ui'; - -function ObjectPreview({ field }) { - return ( - - {(field && field.get('fields')) || field.get('field') || null} - - ); -} - -ObjectPreview.propTypes = { - field: PropTypes.node, -}; - -export default ObjectPreview; diff --git a/src/widgets/object/ObjectPreview.tsx b/src/widgets/object/ObjectPreview.tsx new file mode 100644 index 00000000..787038d6 --- /dev/null +++ b/src/widgets/object/ObjectPreview.tsx @@ -0,0 +1,13 @@ +import React from 'react'; + +import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer'; + +import type { WidgetPreviewProps, ObjectField, ListField, ObjectValue } from '../../interface'; + +function ObjectPreview({ + value, +}: WidgetPreviewProps) { + return {JSON.stringify(value, null, 2)}; +} + +export default ObjectPreview; diff --git a/src/widgets/object/index.js b/src/widgets/object/index.js deleted file mode 100644 index d1b74b7a..00000000 --- a/src/widgets/object/index.js +++ /dev/null @@ -1,16 +0,0 @@ -import controlComponent from './ObjectControl'; -import previewComponent from './ObjectPreview'; -import schema from './schema'; - -function Widget(opts = {}) { - return { - name: 'object', - controlComponent, - previewComponent, - schema, - ...opts, - }; -} - -export const StaticCmsWidgetObject = { Widget, controlComponent, previewComponent }; -export default StaticCmsWidgetObject; diff --git a/src/widgets/object/index.ts b/src/widgets/object/index.ts new file mode 100644 index 00000000..78a81eec --- /dev/null +++ b/src/widgets/object/index.ts @@ -0,0 +1,18 @@ +import controlComponent from './ObjectControl'; +import previewComponent from './ObjectPreview'; +import schema from './schema'; + +import type { ListField, ObjectField, WidgetParam, ObjectValue } from '../../interface'; + +const ObjectWidget = (): WidgetParam => { + return { + name: 'object', + controlComponent, + previewComponent, + options: { + schema, + }, + }; +}; + +export default ObjectWidget; diff --git a/src/widgets/object/schema.js b/src/widgets/object/schema.ts similarity index 100% rename from src/widgets/object/schema.js rename to src/widgets/object/schema.ts diff --git a/src/widgets/relation/RelationControl.js b/src/widgets/relation/RelationControl.js deleted file mode 100644 index 73580ef0..00000000 --- a/src/widgets/relation/RelationControl.js +++ /dev/null @@ -1,319 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { components } from 'react-select'; -import AsyncSelect from 'react-select/async'; -import { debounce, find, get, isEmpty, last, uniqBy } from 'lodash'; -import { fromJS, List, Map } from 'immutable'; -import { FixedSizeList } from 'react-window'; -import { SortableContainer, SortableElement, SortableHandle } from 'react-sortable-hoc'; - -import { reactSelectStyles } from '../../ui'; -import { stringTemplate } from '../../lib/widgets'; - -function arrayMove(array, from, to) { - const slicedArray = array.slice(); - slicedArray.splice(to < 0 ? array.length + to : to, 0, slicedArray.splice(from, 1)[0]); - return slicedArray; -} - -const MultiValue = SortableElement(props => { - // prevent the menu from being opened/closed when the user clicks on a value to begin dragging it - function onMouseDown(e) { - e.preventDefault(); - e.stopPropagation(); - } - - const innerProps = { ...props.innerProps, onMouseDown }; - return ; -}); - -const MultiValueLabel = SortableHandle(props => ); - -const SortableSelect = SortableContainer(AsyncSelect); - -function Option({ index, style, data }) { - return
    {data.options[index]}
    ; -} - -function MenuList(props) { - if (props.isLoading || props.options.length <= 0 || !Array.isArray(props.children)) { - return props.children; - } - const rows = props.children; - const itemSize = 30; - return ( - - {Option} - - ); -} - -function optionToString(option) { - return option && option.value ? option.value : ''; -} - -function convertToOption(raw) { - if (typeof raw === 'string') { - return { label: raw, value: raw }; - } - return Map.isMap(raw) ? raw.toJS() : raw; -} - -function getSelectedOptions(value) { - const selectedOptions = List.isList(value) ? value.toJS() : value; - - if (!selectedOptions || !Array.isArray(selectedOptions)) { - return null; - } - - return selectedOptions; -} - -function uniqOptions(initial, current) { - return uniqBy(initial.concat(current), o => o.value); -} - -function getSearchFieldArray(searchFields) { - return List.isList(searchFields) ? searchFields.toJS() : [searchFields]; -} - -function getSelectedValue({ value, options, isMultiple }) { - if (isMultiple) { - const selectedOptions = getSelectedOptions(value); - if (selectedOptions === null) { - return null; - } - - const selected = selectedOptions - .map(i => options.find(o => o.value === (i.value || i))) - .filter(Boolean) - .map(convertToOption); - return selected; - } else { - return find(options, ['value', value]) || null; - } -} - -export default class RelationControl extends React.Component { - mounted = false; - - state = { - initialOptions: [], - }; - - static propTypes = { - onChange: PropTypes.func.isRequired, - forID: PropTypes.string.isRequired, - value: PropTypes.node, - field: ImmutablePropTypes.map, - query: PropTypes.func.isRequired, - queryHits: PropTypes.array, - classNameWrapper: PropTypes.string.isRequired, - setActiveStyle: PropTypes.func.isRequired, - setInactiveStyle: PropTypes.func.isRequired, - locale: PropTypes.string, - }; - - shouldComponentUpdate(nextProps) { - return ( - this.props.value !== nextProps.value || - this.props.hasActiveStyle !== nextProps.hasActiveStyle || - this.props.queryHits !== nextProps.queryHits - ); - } - - async componentDidMount() { - this.mounted = true; - // if the field has a previous value perform an initial search based on the value field - // this is required since each search is limited by optionsLength so the selected value - // might not show up on the search - const { forID, field, value, query, onChange } = this.props; - const collection = field.get('collection'); - const file = field.get('file'); - const initialSearchValues = value && (this.isMultiple() ? getSelectedOptions(value) : [value]); - if (initialSearchValues && initialSearchValues.length > 0) { - const metadata = {}; - const searchFieldsArray = getSearchFieldArray(field.get('search_fields')); - const { payload } = await query(forID, collection, searchFieldsArray, '', file); - const hits = payload.hits || []; - const options = this.parseHitOptions(hits); - const initialOptions = initialSearchValues - .map(v => { - const selectedOption = options.find(o => o.value === v); - metadata[v] = selectedOption?.data; - return selectedOption; - }) - .filter(Boolean); - - this.mounted && this.setState({ initialOptions }); - - //set metadata - this.mounted && - onChange(value, { - [field.get('name')]: { - [field.get('collection')]: metadata, - }, - }); - } - } - - componentWillUnmount() { - this.mounted = false; - } - - onSortEnd = - options => - ({ oldIndex, newIndex }) => { - const { onChange, field } = this.props; - const value = options.map(optionToString); - const newValue = arrayMove(value, oldIndex, newIndex); - const metadata = - (!isEmpty(options) && { - [field.get('name')]: { - [field.get('collection')]: { - [last(newValue)]: last(options).data, - }, - }, - }) || - {}; - onChange(fromJS(newValue), metadata); - }; - - handleChange = selectedOption => { - const { onChange, field } = this.props; - - if (this.isMultiple()) { - const options = selectedOption; - this.setState({ initialOptions: options.filter(Boolean) }); - const value = options.map(optionToString); - const metadata = - (!isEmpty(options) && { - [field.get('name')]: { - [field.get('collection')]: { - [last(value)]: last(options).data, - }, - }, - }) || - {}; - onChange(fromJS(value), metadata); - } else { - this.setState({ initialOptions: [selectedOption].filter(Boolean) }); - const value = optionToString(selectedOption); - const metadata = selectedOption && { - [field.get('name')]: { - [field.get('collection')]: { [value]: selectedOption.data }, - }, - }; - onChange(value, metadata); - } - }; - - parseNestedFields = (hit, field) => { - const { locale } = this.props; - const hitData = - locale != null && hit.i18n != null && hit.i18n[locale] != null - ? hit.i18n[locale].data - : hit.data; - const templateVars = stringTemplate.extractTemplateVars(field); - // return non template fields as is - if (templateVars.length <= 0) { - return get(hitData, field); - } - const data = stringTemplate.addFileTemplateFields(hit.path, fromJS(hitData)); - const value = stringTemplate.compileStringTemplate(field, null, hit.slug, data); - return value; - }; - - isMultiple() { - return this.props.field.get('multiple', false); - } - - parseHitOptions = hits => { - const { field } = this.props; - const valueField = field.get('value_field'); - const displayField = field.get('display_fields') || List([field.get('value_field')]); - const options = hits.reduce((acc, hit) => { - const valuesPaths = stringTemplate.expandPath({ data: hit.data, path: valueField }); - for (let i = 0; i < valuesPaths.length; i++) { - const label = displayField - .toJS() - .map(key => { - const displayPaths = stringTemplate.expandPath({ data: hit.data, path: key }); - return this.parseNestedFields(hit, displayPaths[i] || displayPaths[0]); - }) - .join(' '); - const value = this.parseNestedFields(hit, valuesPaths[i]); - acc.push({ data: hit.data, value, label }); - } - - return acc; - }, []); - - return options; - }; - - loadOptions = debounce((term, callback) => { - const { field, query, forID } = this.props; - const collection = field.get('collection'); - const optionsLength = field.get('options_length') || 20; - const searchFieldsArray = getSearchFieldArray(field.get('search_fields')); - const file = field.get('file'); - - query(forID, collection, searchFieldsArray, term, file, optionsLength).then(({ payload }) => { - const hits = payload.hits || []; - const options = this.parseHitOptions(hits); - const uniq = uniqOptions(this.state.initialOptions, options); - callback(uniq); - }); - }, 500); - - render() { - const { value, field, forID, classNameWrapper, setActiveStyle, setInactiveStyle, queryHits } = - this.props; - const isMultiple = this.isMultiple(); - const isClearable = !field.get('required', true) || isMultiple; - - const queryOptions = this.parseHitOptions(queryHits); - const options = uniqOptions(this.state.initialOptions, queryOptions); - const selectedValue = getSelectedValue({ - options, - value, - isMultiple, - }); - - return ( - node.getBoundingClientRect()} - // react-select props: - components={{ MenuList, MultiValue, MultiValueLabel }} - value={selectedValue} - inputId={forID} - cacheOptions - defaultOptions - loadOptions={this.loadOptions} - onChange={this.handleChange} - className={classNameWrapper} - onFocus={setActiveStyle} - onBlur={setInactiveStyle} - styles={reactSelectStyles} - isMulti={isMultiple} - isClearable={isClearable} - placeholder="" - /> - ); - } -} diff --git a/src/widgets/relation/RelationControl.tsx b/src/widgets/relation/RelationControl.tsx new file mode 100644 index 00000000..dc78f1bd --- /dev/null +++ b/src/widgets/relation/RelationControl.tsx @@ -0,0 +1,255 @@ +import Autocomplete from '@mui/material/Autocomplete'; +import CircularProgress from '@mui/material/CircularProgress'; +import TextField from '@mui/material/TextField'; +import find from 'lodash/find'; +import get from 'lodash/get'; +import uniqBy from 'lodash/uniqBy'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { QUERY_SUCCESS } from '../../actions/search'; +import { + addFileTemplateFields, + compileStringTemplate, + expandPath, + extractTemplateVars, +} from '../../lib/widgets/stringTemplate'; + +import type { ListChildComponentProps } from 'react-window'; +import type { Entry, EntryData, RelationField, WidgetControlProps } from '../../interface'; + +// TODO Remove if sorting not needed +// function arrayMove(array, from, to) { +// const slicedArray = array.slice(); +// slicedArray.splice(to < 0 ? array.length + to : to, 0, slicedArray.splice(from, 1)[0]); +// return slicedArray; +// } + +function Option({ index, style, data }: ListChildComponentProps<{ options: React.ReactNode[] }>) { + return
    {data.options[index]}
    ; +} + +export interface HitOption { + data: EntryData; + value: string; + label: string; +} + +export interface Option { + value: string; + label: string; +} + +function optionToString(option: Option | HitOption | null): string { + return option && option.value ? option.value : ''; +} + +function convertToOption(raw: string | HitOption): HitOption; +function convertToOption(raw: string | Option | HitOption): Option; +function convertToOption(raw: string | HitOption | undefined): HitOption | undefined; +function convertToOption(raw: string | Option | HitOption | undefined): Option | undefined; +function convertToOption(raw: string | Option | HitOption | undefined): Option | undefined { + if (typeof raw === 'string') { + return { label: raw, value: raw }; + } + return raw; +} + +function getSelectedOptions(value: HitOption[] | undefined | null): HitOption[] | null; +function getSelectedOptions(value: string[] | undefined | null): string[] | null; +function getSelectedOptions(value: string[] | HitOption[] | undefined | null) { + if (!value || !Array.isArray(value)) { + return null; + } + + return value; +} + +function uniqOptions(initial: HitOption[], current: HitOption[]): HitOption[] { + return uniqBy(initial.concat(current), o => o.value); +} + +function getSelectedValue( + value: string, + options: HitOption[], + isMultiple: boolean, +): HitOption | null; +function getSelectedValue( + value: string[], + options: HitOption[], + isMultiple: boolean, +): HitOption[] | null; +function getSelectedValue( + value: string | string[] | null | undefined, + options: HitOption[], + isMultiple: boolean, +): HitOption | HitOption[] | null; +function getSelectedValue( + value: string | string[] | null | undefined, + options: HitOption[], + isMultiple: boolean, +): HitOption | HitOption[] | null { + if (isMultiple && Array.isArray(value)) { + const selectedOptions = getSelectedOptions(value); + if (selectedOptions === null) { + return null; + } + + const selected = selectedOptions + .map(i => options.find(o => o.value === i)) + .filter(Boolean) + .map(convertToOption) as HitOption[]; + + return selected; + } else { + return find(options, ['value', value]) ?? null; + } +} + +const RelationControl = ({ + path, + value, + field, + onChange, + query, + locale, + label, +}: WidgetControlProps) => { + const [internalValue, setInternalValue] = useState(value); + const [initialOptions, setInitialOptions] = useState([]); + + const isMultiple = useMemo(() => { + return field.multiple ?? false; + }, [field.multiple]); + + const parseNestedFields = useCallback( + (hit: Entry, field: string): string => { + const hitData = + locale != null && hit.i18n != null && hit.i18n[locale] != null + ? hit.i18n[locale].data + : hit.data; + const templateVars = extractTemplateVars(field); + // return non template fields as is + if (templateVars.length <= 0) { + return get(hitData, field) as string; + } + const data = addFileTemplateFields(hit.path, hitData); + return compileStringTemplate(field, null, hit.slug, data); + }, + [locale], + ); + + const parseHitOptions = useCallback( + (hits: Entry[]) => { + const valueField = field.value_field; + const displayField = field.display_fields || [field.value_field]; + const options = hits.reduce((acc, hit) => { + const valuesPaths = expandPath({ data: hit.data, path: valueField }); + for (let i = 0; i < valuesPaths.length; i++) { + const label = displayField + .map(key => { + const displayPaths = expandPath({ data: hit.data, path: key }); + return parseNestedFields(hit, displayPaths[i] || displayPaths[0]); + }) + .join(' '); + const value = parseNestedFields(hit, valuesPaths[i]) as string; + acc.push({ data: hit.data, value, label }); + } + + return acc; + }, [] as HitOption[]); + + return options; + }, + [field.display_fields, field.value_field, parseNestedFields], + ); + + const handleChange = useCallback( + (selectedOption: HitOption | HitOption[] | null) => { + if (Array.isArray(selectedOption)) { + const options = selectedOption; + setInitialOptions(options.filter(Boolean)); + const newValue = options.map(optionToString); + setInternalValue(newValue); + onChange(newValue); + } else { + setInitialOptions([selectedOption].filter(Boolean) as HitOption[]); + const newValue = optionToString(selectedOption); + setInternalValue(newValue); + onChange(newValue); + } + }, + [onChange], + ); + + const [options, setOptions] = useState([]); + const [open, setOpen] = React.useState(false); + const loading = useMemo(() => open && options.length === 0, [open, options.length]); + + useEffect(() => { + let alive = true; + if (!loading) { + return undefined; + } + + (async () => { + const collection = field.collection; + const optionsLength = field.options_length || 20; + const searchFieldsArray = field.search_fields; + const file = field.file; + + const response = await query(path, collection, searchFieldsArray, '', file, optionsLength); + if (alive) { + if (response?.type === QUERY_SUCCESS) { + const hits = response.payload.hits ?? []; + const options = parseHitOptions(hits); + setOptions(uniqOptions(initialOptions, options)); + } + } + })(); + + return () => { + alive = false; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [field.collection, field.file, field.options_length, field.search_fields, loading]); + + const uniqueOptions = uniqOptions(initialOptions, options); + const selectedValue = getSelectedValue(internalValue, uniqueOptions, isMultiple); + + return ( + ( + + {loading ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + value={selectedValue ? selectedValue : isMultiple ? [] : null} + onChange={(_event, newValue) => handleChange(newValue)} + multiple={isMultiple} + open={open} + onOpen={() => { + setOpen(true); + }} + onClose={() => { + setOpen(false); + }} + /> + ); +}; + +export default RelationControl; diff --git a/src/widgets/relation/RelationPreview.js b/src/widgets/relation/RelationPreview.js deleted file mode 100644 index e3e74be8..00000000 --- a/src/widgets/relation/RelationPreview.js +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -import { WidgetPreviewContainer } from '../../ui'; - -function RelationPreview({ value }) { - return {value}; -} - -RelationPreview.propTypes = { - value: PropTypes.node, -}; - -export default RelationPreview; diff --git a/src/widgets/relation/RelationPreview.tsx b/src/widgets/relation/RelationPreview.tsx new file mode 100644 index 00000000..a6be1a62 --- /dev/null +++ b/src/widgets/relation/RelationPreview.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer'; + +import type { RelationField, WidgetPreviewProps } from '../../interface'; + +function RelationPreview({ value }: WidgetPreviewProps) { + return {value}; +} + +export default RelationPreview; diff --git a/src/widgets/relation/index.js b/src/widgets/relation/index.js deleted file mode 100644 index 49be68c1..00000000 --- a/src/widgets/relation/index.js +++ /dev/null @@ -1,39 +0,0 @@ -import controlComponent from './RelationControl'; -import previewComponent from './RelationPreview'; -import schema from './schema'; -import { validations } from '../../lib/widgets'; - -function isMultiple(field) { - return field.get('multiple', false); -} - -function Widget(opts = {}) { - return { - name: 'relation', - controlComponent, - previewComponent, - validator: ({ field, value, t }) => { - const min = field.get('min'); - const max = field.get('max'); - - if (!isMultiple(field)) { - return { error: false }; - } - - const error = validations.validateMinMax( - t, - field.get('label', field.get('name')), - value, - min, - max, - ); - - return error ? { error } : { error: false }; - }, - schema, - ...opts, - }; -} - -export const StaticCmsWidgetRelation = { Widget, controlComponent, previewComponent }; -export default StaticCmsWidgetRelation; diff --git a/src/widgets/relation/index.ts b/src/widgets/relation/index.ts new file mode 100644 index 00000000..f3083138 --- /dev/null +++ b/src/widgets/relation/index.ts @@ -0,0 +1,35 @@ +import controlComponent from './RelationControl'; +import previewComponent from './RelationPreview'; +import schema from './schema'; +import { validations } from '../../lib/widgets'; + +import type { RelationField, WidgetParam } from '../../interface'; + +function isMultiple(field: RelationField) { + return field.multiple ?? false; +} + +function RelationWidget(): WidgetParam { + return { + name: 'relation', + controlComponent, + previewComponent, + options: { + validator: ({ field, value, t }) => { + const min = field.min; + const max = field.max; + + if (!isMultiple(field)) { + return false; + } + + const error = validations.validateMinMax(t, field.label ?? field.name, value, min, max); + + return error ? error : false; + }, + schema, + }, + }; +} + +export default RelationWidget; diff --git a/src/widgets/relation/schema.js b/src/widgets/relation/schema.ts similarity index 100% rename from src/widgets/relation/schema.js rename to src/widgets/relation/schema.ts diff --git a/src/widgets/select/SelectControl.js b/src/widgets/select/SelectControl.js deleted file mode 100644 index 9591dac2..00000000 --- a/src/widgets/select/SelectControl.js +++ /dev/null @@ -1,117 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import ImmutablePropTypes from 'react-immutable-proptypes'; -import { Map, List, fromJS } from 'immutable'; -import { find } from 'lodash'; -import Select from 'react-select'; - -import { reactSelectStyles } from '../../ui'; -import { validations } from '../../lib/widgets'; - -function optionToString(option) { - return option && option.value ? option.value : null; -} - -function convertToOption(raw) { - if (typeof raw === 'string') { - return { label: raw, value: raw }; - } - return Map.isMap(raw) ? raw.toJS() : raw; -} - -function getSelectedValue({ value, options, isMultiple }) { - if (isMultiple) { - const selectedOptions = List.isList(value) ? value.toJS() : value; - - if (!selectedOptions || !Array.isArray(selectedOptions)) { - return null; - } - - return selectedOptions - .map(i => options.find(o => o.value === (i.value || i))) - .filter(Boolean) - .map(convertToOption); - } else { - return find(options, ['value', value]) || null; - } -} - -export default class SelectControl extends React.Component { - static propTypes = { - onChange: PropTypes.func.isRequired, - value: PropTypes.node, - forID: PropTypes.string.isRequired, - classNameWrapper: PropTypes.string.isRequired, - setActiveStyle: PropTypes.func.isRequired, - setInactiveStyle: PropTypes.func.isRequired, - field: ImmutablePropTypes.contains({ - options: ImmutablePropTypes.listOf( - PropTypes.oneOfType([ - PropTypes.string, - ImmutablePropTypes.contains({ - label: PropTypes.string.isRequired, - value: PropTypes.string.isRequired, - }), - ]), - ).isRequired, - }), - }; - - handleChange = selectedOption => { - const { onChange, field } = this.props; - const isMultiple = field.get('multiple', false); - const isEmpty = isMultiple ? !selectedOption?.length : !selectedOption; - - if (field.get('required') && isEmpty && isMultiple) { - onChange(List()); - } else if (isEmpty) { - onChange(null); - } else if (isMultiple) { - const options = selectedOption.map(optionToString); - onChange(fromJS(options)); - } else { - onChange(optionToString(selectedOption)); - } - }; - - componentDidMount() { - const { field, onChange, value } = this.props; - if (field.get('required') && field.get('multiple')) { - if (value && !List.isList(value)) { - onChange(fromJS([value])); - } else if (!value) { - onChange(fromJS([])); - } - } - } - - render() { - const { field, value, forID, classNameWrapper, setActiveStyle, setInactiveStyle } = this.props; - const fieldOptions = field.get('options'); - const isMultiple = field.get('multiple', false); - const isClearable = !field.get('required', true) || isMultiple; - - const options = [...fieldOptions.map(convertToOption)]; - const selectedValue = getSelectedValue({ - options, - value, - isMultiple, - }); - - return ( - : undefined} + renderValue={selectValues => + Array.isArray(selectValues) ? ( + + {selectValues.map(selectValue => { + const label = optionsByValue[selectValue]?.label ?? selectValue; + return + })} + + ) : ( + selectValues + ) + } + > + +   + + {options.map(option => ( + + {option.label} + + ))} + + + ); +}; + +export default SelectControl; diff --git a/src/widgets/select/SelectPreview.js b/src/widgets/select/SelectPreview.js deleted file mode 100644 index 850a0851..00000000 --- a/src/widgets/select/SelectPreview.js +++ /dev/null @@ -1,31 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { List } from 'immutable'; -import ImmutablePropTypes from 'react-immutable-proptypes'; - -import { WidgetPreviewContainer } from '../../ui'; - -function ListPreview({ values }) { - return ( -
      - {values.map((value, idx) => ( -
    • {value}
    • - ))} -
    - ); -} - -function SelectPreview({ value }) { - return ( - - {value && (List.isList(value) ? : value)} - {!value && null} - - ); -} - -SelectPreview.propTypes = { - value: PropTypes.oneOfType([PropTypes.string, ImmutablePropTypes.list]), -}; - -export default SelectPreview; diff --git a/src/widgets/select/SelectPreview.tsx b/src/widgets/select/SelectPreview.tsx new file mode 100644 index 00000000..e32211df --- /dev/null +++ b/src/widgets/select/SelectPreview.tsx @@ -0,0 +1,33 @@ +import React from 'react'; + +import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer'; + +import type { SelectField, WidgetPreviewProps } from '../../interface'; + +interface ListPreviewProps { + values: string[]; +} + +const ListPreview = ({ values }: ListPreviewProps) => { + return ( +
      + {(values as string[]).map((value, idx) => ( +
    • {value}
    • + ))} +
    + ); +}; + +const SelectPreview = ({ value }: WidgetPreviewProps) => { + if (!value) { + return ; + } + + return ( + + {typeof value === 'string' ? value : } + + ); +}; + +export default SelectPreview; diff --git a/src/widgets/select/index.js b/src/widgets/select/index.js deleted file mode 100644 index 363c299d..00000000 --- a/src/widgets/select/index.js +++ /dev/null @@ -1,34 +0,0 @@ -import controlComponent from './SelectControl'; -import previewComponent from './SelectPreview'; -import schema from './schema'; - -function Widget(opts = {}) { - return { - name: 'select', - controlComponent, - previewComponent, - validator: ({ field, value, t }) => { - const min = field.get('min'); - const max = field.get('max'); - - if (!field.get('multiple')) { - return { error: false }; - } - - const error = validations.validateMinMax( - t, - field.get('label', field.get('name')), - value, - min, - max, - ); - - return error ? { error } : { error: false }; - }, - schema, - ...opts, - }; -} - -export const StaticCmsWidgetSelect = { Widget, controlComponent, previewComponent }; -export default StaticCmsWidgetSelect; diff --git a/src/widgets/select/index.ts b/src/widgets/select/index.ts new file mode 100644 index 00000000..f7171078 --- /dev/null +++ b/src/widgets/select/index.ts @@ -0,0 +1,31 @@ +import controlComponent from './SelectControl'; +import previewComponent from './SelectPreview'; +import schema from './schema'; +import { validateMinMax } from '../../lib/widgets/validations'; + +import type { SelectField, WidgetParam } from '../../interface'; + +const SelectWidget = (): WidgetParam => { + return { + name: 'select', + controlComponent, + previewComponent, + options: { + validator: ({ field, value, t }) => { + const min = field.min; + const max = field.max; + + if (!field.multiple || typeof value === 'string') { + return false; + } + + const error = validateMinMax(t, field.label ?? field.name, value, min, max); + + return error ? error : false; + }, + schema, + }, + }; +}; + +export default SelectWidget; diff --git a/src/widgets/select/schema.js b/src/widgets/select/schema.ts similarity index 100% rename from src/widgets/select/schema.js rename to src/widgets/select/schema.ts diff --git a/src/widgets/string/StringControl.tsx b/src/widgets/string/StringControl.tsx index 6dba07ba..575fce5f 100644 --- a/src/widgets/string/StringControl.tsx +++ b/src/widgets/string/StringControl.tsx @@ -1,50 +1,36 @@ -import React from 'react'; +import TextField from '@mui/material/TextField'; +import React, { useCallback, useState } from 'react'; import type { ChangeEvent } from 'react'; -import type { CmsWidgetControlProps } from '../../interface'; +import type { StringOrTextField, WidgetControlProps } from '../../interface'; -export default class StringControl extends React.Component> { - // The selection to maintain for the input element - private _sel: number | null = 0; +const StringControl = ({ + value, + label, + onChange, + hasErrors, +}: WidgetControlProps) => { + const [internalValue, setInternalValue] = useState(value ?? ''); - // The input element ref - private _el: HTMLInputElement | null = null; + const handleChange = useCallback( + (event: ChangeEvent) => { + setInternalValue(event.target.value); + onChange(event.target.value); + }, + [onChange], + ); - // NOTE: This prevents the cursor from jumping to the end of the text for - // nested inputs. In other words, this is not an issue on top-level text - // fields such as the `title` of a collection post. However, it becomes an - // issue on fields nested within other components, namely widgets nested - // within a `markdown` widget. For example, the alt text on a block image - // within markdown. - // SEE: https://github.com/netlify/netlify-cms/issues/4539 - // SEE: https://github.com/netlify/netlify-cms/issues/3578 - componentDidUpdate() { - if (this._el && this._el.selectionStart !== this._sel) { - this._el.setSelectionRange(this._sel, this._sel); - } - } + return ( + + ); +}; - handleChange = (e: ChangeEvent) => { - this._sel = e.target.selectionStart; - this.props.onChange(e.target.value); - }; - - render() { - const { forID, value, classNameWrapper, setActiveStyle, setInactiveStyle } = this.props; - - return ( - { - this._el = el; - }} - type="text" - id={forID} - className={classNameWrapper} - value={value || ''} - onChange={this.handleChange} - onFocus={setActiveStyle} - onBlur={setInactiveStyle} - /> - ); - } -} +export default StringControl; diff --git a/src/widgets/string/StringPreview.tsx b/src/widgets/string/StringPreview.tsx index 483dd91d..baa092ec 100644 --- a/src/widgets/string/StringPreview.tsx +++ b/src/widgets/string/StringPreview.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import { CmsWidgetPreviewProps } from '../../interface'; -import { WidgetPreviewContainer } from '../../ui'; +import WidgetPreviewContainer from '../../components/UI/WidgetPreviewContainer'; -function StringPreview({ value = '' }: CmsWidgetPreviewProps) { +import type { StringOrTextField, WidgetPreviewProps } from '../../interface'; + +const StringPreview = ({ value = '' }: WidgetPreviewProps) => { return {value}; -} +}; export default StringPreview; diff --git a/src/widgets/string/index.ts b/src/widgets/string/index.ts new file mode 100644 index 00000000..bdafe8f6 --- /dev/null +++ b/src/widgets/string/index.ts @@ -0,0 +1,14 @@ +import controlComponent from './StringControl'; +import previewComponent from './StringPreview'; + +import type { StringOrTextField, WidgetParam } from '../../interface'; + +const StringWidget = (): WidgetParam => { + return { + name: 'string', + controlComponent, + previewComponent, + }; +}; + +export default StringWidget; diff --git a/src/widgets/string/index.tsx b/src/widgets/string/index.tsx deleted file mode 100644 index 9f77f1e4..00000000 --- a/src/widgets/string/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { CmsWidgetParam } from '../../interface'; -import controlComponent from './StringControl'; -import previewComponent from './StringPreview'; - -function Widget(opts = {}): CmsWidgetParam { - return { - name: 'string', - controlComponent, - previewComponent, - ...opts, - }; -} - -export const StaticCmsWidgetString = { Widget, controlComponent, previewComponent }; -export default StaticCmsWidgetString; diff --git a/src/widgets/text/TextControl.js b/src/widgets/text/TextControl.js deleted file mode 100644 index 26cbe443..00000000 --- a/src/widgets/text/TextControl.js +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import Textarea from 'react-textarea-autosize'; - -export default class TextControl extends React.Component { - static propTypes = { - onChange: PropTypes.func.isRequired, - forID: PropTypes.string, - value: PropTypes.node, - classNameWrapper: PropTypes.string.isRequired, - setActiveStyle: PropTypes.func.isRequired, - setInactiveStyle: PropTypes.func.isRequired, - }; - - static defaultProps = { - value: '', - }; - - /** - * Always update to ensure `react-textarea-autosize` properly calculates - * height. Certain situations, such as this widget being nested in a list - * item that gets rearranged, can leave the textarea in a minimal height - * state. Always updating this particular widget should generally be low cost, - * but this should be optimized in the future. - */ - shouldComponentUpdate() { - return true; - } - - render() { - const { forID, value, onChange, classNameWrapper, setActiveStyle, setInactiveStyle } = - this.props; - - return ( -