From d0ecae310c2586a19d3b07279355a5043fb9114a Mon Sep 17 00:00:00 2001 From: Daniel Lautzenheiser Date: Tue, 4 Apr 2023 15:12:32 -0400 Subject: [PATCH] feat: nested collections (#680) --- BREAKING_CHANGES.md | 1 + .../_nested_pages/authors/author-1/index.md | 5 + .../proxy/_nested_pages/authors/index.md | 3 + .../backends/proxy/_nested_pages/index.md | 3 + .../_nested_pages/posts/hello-world/index.md | 5 + .../proxy/_nested_pages/posts/index.md | 3 + .../core/dev-test/backends/proxy/config.yml | 19 + packages/core/dev-test/config.yml | 19 + packages/core/dev-test/index.html | 26 + packages/core/src/__tests__/testConfig.ts | 1804 +++++++++++++++++ packages/core/src/actions/config.ts | 8 +- packages/core/src/actions/entries.ts | 8 +- packages/core/src/actions/media.ts | 8 +- packages/core/src/actions/mediaLibrary.ts | 6 +- packages/core/src/backend.ts | 92 +- .../core/src/backends/test/implementation.ts | 10 +- .../collections/CollectionHeader.tsx | 53 +- .../components/collections/CollectionPage.tsx | 40 + .../collections/CollectionRoute.tsx | 22 +- .../{Collection.tsx => CollectionView.tsx} | 12 +- .../collections/NestedCollection.tsx | 84 +- .../src/components/common/image/Image.tsx | 18 +- .../components/common/widget/widgetFor.tsx | 5 +- .../entry-editor/EditorInterface.tsx | 25 +- .../editor-control-pane/EditorControl.tsx | 7 +- .../editor-control-pane/EditorControlPane.tsx | 35 + .../common/CurrentMediaDetails.tsx | 2 +- .../media-library/common/MediaLibraryCard.tsx | 16 +- .../core/src/components/navbar/NavLink.tsx | 6 +- .../core/src/components/navbar/Sidebar.tsx | 32 +- packages/core/src/constants/configSchema.tsx | 8 + packages/core/src/formats/formats.ts | 13 +- packages/core/src/interface.ts | 22 +- packages/core/src/lib/formatters.ts | 22 +- packages/core/src/lib/hooks/useBreadcrumbs.ts | 91 + .../core/src/lib/hooks/useIsMediaAsset.ts | 14 +- packages/core/src/lib/hooks/useMediaAsset.ts | 14 +- packages/core/src/lib/i18n.ts | 41 +- packages/core/src/lib/registry.ts | 17 +- packages/core/src/lib/util/collection.util.ts | 72 +- packages/core/src/lib/util/field.util.ts | 12 +- packages/core/src/lib/util/media.util.ts | 58 +- packages/core/src/lib/util/nested.util.ts | 42 + .../src/reducers/__tests__/entryDraft.spec.ts | 28 + packages/core/src/reducers/collections.ts | 4 +- packages/core/src/reducers/config.ts | 6 +- packages/core/src/reducers/entryDraft.ts | 20 +- .../src/reducers/selectors/collections.ts | 11 +- packages/core/src/store/index.ts | 12 +- .../core/src/widgets/file/withFileControl.tsx | 10 +- packages/core/webpack.config.js | 6 +- packages/demo/public/config.yml | 19 + packages/demo/public/index.html | 26 + .../docs/content/docs/collection-types.mdx | 21 +- 54 files changed, 2671 insertions(+), 295 deletions(-) create mode 100644 packages/core/dev-test/backends/proxy/_nested_pages/authors/author-1/index.md create mode 100644 packages/core/dev-test/backends/proxy/_nested_pages/authors/index.md create mode 100644 packages/core/dev-test/backends/proxy/_nested_pages/index.md create mode 100644 packages/core/dev-test/backends/proxy/_nested_pages/posts/hello-world/index.md create mode 100644 packages/core/dev-test/backends/proxy/_nested_pages/posts/index.md create mode 100644 packages/core/src/__tests__/testConfig.ts create mode 100644 packages/core/src/components/collections/CollectionPage.tsx rename packages/core/src/components/collections/{Collection.tsx => CollectionView.tsx} (95%) create mode 100644 packages/core/src/lib/hooks/useBreadcrumbs.ts create mode 100644 packages/core/src/lib/util/nested.util.ts diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md index 5ce29aac..0a9a8c22 100644 --- a/BREAKING_CHANGES.md +++ b/BREAKING_CHANGES.md @@ -7,6 +7,7 @@ BREAKING_CHANGES - widget prop `isHidden` renamed to `hidden` - useMediaInsert now requires collection to be passed - media path changed from `string | string[]` to `{ path: string | string[], alt?: string }` +- Nested collections, meta config moved into nested config. ADDED - `forSingleList` - Allows for changing styles for single list items diff --git a/packages/core/dev-test/backends/proxy/_nested_pages/authors/author-1/index.md b/packages/core/dev-test/backends/proxy/_nested_pages/authors/author-1/index.md new file mode 100644 index 00000000..d271d560 --- /dev/null +++ b/packages/core/dev-test/backends/proxy/_nested_pages/authors/author-1/index.md @@ -0,0 +1,5 @@ +--- +title: An Author +--- + +Author details go here!. diff --git a/packages/core/dev-test/backends/proxy/_nested_pages/authors/index.md b/packages/core/dev-test/backends/proxy/_nested_pages/authors/index.md new file mode 100644 index 00000000..c19d421b --- /dev/null +++ b/packages/core/dev-test/backends/proxy/_nested_pages/authors/index.md @@ -0,0 +1,3 @@ +--- +title: Authors +--- diff --git a/packages/core/dev-test/backends/proxy/_nested_pages/index.md b/packages/core/dev-test/backends/proxy/_nested_pages/index.md new file mode 100644 index 00000000..da761cbf --- /dev/null +++ b/packages/core/dev-test/backends/proxy/_nested_pages/index.md @@ -0,0 +1,3 @@ +--- +title: Pages +--- diff --git a/packages/core/dev-test/backends/proxy/_nested_pages/posts/hello-world/index.md b/packages/core/dev-test/backends/proxy/_nested_pages/posts/hello-world/index.md new file mode 100644 index 00000000..7e51e832 --- /dev/null +++ b/packages/core/dev-test/backends/proxy/_nested_pages/posts/hello-world/index.md @@ -0,0 +1,5 @@ +--- +title: Hello World +--- + +Coffee is a small tree or shrub that grows in the forest understory in its wild form, and traditionally was grown commercially under other trees that provided shade. The forest-like structure of shade coffee farms provides habitat for a great number of migratory and resident species. diff --git a/packages/core/dev-test/backends/proxy/_nested_pages/posts/index.md b/packages/core/dev-test/backends/proxy/_nested_pages/posts/index.md new file mode 100644 index 00000000..76fa783b --- /dev/null +++ b/packages/core/dev-test/backends/proxy/_nested_pages/posts/index.md @@ -0,0 +1,3 @@ +--- +title: Posts +--- diff --git a/packages/core/dev-test/backends/proxy/config.yml b/packages/core/dev-test/backends/proxy/config.yml index d4e926a7..172f509f 100644 --- a/packages/core/dev-test/backends/proxy/config.yml +++ b/packages/core/dev-test/backends/proxy/config.yml @@ -504,3 +504,22 @@ collections: label: Date widget: datetime i18n: duplicate + - name: pages + label: Nested Pages + label_singular: 'Page' + folder: packages/core/dev-test/backends/proxy/_nested_pages + create: true + # adding a nested object will show the collection folder structure + nested: + depth: 100 # max depth to show in the collection tree + summary: '{{title}}' # optional summary for a tree node, defaults to the inferred title field + # adding a path object allows editing the path of entries + # moving an existing entry will move the entire sub tree of the entry to the new location + path: { label: 'Path', index_file: 'index' } + fields: + - label: Title + name: title + widget: string + - label: Body + name: body + widget: markdown diff --git a/packages/core/dev-test/config.yml b/packages/core/dev-test/config.yml index 5843ff71..6c931bda 100644 --- a/packages/core/dev-test/config.yml +++ b/packages/core/dev-test/config.yml @@ -1248,3 +1248,22 @@ collections: label: Date widget: datetime i18n: duplicate + - name: pages + label: Nested Pages + label_singular: 'Page' + folder: _nested_pages + create: true + # adding a nested object will show the collection folder structure + nested: + depth: 100 # max depth to show in the collection tree + summary: '{{title}}' # optional summary for a tree node, defaults to the inferred title field + # adding a path object allows editing the path of entries + # moving an existing entry will move the entire sub tree of the entry to the new location + path: { label: 'Path', index_file: 'index' } + fields: + - label: Title + name: title + widget: string + - label: Body + name: body + widget: markdown diff --git a/packages/core/dev-test/index.html b/packages/core/dev-test/index.html index 6e327072..13f09246 100644 --- a/packages/core/dev-test/index.html +++ b/packages/core/dev-test/index.html @@ -131,6 +131,32 @@ "---\ndescription: Le café est un petit arbre ou un arbuste qui pousse dans le sous-étage de la forêt sous sa forme sauvage et qui était traditionnellement cultivé commercialement sous d'autres arbres qui fournissaient de l'ombre. La structure forestière des plantations de café d'ombre fournit un habitat à un grand nombre d'espèces migratrices et résidentes.\ndate: 2015-02-14T00:00:00.000Z\n---\n", }, }, + _nested_pages: { + authors: { + 'author-1': { + 'index.md': { + content: '---\ntitle: An Author\n---\nAuthor details go here!.\n', + }, + }, + 'index.md': { + content: '---\ntitle: Authors\n---\n', + }, + }, + posts: { + 'hello-world': { + 'index.md': { + content: + '---\ntitle: Hello World\n---\nCoffee is a small tree or shrub that grows in the forest understory in its wild form, and traditionally was grown commercially under other trees that provided shade. The forest-like structure of shade coffee farms provides habitat for a great number of migratory and resident species.\n', + }, + }, + 'index.md': { + content: '---\ntitle: Posts\n---\n', + }, + }, + 'index.md': { + content: '---\ntitle: Pages\n---\n', + }, + }, }; var ONE_DAY = 60 * 60 * 24 * 1000; diff --git a/packages/core/src/__tests__/testConfig.ts b/packages/core/src/__tests__/testConfig.ts new file mode 100644 index 00000000..645f26f4 --- /dev/null +++ b/packages/core/src/__tests__/testConfig.ts @@ -0,0 +1,1804 @@ +import type { Config, RelationField } from '../interface'; + +export interface RelationKitchenSinkPostField extends Omit { + widget: 'relationKitchenSinkPost'; +} + +const testConfig: Config = { + backend: { + name: 'test-repo', + }, + site_url: 'https://example.com', + media_folder: 'assets/uploads', + locale: 'en', + i18n: { + structure: 'multiple_files', + locales: ['en', 'de', 'fr'], + defaultLocale: 'en', + }, + 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_fields: ['title', 'date', 'draft'], + 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'T'HH:mm:ss.SSSXXX", + }, + { + 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, + editor: { + frame: false, + }, + 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: 'widgets', + label: 'Widgets', + delete: false, + files: [ + { + name: 'boolean', + label: 'Boolean', + file: '_widgets/boolean.json', + description: 'Boolean widget', + fields: [ + { + name: 'required', + label: 'Required Validation', + widget: 'boolean', + }, + { + name: 'with_default', + label: 'Required With Default', + widget: 'boolean', + default: true, + }, + { + 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: 'with_default', + label: 'Required With Default', + widget: 'code', + default: '
Some html!
', + }, + { + name: 'pattern', + label: 'Pattern Validation', + widget: 'code', + pattern: ['.{12,}', 'Must have at least 12 characters'], + required: false, + }, + { + name: 'language', + label: 'Language Selection', + widget: 'code', + allow_language_selection: true, + required: false, + }, + { + name: 'language_with_default', + label: 'Language Selection With Default Language', + widget: 'code', + allow_language_selection: true, + required: false, + default_language: 'html', + }, + { + name: 'language_with_default_language_and_value', + label: 'Language Selection With Default Language and Value', + widget: 'code', + allow_language_selection: true, + required: false, + default: { + lang: 'html', + code: '
Some html!
', + }, + }, + { + name: 'language_with_default_language_and_value_string_default', + label: 'Language Selection With Default Language and Value (String Default)', + widget: 'code', + allow_language_selection: true, + required: false, + default_language: 'html', + default: '
Some html!
', + }, + ], + }, + { + name: 'color', + label: 'Color', + file: '_widgets/color.json', + description: 'Color widget', + fields: [ + { + name: 'required', + label: 'Required Validation', + widget: 'color', + }, + { + name: 'with_default', + label: 'Required With Default', + widget: 'color', + default: '#2121c5', + }, + { + 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: 'alpha_with_default', + label: 'Alpha With Default', + widget: 'color', + enable_alpha: true, + required: false, + default: 'rgba(175, 28, 28, 0.65)', + }, + ], + }, + { + 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_and_time_with_default', + label: 'Date and Time With Default', + widget: 'datetime', + format: 'MMM d, yyyy h:mm aaa', + date_format: 'MMM d, yyyy', + time_format: 'h:mm aaa', + required: false, + default: 'Jan 12, 2023 12:00 am', + }, + { + name: 'date', + label: 'Date', + widget: 'datetime', + format: 'MMM d, yyyy', + time_format: false, + required: false, + }, + { + name: 'date_with_default', + label: 'Date With Default', + widget: 'datetime', + format: 'MMM d, yyyy', + date_format: 'MMM d, yyyy', + time_format: false, + required: false, + default: 'Jan 12, 2023', + }, + { + name: 'time', + label: 'Time', + widget: 'datetime', + format: 'h:mm aaa', + date_format: false, + time_format: 'h:mm aaa', + required: false, + }, + { + name: 'time_with_default', + label: 'Time With Default', + widget: 'datetime', + format: 'h:mm aaa', + date_format: false, + time_format: 'h:mm aaa', + required: false, + default: '12:00 am', + }, + ], + }, + { + name: 'file', + label: 'File', + file: '_widgets/file.json', + description: 'File widget', + fields: [ + { + name: 'required', + label: 'Required Validation', + widget: 'file', + }, + { + name: 'with_default', + label: 'Required With Default', + widget: 'file', + default: '/assets/uploads/moby-dick.jpg', + }, + { + 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: 'with_default', + label: 'Required With Default', + widget: 'image', + default: '/assets/uploads/moby-dick.jpg', + }, + { + 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: 'Required List', + widget: 'list', + fields: [ + { + label: 'Name', + name: 'name', + widget: 'string', + hint: 'First and Last', + }, + { + label: 'Description', + name: 'description', + widget: 'text', + }, + ], + }, + { + name: 'with_default', + label: 'Required With Default', + widget: 'list', + default: [ + { + name: 'Bob Billy', + description: 'Some text about bob', + }, + ], + fields: [ + { + label: 'Name', + name: 'name', + widget: 'string', + hint: 'First and Last', + }, + { + label: 'Description', + name: 'description', + widget: 'text', + }, + ], + }, + { + name: 'optional', + label: 'Optional List', + widget: 'list', + required: false, + fields: [ + { + label: 'Name', + name: 'name', + widget: 'string', + hint: 'First and Last', + }, + { + label: 'Description', + name: 'description', + widget: 'text', + }, + ], + }, + { + name: 'string_list', + label: 'String List', + widget: 'list', + fields: [ + { + label: 'Tag', + name: 'tag', + widget: 'string', + }, + ], + }, + { + name: 'number_list', + label: 'Number List', + widget: 'list', + default: [5, 13, 2], + fields: [ + { + label: 'Value', + name: 'value', + widget: 'number', + }, + ], + }, + { + name: 'boolean_list', + label: 'Boolean List', + widget: 'list', + default: [false, true], + fields: [ + { + label: 'Active', + name: 'active', + widget: 'boolean', + }, + ], + }, + { + 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: 'markdown', + }, + ], + }, + { + label: 'Type 3 Object', + name: 'type_3_object', + widget: 'object', + fields: [ + { + label: 'Image', + name: 'image', + widget: 'image', + }, + { + label: 'File', + name: 'file', + widget: 'file', + }, + ], + }, + ], + }, + { + name: 'typed_list_with_default', + label: 'Typed List With Default', + widget: 'list', + default: [ + { + type: 'type_2_object', + number: 5, + select: 'c', + datetime: '2022-12-05T20:22:52+0000', + markdown: 'Some ***Markdown*** ~content~ text', + }, + ], + 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: 'markdown', + }, + ], + }, + { + 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: 'with_default', + label: 'Required With Default', + widget: 'map', + default: '{ "type": "Point", "coordinates": [-73.9852661, 40.7478738] }', + }, + { + 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: 'with_default', + label: 'Required With Default', + widget: 'markdown', + default: 'Default **markdown** value', + }, + { + 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: 'with_default', + label: 'Required With Default', + widget: 'number', + default: 5, + }, + { + 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: 'Author', + name: 'author', + widget: 'string', + }, + { + label: 'Thumbnail', + name: 'thumb', + widget: 'image', + }, + ], + }, + { + label: 'Required With Defaults', + name: 'with_defaults', + widget: 'object', + fields: [ + { + label: 'Number of posts on frontpage', + name: 'front_limit', + widget: 'number', + default: 5, + }, + { + label: 'Author', + name: 'author', + widget: 'string', + default: 'Bob', + }, + { + label: 'Thumbnail', + name: 'thumb', + widget: 'image', + default: '/assets/uploads/moby-dick.jpg', + }, + ], + }, + { + label: 'Optional Validation', + name: 'optional', + widget: 'object', + required: false, + fields: [ + { + label: 'Number of posts on frontpage', + name: 'front_limit', + widget: 'number', + required: false, + }, + { + label: 'Author', + name: 'author', + widget: 'string', + required: false, + }, + { + label: 'Thumbnail', + name: 'thumb', + widget: 'image', + required: false, + }, + ], + }, + { + label: 'With Hidden Field', + name: 'hidden_field', + widget: 'object', + required: false, + fields: [ + { + name: 'layout', + widget: 'hidden', + default: 'post', + }, + { + label: 'Number of posts on frontpage', + name: 'front_limit', + widget: 'number', + required: false, + }, + { + label: 'Author', + name: 'author', + widget: 'string', + required: false, + }, + { + label: 'Thumbnail', + name: 'thumb', + widget: 'image', + required: false, + }, + ], + }, + { + label: 'Collapsed, optional with required children', + name: 'collapsed_optional_with_required_children', + widget: 'object', + required: false, + collapsed: true, + fields: [ + { + name: 'layout', + widget: 'hidden', + default: 'post', + }, + { + label: 'Number of posts on frontpage', + name: 'front_limit', + widget: 'number', + required: true, + }, + { + label: 'Author', + name: 'author', + widget: 'string', + required: true, + }, + { + label: 'Thumbnail', + name: 'thumb', + widget: 'image', + required: true, + }, + ], + }, + ], + }, + { + 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', 'date'], + value_field: 'title', + }, + { + label: 'Required With Default', + name: 'with_default', + widget: 'relation', + collection: 'posts', + display_fields: ['title', 'date'], + search_fields: ['title', 'date'], + value_field: 'title', + default: 'This is a YAML front matter post', + }, + { + label: 'Optional Validation', + name: 'optional', + widget: 'relation', + required: false, + collection: 'posts', + display_fields: ['title', 'date'], + search_fields: ['title', 'date'], + value_field: 'title', + }, + { + label: 'Multiple', + name: 'multiple', + widget: 'relation', + multiple: true, + required: false, + collection: 'posts', + display_fields: ['title', 'date'], + search_fields: ['title', 'date'], + value_field: 'title', + }, + { + label: 'Multiple With Default', + name: 'multiple_with_default', + widget: 'relation', + multiple: true, + required: false, + collection: 'posts', + default: ['This is a JSON front matter post', 'This is a YAML front matter post'], + display_fields: ['title', 'date'], + search_fields: ['title', 'date'], + 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: 'Required With Default', + name: 'with_default', + widget: 'select', + default: 'b', + 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: 'Number Value', + name: 'number', + widget: 'select', + options: [1, 2, 3], + }, + { + label: 'Number With Default', + name: 'number_with_default', + widget: 'select', + default: 3, + options: [1, 2, 3], + }, + { + label: 'Value and Label', + name: 'value_and_label', + widget: 'select', + options: [ + { + value: 'a', + label: 'A fancy label', + }, + { + value: 2, + label: 'Another fancy label', + }, + { + value: 'c', + label: 'And one more fancy label', + }, + ], + }, + { + label: 'Value and Label With Default', + name: 'value_and_label_with_default', + widget: 'select', + default: 2, + options: [ + { + value: 'a', + label: 'A fancy label', + }, + { + value: 2, + 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: 'Multiple With Default', + name: 'multiple_with_default', + widget: 'select', + default: ['b', 'c'], + 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: 'with_default', + label: 'Required With Default', + widget: 'string', + default: 'Default value', + }, + { + 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: 'with_default', + label: 'Required With Default', + widget: 'text', + default: 'Default value', + }, + { + name: 'pattern', + label: 'Pattern Validation', + widget: 'text', + pattern: ['.{12,}', 'Must have at least 12 characters'], + required: false, + }, + ], + }, + ], + }, + { + name: 'settings', + label: 'Settings', + delete: false, + editor: { + preview: false, + }, + files: [ + { + name: 'general', + label: 'Site Settings', + file: '_data/settings.json', + description: 'General Site Settings', + editor: { + preview: true, + }, + 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', + editor: { + preview: true, + }, + 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: 'Select mixed string and numeric', + name: 'select_mixed_string_numeric', + widget: 'select', + options: [ + { + label: 'One', + value: 'One', + }, + { + 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: '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: '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: '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: '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: 'markdown', + }, + ], + }, + { + label: 'Type 3 Object', + name: 'type_3_object', + widget: 'object', + fields: [ + { + label: 'Image', + name: 'image', + widget: 'image', + }, + { + label: 'File', + name: 'file', + widget: 'file', + }, + ], + }, + ], + }, + ], + }, + { + name: 'i18n_playground', + label: 'i18n Playground', + i18n: true, + folder: '_i18n_playground', + identifier_field: 'slug', + create: true, + fields: [ + { + name: 'slug', + label: 'Slug', + widget: 'string', + }, + { + name: 'description', + label: 'Description', + widget: 'text', + i18n: true, + }, + { + name: 'date', + label: 'Date', + widget: 'datetime', + i18n: 'duplicate', + }, + ], + }, + { + name: 'pages', + label: 'Nested Pages', + label_singular: 'Page', + folder: '_nested_pages', + create: true, + nested: { + depth: 100, + summary: '{{title}}', + path: { + label: 'Path', + index_file: 'index', + }, + }, + fields: [ + { + label: 'Title', + name: 'title', + widget: 'string', + }, + { + label: 'Body', + name: 'body', + widget: 'markdown', + }, + ], + }, + ], +}; + +export default testConfig; diff --git a/packages/core/src/actions/config.ts b/packages/core/src/actions/config.ts index 0a9e8093..7c41ed77 100644 --- a/packages/core/src/actions/config.ts +++ b/packages/core/src/actions/config.ts @@ -39,9 +39,9 @@ function isFieldList(field: Field): field return 'types' in (field as ListField) || 'field' in (field as ListField); } -function traverseFieldsJS( +function traverseFieldsJS( fields: F[], - updater: (field: T) => T, + updater: (field: T) => T, ): F[] { return fields.map(field => { const newField = updater(field); @@ -68,14 +68,14 @@ 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 }; } return field; } -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]) { diff --git a/packages/core/src/actions/entries.ts b/packages/core/src/actions/entries.ts index aafbe057..fc8ea6fc 100644 --- a/packages/core/src/actions/entries.ts +++ b/packages/core/src/actions/entries.ts @@ -39,10 +39,10 @@ import { } from '../constants'; import ValidationErrorTypes from '../constants/validationErrorTypes'; import { - duplicateDefaultI18nFields, - hasI18n, I18N_FIELD_DUPLICATE, I18N_FIELD_TRANSLATE, + duplicateDefaultI18nFields, + hasI18n, serializeI18n, } from '../lib/i18n'; import { serializeValues } from '../lib/serializeEntryValues'; @@ -463,15 +463,17 @@ export function changeDraftField({ field, value, i18n, + isMeta, }: { path: string; field: Field; value: ValueOrNestedValue; i18n?: I18nSettings; + isMeta: boolean; }) { return { type: DRAFT_CHANGE_FIELD, - payload: { path, field, value, i18n }, + payload: { path, field, value, i18n, isMeta }, } as const; } diff --git a/packages/core/src/actions/media.ts b/packages/core/src/actions/media.ts index b9ec48b8..93d2011f 100644 --- a/packages/core/src/actions/media.ts +++ b/packages/core/src/actions/media.ts @@ -12,7 +12,7 @@ import { getMediaFile } from './mediaLibrary'; import type { AnyAction } from 'redux'; import type { ThunkDispatch } from 'redux-thunk'; -import type { BaseField, Collection, Entry, Field, UnknownField } from '../interface'; +import type { BaseField, Collection, Entry, Field, MediaField, UnknownField } from '../interface'; import type { RootState } from '../store'; import type AssetProxy from '../valueObjects/AssetProxy'; @@ -72,11 +72,11 @@ async function loadAsset( const promiseCache: Record> = {}; -export function getAsset( - collection: Collection | null | undefined, +export function getAsset( + collection: Collection | null | undefined, entry: Entry | null | undefined, path: string, - field?: F, + field?: T, ) { return ( dispatch: ThunkDispatch, diff --git a/packages/core/src/actions/mediaLibrary.ts b/packages/core/src/actions/mediaLibrary.ts index a6d7b54e..d34a9926 100644 --- a/packages/core/src/actions/mediaLibrary.ts +++ b/packages/core/src/actions/mediaLibrary.ts @@ -77,7 +77,7 @@ export function removeMediaControl(id: string) { }; } -export function openMediaLibrary( +export function openMediaLibrary( payload: { controlID?: string; forImage?: boolean; @@ -86,8 +86,8 @@ export function openMediaLibrary( allowMultiple?: boolean; replaceIndex?: number; config?: Record; - collection?: Collection; - field?: F; + collection?: Collection; + field?: EF; insertOptions?: MediaLibrarInsertOptions; } = {}, ) { diff --git a/packages/core/src/backend.ts b/packages/core/src/backend.ts index 49eab1bd..fda2fd4c 100644 --- a/packages/core/src/backend.ts +++ b/packages/core/src/backend.ts @@ -20,10 +20,10 @@ import { import { getBackend, invokeEvent } from './lib/registry'; import { sanitizeChar } from './lib/urlHelper'; import { + CURSOR_COMPATIBILITY_SYMBOL, + Cursor, asyncLock, blobToFileObj, - Cursor, - CURSOR_COMPATIBILITY_SYMBOL, getPathDepth, localForage, } from './lib/util'; @@ -39,6 +39,7 @@ import { selectMediaFolders, } from './lib/util/collection.util'; import { selectMediaFilePath, selectMediaFilePublicPath } from './lib/util/media.util'; +import { selectCustomPath, slugFromCustomPath } from './lib/util/nested.util'; import { set } from './lib/util/object.util'; import { dateParsers, expandPath, extractTemplateVars } from './lib/widgets/stringTemplate'; import createEntry from './valueObjects/createEntry'; @@ -59,6 +60,7 @@ import type { Field, FilterRule, ImplementationEntry, + PersistArgs, SearchQueryResponse, SearchResponse, UnknownField, @@ -230,9 +232,9 @@ interface AuthStore { logout: () => void; } -interface BackendOptions { +interface BackendOptions { backendName: string; - config: Config; + config: Config; authStore?: AuthStore; } @@ -258,16 +260,7 @@ interface BackupEntry { i18n?: Record; } -interface PersistArgs { - config: Config; - collection: Collection; - entryDraft: EntryDraft; - assetProxies: AssetProxy[]; - usedSlugs: string[]; - status?: string; -} - -function collectionDepth(collection: Collection) { +function collectionDepth(collection: Collection) { let depth; depth = ('nested' in collection && collection.nested?.depth) || getPathDepth(collection.path ?? ''); @@ -279,17 +272,17 @@ function collectionDepth(collection: Collection) { return depth; } -export class Backend { +export class Backend { implementation: BC; backendName: string; - config: Config; + config: Config; authStore?: AuthStore; user?: User | null; backupSync: AsyncLock; constructor( - implementation: BackendInitializer, - { backendName, authStore, config }: BackendOptions, + implementation: BackendInitializer, + { backendName, authStore, config }: BackendOptions, ) { // We can't reliably run this on exit, so we do cleanup on load. this.deleteAnonymousBackup(); @@ -401,9 +394,15 @@ export class Backend { entryData: EntryData, config: Config, usedSlugs: string[], + customPath: string | undefined, ) { const slugConfig = config.slug; - const slug = slugFormatter(collection, entryData, slugConfig); + let slug: string; + if (customPath) { + slug = slugFromCustomPath(collection, customPath); + } else { + slug = slugFormatter(collection, entryData, slugConfig); + } let i = 1; let uniqueSlug = slug; @@ -417,7 +416,10 @@ export class Backend { return uniqueSlug; } - processEntries(loadedEntries: ImplementationEntry[], collection: Collection): Entry[] { + processEntries( + loadedEntries: ImplementationEntry[], + collection: Collection, + ): Entry[] { const entries = loadedEntries.map(loadedEntry => createEntry( collection.name, @@ -486,13 +488,13 @@ export class Backend { // repeats the process. Once there is no available "next" action, it // returns all the collected entries. Used to retrieve all entries // for local searches and queries. - async listAllEntries(collection: Collection) { + async listAllEntries(collection: Collection) { if ('folder' in collection && collection.folder && this.implementation.allEntriesByFolder) { - const depth = collectionDepth(collection as Collection); - const extension = selectFolderEntryExtension(collection as Collection); + const depth = collectionDepth(collection); + const extension = selectFolderEntryExtension(collection); return this.implementation .allEntriesByFolder(collection.folder as string, extension, depth) - .then(entries => this.processEntries(entries, collection as Collection)); + .then(entries => this.processEntries(entries, collection)); } const response = await this.listEntries(collection as Collection); @@ -565,8 +567,8 @@ export class Backend { return { entries: hits, pagination: 1 }; } - async query( - collection: Collection, + async query( + collection: Collection, searchFields: string[], searchTerm: string, file?: string, @@ -714,7 +716,11 @@ export class Backend { return localForage.removeItem(getEntryBackupKey()); } - async getEntry(state: RootState, 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); @@ -762,7 +768,7 @@ export class Backend { return Promise.reject(err); } - entryWithFormat(collection: Collection) { + entryWithFormat(collection: Collection) { return (entry: Entry): Entry => { const format = resolveFormat(collection, entry); if (entry && entry.raw !== undefined) { @@ -777,7 +783,11 @@ export class Backend { }; } - async processEntry(state: RootState, collection: Collection, entry: Entry) { + async processEntry( + state: RootState, + collection: Collection, + entry: Entry, + ) { const configState = state.config; if (!configState.config) { throw new Error('Config not loaded'); @@ -826,6 +836,8 @@ export class Backend { const newEntry = entryDraft.entry.newRecord ?? false; + const customPath = selectCustomPath(draft.entry, collection); + let dataFile: DataFile; if (newEntry) { if (!selectAllowNewEntries(collection)) { @@ -836,8 +848,9 @@ export class Backend { entryDraft.entry.data, config, usedSlugs, + customPath, ); - const path = selectEntryPath(collection, slug) ?? ''; + const path = customPath || (selectEntryPath(collection, slug) ?? ''); dataFile = { path, slug, @@ -849,8 +862,9 @@ export class Backend { const slug = entryDraft.entry.slug; dataFile = { path: entryDraft.entry.path, - slug, + slug: customPath ? slugFromCustomPath(collection, customPath) : slug, raw: this.entryToRaw(collection, entryDraft.entry), + newPath: customPath, }; } @@ -938,7 +952,11 @@ export class Backend { return this.implementation.persistMedia(file, options); } - async deleteEntry(state: RootState, collection: Collection, slug: string) { + async deleteEntry( + state: RootState, + collection: Collection, + slug: string, + ) { const configState = state.config; if (!configState.config) { throw new Error('Config not loaded'); @@ -1012,7 +1030,7 @@ export class Backend { } } -export function resolveBackend(config?: Config) { +export function resolveBackend(config?: Config) { if (!config?.backend.name) { throw new Error('No backend defined in configuration'); } @@ -1020,22 +1038,22 @@ export function resolveBackend(config?: Config) { const { name } = config.backend; const authStore = new LocalStorageAuthStore(); - const backend = getBackend(name); + const backend = getBackend(name); if (!backend) { throw new Error(`Backend not found: ${name}`); } else { - return new Backend(backend, { backendName: name, authStore, config }); + return new Backend(backend, { backendName: name, authStore, config }); } } export const currentBackend = (function () { let backend: Backend; - return (config: Config) => { + return (config: Config) => { if (backend) { return backend; } - return (backend = resolveBackend(config as Config)); + return (backend = resolveBackend(config) as unknown as Backend); }; })(); diff --git a/packages/core/src/backends/test/implementation.ts b/packages/core/src/backends/test/implementation.ts index 0471a594..754e9722 100644 --- a/packages/core/src/backends/test/implementation.ts +++ b/packages/core/src/backends/test/implementation.ts @@ -207,8 +207,14 @@ export default class TestBackend implements BackendClass { async persistEntry(entry: BackendEntry) { entry.dataFiles.forEach(dataFile => { - const { path, raw } = dataFile; - writeFile(path, raw, window.repoFiles); + const { path, newPath, raw } = dataFile; + + if (newPath) { + deleteFile(path, window.repoFiles); + writeFile(newPath, raw, window.repoFiles); + } else { + writeFile(path, raw, window.repoFiles); + } }); entry.assets.forEach(a => { writeFile(a.path, a, window.repoFiles); diff --git a/packages/core/src/components/collections/CollectionHeader.tsx b/packages/core/src/components/collections/CollectionHeader.tsx index 44f88483..18a528ed 100644 --- a/packages/core/src/components/collections/CollectionHeader.tsx +++ b/packages/core/src/components/collections/CollectionHeader.tsx @@ -1,11 +1,17 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { translate } from 'react-polyglot'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; +import useEntries from '@staticcms/core/lib/hooks/useEntries'; import useIcon from '@staticcms/core/lib/hooks/useIcon'; +import { + selectEntryCollectionTitle, + selectFolderEntryExtension, +} from '@staticcms/core/lib/util/collection.util'; +import { addFileTemplateFields } from '@staticcms/core/lib/widgets/stringTemplate'; import Button from '../common/button/Button'; -import type { Collection, TranslatedProps } from '@staticcms/core/interface'; +import type { Collection, Entry, TranslatedProps } from '@staticcms/core/interface'; interface CollectionHeaderProps { collection: Collection; @@ -30,17 +36,54 @@ const CollectionHeader = ({ const icon = useIcon(collection.icon); + const params = useParams(); + const filterTerm = useMemo(() => params['*'], [params]); + const entries = useEntries(collection); + + const pluralLabel = useMemo(() => { + if ('nested' in collection && collection.nested?.path && filterTerm) { + const entriesByPath = entries.reduce((acc, entry) => { + acc[entry.path] = entry; + return acc; + }, {} as Record); + + const path = filterTerm.split('/'); + if (path.length > 0) { + const extension = selectFolderEntryExtension(collection); + + const finalPathPart = path[path.length - 1]; + + let entry = + entriesByPath[ + `${collection.folder}/${finalPathPart}/${collection.nested.path.index_file}.${extension}` + ]; + + if (entry) { + entry = { + ...entry, + data: addFileTemplateFields(entry.path, entry.data as Record), + }; + const title = selectEntryCollectionTitle(collection, entry); + + return title; + } + } + } + + return collectionLabel; + }, [collection, collectionLabel, entries, filterTerm]); + return ( <>

{icon}
- {collectionLabel} + {pluralLabel}

{newEntryUrl ? ( ) : null} diff --git a/packages/core/src/components/collections/CollectionPage.tsx b/packages/core/src/components/collections/CollectionPage.tsx new file mode 100644 index 00000000..e0c2233a --- /dev/null +++ b/packages/core/src/components/collections/CollectionPage.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; + +import useBreadcrumbs from '@staticcms/core/lib/hooks/useBreadcrumbs'; +import MainView from '../MainView'; +import CollectionView from './CollectionView'; + +import type { Collection } from '@staticcms/core/interface'; +import type { FC } from 'react'; + +interface CollectionViewProps { + collection: Collection; + isSearchResults?: boolean; + isSingleSearchResult?: boolean; +} + +const CollectionPage: FC = ({ + collection, + isSearchResults, + isSingleSearchResult, +}) => { + const { name, searchTerm, ...params } = useParams(); + const filterTerm = params['*']; + + const breadcrumbs = useBreadcrumbs(collection, filterTerm); + + return ( + + + + ); +}; + +export default CollectionPage; diff --git a/packages/core/src/components/collections/CollectionRoute.tsx b/packages/core/src/components/collections/CollectionRoute.tsx index 487002c7..0f00a10d 100644 --- a/packages/core/src/components/collections/CollectionRoute.tsx +++ b/packages/core/src/components/collections/CollectionRoute.tsx @@ -7,8 +7,9 @@ import { } from '@staticcms/core/reducers/selectors/collections'; import { useAppSelector } from '@staticcms/core/store/hooks'; import { getDefaultPath } from '../../lib/util/collection.util'; -import MainView from '../MainView'; -import Collection from './Collection'; +import CollectionPage from './CollectionPage'; + +import type { Collection } from '@staticcms/core/interface'; interface CollectionRouteProps { isSearchResults?: boolean; @@ -16,8 +17,7 @@ interface CollectionRouteProps { } const CollectionRoute = ({ isSearchResults, isSingleSearchResult }: CollectionRouteProps) => { - const { name, searchTerm, ...params } = useParams(); - const filterTerm = params['*']; + const { name, searchTerm } = useParams(); const collectionSelector = useMemo(() => selectCollection(name), [name]); const collection = useAppSelector(collectionSelector); @@ -34,15 +34,11 @@ const CollectionRoute = ({ isSearchResults, isSingleSearchResult }: CollectionRo } return ( - - - + ); }; diff --git a/packages/core/src/components/collections/Collection.tsx b/packages/core/src/components/collections/CollectionView.tsx similarity index 95% rename from packages/core/src/components/collections/Collection.tsx rename to packages/core/src/components/collections/CollectionView.tsx index c6574111..b90154f1 100644 --- a/packages/core/src/components/collections/Collection.tsx +++ b/packages/core/src/components/collections/CollectionView.tsx @@ -42,7 +42,6 @@ const CollectionView = ({ collection, collections, collectionName, - // TODO isSearchEnabled, isSearchResults, isSingleSearchResult, searchTerm, @@ -72,13 +71,8 @@ const CollectionView = ({ return undefined; } - let url = 'fields' in collection && collection.create ? getNewEntryUrl(collectionName) : ''; - if (url && filterTerm) { - url = `${url}?path=${filterTerm}`; - } - - return url; - }, [collection, collectionName, filterTerm]); + return 'fields' in collection && collection.create ? getNewEntryUrl(collectionName) : ''; + }, [collection, collectionName]); const searchResultKey = useMemo( () => `collection.collectionTop.searchResults${isSingleSearchResult ? 'InCollection' : ''}`, @@ -248,7 +242,6 @@ interface CollectionViewOwnProps { function mapStateToProps(state: RootState, ownProps: TranslatedProps) { const { collections } = state; - const isSearchEnabled = state.config.config && state.config.config.search != false; const { isSearchResults, isSingleSearchResult, @@ -275,7 +268,6 @@ function mapStateToProps(state: RootState, ownProps: TranslatedProps - onToggle({ node, expanded: !node.expanded })} - data-testid={node.path} - > - {/* TODO $activeClassName="sidebar-active" */} - {/* TODO $depth={depth} */} - -
-
{title}
- {hasChildren && (node.expanded ?
:
)} +
+ onToggle({ node, expanded: !node.expanded })} + data-testid={node.path} + icon={} + > +
+
{title}
+ {hasChildren && ( + + )} +
+
+
+ {node.expanded && ( + + )}
- - {node.expanded && ( - - )} +
); })} @@ -141,12 +159,12 @@ export function getTreeData(collection: Collection, entries: Entry[]): TreeNodeD isRoot: false, })), ...entriesObj.map((e, index) => { - let entryMap = entries[index]; - entryMap = { - ...entryMap, - data: addFileTemplateFields(entryMap.path, entryMap.data as Record), + let entry = entries[index]; + entry = { + ...entry, + data: addFileTemplateFields(entry.path, entry.data as Record), }; - const title = selectEntryCollectionTitle(collection, entryMap); + const title = selectEntryCollectionTitle(collection, entry); return { ...e, title, @@ -219,9 +237,11 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) => 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); + const [prevCollection, setPrevCollection] = useState(null); + const [prevEntries, setPrevEntries] = useState(null); + const [prevFilterTerm, setPrevFilterTerm] = useState(null); + + const { pathname } = useLocation(); useEffect(() => { if (collection !== prevCollection || entries !== prevEntries || filterTerm !== prevFilterTerm) { @@ -235,7 +255,12 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) => const path = `/${filterTerm}`; walk(newTreeData, node => { - if (expanded[node.path] || (useFilter && path.startsWith(node.path))) { + if ( + expanded[node.path] || + (useFilter && + path.startsWith(node.path) && + pathname.startsWith(`/collections/${collection.name}`)) + ) { node.expanded = true; } }); @@ -250,6 +275,7 @@ const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) => collection, entries, filterTerm, + pathname, prevCollection, prevEntries, prevFilterTerm, diff --git a/packages/core/src/components/common/image/Image.tsx b/packages/core/src/components/common/image/Image.tsx index 351303cd..98b2b911 100644 --- a/packages/core/src/components/common/image/Image.tsx +++ b/packages/core/src/components/common/image/Image.tsx @@ -5,25 +5,25 @@ import classNames from '@staticcms/core/lib/util/classNames.util'; import { selectEditingDraft } from '@staticcms/core/reducers/selectors/entryDraft'; import { useAppSelector } from '@staticcms/core/store/hooks'; -import type { Collection, MediaField } from '@staticcms/core/interface'; +import type { BaseField, Collection, MediaField, UnknownField } from '@staticcms/core/interface'; -export interface ImageProps { +export interface ImageProps { src?: string; alt?: string; className?: string; - collection?: Collection; - field?: F; + collection?: Collection; + field?: MediaField; 'data-testid'?: string; } -const Image = ({ +const Image = ({ src, alt, className, collection, field, 'data-testid': dataTestId, -}: ImageProps) => { +}: ImageProps) => { const entry = useAppSelector(selectEditingDraft); const assetSource = useMediaAsset(src, collection, field, entry); @@ -40,11 +40,11 @@ const Image = ({ ); }; -export const withMdxImage = ({ +export const withMdxImage = ({ collection, field, -}: Pick, 'collection' | 'field'>) => { - const MdxImage = (props: Omit, 'collection' | 'field'>) => ( +}: Pick, 'collection' | 'field'>) => { + const MdxImage = (props: Omit, 'collection' | 'field'>) => ( ); diff --git a/packages/core/src/components/common/widget/widgetFor.tsx b/packages/core/src/components/common/widget/widgetFor.tsx index 2b110058..47783de3 100644 --- a/packages/core/src/components/common/widget/widgetFor.tsx +++ b/packages/core/src/components/common/widget/widgetFor.tsx @@ -2,6 +2,7 @@ import React, { Fragment, isValidElement } from 'react'; import { resolveWidget } from '@staticcms/core/lib/registry'; import { selectField } from '@staticcms/core/lib/util/field.util'; +import { isNullish } from '@staticcms/core/lib/util/null.util'; import { getTypedFieldForValue } from '@staticcms/list/typedListHelpers'; import PreviewHOC from './PreviewHOC'; @@ -86,7 +87,7 @@ export default function getWidgetFor( let renderedValue: ValueOrNestedValue | ReactNode = value; if (inferredField) { - renderedValue = inferredField.defaultPreview(String(value)); + renderedValue = inferredField.defaultPreview(isNullish(value) ? '' : String(value)); } else if ( value && fieldWithWidgets.widget && @@ -103,7 +104,7 @@ export default function getWidgetFor( " > {field.label ?? field.name}: - {' '} + {value}
diff --git a/packages/core/src/components/entry-editor/EditorInterface.tsx b/packages/core/src/components/entry-editor/EditorInterface.tsx index fa083596..a482b16e 100644 --- a/packages/core/src/components/entry-editor/EditorInterface.tsx +++ b/packages/core/src/components/entry-editor/EditorInterface.tsx @@ -1,15 +1,17 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ScrollSyncPane } from 'react-scroll-sync'; +import useBreadcrumbs from '@staticcms/core/lib/hooks/useBreadcrumbs'; import { getI18nInfo, getPreviewEntry, hasI18n } from '@staticcms/core/lib/i18n'; import { getFileFromSlug, selectEntryCollectionTitle, } from '@staticcms/core/lib/util/collection.util'; +import { customPathFromSlug } from '@staticcms/core/lib/util/nested.util'; import MainView from '../MainView'; +import EditorToolbar from './EditorToolbar'; import EditorControlPane from './editor-control-pane/EditorControlPane'; import EditorPreviewPane from './editor-preview-pane/EditorPreviewPane'; -import EditorToolbar from './EditorToolbar'; import type { Collection, @@ -84,7 +86,7 @@ const EditorInterface = ({ displayUrl, isNewEntry, isModification, - draftKey, // TODO Review usage + draftKey, scrollSyncActive, t, loadScroll, @@ -232,22 +234,15 @@ const EditorInterface = ({ ); const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]); + const nestedFieldPath = useMemo( + () => customPathFromSlug(collection, entry.slug), + [collection, entry.slug], + ); + const breadcrumbs = useBreadcrumbs(collection, nestedFieldPath, { isNewEntry, summary, t }); return ( ) => { const dispatch = useAppDispatch(); @@ -103,7 +104,6 @@ const EditorControl = ({ } const validateValue = async () => { - console.log('VALIDATING', field.name); const errors = await validate(field, value, widget, t); dispatch(changeDraftFieldValidation(path, errors, i18n)); }; @@ -114,9 +114,9 @@ const EditorControl = ({ const handleChangeDraftField = useCallback( (value: ValueOrNestedValue) => { setDirty(true); - changeDraftField({ path, field, value, i18n }); + changeDraftField({ path, field, value, i18n, isMeta }); }, - [changeDraftField, field, i18n, path], + [changeDraftField, field, i18n, isMeta, path], ); const config = useMemo(() => configState.config, [configState.config]); @@ -232,6 +232,7 @@ interface EditorControlOwnProps { forSingleList?: boolean; i18n: I18nSettings | undefined; fieldName?: string; + isMeta?: boolean; } function mapStateToProps(state: RootState, ownProps: EditorControlOwnProps) { diff --git a/packages/core/src/components/entry-editor/editor-control-pane/EditorControlPane.tsx b/packages/core/src/components/entry-editor/editor-control-pane/EditorControlPane.tsx index e9762e7b..72dd52eb 100644 --- a/packages/core/src/components/entry-editor/editor-control-pane/EditorControlPane.tsx +++ b/packages/core/src/components/entry-editor/editor-control-pane/EditorControlPane.tsx @@ -3,6 +3,7 @@ import React, { useMemo } from 'react'; import { getI18nInfo, hasI18n, isFieldTranslatable } from '@staticcms/core/lib/i18n'; import classNames from '@staticcms/core/lib/util/classNames.util'; import { getFieldValue } from '@staticcms/core/lib/util/field.util'; +import { customPathFromSlug } from '@staticcms/core/lib/util/nested.util'; import EditorControl from './EditorControl'; import LocaleDropdown from './LocaleDropdown'; @@ -12,6 +13,7 @@ import type { Field, FieldsErrors, I18nSettings, + StringOrTextField, TranslatedProps, } from '@staticcms/core/interface'; @@ -39,6 +41,25 @@ const EditorControlPane = ({ onLocaleChange, t, }: TranslatedProps) => { + const nestedFieldPath = useMemo( + () => customPathFromSlug(collection, entry.slug), + [collection, entry.slug], + ); + + const pathField = useMemo( + () => + ({ + name: 'path', + label: + 'nested' in collection && collection.nested?.path?.label + ? collection.nested.path.label + : 'Path', + widget: 'string', + i18n: 'none', + } as StringOrTextField), + [collection], + ); + const i18n = useMemo(() => { if (hasI18n(collection)) { const { locales, defaultLocale } = getI18nInfo(collection); @@ -66,6 +87,7 @@ const EditorControlPane = ({ ` flex flex-col + min-h-full `, !hideBorder && ` @@ -91,6 +113,19 @@ const EditorControlPane = ({ />
) : null} + {'nested' in collection && collection.nested?.path ? ( + + ) : null} {fields.map(field => { const isTranslatable = isFieldTranslatable(field, locale, i18n?.defaultLocale); const key = i18n ? `field-${locale}_${field.name}` : `field-${field.name}`; diff --git a/packages/core/src/components/media-library/common/CurrentMediaDetails.tsx b/packages/core/src/components/media-library/common/CurrentMediaDetails.tsx index 22a6c2d6..833b73f7 100644 --- a/packages/core/src/components/media-library/common/CurrentMediaDetails.tsx +++ b/packages/core/src/components/media-library/common/CurrentMediaDetails.tsx @@ -8,7 +8,7 @@ import type { Collection, MediaField, MediaLibrarInsertOptions } from '@staticcm import type { FC } from 'react'; interface CurrentMediaDetailsProps { - collection?: Collection; + collection?: Collection; field?: MediaField; canInsert: boolean; url?: string | string[]; diff --git a/packages/core/src/components/media-library/common/MediaLibraryCard.tsx b/packages/core/src/components/media-library/common/MediaLibraryCard.tsx index 80d7b449..48455d19 100644 --- a/packages/core/src/components/media-library/common/MediaLibraryCard.tsx +++ b/packages/core/src/components/media-library/common/MediaLibraryCard.tsx @@ -13,14 +13,16 @@ import Pill from '../../common/pill/Pill'; import CopyToClipBoardButton from './CopyToClipBoardButton'; import type { + BaseField, Collection, - Field, + MediaField, MediaLibraryDisplayURL, TranslatedProps, + UnknownField, } from '@staticcms/core/interface'; import type { FC, KeyboardEvent } from 'react'; -interface MediaLibraryCardProps { +interface MediaLibraryCardProps { isSelected?: boolean; displayURL: MediaLibraryDisplayURL; text: string; @@ -28,14 +30,14 @@ interface MediaLibraryCardProps { type?: string; isViewableImage: boolean; isDraft?: boolean; - collection?: Collection; - field?: Field; + collection?: Collection; + field?: T; onSelect: () => void; loadDisplayURL: () => void; onDelete: () => void; } -const MediaLibraryCard: FC> = ({ +const MediaLibraryCard = ({ isSelected = false, displayURL, text, @@ -49,7 +51,7 @@ const MediaLibraryCard: FC> = ({ loadDisplayURL, onDelete, t, -}) => { +}: TranslatedProps>) => { const entry = useAppSelector(selectEditingDraft); const url = useMediaAsset(displayURL.url, collection, field, entry); @@ -258,4 +260,4 @@ const MediaLibraryCard: FC> = ({ ); }; -export default translate()(MediaLibraryCard) as FC; +export default translate()(MediaLibraryCard) as FC>; diff --git a/packages/core/src/components/navbar/NavLink.tsx b/packages/core/src/components/navbar/NavLink.tsx index 4a346c0e..44bea19c 100644 --- a/packages/core/src/components/navbar/NavLink.tsx +++ b/packages/core/src/components/navbar/NavLink.tsx @@ -27,10 +27,10 @@ const linkClassNames = 'btn btn-text-primary w-full justify-start'; const NavLink = ({ icon, children, onClick, ...otherProps }: NavLinkProps) => { const content = useMemo( () => ( - <> +
{icon} - {children} - + {children} +
), [children, icon], ); diff --git a/packages/core/src/components/navbar/Sidebar.tsx b/packages/core/src/components/navbar/Sidebar.tsx index 9e44b72a..d6965840 100644 --- a/packages/core/src/components/navbar/Sidebar.tsx +++ b/packages/core/src/components/navbar/Sidebar.tsx @@ -10,6 +10,7 @@ import { selectCollections } from '@staticcms/core/reducers/selectors/collection import { selectIsSearchEnabled } from '@staticcms/core/reducers/selectors/config'; import { useAppSelector } from '@staticcms/core/store/hooks'; import CollectionSearch from '../collections/CollectionSearch'; +import NestedCollection from '../collections/NestedCollection'; import NavLink from './NavLink'; import type { Collection } from '@staticcms/core/interface'; @@ -17,7 +18,9 @@ import type { FC } from 'react'; import type { TranslateProps } from 'react-polyglot'; const Sidebar: FC = ({ t }) => { - const { name, searchTerm } = useParams(); + const { name, searchTerm, ...params } = useParams(); + const filterTerm = useMemo(() => params['*'] ?? '', [params]); + const navigate = useNavigate(); const isSearchEnabled = useAppSelector(selectIsSearchEnabled); const collections = useAppSelector(selectCollections); @@ -35,18 +38,17 @@ const Sidebar: FC = ({ t }) => { const collectionName = collection.name; const icon = getIcon(collection.icon); - // TODO - // if ('nested' in collection) { - // return ( - //
  • - // - //
  • - // ); - // } + if ('nested' in collection) { + return ( +
  • + +
  • + ); + } return ( @@ -54,7 +56,7 @@ const Sidebar: FC = ({ t }) => { ); }), - [collections], + [collections, filterTerm], ); const additionalLinks = useMemo(() => getAdditionalLinks(), []); @@ -126,7 +128,7 @@ const Sidebar: FC = ({ t }) => { )} {collectionLinks} {links} - }> + }> {t('app.header.media')} diff --git a/packages/core/src/constants/configSchema.tsx b/packages/core/src/constants/configSchema.tsx index 0dd20990..d4700c37 100644 --- a/packages/core/src/constants/configSchema.tsx +++ b/packages/core/src/constants/configSchema.tsx @@ -292,6 +292,14 @@ function getConfigSchema() { properties: { depth: { type: 'number', minimum: 1, maximum: 1000 }, summary: { type: 'string' }, + path: { + type: 'object', + properties: { + label: { type: 'string' }, + index_file: { type: 'string' }, + }, + required: ['index_file'], + }, }, required: ['depth'], }, diff --git a/packages/core/src/formats/formats.ts b/packages/core/src/formats/formats.ts index 67292cef..cb728211 100644 --- a/packages/core/src/formats/formats.ts +++ b/packages/core/src/formats/formats.ts @@ -1,11 +1,11 @@ -import YamlFormatter from './YamlFormatter'; -import TomlFormatter from './TomlFormatter'; import JsonFormatter from './JsonFormatter'; +import TomlFormatter from './TomlFormatter'; +import YamlFormatter from './YamlFormatter'; import { FrontmatterInfer, frontmatterJSON, frontmatterTOML, frontmatterYAML } from './frontmatter'; -import type { Delimiter } from './frontmatter'; -import type { Collection, Entry, Format } from '../interface'; +import type { BaseField, Collection, Entry, Format } from '../interface'; import type FileFormatter from './FileFormatter'; +import type { Delimiter } from './frontmatter'; export const frontmatterFormats = ['yaml-frontmatter', 'toml-frontmatter', 'json-frontmatter']; @@ -45,7 +45,10 @@ function formatByName(name: Format, customDelimiter?: Delimiter): FileFormatter return fileFormatter[name]; } -export function resolveFormat(collection: Collection, entry: Entry): FileFormatter | undefined { +export function resolveFormat( + collection: Collection, + entry: Entry, +): FileFormatter | undefined { // Check for custom delimiter const frontmatter_delimiter = collection.frontmatter_delimiter; diff --git a/packages/core/src/interface.ts b/packages/core/src/interface.ts index ae700d2e..6bf54cb5 100644 --- a/packages/core/src/interface.ts +++ b/packages/core/src/interface.ts @@ -105,6 +105,9 @@ export interface Entry { data: EntryData; }; }; + meta?: { + path: string; + }; } export type Entities = Record; @@ -168,6 +171,10 @@ export interface CollectionFile { interface Nested { summary?: string; depth: number; + path?: { + label?: string; + index_file: string; + }; } export interface I18nSettings { @@ -385,6 +392,15 @@ export interface PersistOptions { status?: string; } +export interface PersistArgs { + config: Config; + collection: Collection; + entryDraft: EntryDraft; + assetProxies: AssetProxy[]; + usedSlugs: string[]; + status?: string; +} + export interface ImplementationEntry { data: string; file: { path: string; label?: string; id?: string | null; author?: string; updatedOn?: string }; @@ -621,7 +637,7 @@ export interface ListField extends BaseFiel max?: number; min?: number; add_to_top?: boolean; - types?: ObjectField[]; + types?: ObjectField[]; type_key?: string; } @@ -802,8 +818,8 @@ export interface BackendInitializerOptions { updateUserCredentials: (credentials: Credentials) => void; } -export interface BackendInitializer { - init: (config: Config, options: BackendInitializerOptions) => BackendClass; +export interface BackendInitializer { + init: (config: Config, options: BackendInitializerOptions) => BackendClass; } export interface EventData { diff --git a/packages/core/src/lib/formatters.ts b/packages/core/src/lib/formatters.ts index d34566a5..0a3de953 100644 --- a/packages/core/src/lib/formatters.ts +++ b/packages/core/src/lib/formatters.ts @@ -14,7 +14,7 @@ import { parseDateFromEntry, } from './widgets/stringTemplate'; -import type { Collection, Config, Entry, EntryData, Slug } from '../interface'; +import type { BaseField, Collection, Config, Entry, EntryData, Slug } from '../interface'; const commitMessageTemplates = { create: 'Create {{collection}} “{{slug}}”', @@ -26,18 +26,18 @@ const commitMessageTemplates = { const variableRegex = /\{\{([^}]+)\}\}/g; -type Options = { +type Options = { slug?: string; path?: string; - collection?: Collection; + collection?: Collection; authorLogin?: string; authorName?: string; }; -export function commitMessageFormatter( +export function commitMessageFormatter( type: keyof typeof commitMessageTemplates, - config: Config, - { slug, path, collection, authorLogin, authorName }: Options, + config: Config, + { slug, path, collection, authorLogin, authorName }: Options, ) { const templates = { ...commitMessageTemplates, ...(config.backend.commit_messages || {}) }; @@ -106,7 +106,11 @@ export function slugFormatter(collection: Collection, entryData: EntryData, slug } } -export function summaryFormatter(summaryTemplate: string, entry: Entry, collection: Collection) { +export function summaryFormatter( + summaryTemplate: string, + entry: Entry, + collection: Collection, +) { let entryData = entry.data; const date = parseDateFromEntry(entry, selectInferredField(collection, 'date')) || null; const identifier = get(entryData, keyToPathArray(selectIdentifier(collection))); @@ -125,10 +129,10 @@ export function summaryFormatter(summaryTemplate: string, entry: Entry, collecti return summary; } -export function folderFormatter( +export function folderFormatter( folderTemplate: string, entry: Entry | null | undefined, - collection: Collection, + collection: Collection, defaultFolder: string, folderKey: string, slugConfig?: Slug, diff --git a/packages/core/src/lib/hooks/useBreadcrumbs.ts b/packages/core/src/lib/hooks/useBreadcrumbs.ts new file mode 100644 index 00000000..ae0636c3 --- /dev/null +++ b/packages/core/src/lib/hooks/useBreadcrumbs.ts @@ -0,0 +1,91 @@ +import { useEffect, useMemo } from 'react'; + +import { loadEntries } from '@staticcms/core/actions/entries'; +import { useAppDispatch } from '@staticcms/core/store/hooks'; +import { selectEntryCollectionTitle, selectFolderEntryExtension } from '../util/collection.util'; +import { addFileTemplateFields } from '../widgets/stringTemplate'; +import useEntries from './useEntries'; + +import type { Breadcrumb, Collection, Entry } from '@staticcms/core/interface'; +import type { t } from 'react-polyglot'; + +interface EntryDetails { + isNewEntry: boolean; + summary: string; + t: t; +} + +export default function useBreadcrumbs( + collection: Collection, + filterTerm: string | undefined | null, + entryDetails?: EntryDetails, +) { + const entries = useEntries(collection); + const dispatch = useAppDispatch(); + + useEffect(() => { + if (!entries || entries.length === 0) { + dispatch(loadEntries(collection)); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return useMemo(() => { + const crumbs: Breadcrumb[] = [ + { + name: collection.label, + to: `/collections/${collection.name}`, + }, + ]; + + if ('nested' in collection && collection.nested?.path && filterTerm) { + const entriesByPath = entries.reduce((acc, entry) => { + acc[entry.path] = entry; + return acc; + }, {} as Record); + + const path = filterTerm.split('/'); + if (path.length > 0) { + const extension = selectFolderEntryExtension(collection); + + for (let i = 0; i < path.length; i++) { + const pathSoFar = path.slice(0, i + 1).join('/'); + + let entry = + entriesByPath[ + `${collection.folder}/${pathSoFar}/${collection.nested.path.index_file}.${extension}` + ]; + + let title = path[i]; + if (entry) { + entry = { + ...entry, + data: addFileTemplateFields(entry.path, entry.data as Record), + }; + title = selectEntryCollectionTitle(collection, entry); + } + + crumbs.push({ + name: title, + to: `/collections/${collection.name}/filter/${pathSoFar}`, + }); + } + + return crumbs; + } + } + + if (entryDetails) { + const { isNewEntry, summary, t } = entryDetails; + crumbs.push({ + name: isNewEntry + ? t('collection.collectionTop.newButton', { + collectionLabel: collection.label_singular || collection.label, + }) + : summary, + }); + } + + return crumbs; + }, [collection, entries, entryDetails, filterTerm]); +} diff --git a/packages/core/src/lib/hooks/useIsMediaAsset.ts b/packages/core/src/lib/hooks/useIsMediaAsset.ts index b4d0b59a..cb757c06 100644 --- a/packages/core/src/lib/hooks/useIsMediaAsset.ts +++ b/packages/core/src/lib/hooks/useIsMediaAsset.ts @@ -5,11 +5,17 @@ import { useAppDispatch } from '@staticcms/core/store/hooks'; import { isEmpty, isNotEmpty } from '../util/string.util'; import useDebounce from './useDebounce'; -import type { Collection, Entry, MediaField } from '@staticcms/core/interface'; +import type { + BaseField, + Collection, + Entry, + MediaField, + UnknownField, +} from '@staticcms/core/interface'; -export default function useIsMediaAsset( +export default function useIsMediaAsset( url: string, - collection: Collection, + collection: Collection, field: T, entry: Entry, ): boolean { @@ -23,7 +29,7 @@ export default function useIsMediaAsset( } const checkMediaExistence = async () => { - const asset = await dispatch(getAsset(collection, entry, debouncedUrl, field)); + const asset = await dispatch(getAsset(collection, entry, debouncedUrl, field)); setExists( Boolean(asset && asset !== emptyAsset && isNotEmpty(asset.toString()) && asset.fileObj), ); diff --git a/packages/core/src/lib/hooks/useMediaAsset.ts b/packages/core/src/lib/hooks/useMediaAsset.ts index 71e2f3ea..8d31c3bb 100644 --- a/packages/core/src/lib/hooks/useMediaAsset.ts +++ b/packages/core/src/lib/hooks/useMediaAsset.ts @@ -5,11 +5,17 @@ import { useAppDispatch } from '@staticcms/core/store/hooks'; import { isNotEmpty } from '../util/string.util'; import useDebounce from './useDebounce'; -import type { Collection, Entry, MediaField } from '@staticcms/core/interface'; +import type { + BaseField, + Collection, + Entry, + MediaField, + UnknownField, +} from '@staticcms/core/interface'; -export default function useMediaAsset( +export default function useMediaAsset( url: string | undefined | null, - collection?: Collection, + collection?: Collection, field?: T, entry?: Entry, ): string { @@ -28,7 +34,7 @@ export default function useMediaAsset( } const fetchMedia = async () => { - const asset = await dispatch(getAsset(collection, entry, debouncedUrl, field)); + const asset = await dispatch(getAsset(collection, entry, debouncedUrl, field)); if (asset !== emptyAsset) { setAssetSource(asset?.toString() ?? ''); } diff --git a/packages/core/src/lib/i18n.ts b/packages/core/src/lib/i18n.ts index 24eef87b..974c3c53 100644 --- a/packages/core/src/lib/i18n.ts +++ b/packages/core/src/lib/i18n.ts @@ -6,13 +6,14 @@ import { selectEntrySlug } from './util/collection.util'; import { set } from './util/object.util'; import type { - Field, + BaseField, Collection, Entry, EntryData, - i18nCollection, + Field, I18nInfo, I18nStructure, + i18nCollection, } from '../interface'; import type { EntryDraftState } from '../reducers/entryDraft'; @@ -26,20 +27,24 @@ export const I18N_FIELD_TRANSLATE = 'translate'; export const I18N_FIELD_DUPLICATE = 'duplicate'; export const I18N_FIELD_NONE = 'none'; -export function hasI18n(collection: Collection | i18nCollection): collection is i18nCollection { +export function hasI18n( + collection: Collection | i18nCollection, +): collection is i18nCollection { return I18N in collection; } -export function getI18nInfo(collection: i18nCollection): I18nInfo; -export function getI18nInfo(collection: Collection): I18nInfo | null; -export function getI18nInfo(collection: Collection | i18nCollection): I18nInfo | null { +export function getI18nInfo(collection: i18nCollection): I18nInfo; +export function getI18nInfo(collection: Collection): I18nInfo | null; +export function getI18nInfo( + collection: Collection | i18nCollection, +): I18nInfo | null { if (!hasI18n(collection) || typeof collection[I18N] !== 'object') { return null; } return collection.i18n; } -export function getI18nFilesDepth(collection: Collection, depth: number) { +export function getI18nFilesDepth(collection: Collection, depth: number) { const { structure } = getI18nInfo(collection) as I18nInfo; if (structure === I18N_STRUCTURE_MULTIPLE_FOLDERS) { return depth + 1; @@ -105,8 +110,8 @@ export function getLocaleFromPath(structure: I18nStructure, extension: string, p } } -export function getFilePaths( - collection: Collection, +export function getFilePaths( + collection: Collection, extension: string, path: string, slug: string, @@ -136,8 +141,8 @@ export function normalizeFilePath(structure: I18nStructure, path: string, locale } } -export function getI18nFiles( - collection: Collection, +export function getI18nFiles( + collection: Collection, extension: string, entryDraft: Entry, entryToRaw: (entryDraft: Entry) => string, @@ -232,8 +237,8 @@ export function formatI18nBackup( return i18n; } -function mergeValues( - collection: Collection, +function mergeValues( + collection: Collection, structure: I18nStructure, defaultLocale: string, values: { locale: string; value: Entry }[], @@ -281,8 +286,8 @@ function mergeSingleFileValue(entryValue: Entry, defaultLocale: string, locales: }; } -export async function getI18nEntry( - collection: Collection, +export async function getI18nEntry( + collection: Collection, extension: string, path: string, slug: string, @@ -317,7 +322,11 @@ export async function getI18nEntry( return entryValue; } -export function groupEntries(collection: Collection, extension: string, entries: Entry[]): Entry[] { +export function groupEntries( + collection: Collection, + extension: string, + entries: Entry[], +): Entry[] { const { structure = I18N_STRUCTURE_SINGLE_FILE, defaultLocale, diff --git a/packages/core/src/lib/registry.ts b/packages/core/src/lib/registry.ts index 63399f60..c7013aed 100644 --- a/packages/core/src/lib/registry.ts +++ b/packages/core/src/lib/registry.ts @@ -11,7 +11,6 @@ import type { Entry, EventData, EventListener, - Field, FieldPreviewComponent, LocalePhrasesRoot, MediaLibraryExternalLibrary, @@ -246,8 +245,10 @@ export function registerWidget( } } -export function getWidget(name: string): Widget { - return registry.widgets[name] as unknown as Widget; +export function getWidget( + name: string, +): Widget { + return registry.widgets[name] as unknown as Widget; } export function getWidgets(): ({ @@ -259,7 +260,9 @@ export function getWidgets(): ({ })); } -export function resolveWidget(name?: string): Widget { +export function resolveWidget( + name?: string, +): Widget { return getWidget(name || 'string') || getWidget('unknown'); } @@ -297,8 +300,10 @@ export function registerBackend< } } -export function getBackend(name: string): BackendInitializer { - return registry.backends[name]; +export function getBackend( + name: string, +): BackendInitializer { + return registry.backends[name] as unknown as BackendInitializer; } /** diff --git a/packages/core/src/lib/util/collection.util.ts b/packages/core/src/lib/util/collection.util.ts index 2e2c6281..574b1e90 100644 --- a/packages/core/src/lib/util/collection.util.ts +++ b/packages/core/src/lib/util/collection.util.ts @@ -16,6 +16,7 @@ import { selectMediaFolder } from './media.util'; import type { Backend } from '@staticcms/core/backend'; import type { + BaseField, Collection, Collections, Config, @@ -27,7 +28,7 @@ import type { SortableField, } from '@staticcms/core/interface'; -function fileForEntry(collection: FilesCollection, slug?: string) { +function fileForEntry(collection: FilesCollection, slug?: string) { const files = collection.files; if (!slug) { return files?.[0]; @@ -35,7 +36,7 @@ function fileForEntry(collection: FilesCollection, slug?: string) { return files && files.filter(f => f?.name === slug)?.[0]; } -export function selectFields(collection: Collection, slug?: string) { +export function selectFields(collection: Collection, slug?: string) { if ('fields' in collection) { return collection.fields; } @@ -44,14 +45,17 @@ export function selectFields(collection: Collection, slug?: string) { return file && file.fields; } -export function selectFolderEntryExtension(collection: Collection) { +export function selectFolderEntryExtension(collection: Collection) { return (collection.extension || formatExtensions[collection.format ?? 'frontmatter']).replace( /^\./, '', ); } -export function selectFileEntryLabel(collection: Collection, slug: string) { +export function selectFileEntryLabel( + collection: Collection, + slug: string, +) { if ('fields' in collection) { return undefined; } @@ -60,7 +64,7 @@ export function selectFileEntryLabel(collection: Collection, slug: string) { return file && file.label; } -export function selectEntryPath(collection: Collection, slug: string) { +export function selectEntryPath(collection: Collection, slug: string) { if ('fields' in collection) { const folder = collection.folder.replace(/\/$/, ''); return `${folder}/${slug}.${selectFolderEntryExtension(collection)}`; @@ -70,7 +74,7 @@ export function selectEntryPath(collection: Collection, slug: string) { return file && file.file; } -export function selectEntrySlug(collection: Collection, path: string) { +export function selectEntrySlug(collection: Collection, path: string) { if ('fields' in collection) { const folder = (collection.folder as string).replace(/\/$/, ''); const slug = path @@ -85,7 +89,7 @@ export function selectEntrySlug(collection: Collection, path: string) { return file && file.name; } -export function selectAllowNewEntries(collection: Collection) { +export function selectAllowNewEntries(collection: Collection) { if ('fields' in collection) { return collection.create ?? true; } @@ -93,7 +97,7 @@ export function selectAllowNewEntries(collection: Collection) { return false; } -export function selectAllowDeletion(collection: Collection) { +export function selectAllowDeletion(collection: Collection) { if ('fields' in collection) { return collection.delete ?? true; } @@ -101,7 +105,7 @@ export function selectAllowDeletion(collection: Collection) { return false; } -export function selectTemplateName(collection: Collection, slug: string) { +export function selectTemplateName(collection: Collection, slug: string) { if ('fields' in collection) { return collection.name; } @@ -109,7 +113,10 @@ export function selectTemplateName(collection: Collection, slug: string) { return slug; } -export function selectEntryCollectionTitle(collection: Collection, entry: Entry): string { +export function selectEntryCollectionTitle( + collection: Collection, + entry: Entry, +): string { // prefer formatted summary over everything else const summaryTemplate = collection.summary; if (summaryTemplate) { @@ -137,7 +144,10 @@ export function selectEntryCollectionTitle(collection: Collection, entry: Entry) return result; } -export function selectDefaultSortableFields(collection: Collection, backend: Backend) { +export function selectDefaultSortableFields( + collection: Collection, + backend: Backend, +) { let defaultSortable = SORTABLE_FIELDS.map((type: string) => { const field = selectInferredField(collection, type); if (backend.isGitBackend() && type === 'author' && !field) { @@ -181,16 +191,19 @@ export function selectSortableFields( return fields; } -export function selectViewFilters(collection?: Collection) { +export function selectViewFilters(collection?: Collection) { return collection?.view_filters; } -export function selectViewGroups(collection?: Collection) { +export function selectViewGroups(collection?: Collection) { return collection?.view_groups; } -export function selectFieldsComments(collection: Collection, entryMap: Entry) { - let fields: Field[] = []; +export function selectFieldsComments( + collection: Collection, + entryMap: Entry, +) { + let fields: Field[] = []; if ('folder' in collection) { fields = collection.fields; } else if ('files' in collection) { @@ -210,7 +223,7 @@ export function selectFieldsComments(collection: Collection, entryMap: Entry) { return comments; } -function getFieldsWithMediaFolders(fields: Field[]) { +function getFieldsWithMediaFolders(fields: Field[]) { const fieldsWithMediaFolders = fields.reduce((acc, f) => { if ('media_folder' in f) { acc = [...acc, f]; @@ -230,11 +243,17 @@ function getFieldsWithMediaFolders(fields: Field[]) { return fieldsWithMediaFolders; } -export function getFileFromSlug(collection: FilesCollection, slug: string) { +export function getFileFromSlug( + collection: FilesCollection, + slug: string, +) { return collection.files?.find(f => f.name === slug); } -export function selectFieldsWithMediaFolders(collection: Collection, slug: string) { +export function selectFieldsWithMediaFolders( + collection: Collection, + slug: string, +) { if ('folder' in collection) { const fields = collection.fields; return getFieldsWithMediaFolders(fields); @@ -246,7 +265,11 @@ export function selectFieldsWithMediaFolders(collection: Collection, slug: strin return []; } -export function selectMediaFolders(config: Config, collection: Collection, entry: Entry) { +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) { @@ -263,7 +286,7 @@ export function selectMediaFolders(config: Config, collection: Collection, entry return [...new Set(folders)]; } -export function getFieldsNames(fields: Field[] | undefined, prefix = '') { +export function getFieldsNames(fields: Field[] | undefined, prefix = '') { let names = fields?.map(f => `${prefix}${f.name}`) ?? []; fields?.forEach((f, index) => { @@ -333,7 +356,7 @@ export function updateFieldByKey( return collection; } -export function selectIdentifier(collection: Collection) { +export function selectIdentifier(collection: Collection) { const identifier = collection.identifier_field; const identifierFields = identifier ? [identifier, ...IDENTIFIER_FIELDS] : [...IDENTIFIER_FIELDS]; const fieldNames = getFieldsNames('fields' in collection ? collection.fields ?? [] : []); @@ -342,7 +365,10 @@ export function selectIdentifier(collection: Collection) { ); } -export function selectInferredField(collection: Collection, fieldName: string) { +export function selectInferredField( + collection: Collection, + fieldName: string, +) { if (fieldName === 'title' && collection.identifier_field) { return selectIdentifier(collection); } @@ -367,7 +393,7 @@ export function selectInferredField(collection: Collection, fieldName: string) { } // 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) + .filter((f: 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) { diff --git a/packages/core/src/lib/util/field.util.ts b/packages/core/src/lib/util/field.util.ts index cf1fcee9..d4c947c4 100644 --- a/packages/core/src/lib/util/field.util.ts +++ b/packages/core/src/lib/util/field.util.ts @@ -3,13 +3,19 @@ import get from 'lodash/get'; import { getLocaleDataPath } from '../i18n'; import { keyToPathArray } from '../widgets/stringTemplate'; -import type { Collection, Entry, Field, ValueOrNestedValue } from '@staticcms/core/interface'; +import type { + BaseField, + Collection, + Entry, + Field, + ValueOrNestedValue, +} from '@staticcms/core/interface'; import type { t } from 'react-polyglot'; -export function selectField(collection: Collection, key: string) { +export function selectField(collection: Collection, key: string) { const array = keyToPathArray(key); let name: string | undefined; - let field: Field | undefined; + let field: Field | undefined; if ('fields' in collection) { let fields = collection.fields ?? []; diff --git a/packages/core/src/lib/util/media.util.ts b/packages/core/src/lib/util/media.util.ts index e44c0a6c..63ea258c 100644 --- a/packages/core/src/lib/util/media.util.ts +++ b/packages/core/src/lib/util/media.util.ts @@ -1,26 +1,30 @@ -import { dirname, join } from 'path'; import trim from 'lodash/trim'; +import { dirname, join } from 'path'; +import { basename, isAbsolutePath } from '.'; import { folderFormatter } from '../formatters'; import { joinUrlPath } from '../urlHelper'; -import { basename, isAbsolutePath } from '.'; import type { - Config, - Field, + BaseField, Collection, CollectionFile, + Config, Entry, + Field, FileOrImageField, - MarkdownField, ListField, - ObjectField, + MarkdownField, MediaField, + ObjectField, } from '@staticcms/core/interface'; export const DRAFT_MEDIA_FILES = 'DRAFT_MEDIA_FILES'; -function getFileField(collectionFiles: CollectionFile[], slug: string | undefined) { +function getFileField( + collectionFiles: CollectionFile[], + slug: string | undefined, +) { const file = collectionFiles.find(f => f?.name === slug); return file; } @@ -32,9 +36,9 @@ function isMediaField( return Boolean(field && folderKey in field); } -function hasCustomFolder( +function hasCustomFolder( folderKey: 'media_folder' | 'public_folder', - collection: Collection | undefined | null, + collection: Collection | undefined | null, slug: string | undefined, field: MediaField | undefined, ): field is FileOrImageField | MarkdownField { @@ -62,10 +66,10 @@ function hasCustomFolder( return false; } -function evaluateFolder( +function evaluateFolder( folderKey: 'media_folder' | 'public_folder', - config: Config, - c: Collection, + config: Config, + c: Collection, entryMap: Entry | null | undefined, field: FileOrImageField | MarkdownField, ) { @@ -114,7 +118,7 @@ function evaluateFolder( collection, entryMap, field, - file.fields! as Field[], + file.fields, currentFolder, ); @@ -142,7 +146,7 @@ function evaluateFolder( collection, entryMap, field, - collection.fields! as Field[], + collection.fields, currentFolder, ); @@ -155,13 +159,13 @@ function evaluateFolder( return currentFolder; } -function traverseFields( +function traverseFields( folderKey: 'media_folder' | 'public_folder', - config: Config, - collection: Collection, + config: Config, + collection: Collection, entryMap: Entry | null | undefined, - field: FileOrImageField | MarkdownField | ListField | ObjectField, - fields: Field[], + field: FileOrImageField | MarkdownField | ListField | ObjectField, + fields: Field[], currentFolder: string, ): string | null { const matchedField = fields.filter(f => f === field)[0] as @@ -182,7 +186,7 @@ function traverseFields( } for (const f of fields) { - const childField: Field = { ...f }; + const childField: Field = { ...f }; if (isMediaField(folderKey, childField) && !childField[folderKey]) { // add identity template if doesn't exist childField[folderKey] = `{{${folderKey}}}`; @@ -225,9 +229,9 @@ function traverseFields( return null; } -export function selectMediaFolder( - config: Config, - collection: Collection | undefined | null, +export function selectMediaFolder( + config: Config, + collection: Collection | undefined | null, entryMap: Entry | null | undefined, field: MediaField | undefined, ) { @@ -250,12 +254,12 @@ export function selectMediaFolder( return trim(mediaFolder, '/'); } -export function selectMediaFilePublicPath( - config: Config, - collection: Collection | null, +export function selectMediaFilePublicPath( + config: Config, + collection: Collection | null, mediaPath: string, entryMap: Entry | undefined, - field: Field | undefined, + field: Field | undefined, ) { if (isAbsolutePath(mediaPath)) { return mediaPath; diff --git a/packages/core/src/lib/util/nested.util.ts b/packages/core/src/lib/util/nested.util.ts new file mode 100644 index 00000000..53657daa --- /dev/null +++ b/packages/core/src/lib/util/nested.util.ts @@ -0,0 +1,42 @@ +import trim from 'lodash/trim'; +import { basename, dirname, extname, join } from 'path'; + +import { selectFolderEntryExtension } from './collection.util'; + +import type { Collection, Entry } from '@staticcms/core/interface'; + +export function selectCustomPath(entry: Entry, collection: Collection): string | undefined { + if (!('nested' in collection) || !collection.nested?.path || !entry.meta) { + return undefined; + } + + const indexFile = collection.nested.path.index_file; + const extension = selectFolderEntryExtension(collection); + const customPath = join(collection.folder, entry.meta.path, `${indexFile}.${extension}`); + return customPath; +} + +export function customPathFromSlug(collection: Collection, slug: string): string { + if (!('nested' in collection) || !collection.nested) { + return ''; + } + + if (collection.nested.path) { + if ('nested' in collection && collection.nested?.path) { + return slug.replace(new RegExp(`/${collection.nested.path.index_file}$`, 'g'), ''); + } + } + + return slug; +} + +export function slugFromCustomPath(collection: Collection, customPath: string): string { + if (!('folder' in collection)) { + return ''; + } + + const folderPath = collection.folder; + const entryPath = customPath.toLowerCase().replace(folderPath.toLowerCase(), ''); + const slug = join(dirname(trim(entryPath, '/')), basename(entryPath, extname(customPath))); + return slug; +} diff --git a/packages/core/src/reducers/__tests__/entryDraft.spec.ts b/packages/core/src/reducers/__tests__/entryDraft.spec.ts index 39188975..5981bee4 100644 --- a/packages/core/src/reducers/__tests__/entryDraft.spec.ts +++ b/packages/core/src/reducers/__tests__/entryDraft.spec.ts @@ -28,6 +28,7 @@ describe('entryDraft', () => { }, value: 'newValue', i18n: undefined, + isMeta: false, }, }); @@ -38,6 +39,28 @@ describe('entryDraft', () => { }); }); + it('should update meta path with value', () => { + const state = entryDraftReducer(startState, { + type: DRAFT_CHANGE_FIELD, + payload: { + path: 'path1.path2', + field: { + widget: 'string', + name: 'stringInput', + }, + value: 'newValue', + i18n: undefined, + isMeta: true, + }, + }); + + expect(state.entry?.meta).toEqual({ + path1: { + path2: 'newValue', + }, + }); + }); + it('should update path with value for singleton list', () => { let state = entryDraftReducer(startState, { type: DRAFT_CHANGE_FIELD, @@ -49,6 +72,7 @@ describe('entryDraft', () => { }, value: ['newValue1', 'newValue2', 'newValue3'], i18n: undefined, + isMeta: false, }, }); @@ -66,6 +90,7 @@ describe('entryDraft', () => { }, value: 'newValue2Updated', i18n: undefined, + isMeta: false, }, }); @@ -91,6 +116,7 @@ describe('entryDraft', () => { defaultLocale: 'en', currentLocale: 'en', }, + isMeta: false, }, }); @@ -138,6 +164,7 @@ describe('entryDraft', () => { field, value: ['newValue1', 'newValue2', 'newValue3'], i18n, + isMeta: false, }, }); @@ -165,6 +192,7 @@ describe('entryDraft', () => { field, value: 'newValue2Updated', i18n, + isMeta: false, }, }); diff --git a/packages/core/src/reducers/collections.ts b/packages/core/src/reducers/collections.ts index caf57eef..a6c017a8 100644 --- a/packages/core/src/reducers/collections.ts +++ b/packages/core/src/reducers/collections.ts @@ -1,9 +1,9 @@ import { CONFIG_SUCCESS } from '../constants'; import type { ConfigAction } from '../actions/config'; -import type { Collection, Collections } from '../interface'; +import type { BaseField, Collection, Collections, UnknownField } from '../interface'; -export type CollectionsState = Collections; +export type CollectionsState = Collections; const defaultState: CollectionsState = {}; diff --git a/packages/core/src/reducers/config.ts b/packages/core/src/reducers/config.ts index 6c38be68..20d4a5d9 100644 --- a/packages/core/src/reducers/config.ts +++ b/packages/core/src/reducers/config.ts @@ -1,10 +1,10 @@ import { CONFIG_FAILURE, CONFIG_REQUEST, CONFIG_SUCCESS } from '../constants'; import type { ConfigAction } from '../actions/config'; -import type { Config } from '../interface'; +import type { BaseField, Config, UnknownField } from '../interface'; -export interface ConfigState { - config?: Config; +export interface ConfigState { + config?: Config; isFetching: boolean; error?: string; } diff --git a/packages/core/src/reducers/entryDraft.ts b/packages/core/src/reducers/entryDraft.ts index 76b5e443..bb5008a2 100644 --- a/packages/core/src/reducers/entryDraft.ts +++ b/packages/core/src/reducers/entryDraft.ts @@ -1,4 +1,3 @@ -import get from 'lodash/get'; import isEqual from 'lodash/isEqual'; import { v4 as uuid } from 'uuid'; @@ -154,8 +153,10 @@ function entryDraftReducer( return state; } - const { path, field, value, i18n } = action.payload; - const dataPath = (i18n && getDataPath(i18n.currentLocale, i18n.defaultLocale)) || ['data']; + const { path, field, value, i18n, isMeta } = action.payload; + const dataPath = isMeta + ? ['meta'] + : (i18n && getDataPath(i18n.currentLocale, i18n.defaultLocale)) || ['data']; newState = { ...newState, @@ -166,11 +167,20 @@ function entryDraftReducer( newState = duplicateI18nFields(newState, field, i18n.locales, i18n.defaultLocale, path); } - const newData = get(newState.entry, dataPath) ?? {}; + let hasChanged = + !isEqual(newState.entry?.meta, newState.original?.meta) || + !isEqual(newState.entry?.data, newState.original?.data); + + const i18nData = newState.entry?.i18n ?? {}; + for (const locale in i18nData) { + hasChanged = + hasChanged || + !isEqual(newState.entry?.i18n?.[locale]?.data, newState.original?.i18n?.[locale]?.data); + } return { ...newState, - hasChanged: !newState.original || !isEqual(newData, get(newState.original, dataPath)), + hasChanged: !newState.original || hasChanged, }; } diff --git a/packages/core/src/reducers/selectors/collections.ts b/packages/core/src/reducers/selectors/collections.ts index d23debe0..8c01c1ae 100644 --- a/packages/core/src/reducers/selectors/collections.ts +++ b/packages/core/src/reducers/selectors/collections.ts @@ -1,10 +1,13 @@ /* eslint-disable import/prefer-default-export */ +import type { BaseField, UnknownField } from '@staticcms/core/interface'; import type { RootState } from '@staticcms/core/store'; -export const selectCollections = (state: RootState) => { +export const selectCollections = (state: RootState) => { return state.collections; }; -export const selectCollection = (collectionName: string | undefined) => (state: RootState) => { - return Object.values(state.collections).find(collection => collection.name === collectionName); -}; +export const selectCollection = + (collectionName: string | undefined) => + (state: RootState) => { + return Object.values(state.collections).find(collection => collection.name === collectionName); + }; diff --git a/packages/core/src/store/index.ts b/packages/core/src/store/index.ts index becbc84c..0ce9db5d 100644 --- a/packages/core/src/store/index.ts +++ b/packages/core/src/store/index.ts @@ -3,6 +3,10 @@ import { configureStore } from '@reduxjs/toolkit'; import createRootReducer from '../reducers/combinedReducer'; import { waitUntilAction } from './middleware/waitUntilAction'; +import type { BaseField, UnknownField } from '../interface'; +import type { CollectionsState } from '../reducers/collections'; +import type { ConfigState } from '../reducers/config'; + const store = configureStore({ reducer: createRootReducer(), middleware: getDefaultMiddleware => @@ -13,6 +17,12 @@ const store = configureStore({ }); export { store }; -export type RootState = ReturnType; +export type RootState = Omit< + ReturnType, + 'collection' | 'config' +> & { + collection: CollectionsState; + config: ConfigState; +}; export type AppStore = typeof store; export type AppDispatch = typeof store.dispatch; diff --git a/packages/core/src/widgets/file/withFileControl.tsx b/packages/core/src/widgets/file/withFileControl.tsx index 8e7cbb80..7cba361f 100644 --- a/packages/core/src/widgets/file/withFileControl.tsx +++ b/packages/core/src/widgets/file/withFileControl.tsx @@ -10,7 +10,13 @@ import { basename } from '@staticcms/core/lib/util'; import { isEmpty } from '@staticcms/core/lib/util/string.util'; import SortableImage from './components/SortableImage'; -import type { FileOrImageField, MediaPath, WidgetControlProps } from '@staticcms/core/interface'; +import type { + BaseField, + Collection, + FileOrImageField, + MediaPath, + WidgetControlProps, +} from '@staticcms/core/interface'; import type { FC, MouseEvent } from 'react'; const MAX_DISPLAY_LENGTH = 50; @@ -143,7 +149,7 @@ const withFileControl = ({ forImage = false }: WithFileControlProps = {}) => { replaceIndex: index, allowMultiple: false, config, - collection, + collection: collection as Collection, field, }); }, diff --git a/packages/core/webpack.config.js b/packages/core/webpack.config.js index fa425f8a..d732a1b8 100644 --- a/packages/core/webpack.config.js +++ b/packages/core/webpack.config.js @@ -54,7 +54,10 @@ module.exports = { }, { test: /\.css$/, - include: [...['ol', 'codemirror', '@toast-ui'].map(moduleNameToPath), path.resolve(__dirname, 'src')], + include: [ + ...['ol', 'codemirror', '@toast-ui'].map(moduleNameToPath), + path.resolve(__dirname, 'src'), + ], use: [ !isProduction ? 'style-loader' : MiniCssExtractPlugin.loader, 'css-loader', @@ -112,6 +115,7 @@ module.exports = { devServer: { static: { directory: './dev-test', + watch: false, }, host: '0.0.0.0', port: devServerPort, diff --git a/packages/demo/public/config.yml b/packages/demo/public/config.yml index 49f35545..4ba5bec8 100644 --- a/packages/demo/public/config.yml +++ b/packages/demo/public/config.yml @@ -1249,3 +1249,22 @@ collections: label: Date widget: datetime i18n: duplicate + - name: pages + label: Nested Pages + label_singular: 'Page' + folder: _nested_pages + create: true + # adding a nested object will show the collection folder structure + nested: + depth: 100 # max depth to show in the collection tree + summary: '{{title}}' # optional summary for a tree node, defaults to the inferred title field + # adding a path object allows editing the path of entries + # moving an existing entry will move the entire sub tree of the entry to the new location + path: { label: 'Path', index_file: 'index' } + fields: + - label: Title + name: title + widget: string + - label: Body + name: body + widget: markdown diff --git a/packages/demo/public/index.html b/packages/demo/public/index.html index 42ed650d..454faf07 100644 --- a/packages/demo/public/index.html +++ b/packages/demo/public/index.html @@ -134,6 +134,32 @@ "---\ndescription: Le café est un petit arbre ou un arbuste qui pousse dans le sous-étage de la forêt sous sa forme sauvage et qui était traditionnellement cultivé commercialement sous d'autres arbres qui fournissaient de l'ombre. La structure forestière des plantations de café d'ombre fournit un habitat à un grand nombre d'espèces migratrices et résidentes.\ndate: 2015-02-14T00:00:00.000Z\n---\n", }, }, + _nested_pages: { + authors: { + 'author-1': { + 'index.md': { + content: '---\ntitle: An Author\n---\nAuthor details go here!.\n', + }, + }, + 'index.md': { + content: '---\ntitle: Authors\n---\n', + }, + }, + posts: { + 'hello-world': { + 'index.md': { + content: + '---\ntitle: Hello World\n---\nCoffee is a small tree or shrub that grows in the forest understory in its wild form, and traditionally was grown commercially under other trees that provided shade. The forest-like structure of shade coffee farms provides habitat for a great number of migratory and resident species.\n', + }, + }, + 'index.md': { + content: '---\ntitle: Posts\n---\n', + }, + }, + 'index.md': { + content: '---\ntitle: Pages\n---\n', + }, + }, }; var ONE_DAY = 60 * 60 * 24 * 1000; diff --git a/packages/docs/content/docs/collection-types.mdx b/packages/docs/content/docs/collection-types.mdx index b043709d..2e5b1e60 100644 --- a/packages/docs/content/docs/collection-types.mdx +++ b/packages/docs/content/docs/collection-types.mdx @@ -311,6 +311,9 @@ collections: nested: depth: 100 # max depth to show in the collection tree summary: '{{title}}' # optional summary for a tree node, defaults to the inferred title field + # adding a path object allows editing the path of entries + # moving an existing entry will move the entire sub tree of the entry to the new location + path: { widget: string, index_file: 'index' } fields: - label: Title name: title @@ -318,9 +321,6 @@ collections: - label: Body name: body widget: markdown - # adding a meta object with a path property allows editing the path of entries - # moving an existing entry will move the entire sub tree of the entry to the new location - meta: { path: { widget: string, label: 'Path', index_file: 'index' } } ``` ```js @@ -334,7 +334,11 @@ collections: "create": true, "nested": { "depth": 100, - "summary": "{{title}}" + "summary": "{{title}}", + "path": { + "label": "Path", + "index_file": "index" + } }, "fields": [ { @@ -347,14 +351,7 @@ collections: "name": "body", "widget": "markdown" } - ], - "meta": { - "path": { - "widget": "string", - "label": "Path", - "index_file": "index" - } - } + ] } ] }