feat: v4.0.0 (#1016)

Co-authored-by: Denys Konovalov <kontakt@denyskon.de>
Co-authored-by: Mathieu COSYNS <64072917+Mathieu-COSYNS@users.noreply.github.com>
This commit is contained in:
Daniel Lautzenheiser
2024-01-03 15:14:09 -05:00
committed by GitHub
parent 682576ffc4
commit 799c7e6936
732 changed files with 48477 additions and 10886 deletions

View File

@ -34,9 +34,6 @@ module.exports = {
},
],
'no-duplicate-imports': 'error',
'@emotion/no-vanilla': 'off',
'@emotion/import-from-emotion': 'error',
'@emotion/styled-import': 'error',
'require-atomic-updates': [0],
'object-shorthand': ['error', 'always'],
'prefer-const': [
@ -57,7 +54,7 @@ module.exports = {
],
'import/prefer-default-export': 'error',
},
plugins: ['babel', '@emotion', 'cypress', 'unicorn', 'react-hooks'],
plugins: ['babel', 'cypress', 'unicorn', 'react-hooks'],
settings: {
react: {
version: 'detect',
@ -97,7 +94,6 @@ module.exports = {
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/explicit-function-return-type': [0],
'@typescript-eslint/explicit-module-boundary-types': [0],
'@typescript-eslint/no-duplicate-imports': 'error',
'@typescript-eslint/no-use-before-define': [
'error',
{ functions: false, classes: true, variables: true },

View File

@ -42,12 +42,6 @@ function presets() {
return [
'@babel/preset-react',
'@babel/preset-env',
[
'@emotion/babel-preset-css-prop',
{
autoLabel: 'always',
},
],
'@babel/typescript',
];
}

View File

@ -1,6 +1,6 @@
{
"name": "@staticcms/app",
"version": "3.4.8",
"version": "4.0.0",
"license": "MIT",
"description": "Static CMS application.",
"repository": "https://github.com/StaticJsCMS/static-cms",
@ -37,10 +37,9 @@
"last 2 Safari versions"
],
"dependencies": {
"@babel/eslint-parser": "7.21.3",
"@babel/runtime": "7.21.0",
"@emotion/babel-preset-css-prop": "11.10.0",
"@staticcms/core": "^3.4.8",
"@babel/eslint-parser": "7.22.15",
"@babel/runtime": "7.23.1",
"@staticcms/core": "^4.0.0",
"buffer": "6.0.3",
"react": "18.2.0",
"react-dom": "18.2.0",
@ -48,59 +47,57 @@
"ts-loader": "9.4.2"
},
"devDependencies": {
"@babel/cli": "7.21.0",
"@babel/core": "7.21.4",
"@babel/cli": "7.23.0",
"@babel/core": "7.23.0",
"@babel/plugin-proposal-class-properties": "7.18.6",
"@babel/plugin-proposal-export-default-from": "7.18.10",
"@babel/plugin-proposal-export-default-from": "7.22.17",
"@babel/plugin-proposal-nullish-coalescing-operator": "7.18.6",
"@babel/plugin-proposal-numeric-separator": "7.18.6",
"@babel/plugin-proposal-object-rest-spread": "7.20.7",
"@babel/plugin-proposal-optional-chaining": "7.21.0",
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/preset-env": "7.21.4",
"@babel/preset-react": "7.18.6",
"@babel/preset-typescript": "7.21.4",
"@emotion/eslint-plugin": "11.10.0",
"@emotion/jest": "11.10.5",
"@types/node": "18.16.14",
"@types/react": "18.2.0",
"@types/react-dom": "18.2.1",
"@typescript-eslint/eslint-plugin": "5.59.1",
"@typescript-eslint/parser": "5.59.1",
"autoprefixer": "10.4.14",
"@babel/preset-env": "7.22.20",
"@babel/preset-react": "7.22.15",
"@babel/preset-typescript": "7.23.0",
"@types/node": "18.17.19",
"@types/react": "18.2.25",
"@types/react-dom": "18.2.10",
"@typescript-eslint/eslint-plugin": "6.7.4",
"@typescript-eslint/parser": "6.7.4",
"autoprefixer": "10.4.16",
"babel-core": "7.0.0-bridge.0",
"babel-loader": "9.1.2",
"babel-plugin-emotion": "11.0.0",
"babel-loader": "9.1.3",
"babel-plugin-inline-json-import": "0.3.2",
"babel-plugin-inline-react-svg": "2.0.2",
"babel-plugin-lodash": "3.3.4",
"babel-plugin-transform-builtin-extend": "1.1.2",
"babel-plugin-transform-define": "2.1.0",
"babel-plugin-transform-define": "2.1.4",
"babel-plugin-transform-export-extensions": "6.22.0",
"babel-plugin-transform-inline-environment-variables": "0.4.4",
"cross-env": "7.0.3",
"css-loader": "6.7.3",
"dotenv": "16.0.3",
"eslint": "8.39.0",
"eslint-import-resolver-typescript": "3.5.5",
"eslint-plugin-cypress": "2.13.3",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.32.2",
"css-loader": "6.8.1",
"dotenv": "16.3.1",
"eslint": "8.50.0",
"eslint-import-resolver-typescript": "3.6.1",
"eslint-plugin-cypress": "2.15.1",
"eslint-plugin-import": "2.28.1",
"eslint-plugin-prettier": "5.0.0",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-unicorn": "46.0.1",
"mini-css-extract-plugin": "2.7.5",
"eslint-plugin-unicorn": "48.0.1",
"mini-css-extract-plugin": "2.7.6",
"npm-run-all": "4.1.5",
"postcss": "8.4.23",
"postcss-scss": "4.0.6",
"prettier": "2.8.8",
"postcss": "8.4.31",
"postcss-loader": "7.3.3",
"postcss-scss": "4.0.9",
"prettier": "3.0.3",
"source-map-loader": "4.0.1",
"style-loader": "3.3.2",
"style-loader": "3.3.3",
"tailwindcss": "3.3.3",
"to-string-loader": "1.2.0",
"tsconfig-paths-webpack-plugin": "4.0.1",
"typescript": "5.0.4",
"webpack": "5.80.0",
"webpack-cli": "5.0.2"
"typescript": "5.2.2",
"webpack": "5.88.2",
"webpack-cli": "5.1.4"
},
"publishConfig": {
"access": "public",

View File

@ -52,7 +52,7 @@
"@staticcms/core": ["../core/src"],
"@staticcms/core/*": ["../core/src/*"]
},
"types": ["@emotion/react/types/css-prop", "@types/jest", "@testing-library/jest-dom"]
"types": ["@types/jest", "@testing-library/jest-dom"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.spec.ts"]

View File

@ -86,7 +86,6 @@ module.exports = {
!isProduction && new ReactRefreshWebpackPlugin(),
isProduction && new MiniCssExtractPlugin(),
new webpack.IgnorePlugin({ resourceRegExp: /^esprima$/ }),
new webpack.IgnorePlugin({ resourceRegExp: /moment\/locale\// }),
new webpack.ProvidePlugin({
process: 'process/browser',
Buffer: ['buffer', 'Buffer'],

View File

@ -34,9 +34,6 @@ module.exports = {
},
],
'no-duplicate-imports': 'error',
'@emotion/no-vanilla': 'off',
'@emotion/import-from-emotion': 'error',
'@emotion/styled-import': 'error',
'require-atomic-updates': [0],
'object-shorthand': ['error', 'always'],
'prefer-const': [
@ -57,7 +54,7 @@ module.exports = {
],
'import/prefer-default-export': 'error',
},
plugins: ['babel', '@emotion', 'cypress', 'unicorn', 'react-hooks'],
plugins: ['babel', 'cypress', 'unicorn', 'react-hooks'],
settings: {
react: {
version: 'detect',
@ -99,7 +96,6 @@ module.exports = {
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/explicit-function-return-type': [0],
'@typescript-eslint/explicit-module-boundary-types': [0],
'@typescript-eslint/no-duplicate-imports': 'error',
'@typescript-eslint/no-use-before-define': [
'error',
{ functions: false, classes: true, variables: true },

View File

@ -42,12 +42,6 @@ function presets() {
return [
'@babel/preset-react',
'@babel/preset-env',
[
'@emotion/babel-preset-css-prop',
{
autoLabel: 'always',
},
],
'@babel/typescript',
];
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -24,27 +24,6 @@ const PostDateFieldPreview = ({ value }) => {
);
};
const PostDraftFieldPreview = ({ value }) => {
return h(
'div',
{
style: {
backgroundColor: value === true ? 'rgb(37 99 235)' : 'rgb(22 163 74)',
color: 'white',
border: 'none',
padding: '2px 6px',
textAlign: 'center',
textDecoration: 'none',
display: 'inline-block',
cursor: 'pointer',
borderRadius: '4px',
fontSize: '14px',
},
},
value === true ? 'Draft' : 'Published',
);
};
const GeneralPreview = ({ widgetsFor, entry, collection }) => {
const title = entry.data.site_title;
const posts = entry.data.posts;
@ -88,38 +67,16 @@ const AuthorsPreview = ({ widgetsFor }) => {
);
};
const RelationKitchenSinkPostPreview = ({ fieldsMetaData }) => {
// When a post is selected from the relation field, all of it's data
// will be available in the field's metadata nested under the collection
// name, and then further nested under the value specified in `value_field`.
// In this case, the post would be nested under "posts" and then under
// the title of the selected post, since our `value_field` in the config
// is "title".
const post = fieldsMetaData && fieldsMetaData.posts.value;
const style = { border: '2px solid #ccc', borderRadius: '8px', padding: '20px' };
return post
? h(
'div',
{ style: style },
h('h2', {}, 'Related Post'),
h('h3', {}, post.title),
h('img', { src: post.image }),
h('p', {}, (post.body ?? '').slice(0, 100) + '...'),
)
: null;
};
const CustomPage = () => {
return h('div', {}, 'I am a custom page!');
};
CMS.registerPreviewTemplate('posts', PostPreview);
CMS.registerFieldPreview('posts', 'date', PostDateFieldPreview);
CMS.registerFieldPreview('posts', 'draft', PostDraftFieldPreview);
CMS.registerPreviewTemplate('general', GeneralPreview);
CMS.registerPreviewTemplate('authors', AuthorsPreview);
// Pass the name of a registered control to reuse with a new widget preview.
CMS.registerWidget('relationKitchenSinkPost', 'relation', RelationKitchenSinkPostPreview);
CMS.registerWidget('relationKitchenSinkPost', 'relation');
CMS.registerAdditionalLink({
id: 'example',
title: 'Example.com',
@ -152,7 +109,9 @@ CMS.registerShortcode('youtube', {
toArgs: ({ src }) => {
return [src];
},
control: ({ src, onChange, theme }) => {
control: ({ src, onChange }) => {
const theme = useTheme();
return h('span', {}, [
h('input', {
key: 'control-input',
@ -162,8 +121,8 @@ CMS.registerShortcode('youtube', {
},
style: {
width: '100%',
backgroundColor: theme === 'dark' ? 'rgb(30, 41, 59)' : 'white',
color: theme === 'dark' ? 'white' : 'black',
backgroundColor: theme.common.gray,
color: theme.text.primary,
padding: '4px 8px',
},
}),

View File

@ -1,6 +1,5 @@
---
title: Something something something2...
draft: false
date: 2022-11-01 06:30
image: static-cms-icon.svg
---

View File

@ -1,6 +1,5 @@
---
title: Test
draft: false
date: 2022-11-01 14:28
image: kittens.jpg
---

View File

@ -1,6 +1,5 @@
---
title: Test3
draft: false
date: 2022-11-02 08:43
image: ori_3587884_d966kldqzc6mvdeq67hyk16rnbe3gb1k8eeoy31s_shark-icon.jpg
---

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Static CMS Development Test</title>
</head>
<body>
<script src="/static-cms-core.js"></script>
<script src="/data.js"></script>
<script type="module" src="/index.js"></script>
</body>
</html>

View File

@ -1,8 +1,7 @@
backend:
name: test-repo
site_url: 'https://example.com'
media_folder: assets/uploads
public_folder: /assets/uploads
media_folder: /assets/uploads
media_library:
folder_support: true
locale: en
@ -18,7 +17,7 @@ i18n:
# Optional, defaults to the first item in locales.
# The locale to be used for fields validation and as a baseline for the entry.
defaultLocale: en
default_locale: en
collections:
- name: posts
label: Posts
@ -31,7 +30,6 @@ collections:
summary_fields:
- title
- date
- draft
sortable_fields:
fields:
- title
@ -40,28 +38,35 @@ collections:
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
filters:
- name: posts-with-index
label: Posts With Index
field: title
pattern: 'This is post #'
- name: posts-without-index
label: Posts Without Index
field: title
pattern: front matter post
- name: draft
label: Drafts
field: draft
pattern: true
view_groups:
- label: Year
field: date
pattern: '\d{4}'
- label: Drafts
field: draft
groups:
- name: by-year
label: Year
field: date
pattern: '\d{4}'
- name: draft
label: Drafts
field: draft
fields:
- label: Title
name: title
widget: string
- label: Draft
name: draft
widget: boolean
- label: 'Draft'
name: 'draft'
widget: 'boolean'
default: false
- label: Publish Date
name: date
@ -73,10 +78,19 @@ collections:
name: image
widget: image
required: false
- label: Description
name: description
widget: text
- label: Category
name: category
widget: string
- label: Body
name: body
widget: markdown
hint: "*Main* __content__ __*goes*__ [here](https://example.com/)."
hint: '*Main* __content__ __*goes*__ [here](https://example.com/).'
- label: Tags
name: tags
widget: list
- name: faq
label: FAQ
folder: _faqs
@ -150,6 +164,19 @@ collections:
widget: boolean
pattern: ['true', 'Must be true']
required: false
- name: prefix
label: With Prefix
widget: boolean
prefix: "I'm a prefix"
- name: suffix
label: With Suffix
widget: boolean
suffix: "I'm a suffix"
- name: prefix_and_suffix
label: With Prefix and Suffix
widget: boolean
prefix: "I'm a prefix"
suffix: "I'm a suffix"
- name: code
label: Code
file: _widgets/code.json
@ -571,6 +598,7 @@ collections:
- label: Type 2 Object
name: type_2_object
widget: object
summary: "{{datetime | date('yyyy-MM-dd')}}"
fields:
- label: Number
name: number
@ -778,6 +806,19 @@ collections:
widget: number
pattern: ['[0-9]{3,}', 'Must be at least 3 digits']
required: false
- name: prefix
label: With Prefix
widget: number
prefix: '$'
- name: suffix
label: With Suffix
widget: number
suffix: '%'
- name: prefix_and_suffix
label: With Prefix and Suffix
widget: number
prefix: '$'
suffix: '%'
- name: object
label: Object
file: _widgets/object.json
@ -1055,6 +1096,19 @@ collections:
widget: string
pattern: ['.{12,}', 'Must have at least 12 characters']
required: false
- name: prefix
label: With Prefix
widget: string
prefix: '$'
- name: suffix
label: With Suffix
widget: string
suffix: '%'
- name: prefix_and_suffix
label: With Prefix and Suffix
widget: string
prefix: '$'
suffix: '%'
- name: text
label: Text
file: _widgets/text.json
@ -1099,11 +1153,6 @@ collections:
file: _data/settings.json
description: General Site Settings
fields:
- label: Number of posts on frontpage
name: front_limit
widget: number
min: 1
max: 10
- label: Global title
name: site_title
widget: string
@ -1142,6 +1191,34 @@ collections:
- label: Description
name: description
widget: text
- name: hotels
label: Hotel Locations
file: _data/hotel_locations.yml
fields:
- name: country
label: Country
widget: string
- name: hotel_locations
label: Hotel Locations
widget: list
fields:
- name: cities
label: Cities
widget: list
fields:
- name: city
label: City
widget: string
- name: number_of_hotels_in_city
label: Number of Hotels in City
widget: number
- name: city_locations
label: City Locations
widget: list
fields:
- name: hotel_name
label: Hotel Name
widget: string
- name: kitchenSink
label: Kitchen Sink
folder: _sink

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -11,134 +11,6 @@ const PostPreview = ({ entry, widgetFor }) => {
);
};
const PostPreviewCard = ({ entry, theme, hasLocalBackup, collection }) => {
const date = new Date(entry.data.date);
const month = date.getMonth() + 1;
const day = date.getDate();
const imageField = useMemo(() => collection.fields.find((f) => f.name === 'image'), []);
const image = useMediaAsset(entry.data.image, collection, imageField, entry);
return h(
'div',
{ style: { width: '100%' } },
h('div', {
style: {
width: '100%',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
overflow: 'hidden',
height: '140px',
backgroundSize: 'cover',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
objectFit: 'cover',
backgroundImage: `url('${image}')`,
},
}),
h(
'div',
{ style: { padding: '16px', width: '100%' } },
h(
'div',
{
style: {
display: 'flex',
width: '100%',
justifyContent: 'space-between',
alignItems: 'start',
gap: '4px',
color: theme === 'dark' ? 'white' : 'inherit',
},
},
h(
'div',
{
style: {
display: 'flex',
flexDirection: 'column',
alignItems: 'baseline',
gap: '4px',
},
},
h(
'div',
{
style: {
fontSize: '14px',
fontWeight: 700,
color: 'rgb(107, 114, 128)',
lineHeight: '18px',
},
},
entry.data.title,
),
h(
'span',
{ style: { fontSize: '14px' } },
`${date.getFullYear()}-${month < 10 ? `0${month}` : month}-${
day < 10 ? `0${day}` : day
}`,
),
),
h(
'div',
{
style: {
display: 'flex',
alignItems: 'center',
whiteSpace: 'no-wrap',
gap: '8px',
},
},
hasLocalBackup
? h(
'div',
{
style: {
border: '2px solid rgb(147, 197, 253)',
borderRadius: '50%',
color: 'rgb(147, 197, 253)',
height: '18px',
width: '18px',
fontWeight: 'bold',
fontSize: '11px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
},
title: 'Has local backup',
},
'i',
)
: null,
h(
'div',
{
style: {
backgroundColor:
entry.data.draft === true ? 'rgb(37, 99, 235)' : 'rgb(22, 163, 74)',
color: 'white',
border: 'none',
padding: '2px 6px',
textAlign: 'center',
textDecoration: 'none',
display: 'inline-block',
cursor: 'pointer',
borderRadius: '4px',
fontSize: '14px',
},
},
entry.data.draft === true ? 'Draft' : 'Published',
),
),
),
),
);
};
const PostDateFieldPreview = ({ value }) => {
const date = new Date(value);
@ -152,29 +24,6 @@ const PostDateFieldPreview = ({ value }) => {
);
};
const PostDraftFieldPreview = ({ value }) => {
return h(
'div',
{
style: {
backgroundColor: value === true ? 'rgb(37 99 235)' : 'rgb(22 163 74)',
color: 'white',
border: 'none',
padding: '2px 6px',
textAlign: 'center',
textDecoration: 'none',
display: 'inline-block',
cursor: 'pointer',
borderRadius: '4px',
fontSize: '14px',
lineHeight: '16px',
height: '20px',
},
},
value === true ? 'Draft' : 'Published',
);
};
const GeneralPreview = ({ widgetsFor, entry, collection }) => {
const title = entry.data.site_title;
const posts = entry.data.posts;
@ -218,39 +67,16 @@ const AuthorsPreview = ({ widgetsFor }) => {
);
};
const RelationKitchenSinkPostPreview = ({ fieldsMetaData }) => {
// When a post is selected from the relation field, all of it's data
// will be available in the field's metadata nested under the collection
// name, and then further nested under the value specified in `value_field`.
// In this case, the post would be nested under "posts" and then under
// the title of the selected post, since our `value_field` in the config
// is "title".
const post = fieldsMetaData && fieldsMetaData.posts.value;
const style = { border: '2px solid #ccc', borderRadius: '8px', padding: '20px' };
return post
? h(
'div',
{ style: style },
h('h2', {}, 'Related Post'),
h('h3', {}, post.title),
h('img', { src: post.image }),
h('p', {}, (post.body ?? '').slice(0, 100) + '...'),
)
: null;
};
const CustomPage = () => {
return h('div', {}, 'I am a custom page!');
};
CMS.registerPreviewTemplate('posts', PostPreview);
CMS.registerPreviewCard('posts', PostPreviewCard, () => 240);
CMS.registerFieldPreview('posts', 'date', PostDateFieldPreview);
CMS.registerFieldPreview('posts', 'draft', PostDraftFieldPreview);
CMS.registerPreviewTemplate('general', GeneralPreview);
CMS.registerPreviewTemplate('authors', AuthorsPreview);
// Pass the name of a registered control to reuse with a new widget preview.
CMS.registerWidget('relationKitchenSinkPost', 'relation', RelationKitchenSinkPostPreview);
CMS.registerWidget('relationKitchenSinkPost', 'relation');
CMS.registerAdditionalLink({
id: 'example',
title: 'Example.com',
@ -268,6 +94,14 @@ CMS.registerAdditionalLink({
},
});
CMS.registerTheme({
name: 'Custom Red Orange',
extends: 'dark',
primary: {
main: '#ff4500',
}
});
CMS.registerShortcode('youtube', {
label: 'YouTube',
openTag: '[',
@ -283,7 +117,9 @@ CMS.registerShortcode('youtube', {
toArgs: ({ src }) => {
return [src];
},
control: ({ src, onChange, theme }) => {
control: ({ src, onChange }) => {
const theme = useTheme();
return h('span', {}, [
h('input', {
key: 'control-input',
@ -293,8 +129,8 @@ CMS.registerShortcode('youtube', {
},
style: {
width: '100%',
backgroundColor: theme === 'dark' ? 'rgb(30, 41, 59)' : 'white',
color: theme === 'dark' ? 'white' : 'black',
backgroundColor: theme.common.gray,
color: theme.text.primary,
padding: '4px 8px',
},
}),

View File

@ -15,5 +15,4 @@ module.exports = {
setupFiles: ['./test/setupEnv.js'],
globalSetup: './test/globalSetup.js',
testRegex: '\\.spec\\.tsx?$',
snapshotSerializers: ['@emotion/jest/serializer'],
};

View File

@ -1,6 +1,6 @@
{
"name": "@staticcms/core",
"version": "3.4.8",
"version": "4.0.0",
"license": "MIT",
"description": "Static CMS core application.",
"repository": "https://github.com/StaticJsCMS/static-cms",
@ -16,21 +16,22 @@
"build": "cross-env NODE_ENV=production run-s clean build:webpack build:types",
"clean": "rimraf dist dev-test/dist",
"dev": "run-s clean serve",
"format:prettier": "prettier \"src/**/*.{js,jsx,ts,tsx,css}\" --write",
"format:prettier": "prettier \"{src,test}/**/*.{js,jsx,ts,tsx,css}\" --write",
"format": "run-s \"lint:js --fix --quiet\" \"format:prettier\"",
"lint-quiet": "run-p -c --aggregate-output \"lint:* --quiet\"",
"lint:format": "prettier \"src/**/*.{js,jsx,ts,tsx,css}\" --list-different",
"lint:js": "eslint --color \"src/**/*.{ts,tsx}\"",
"lint:format": "prettier \"{src,test}/**/*.{js,jsx,ts,tsx,css}\" --list-different",
"lint:js": "eslint --color \"{src,test}/**/*.{ts,tsx}\" --max-warnings=0",
"lint": "run-p -c --aggregate-output \"lint:*\"",
"prepublishOnly": "yarn build ",
"prepack": "cp ../../README.md ./",
"postpack": "rm ./README.md",
"serve": "webpack serve --config-name configMain",
"test": "cross-env NODE_ENV=test jest",
"test": "cross-env NODE_ENV=test yarn run-s clean test:unit",
"test:unit": "jest",
"test:integration": "cross-env NODE_ENV=test jest -c jest.config.integration.js",
"test:ci": "cross-env NODE_ENV=test jest --maxWorkers=2 --coverage",
"test:integration:ci": "cross-env NODE_ENV=test jest -c jest.config.integration.js --maxWorkers=2",
"type-check": "tsc --watch"
"type-check": "tsc --watch --project tsconfig.dev.json"
},
"main": "dist/static-cms-core.js",
"types": "dist/index.d.ts",
@ -47,33 +48,31 @@
"last 2 Safari versions"
],
"dependencies": {
"@babel/eslint-parser": "7.21.3",
"@babel/runtime": "7.21.0",
"@codemirror/autocomplete": "6.5.1",
"@codemirror/commands": "6.2.3",
"@codemirror/language": "6.6.0",
"@codemirror/language-data": "6.3.0",
"@codemirror/legacy-modes": "6.3.2",
"@codemirror/lint": "6.2.1",
"@codemirror/search": "6.3.0",
"@codemirror/state": "6.2.0",
"@babel/eslint-parser": "7.22.15",
"@babel/runtime": "7.23.1",
"@codemirror/autocomplete": "6.9.1",
"@codemirror/commands": "6.3.0",
"@codemirror/language": "6.9.1",
"@codemirror/language-data": "6.3.1",
"@codemirror/legacy-modes": "6.3.3",
"@codemirror/lint": "6.4.2",
"@codemirror/search": "6.5.4",
"@codemirror/state": "6.2.1",
"@codemirror/theme-one-dark": "6.1.2",
"@codemirror/view": "6.9.5",
"@codemirror/view": "6.21.2",
"@dnd-kit/core": "6.0.8",
"@dnd-kit/sortable": "7.0.2",
"@dnd-kit/utilities": "3.2.1",
"@emotion/babel-preset-css-prop": "11.10.0",
"@emotion/css": "11.10.6",
"@emotion/react": "11.10.6",
"@emotion/styled": "11.10.6",
"@lezer/common": "1.0.2",
"@emotion/react": "11.11.1",
"@emotion/styled": "11.11.0",
"@lezer/common": "1.1.0",
"@mdx-js/mdx": "3.0.0",
"@mdx-js/react": "3.0.0",
"@mui/base": "5.0.0-beta.14",
"@mui/material": "5.11.16",
"@mui/system": "5.11.16",
"@mui/x-date-pickers": "5.0.20",
"@reduxjs/toolkit": "1.9.5",
"@mui/base": "5.0.0-beta.18",
"@mui/material": "5.14.12",
"@mui/system": "5.14.12",
"@mui/x-date-pickers": "6.16.0",
"@reduxjs/toolkit": "1.9.7",
"@styled-icons/bootstrap": "10.47.0",
"@styled-icons/fa-brands": "10.47.0",
"@styled-icons/fluentui-system-regular": "10.47.0",
@ -81,8 +80,8 @@
"@styled-icons/material": "10.47.0",
"@styled-icons/material-outlined": "10.47.0",
"@styled-icons/material-rounded": "10.47.0",
"@styled-icons/remix-editor": "10.46.0",
"@styled-icons/simple-icons": "10.46.0",
"@tanstack/react-virtual": "3.0.0-beta.61",
"@udecode/plate": "23.7.4",
"@udecode/plate-cursor": "23.7.4",
"@udecode/plate-juice": "23.7.4",
@ -98,16 +97,16 @@
"common-tags": "1.8.2",
"copy-text-to-clipboard": "3.1.0",
"create-react-class": "15.7.0",
"date-fns": "2.29.3",
"date-fns": "2.30.0",
"deepmerge": "4.3.1",
"diacritics": "1.3.0",
"escape-html": "1.0.3",
"eslint-config-prettier": "8.8.0",
"eslint-config-prettier": "9.0.0",
"eslint-plugin-babel": "5.3.1",
"fuzzy": "0.1.3",
"globby": "13.1.4",
"gotrue-js": "0.9.29",
"graphql": "16.6.0",
"graphql": "16.8.1",
"graphql-tag": "2.12.6",
"gray-matter": "4.0.3",
"history": "5.3.0",
@ -130,7 +129,6 @@
"micromark-extension-gfm-task-list-item": "2.0.1",
"micromark-util-combine-extensions": "2.0.0",
"minimatch": "9.0.0",
"moment": "2.29.4",
"node-polyglot": "2.5.0",
"ol": "7.3.0",
"path-browserify": "1.0.1",
@ -144,12 +142,12 @@
"react-is": "18.2.0",
"react-markdown": "9.0.1",
"react-polyglot": "0.7.2",
"react-redux": "8.0.5",
"react-router-dom": "6.10.0",
"react-redux": "8.1.3",
"react-resizable-panels": "0.0.55",
"react-router-dom": "6.16.0",
"react-scroll-sync": "0.11.0",
"react-topbar-progress-indicator": "4.1.1",
"react-virtual": "2.10.4",
"react-virtualized-auto-sizer": "1.0.15",
"react-virtualized-auto-sizer": "1.0.20",
"react-waypoint": "10.3.0",
"react-window": "1.8.9",
"remark-gfm": "4.0.0",
@ -164,123 +162,111 @@
"slate-hyperscript": "0.77.0",
"slate-react": "0.98.3",
"stream-browserify": "3.0.0",
"styled-components": "5.3.10",
"styled-components": "5.3.11",
"symbol-observable": "4.0.0",
"unified": "11.0.4",
"unist-util-visit": "5.0.0",
"url": "0.11.0",
"url-join": "5.0.0",
"uuid": "9.0.0",
"uuid": "9.0.1",
"validate-color": "2.2.4",
"vfile": "6.0.1",
"vfile-message": "4.0.2",
"vfile-statistics": "3.0.0",
"what-input": "5.2.12",
"what-the-diff": "0.6.0",
"yaml": "2.2.2"
"yaml": "2.3.2"
},
"devDependencies": {
"@babel/cli": "7.21.0",
"@babel/core": "7.21.4",
"@babel/cli": "7.23.0",
"@babel/core": "7.23.0",
"@babel/plugin-proposal-class-properties": "7.18.6",
"@babel/plugin-proposal-export-default-from": "7.18.10",
"@babel/plugin-proposal-export-default-from": "7.22.17",
"@babel/plugin-proposal-nullish-coalescing-operator": "7.18.6",
"@babel/plugin-proposal-numeric-separator": "7.18.6",
"@babel/plugin-proposal-object-rest-spread": "7.20.7",
"@babel/plugin-proposal-optional-chaining": "7.21.0",
"@babel/plugin-syntax-dynamic-import": "7.8.3",
"@babel/preset-env": "7.21.4",
"@babel/preset-react": "7.18.6",
"@babel/preset-typescript": "7.21.4",
"@emotion/eslint-plugin": "11.10.0",
"@emotion/jest": "11.10.5",
"@babel/preset-env": "7.22.20",
"@babel/preset-react": "7.22.15",
"@babel/preset-typescript": "7.23.0",
"@iarna/toml": "2.2.5",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.10",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.11",
"@simbathesailor/use-what-changed": "2.0.0",
"@testing-library/dom": "9.2.0",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/dom": "9.3.3",
"@testing-library/jest-dom": "6.1.3",
"@testing-library/react": "14.0.0",
"@testing-library/user-event": "14.4.3",
"@testing-library/user-event": "14.5.1",
"@types/common-tags": "1.8.1",
"@types/create-react-class": "15.6.3",
"@types/fs-extra": "11.0.1",
"@types/is-hotkey": "0.1.7",
"@types/jest": "29.5.1",
"@types/js-yaml": "4.0.5",
"@types/jest": "29.5.5",
"@types/js-yaml": "4.0.6",
"@types/jwt-decode": "2.2.1",
"@types/lodash": "4.14.194",
"@types/minimatch": "5.1.2",
"@types/node": "18.16.14",
"@types/node-fetch": "2.6.4",
"@types/react": "18.2.0",
"@types/react-color": "3.0.6",
"@types/react-dom": "18.2.1",
"@types/node": "18.17.19",
"@types/react": "18.2.25",
"@types/react-color": "3.0.7",
"@types/react-dom": "18.2.10",
"@types/react-virtualized-auto-sizer": "1.0.1",
"@types/react-window": "1.8.5",
"@types/styled-components": "5.1.26",
"@types/react-window": "1.8.6",
"@types/unist": "3.0.2",
"@types/url-join": "4.0.1",
"@types/uuid": "9.0.1",
"@typescript-eslint/eslint-plugin": "5.59.1",
"@typescript-eslint/parser": "5.59.1",
"autoprefixer": "10.4.14",
"axios": "1.3.6",
"@types/uuid": "9.0.4",
"@typescript-eslint/eslint-plugin": "6.7.4",
"@typescript-eslint/parser": "6.7.4",
"autoprefixer": "10.4.16",
"babel-core": "7.0.0-bridge.0",
"babel-loader": "9.1.2",
"babel-plugin-emotion": "11.0.0",
"babel-loader": "9.1.3",
"babel-plugin-inline-json-import": "0.3.2",
"babel-plugin-inline-react-svg": "2.0.2",
"babel-plugin-lodash": "3.3.4",
"babel-plugin-transform-builtin-extend": "1.1.2",
"babel-plugin-transform-define": "2.1.0",
"babel-plugin-transform-define": "2.1.4",
"babel-plugin-transform-export-extensions": "6.22.0",
"babel-plugin-transform-inline-environment-variables": "0.4.4",
"cache-me-outside": "1.0.0",
"commonmark": "0.30.0",
"commonmark-spec": "0.30.0",
"cross-env": "7.0.3",
"css-loader": "6.7.3",
"dotenv": "16.0.3",
"eslint": "8.39.0",
"eslint-import-resolver-typescript": "3.5.5",
"eslint-plugin-cypress": "2.13.3",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-react": "7.32.2",
"css-loader": "6.8.1",
"dotenv": "16.3.1",
"eslint": "8.50.0",
"eslint-import-resolver-typescript": "3.6.1",
"eslint-plugin-cypress": "2.15.1",
"eslint-plugin-import": "2.28.1",
"eslint-plugin-prettier": "5.0.0",
"eslint-plugin-react": "7.33.2",
"eslint-plugin-react-hooks": "4.6.0",
"eslint-plugin-unicorn": "46.0.1",
"eslint-plugin-unicorn": "48.0.1",
"execa": "7.1.1",
"fs-extra": "11.1.1",
"gitlab": "14.2.2",
"http-server": "14.1.1",
"jest": "29.5.0",
"jest-environment-jsdom": "29.5.0",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"js-yaml": "4.1.0",
"mini-css-extract-plugin": "2.7.5",
"mockserver-client": "5.15.0",
"mockserver-node": "5.15.0",
"mini-css-extract-plugin": "2.7.6",
"ncp": "2.0.0",
"node-fetch": "3.3.1",
"npm-run-all": "4.1.5",
"postcss": "8.4.23",
"postcss-loader": "7.2.4",
"prettier": "2.8.8",
"postcss": "8.4.31",
"postcss-loader": "7.3.3",
"prettier": "3.0.3",
"process": "0.11.10",
"react-refresh": "0.14.0",
"react-svg-loader": "3.0.3",
"rimraf": "5.0.0",
"simple-git": "3.17.0",
"rimraf": "5.0.5",
"simple-git": "3.20.0",
"source-map-loader": "4.0.1",
"style-loader": "3.3.2",
"tailwindcss": "3.3.1",
"style-loader": "3.3.3",
"tailwindcss": "3.3.3",
"to-string-loader": "1.2.0",
"ts-jest": "29.1.0",
"ts-jest": "29.1.1",
"ts-node": "10.9.1",
"tsconfig-paths-webpack-plugin": "4.0.1",
"typescript": "5.0.4",
"webpack": "5.80.0",
"webpack-cli": "5.0.2",
"webpack-dev-server": "4.13.3"
"tsconfig-paths-webpack-plugin": "4.1.0",
"typescript": "5.2.2",
"webpack": "5.88.2",
"webpack-cli": "5.1.4",
"webpack-dev-server": "4.15.1"
},
"peerDependencies": {
"react": "^18.2.0",

View File

@ -6,11 +6,7 @@ const {
mergeExpandedEntries: actualMergeExpandedEntries,
} = jest.requireActual('@staticcms/core/backend');
const isGitBackend = jest.fn().mockReturnValue(true);
export const resolveBackend = jest.fn().mockReturnValue({
isGitBackend,
});
export const resolveBackend = jest.fn().mockReturnValue({});
export const currentBackend = jest.fn();

View File

@ -1,4 +1,5 @@
export const isNode = jest.fn();
export const isMap = jest.fn();
export const parse = jest.fn();
export default {};

File diff suppressed because it is too large Load Diff

View File

@ -14,7 +14,7 @@ const testConfig: Config<RelationKitchenSinkPostField> = {
i18n: {
structure: 'multiple_files',
locales: ['en', 'de', 'fr'],
defaultLocale: 'en',
default_locale: 'en',
},
collections: [
{
@ -25,7 +25,7 @@ const testConfig: Config<RelationKitchenSinkPostField> = {
'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'],
summary_fields: ['title', 'date'],
sortable_fields: {
fields: ['title', 'date'],
default: {
@ -33,46 +33,39 @@ const testConfig: Config<RelationKitchenSinkPostField> = {
},
},
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',
},
],
view_filters: {
default: 'posts-without-index',
filters: [
{
name: 'posts-with-index',
label: 'Posts With Index',
field: 'title',
pattern: 'This is post #',
},
{
name: 'posts-without-index',
label: 'Posts Without Index',
field: 'title',
pattern: 'front matter post',
},
],
},
view_groups: {
groups: [
{
name: 'by-year',
label: 'Year',
field: 'date',
pattern: '\\d{4}',
},
],
},
fields: [
{
label: 'Title',
name: 'title',
widget: 'string',
},
{
label: 'Draft',
name: 'draft',
widget: 'boolean',
default: false,
},
{
label: 'Publish Date',
name: 'date',
@ -91,7 +84,7 @@ const testConfig: Config<RelationKitchenSinkPostField> = {
label: 'Body',
name: 'body',
widget: 'markdown',
hint: 'Main content goes here.',
hint: '*Main* __content__ __*goes*__ [here](https://example.com/).',
},
],
},
@ -1172,13 +1165,6 @@ const testConfig: Config<RelationKitchenSinkPostField> = {
preview: true,
},
fields: [
{
label: 'Number of posts on frontpage',
name: 'front_limit',
widget: 'number',
min: 1,
max: 10,
},
{
label: 'Global title',
name: 'site_title',
@ -1800,7 +1786,7 @@ const testConfig: Config<RelationKitchenSinkPostField> = {
i18n: {
structure: 'multiple_folders',
locales: ['en', 'de', 'fr'],
defaultLocale: 'en',
default_locale: 'en',
},
folder: 'packages/core/dev-test/backends/proxy/_i18n_playground_multiple_folders',
identifier_field: 'slug',
@ -1831,7 +1817,7 @@ const testConfig: Config<RelationKitchenSinkPostField> = {
i18n: {
structure: 'single_file',
locales: ['en', 'de', 'fr'],
defaultLocale: 'en',
default_locale: 'en',
},
folder: 'packages/core/dev-test/backends/proxy/_i18n_playground_single_file',
identifier_field: 'slug',

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@ import { currentBackend } from '../backend';
import { AUTH_FAILURE, AUTH_REQUEST, AUTH_REQUEST_DONE, AUTH_SUCCESS, LOGOUT } from '../constants';
import { invokeEvent } from '../lib/registry';
import { addSnackbar } from '../store/slices/snackbars';
import { useOpenAuthoring } from './globalUI';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
@ -57,6 +58,9 @@ export function authenticateUser() {
return Promise.resolve(backend.currentUser())
.then(user => {
if (user) {
if (user.useOpenAuthoring) {
dispatch(useOpenAuthoring());
}
dispatch(authenticate(user));
} else {
dispatch(doneAuthenticating());
@ -85,6 +89,9 @@ export function loginUser(credentials: Credentials) {
return backend
.authenticate(credentials)
.then(user => {
if (user.useOpenAuthoring) {
dispatch(useOpenAuthoring());
}
dispatch(authenticate(user));
})
.catch((error: unknown) => {

View File

@ -1,28 +1,32 @@
import deepmerge from 'deepmerge';
import { produce } from 'immer';
import cloneDeep from 'lodash/cloneDeep';
import trim from 'lodash/trim';
import trimStart from 'lodash/trimStart';
import yaml from 'yaml';
import { resolveBackend } from '@staticcms/core/backend';
import { CONFIG_FAILURE, CONFIG_REQUEST, CONFIG_SUCCESS } from '../constants';
import validateConfig from '../constants/configSchema';
import {
I18N,
I18N_FIELD_NONE,
I18N_FIELD_TRANSLATE,
I18N_STRUCTURE_SINGLE_FILE,
} from '../lib/i18n';
import { SIMPLE as SIMPLE_PUBLISH_MODE } from '../constants/publishModes';
import { I18N_FIELD_NONE, I18N_FIELD_TRANSLATE, I18N_STRUCTURE_SINGLE_FILE } from '../lib/i18n';
import { selectDefaultSortableFields } from '../lib/util/collection.util';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { Workflow } from '../constants/publishModes';
import type {
BaseField,
Collection,
CollectionFile,
CollectionFileWithDefaults,
CollectionWithDefaults,
Config,
ConfigWithDefaults,
Field,
FilesCollection,
FilesCollectionWithDefaults,
FolderCollection,
FolderCollectionWithDefaults,
I18nInfo,
LocalBackend,
UnknownField,
@ -60,14 +64,16 @@ function getConfigUrl() {
}
const setFieldDefaults =
(collection: Collection, collectionFile?: CollectionFile) => (field: Field) => {
(collection: Collection, config: Config, collectionFile?: CollectionFile) => (field: Field) => {
if ('media_folder' in field && !('public_folder' in field)) {
return { ...field, public_folder: field.media_folder };
}
if (field.widget === 'image' || field.widget === 'file' || field.widget === 'markdown') {
field.media_library = {
...((collectionFile ?? collection).media_library ?? {}),
...(config.media_library ?? {}),
...(collectionFile?.media_library ?? {}),
...(collection.media_library ?? {}),
...(field.media_library ?? {}),
};
}
@ -76,23 +82,27 @@ const setFieldDefaults =
};
function setI18nField<T extends BaseField = UnknownField>(field: T) {
if (field[I18N] === true) {
return { ...field, [I18N]: I18N_FIELD_TRANSLATE };
} else if (field[I18N] === false || !field[I18N]) {
return { ...field, [I18N]: I18N_FIELD_NONE };
if (field.i18n === true) {
return { ...field, ['i18n']: I18N_FIELD_TRANSLATE };
} else if (field.i18n === false || !field['i18n']) {
return { ...field, ['i18n']: I18N_FIELD_NONE };
}
return field;
}
function getI18nDefaults(collectionOrFileI18n: boolean | I18nInfo, defaultI18n: I18nInfo) {
function getI18nDefaults(
collectionOrFileI18n: boolean | Partial<I18nInfo>,
{ default_locale, locales = ['en'], structure = I18N_STRUCTURE_SINGLE_FILE }: Partial<I18nInfo>,
): I18nInfo {
if (typeof collectionOrFileI18n === 'boolean') {
return defaultI18n;
return { default_locale, locales, structure };
} else {
const locales = collectionOrFileI18n.locales || defaultI18n.locales;
const defaultLocale = collectionOrFileI18n.defaultLocale || locales[0];
const mergedI18n: I18nInfo = deepmerge(defaultI18n, collectionOrFileI18n);
mergedI18n.locales = locales;
mergedI18n.defaultLocale = defaultLocale;
const mergedI18n: I18nInfo = deepmerge(
{ default_locale, locales, structure },
collectionOrFileI18n,
);
mergedI18n.locales = collectionOrFileI18n.locales ?? locales;
mergedI18n.default_locale = collectionOrFileI18n.default_locale || locales?.[0];
throwOnMissingDefaultLocale(mergedI18n);
return mergedI18n;
}
@ -104,13 +114,13 @@ function setI18nDefaultsForFields(collectionOrFileFields: Field[], hasI18n: bool
} else {
return traverseFields(collectionOrFileFields, field => {
const newField = { ...field };
delete newField[I18N];
delete newField.i18n;
return newField;
});
}
}
function throwOnInvalidFileCollectionStructure(i18n?: I18nInfo) {
function throwOnInvalidFileCollectionStructure(i18n?: Partial<I18nInfo>) {
if (i18n && i18n.structure !== I18N_STRUCTURE_SINGLE_FILE) {
throw new Error(
`i18n configuration for files collections is limited to ${I18N_STRUCTURE_SINGLE_FILE} structure`,
@ -118,162 +128,229 @@ function throwOnInvalidFileCollectionStructure(i18n?: I18nInfo) {
}
}
function throwOnMissingDefaultLocale(i18n?: I18nInfo) {
if (i18n && i18n.defaultLocale && !i18n.locales.includes(i18n.defaultLocale)) {
function throwOnMissingDefaultLocale(i18n?: Partial<I18nInfo>) {
if (i18n && i18n.default_locale && !i18n.locales?.includes(i18n.default_locale)) {
throw new Error(
`i18n locales '${i18n.locales.join(', ')}' are missing the default locale ${
i18n.defaultLocale
`i18n locales '${i18n.locales?.join(', ')}' are missing the default locale ${
i18n.default_locale
}`,
);
}
}
export function applyDefaults<EF extends BaseField = UnknownField>(
originalConfig: Config<EF>,
): Config<EF> {
return produce(originalConfig, (config: Config) => {
config.slug = config.slug || {};
config.collections = config.collections || [];
function applyFolderCollectionDefaults(
originalCollection: FolderCollection,
collectionI18n: I18nInfo | undefined,
config: Config,
): FolderCollectionWithDefaults {
const collection: FolderCollectionWithDefaults = {
...originalCollection,
i18n: collectionI18n,
};
// Use `site_url` as default `display_url`.
if (!config.display_url && config.site_url) {
config.display_url = config.site_url;
}
if (collection.path && !collection.media_folder) {
// default value for media folder when using the path config
collection.media_folder = '';
}
// Use media_folder as default public_folder.
const defaultPublicFolder = `/${trimStart(config.media_folder, '/')}`;
if (!('public_folder' in config)) {
config.public_folder = defaultPublicFolder;
}
if ('media_folder' in collection && !('public_folder' in collection)) {
collection.public_folder = collection.media_folder;
}
// default values for the slug config
if (!('encoding' in config.slug)) {
config.slug.encoding = 'unicode';
}
if ('fields' in collection && collection.fields) {
collection.fields = traverseFields(collection.fields, setFieldDefaults(collection, config));
}
if (!('clean_accents' in config.slug)) {
config.slug.clean_accents = false;
}
collection.folder = trim(collection.folder, '/');
collection.publish = collection.publish ?? true;
if (!('sanitize_replacement' in config.slug)) {
config.slug.sanitize_replacement = '-';
}
return collection;
}
const i18n = config[I18N];
function applyCollectionFileDefaults(
originalFile: CollectionFile,
collection: Collection,
collectionI18n: I18nInfo | undefined,
config: Config,
): CollectionFileWithDefaults {
const file: CollectionFileWithDefaults = {
...originalFile,
i18n: undefined,
};
if (i18n) {
i18n.defaultLocale = i18n.defaultLocale || i18n.locales[0];
}
file.file = trimStart(file.file, '/');
throwOnMissingDefaultLocale(i18n);
if ('media_folder' in file && !('public_folder' in file)) {
file.public_folder = file.media_folder;
}
const backend = resolveBackend(config);
file.media_library = {
...(collection.media_library ?? {}),
...(file.media_library ?? {}),
};
for (const collection of config.collections) {
let collectionI18n = collection[I18N];
if (file.fields) {
file.fields = traverseFields(file.fields, setFieldDefaults(collection, config, file));
}
if (config.editor && !collection.editor) {
collection.editor = config.editor;
}
let fileI18n: I18nInfo | undefined;
collection.media_library = {
...(config.media_library ?? {}),
...(collection.media_library ?? {}),
if (originalFile.i18n && collectionI18n) {
fileI18n = getI18nDefaults(originalFile.i18n, {
locales: collectionI18n.locales,
default_locale: collectionI18n.default_locale,
structure: collectionI18n.structure,
});
file.i18n = fileI18n;
} else {
fileI18n = undefined;
delete file.i18n;
}
throwOnInvalidFileCollectionStructure(fileI18n);
if (file.fields) {
file.fields = setI18nDefaultsForFields(file.fields, Boolean(fileI18n));
}
if (collection.editor && !file.editor) {
file.editor = collection.editor;
}
return file;
}
function applyFilesCollectionDefaults(
originalCollection: FilesCollection,
collectionI18n: I18nInfo | undefined,
config: Config,
): FilesCollectionWithDefaults {
const collection: FilesCollectionWithDefaults = {
...originalCollection,
i18n: collectionI18n,
files: originalCollection.files.map(f =>
applyCollectionFileDefaults(f, originalCollection, collectionI18n, config),
),
};
throwOnInvalidFileCollectionStructure(collectionI18n);
return collection;
}
function applyCollectionDefaults(
originalCollection: Collection,
config: Config,
): CollectionWithDefaults {
let collection: CollectionWithDefaults;
let collectionI18n: I18nInfo | undefined;
if (config.i18n && originalCollection.i18n) {
collectionI18n = getI18nDefaults(originalCollection.i18n, config.i18n);
} else {
collectionI18n = undefined;
}
if ('folder' in originalCollection) {
collection = applyFolderCollectionDefaults(originalCollection, collectionI18n, config);
} else {
collection = applyFilesCollectionDefaults(originalCollection, collectionI18n, config);
}
if (config.editor && !collection.editor) {
collection.editor = config.editor;
}
collection.media_library = {
...(config.media_library ?? {}),
...(collection.media_library ?? {}),
};
if ('fields' in collection && collection.fields) {
collection.fields = setI18nDefaultsForFields(collection.fields, Boolean(collectionI18n));
}
const { view_filters, view_groups } = collection;
if (!collection.sortable_fields) {
collection.sortable_fields = {
fields: selectDefaultSortableFields(collection, config),
};
}
collection.view_filters = {
default: collection.view_filters?.default,
filters: (view_filters?.filters ?? []).map(filter => {
return {
...filter,
id: `${filter.field}__${filter.pattern}`,
};
}),
};
if (i18n && collectionI18n) {
collectionI18n = getI18nDefaults(collectionI18n, i18n);
collection[I18N] = collectionI18n;
} else {
collectionI18n = undefined;
delete collection[I18N];
}
collection.view_groups = {
default: collection.view_groups?.default,
groups: (view_groups?.groups ?? []).map(group => {
return {
...group,
id: `${group.field}__${group.pattern}`,
};
}),
};
if ('fields' in collection && collection.fields) {
collection.fields = setI18nDefaultsForFields(collection.fields, Boolean(collectionI18n));
}
return collection;
}
const { view_filters, view_groups } = collection;
export function applyDefaults<EF extends BaseField = UnknownField>(
originConfig: Config<EF>,
): ConfigWithDefaults<EF> {
const clonedConfig = cloneDeep(originConfig) as Config;
if ('folder' in collection && collection.folder) {
if (collection.path && !collection.media_folder) {
// default value for media folder when using the path config
collection.media_folder = '';
}
const i18n = clonedConfig.i18n;
if ('media_folder' in collection && !('public_folder' in collection)) {
collection.public_folder = collection.media_folder;
}
if (i18n) {
i18n.default_locale = i18n.default_locale ?? i18n.locales[0];
}
if ('fields' in collection && collection.fields) {
collection.fields = traverseFields(collection.fields, setFieldDefaults(collection));
}
throwOnMissingDefaultLocale(i18n);
collection.folder = trim(collection.folder, '/');
}
const config: ConfigWithDefaults = {
...clonedConfig,
collections: (clonedConfig.collections ?? []).map(c =>
applyCollectionDefaults(c, clonedConfig),
),
};
if ('files' in collection && collection.files) {
throwOnInvalidFileCollectionStructure(collectionI18n);
config.publish_mode = config.publish_mode ?? SIMPLE_PUBLISH_MODE;
config.slug = config.slug ?? {};
config.collections = config.collections ?? [];
for (const file of collection.files) {
file.file = trimStart(file.file, '/');
// Use `site_url` as default `display_url`.
if (!config.display_url && config.site_url) {
config.display_url = config.site_url;
}
if ('media_folder' in file && !('public_folder' in file)) {
file.public_folder = file.media_folder;
}
// Use media_folder as default public_folder.
const defaultPublicFolder = `/${trimStart(config.media_folder, '/')}`;
if (!('public_folder' in config)) {
config.public_folder = defaultPublicFolder;
}
file.media_library = {
...(collection.media_library ?? {}),
...(file.media_library ?? {}),
};
// default values for the slug config
if (!('encoding' in config.slug)) {
config.slug.encoding = 'unicode';
}
if (file.fields) {
file.fields = traverseFields(file.fields, setFieldDefaults(collection, file));
}
if (!('clean_accents' in config.slug)) {
config.slug.clean_accents = false;
}
let fileI18n = file[I18N];
if (!('sanitize_replacement' in config.slug)) {
config.slug.sanitize_replacement = '-';
}
if (fileI18n && collectionI18n) {
fileI18n = getI18nDefaults(fileI18n, collectionI18n);
file[I18N] = fileI18n;
} else {
fileI18n = undefined;
delete file[I18N];
}
throwOnInvalidFileCollectionStructure(fileI18n);
if (file.fields) {
file.fields = setI18nDefaultsForFields(file.fields, Boolean(fileI18n));
}
if (collection.editor && !file.editor) {
file.editor = collection.editor;
}
}
}
if (!collection.sortable_fields) {
collection.sortable_fields = {
fields: selectDefaultSortableFields(collection, backend),
};
}
collection.view_filters = (view_filters || []).map(filter => {
return {
...filter,
id: `${filter.field}__${filter.pattern}`,
};
});
collection.view_groups = (view_groups || []).map(group => {
return {
...group,
id: `${group.field}__${group.pattern}`,
};
});
}
});
return config as ConfigWithDefaults<EF>;
}
export function parseConfig(data: string) {
@ -305,10 +382,13 @@ async function getConfigYaml(file: string): Promise<Config> {
return parseConfig(await response.text());
}
export function configLoaded(config: Config) {
export function configLoaded(config: ConfigWithDefaults, originalConfig: Config) {
return {
type: CONFIG_SUCCESS,
payload: config,
payload: {
config,
originalConfig,
},
} as const;
}
@ -350,15 +430,16 @@ export async function detectProxyServer(localBackend?: boolean | LocalBackend) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'info' }),
});
const { repo, type } = (await res.json()) as {
const { repo, publish_modes, type } = (await res.json()) as {
repo?: string;
publish_modes?: Workflow[];
type?: string;
};
if (typeof repo === 'string' && typeof type === 'string') {
if (typeof repo === 'string' && Array.isArray(publish_modes) && typeof type === 'string') {
console.info(
`[StaticCMS] Detected Static CMS Proxy Server at '${proxyUrl}' with repo: '${repo}'`,
);
return { proxyUrl, type };
return { proxyUrl, publish_modes, type };
} else {
console.info(`[StaticCMS] Static CMS Proxy Server not detected at '${proxyUrl}'`);
return {};
@ -369,12 +450,28 @@ export async function detectProxyServer(localBackend?: boolean | LocalBackend) {
}
}
function getPublishMode(config: Config, publishModes?: Workflow[], backendType?: string) {
if (config.publish_mode && publishModes && !publishModes.includes(config.publish_mode)) {
const newPublishMode = publishModes[0];
console.info(
`'${config.publish_mode}' is not supported by '${backendType}' backend, switching to '${newPublishMode}'`,
);
return newPublishMode;
}
return config.publish_mode;
}
export async function handleLocalBackend(originalConfig: Config) {
if (!originalConfig.local_backend) {
return originalConfig;
}
const { proxyUrl } = await detectProxyServer(originalConfig.local_backend);
const {
proxyUrl,
publish_modes: publishModes,
type: backendType,
} = await detectProxyServer(originalConfig.local_backend);
if (!proxyUrl) {
return originalConfig;
@ -383,26 +480,36 @@ export async function handleLocalBackend(originalConfig: Config) {
return produce(originalConfig, config => {
config.backend.name = 'proxy';
config.backend.proxy_url = proxyUrl;
if (config.publish_mode) {
config.publish_mode = getPublishMode(config as Config, publishModes, backendType);
}
});
}
export function loadConfig(manualConfig: Config | undefined, onLoad: (config: Config) => unknown) {
if (window.CMS_CONFIG) {
return configLoaded(window.CMS_CONFIG);
}
export function loadConfig(
manualConfig: Config | undefined,
onLoad: (config: ConfigWithDefaults) => unknown,
) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>) => {
dispatch(configLoading());
try {
const configUrl = getConfigUrl();
const mergedConfig = manualConfig ? manualConfig : await getConfigYaml(configUrl);
let originalConfig: Config;
validateConfig(mergedConfig);
if (window.CMS_CONFIG) {
originalConfig = window.CMS_CONFIG;
} else {
const configUrl = getConfigUrl();
originalConfig = manualConfig ? manualConfig : await getConfigYaml(configUrl);
}
const withLocalBackend = await handleLocalBackend(mergedConfig);
validateConfig(originalConfig);
const withLocalBackend = await handleLocalBackend(originalConfig);
const config = applyDefaults(withLocalBackend);
dispatch(configLoaded(config));
dispatch(configLoaded(config, originalConfig));
if (typeof onLoad === 'function') {
onLoad(config);

View File

@ -0,0 +1,657 @@
import { currentBackend } from '../backend';
import {
UNPUBLISHED_ENTRIES_FAILURE,
UNPUBLISHED_ENTRIES_REQUEST,
UNPUBLISHED_ENTRIES_SUCCESS,
UNPUBLISHED_ENTRY_DELETE_FAILURE,
UNPUBLISHED_ENTRY_DELETE_REQUEST,
UNPUBLISHED_ENTRY_DELETE_SUCCESS,
UNPUBLISHED_ENTRY_PERSIST_FAILURE,
UNPUBLISHED_ENTRY_PERSIST_REQUEST,
UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
UNPUBLISHED_ENTRY_PUBLISH_FAILURE,
UNPUBLISHED_ENTRY_PUBLISH_REQUEST,
UNPUBLISHED_ENTRY_PUBLISH_SUCCESS,
UNPUBLISHED_ENTRY_REDIRECT,
UNPUBLISHED_ENTRY_REQUEST,
UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE,
UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST,
UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS,
UNPUBLISHED_ENTRY_SUCCESS,
} from '../constants';
import { EDITORIAL_WORKFLOW, WorkflowStatus } from '../constants/publishModes';
import ValidationErrorTypes from '../constants/validationErrorTypes';
import { EditorialWorkflowError } from '../lib';
import { slugFromCustomPath } from '../lib/util/nested.util';
import {
selectUnpublishedEntry,
selectUnpublishedSlugs,
} from '../reducers/selectors/editorialWorkflow';
import { selectEntry, selectPublishedSlugs } from '../reducers/selectors/entries';
import { selectEditingDraft } from '../reducers/selectors/entryDraft';
import { addSnackbar } from '../store/slices/snackbars';
import { createAssetProxy } from '../valueObjects/AssetProxy';
import {
createDraftFromEntry,
entryDeleted,
getMediaAssets,
getSerializedEntry,
loadEntries,
loadEntry,
} from './entries';
import { addAssets } from './media';
import { loadMedia } from './mediaLibrary';
import type { NavigateFunction } from 'react-router-dom';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type {
CollectionWithDefaults,
CollectionsWithDefaults,
ConfigWithDefaults,
Entry,
EntryDraft,
} from '../interface';
import type { RootState } from '../store';
/*
* Simple Action Creators (Internal)
*/
function unpublishedEntryLoading(collection: CollectionWithDefaults, slug: string) {
return {
type: UNPUBLISHED_ENTRY_REQUEST,
payload: {
collection: collection.name,
slug,
},
} as const;
}
function unpublishedEntryLoaded(collection: CollectionWithDefaults, entry: Entry) {
return {
type: UNPUBLISHED_ENTRY_SUCCESS,
payload: {
collection: collection.name,
entry,
},
} as const;
}
function unpublishedEntryRedirected(collection: CollectionWithDefaults, slug: string) {
return {
type: UNPUBLISHED_ENTRY_REDIRECT,
payload: {
collection: collection.name,
slug,
},
} as const;
}
function unpublishedEntriesLoading() {
return {
type: UNPUBLISHED_ENTRIES_REQUEST,
} as const;
}
function unpublishedEntriesLoaded(entries: Entry[], pagination: number) {
return {
type: UNPUBLISHED_ENTRIES_SUCCESS,
payload: {
entries,
pages: pagination,
},
} as const;
}
function unpublishedEntriesFailed(error: Error) {
return {
type: UNPUBLISHED_ENTRIES_FAILURE,
error: 'Failed to load entries',
payload: error,
} as const;
}
function unpublishedEntryPersisting(collection: CollectionWithDefaults, slug: string) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_REQUEST,
payload: {
collection: collection.name,
slug,
},
} as const;
}
function unpublishedEntryPersisted(collection: CollectionWithDefaults, entry: Entry) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_SUCCESS,
payload: {
collection: collection.name,
entry,
slug: entry.slug,
},
} as const;
}
function unpublishedEntryPersistedFail(
error: unknown,
collection: CollectionWithDefaults,
slug: string,
) {
return {
type: UNPUBLISHED_ENTRY_PERSIST_FAILURE,
payload: {
error,
collection: collection.name,
slug,
},
error,
} as const;
}
function unpublishedEntryStatusChangeRequest(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_REQUEST,
payload: {
collection,
slug,
},
} as const;
}
function unpublishedEntryStatusChangePersisted(
collection: string,
slug: string,
newStatus: WorkflowStatus,
) {
return {
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_SUCCESS,
payload: {
collection,
slug,
newStatus,
},
} as const;
}
function unpublishedEntryStatusChangeError(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_STATUS_CHANGE_FAILURE,
payload: { collection, slug },
} as const;
}
function unpublishedEntryPublishRequest(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_PUBLISH_REQUEST,
payload: { collection, slug },
} as const;
}
function unpublishedEntryPublished(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_PUBLISH_SUCCESS,
payload: { collection, slug },
} as const;
}
function unpublishedEntryPublishError(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_PUBLISH_FAILURE,
payload: { collection, slug },
} as const;
}
function unpublishedEntryDeleteRequest(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_DELETE_REQUEST,
payload: { collection, slug },
} as const;
}
function unpublishedEntryDeleted(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_DELETE_SUCCESS,
payload: { collection, slug },
} as const;
}
function unpublishedEntryDeleteError(collection: string, slug: string) {
return {
type: UNPUBLISHED_ENTRY_DELETE_FAILURE,
payload: { collection, slug },
} as const;
}
/*
* Exported Thunk Action Creators
*/
export function loadUnpublishedEntry(collection: CollectionWithDefaults, slug: string) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
if (!state.config.config) {
return;
}
const backend = currentBackend(state.config.config);
const entriesLoaded = state.editorialWorkflow.ids;
//run possible unpublishedEntries migration
if (!entriesLoaded) {
try {
const { entries, pagination } = await backend.unpublishedEntries(
state.collections,
state.config.config,
);
dispatch(unpublishedEntriesLoaded(entries, pagination));
// eslint-disable-next-line no-empty
} catch (e) {}
}
dispatch(unpublishedEntryLoading(collection, slug));
try {
const entry = await backend.unpublishedEntry(state, collection, state.config.config, slug);
const assetProxies = await Promise.all(
entry.mediaFiles
.filter(file => file.draft)
.map(({ url, file, path }) =>
createAssetProxy({
path,
url,
file,
}),
),
);
dispatch(addAssets(assetProxies));
dispatch(unpublishedEntryLoaded(collection, entry));
dispatch(createDraftFromEntry(collection, entry));
} catch (error) {
if (error instanceof EditorialWorkflowError && error.notUnderEditorialWorkflow) {
dispatch(unpublishedEntryRedirected(collection, slug));
dispatch(loadEntry(collection, slug));
} else {
console.error(error);
dispatch(
addSnackbar({
type: 'error',
message: {
key: 'ui.toast.onFailToLoadEntries',
options: {
details: error,
},
},
}),
);
}
}
};
}
export function loadUnpublishedEntries(collections: CollectionsWithDefaults) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
if (!state.config.config) {
return;
}
const backend = currentBackend(state.config.config);
if (state.config.config.publish_mode !== EDITORIAL_WORKFLOW) {
return;
}
dispatch(unpublishedEntriesLoading());
backend
.unpublishedEntries(collections, state.config.config)
.then(response => dispatch(unpublishedEntriesLoaded(response.entries, response.pagination)))
.catch((error: Error) => {
console.error(error);
dispatch(
addSnackbar({
type: 'error',
message: {
key: 'ui.toast.onFailToLoadEntries',
options: {
details: error,
},
},
}),
);
dispatch(unpublishedEntriesFailed(error));
Promise.reject(error);
});
};
}
export function persistUnpublishedEntry(
collection: CollectionWithDefaults,
rootSlug: string | undefined,
existingUnpublishedEntry: boolean,
navigate: NavigateFunction,
) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
if (!state.config.config) {
return;
}
const entryDraft = state.entryDraft;
const fieldsErrors = entryDraft.fieldsErrors;
const unpublishedSlugs = selectUnpublishedSlugs(state, collection.name);
const publishedSlugs = selectPublishedSlugs(state, collection.name);
const usedSlugs = publishedSlugs.concat(unpublishedSlugs);
const entriesLoaded = state.editorialWorkflow.ids;
//load unpublishedEntries
!entriesLoaded && dispatch(loadUnpublishedEntries(state.collections));
// Early return if draft contains validation errors
if (Object.keys(fieldsErrors).length > 0) {
const hasPresenceErrors = Object.values(fieldsErrors).find(errors =>
errors.some(error => error.type && error.type === ValidationErrorTypes.PRESENCE),
);
if (hasPresenceErrors) {
dispatch(
addSnackbar({
type: 'error',
message: {
key: 'ui.toast.missingRequiredField',
},
}),
);
}
return Promise.reject();
}
const backend = currentBackend(state.config.config);
const entry = entryDraft.entry;
if (!entry) {
return;
}
entry.status = WorkflowStatus.DRAFT;
const assetProxies = getMediaAssets({
entry,
});
let serializedEntry = getSerializedEntry(collection, entry);
serializedEntry = {
...serializedEntry,
raw: backend.entryToRaw(collection, serializedEntry, state.config.config),
};
const serializedEntryDraft: EntryDraft = {
...(entryDraft as EntryDraft),
entry: serializedEntry,
};
dispatch(unpublishedEntryPersisting(collection, entry.slug));
const persistAction = existingUnpublishedEntry
? backend.persistUnpublishedEntry
: backend.persistEntry;
try {
const newSlug = await persistAction.call(backend, {
config: state.config.config,
collection,
entryDraft: serializedEntryDraft,
assetProxies,
rootSlug,
usedSlugs,
status: WorkflowStatus.DRAFT,
});
dispatch(
addSnackbar({
type: 'success',
message: {
key: 'ui.toast.entrySaved',
},
}),
);
dispatch(unpublishedEntryPersisted(collection, serializedEntry));
if (entry.slug !== newSlug) {
navigate(`/collections/${collection.name}/entries/${newSlug}`);
return;
}
} catch (error) {
dispatch(
addSnackbar({
type: 'error',
message: {
key: 'ui.toast.onFailToPersist',
options: {
details: error,
},
},
}),
);
return Promise.reject(dispatch(unpublishedEntryPersistedFail(error, collection, entry.slug)));
}
};
}
export function updateUnpublishedEntryStatus(
collection: string,
slug: string,
oldStatus: WorkflowStatus,
newStatus: WorkflowStatus,
) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
if (oldStatus === newStatus) {
return;
}
const state = getState();
if (!state.config.config) {
return;
}
const backend = currentBackend(state.config.config);
dispatch(unpublishedEntryStatusChangeRequest(collection, slug));
backend
.updateUnpublishedEntryStatus(collection, slug, newStatus)
.then(() => {
dispatch(
addSnackbar({
type: 'success',
message: {
key: 'ui.toast.entryUpdated',
},
}),
);
dispatch(unpublishedEntryStatusChangePersisted(collection, slug, newStatus));
})
.catch((error: Error) => {
dispatch(
addSnackbar({
type: 'error',
message: {
key: 'ui.toast.onFailToUpdateStatus',
options: {
details: error,
},
},
}),
);
dispatch(unpublishedEntryStatusChangeError(collection, slug));
});
};
}
export function deleteUnpublishedEntry(collection: string, slug: string) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
if (!state.config.config) {
return;
}
const backend = currentBackend(state.config.config);
dispatch(unpublishedEntryDeleteRequest(collection, slug));
return backend
.deleteUnpublishedEntry(collection, slug)
.then(() => {
dispatch(
addSnackbar({
type: 'success',
message: {
key: 'ui.toast.onDeleteUnpublishedChanges',
},
}),
);
dispatch(unpublishedEntryDeleted(collection, slug));
})
.catch((error: Error) => {
dispatch(
addSnackbar({
type: 'error',
message: {
key: 'ui.toast.onDeleteUnpublishedChanges',
options: {
details: error,
},
},
}),
);
dispatch(unpublishedEntryDeleteError(collection, slug));
});
};
}
export function publishUnpublishedEntry(
collectionName: string,
slug: string,
navigate?: NavigateFunction,
) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
if (!state.config.config) {
return;
}
const collections = state.collections;
const backend = currentBackend(state.config.config);
const entry = selectUnpublishedEntry(state, collectionName, slug);
if (!entry) {
return;
}
dispatch(unpublishedEntryPublishRequest(collectionName, slug));
try {
const collection = collections[collectionName];
if (!collection) {
return;
}
await backend.publishUnpublishedEntry(collection, entry);
// re-load media after entry was published
dispatch(loadMedia());
dispatch(
addSnackbar({
type: 'success',
message: {
key: 'ui.toast.entryPublished',
},
}),
);
dispatch(unpublishedEntryPublished(collectionName, slug));
if ('nested' in collection) {
dispatch(loadEntries(collection));
const newSlug = slugFromCustomPath(collection, entry.path);
loadEntry(collection, newSlug);
if (slug !== newSlug && selectEditingDraft(state)) {
navigate?.(`/collections/${collection.name}/entries/${newSlug}`);
}
} else {
return dispatch(loadEntry(collection, slug));
}
} catch (error) {
dispatch(
addSnackbar({
type: 'error',
message: { key: 'ui.toast.onFailToPublishEntry', options: { details: error } },
}),
);
dispatch(unpublishedEntryPublishError(collectionName, slug));
}
};
}
export function unpublishPublishedEntry(collection: CollectionWithDefaults, slug: string) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
if (!state.config.config) {
return;
}
const backend = currentBackend(state.config.config);
const entry = selectEntry(state, collection.name, slug);
if (!entry) {
return;
}
const entryDraft: EntryDraft = { entry, fieldsErrors: {} };
dispatch(unpublishedEntryPersisting(collection, slug));
return backend
.deleteEntry(state, collection, slug)
.then(() =>
backend.persistEntry({
config: state.config.config as ConfigWithDefaults,
collection,
entryDraft,
assetProxies: [],
usedSlugs: [],
rootSlug: slug,
status: WorkflowStatus.PENDING_PUBLISH,
}),
)
.then(async () => {
dispatch(unpublishedEntryPersisted(collection, entry));
dispatch(entryDeleted(collection, slug));
await dispatch(loadUnpublishedEntry(collection, slug));
dispatch(
addSnackbar({
type: 'success',
message: {
key: 'ui.toast.entryUnpublished',
},
}),
);
})
.catch((error: Error) => {
dispatch(
addSnackbar({
type: 'error',
message: {
key: 'ui.toast.onFailToUnpublishEntry',
options: { details: error },
},
}),
);
dispatch(unpublishedEntryPersistedFail(error, collection, entry.slug));
});
};
}
export type EditorialWorkflowAction = ReturnType<
| typeof unpublishedEntryLoading
| typeof unpublishedEntryLoaded
| typeof unpublishedEntryRedirected
| typeof unpublishedEntriesLoading
| typeof unpublishedEntriesLoaded
| typeof unpublishedEntriesFailed
| typeof unpublishedEntryPersisting
| typeof unpublishedEntryPersisted
| typeof unpublishedEntryPersistedFail
| typeof unpublishedEntryStatusChangeRequest
| typeof unpublishedEntryStatusChangePersisted
| typeof unpublishedEntryStatusChangeError
| typeof unpublishedEntryPublishRequest
| typeof unpublishedEntryPublished
| typeof unpublishedEntryPublishError
| typeof unpublishedEntryDeleteRequest
| typeof unpublishedEntryDeleted
| typeof unpublishedEntryDeleteError
>;

View File

@ -41,11 +41,11 @@ import ValidationErrorTypes from '../constants/validationErrorTypes';
import { hasI18n, serializeI18n } from '../lib/i18n';
import { serializeValues } from '../lib/serializeEntryValues';
import { Cursor } from '../lib/util';
import { selectFields, updateFieldByKey } from '../lib/util/collection.util';
import { getFields, updateFieldByKey } from '../lib/util/collection.util';
import { createEmptyDraftData, createEmptyDraftI18nData } from '../lib/util/entry.util';
import { selectCollectionEntriesCursor } from '../reducers/selectors/cursors';
import {
selectEntriesSortField,
selectEntriesSelectedSort,
selectIsFetching,
selectPublishedSlugs,
} from '../reducers/selectors/entries';
@ -54,7 +54,6 @@ import { createAssetProxy } from '../valueObjects/AssetProxy';
import createEntry from '../valueObjects/createEntry';
import { addAssets, getAsset } from './media';
import { loadMedia } from './mediaLibrary';
import { waitUntil } from './waitUntil';
import type { NavigateFunction } from 'react-router-dom';
import type { AnyAction } from 'redux';
@ -62,7 +61,8 @@ import type { ThunkDispatch } from 'redux-thunk';
import type { Backend } from '../backend';
import type { ViewStyle } from '../constants/views';
import type {
Collection,
CollectionWithDefaults,
ConfigWithDefaults,
Entry,
EntryData,
EntryDraft,
@ -82,7 +82,7 @@ import type AssetProxy from '../valueObjects/AssetProxy';
* Simple Action Creators (Internal)
* We still need to export them for tests
*/
export function entryLoading(collection: Collection, slug: string) {
export function entryLoading(collection: CollectionWithDefaults, slug: string) {
return {
type: ENTRY_REQUEST,
payload: {
@ -92,7 +92,7 @@ export function entryLoading(collection: Collection, slug: string) {
} as const;
}
export function entryLoaded(collection: Collection, entry: Entry) {
export function entryLoaded(collection: CollectionWithDefaults, entry: Entry) {
return {
type: ENTRY_SUCCESS,
payload: {
@ -102,7 +102,7 @@ export function entryLoaded(collection: Collection, entry: Entry) {
} as const;
}
export function entryLoadError(error: Error, collection: Collection, slug: string) {
export function entryLoadError(error: Error, collection: CollectionWithDefaults, slug: string) {
return {
type: ENTRY_FAILURE,
payload: {
@ -113,7 +113,7 @@ export function entryLoadError(error: Error, collection: Collection, slug: strin
} as const;
}
export function entriesLoading(collection: Collection) {
export function entriesLoading(collection: CollectionWithDefaults) {
return {
type: ENTRIES_REQUEST,
payload: {
@ -122,7 +122,7 @@ export function entriesLoading(collection: Collection) {
} as const;
}
export function filterEntriesRequest(collection: Collection, filter: ViewFilter) {
export function filterEntriesRequest(collection: CollectionWithDefaults, filter: ViewFilter) {
return {
type: FILTER_ENTRIES_REQUEST,
payload: {
@ -132,7 +132,11 @@ export function filterEntriesRequest(collection: Collection, filter: ViewFilter)
} as const;
}
export function filterEntriesSuccess(collection: Collection, filter: ViewFilter, entries: Entry[]) {
export function filterEntriesSuccess(
collection: CollectionWithDefaults,
filter: ViewFilter,
entries: Entry[],
) {
return {
type: FILTER_ENTRIES_SUCCESS,
payload: {
@ -143,7 +147,11 @@ export function filterEntriesSuccess(collection: Collection, filter: ViewFilter,
} as const;
}
export function filterEntriesFailure(collection: Collection, filter: ViewFilter, error: unknown) {
export function filterEntriesFailure(
collection: CollectionWithDefaults,
filter: ViewFilter,
error: unknown,
) {
return {
type: FILTER_ENTRIES_FAILURE,
payload: {
@ -154,7 +162,7 @@ export function filterEntriesFailure(collection: Collection, filter: ViewFilter,
} as const;
}
export function groupEntriesRequest(collection: Collection, group: ViewGroup) {
export function groupEntriesRequest(collection: CollectionWithDefaults, group: ViewGroup) {
return {
type: GROUP_ENTRIES_REQUEST,
payload: {
@ -164,7 +172,11 @@ export function groupEntriesRequest(collection: Collection, group: ViewGroup) {
} as const;
}
export function groupEntriesSuccess(collection: Collection, group: ViewGroup, entries: Entry[]) {
export function groupEntriesSuccess(
collection: CollectionWithDefaults,
group: ViewGroup,
entries: Entry[],
) {
return {
type: GROUP_ENTRIES_SUCCESS,
payload: {
@ -175,7 +187,11 @@ export function groupEntriesSuccess(collection: Collection, group: ViewGroup, en
} as const;
}
export function groupEntriesFailure(collection: Collection, group: ViewGroup, error: unknown) {
export function groupEntriesFailure(
collection: CollectionWithDefaults,
group: ViewGroup,
error: unknown,
) {
return {
type: GROUP_ENTRIES_FAILURE,
payload: {
@ -186,7 +202,11 @@ export function groupEntriesFailure(collection: Collection, group: ViewGroup, er
} as const;
}
export function sortEntriesRequest(collection: Collection, key: string, direction: SortDirection) {
export function sortEntriesRequest(
collection: CollectionWithDefaults,
key: string,
direction: SortDirection,
) {
return {
type: SORT_ENTRIES_REQUEST,
payload: {
@ -198,7 +218,7 @@ export function sortEntriesRequest(collection: Collection, key: string, directio
}
export function sortEntriesSuccess(
collection: Collection,
collection: CollectionWithDefaults,
key: string,
direction: SortDirection,
entries: Entry[],
@ -215,7 +235,7 @@ export function sortEntriesSuccess(
}
export function sortEntriesFailure(
collection: Collection,
collection: CollectionWithDefaults,
key: string,
direction: SortDirection,
error: unknown,
@ -232,7 +252,7 @@ export function sortEntriesFailure(
}
export function entriesLoaded(
collection: Collection,
collection: CollectionWithDefaults,
entries: Entry[],
pagination: number | null,
cursor: Cursor,
@ -250,7 +270,7 @@ export function entriesLoaded(
} as const;
}
export function entriesFailed(collection: Collection, error: Error) {
export function entriesFailed(collection: CollectionWithDefaults, error: Error) {
return {
type: ENTRIES_FAILURE,
error: 'Failed to load entries',
@ -261,18 +281,18 @@ export function entriesFailed(collection: Collection, error: Error) {
} as const;
}
async function getAllEntries(state: RootState, collection: Collection) {
async function getAllEntries(state: RootState, collection: CollectionWithDefaults) {
const configState = state.config;
if (!configState.config) {
throw new Error('Config not loaded');
}
const backend = currentBackend(configState.config);
return backend.listAllEntries(collection);
return backend.listAllEntries(collection, configState.config);
}
export function sortByField(
collection: Collection,
collection: CollectionWithDefaults,
key: string,
direction: SortDirection = SORT_DIRECTION_ASCENDING,
) {
@ -295,7 +315,7 @@ export function sortByField(
};
}
export function filterByField(collection: Collection, filter: ViewFilter) {
export function filterByField(collection: CollectionWithDefaults, filter: ViewFilter) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
// if we're already fetching we update the filter key, but skip loading entries
@ -314,7 +334,7 @@ export function filterByField(collection: Collection, filter: ViewFilter) {
};
}
export function groupByField(collection: Collection, group: ViewGroup) {
export function groupByField(collection: CollectionWithDefaults, group: ViewGroup) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const isFetching = selectIsFetching(state, collection.name);
@ -354,7 +374,7 @@ export function changeViewStyle(viewStyle: ViewStyle) {
} as const;
}
export function entryPersisting(collection: Collection, entry: Entry) {
export function entryPersisting(collection: CollectionWithDefaults, entry: Entry) {
return {
type: ENTRY_PERSIST_REQUEST,
payload: {
@ -364,7 +384,7 @@ export function entryPersisting(collection: Collection, entry: Entry) {
} as const;
}
export function entryPersisted(collection: Collection, entry: Entry, slug: string) {
export function entryPersisted(collection: CollectionWithDefaults, entry: Entry, slug: string) {
return {
type: ENTRY_PERSIST_SUCCESS,
payload: {
@ -379,7 +399,7 @@ export function entryPersisted(collection: Collection, entry: Entry, slug: strin
} as const;
}
export function entryPersistFail(collection: Collection, entry: Entry, error: Error) {
export function entryPersistFail(collection: CollectionWithDefaults, entry: Entry, error: Error) {
return {
type: ENTRY_PERSIST_FAILURE,
error: 'Failed to persist entry',
@ -391,7 +411,7 @@ export function entryPersistFail(collection: Collection, entry: Entry, error: Er
} as const;
}
export function entryDeleting(collection: Collection, slug: string) {
export function entryDeleting(collection: CollectionWithDefaults, slug: string) {
return {
type: ENTRY_DELETE_REQUEST,
payload: {
@ -401,7 +421,7 @@ export function entryDeleting(collection: Collection, slug: string) {
} as const;
}
export function entryDeleted(collection: Collection, slug: string) {
export function entryDeleted(collection: CollectionWithDefaults, slug: string) {
return {
type: ENTRY_DELETE_SUCCESS,
payload: {
@ -411,7 +431,7 @@ export function entryDeleted(collection: Collection, slug: string) {
} as const;
}
export function entryDeleteFail(collection: Collection, slug: string, error: Error) {
export function entryDeleteFail(collection: CollectionWithDefaults, slug: string, error: Error) {
return {
type: ENTRY_DELETE_FAILURE,
payload: {
@ -431,23 +451,13 @@ export function emptyDraftCreated(entry: Entry) {
/*
* Exported simple Action Creators
*/
export function createDraftFromEntry(collection: Collection, entry: Entry) {
export function createDraftFromEntry(collection: CollectionWithDefaults, entry: Entry) {
return {
type: DRAFT_CREATE_FROM_ENTRY,
payload: { collection, entry },
} as const;
}
export function draftDuplicateEntry(entry: Entry) {
return {
type: DRAFT_CREATE_DUPLICATE_FROM_ENTRY,
payload: createEntry(entry.collection, '', '', {
data: entry.data,
mediaFiles: entry.mediaFiles,
}),
} as const;
}
export function discardDraft() {
return { type: DRAFT_DISCARD } as const;
}
@ -528,7 +538,18 @@ export function removeDraftEntryMediaFile({ id }: { id: string }) {
return { type: REMOVE_DRAFT_ENTRY_MEDIA_FILE, payload: { id } } as const;
}
export function persistLocalBackup(entry: Entry, collection: Collection) {
export function loadBackup() {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
if (!state.entryDraft.localBackup) {
return;
}
dispatch(loadLocalBackup());
};
}
export function persistLocalBackup(entry: Entry, collection: CollectionWithDefaults) {
return (_dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const configState = state.config;
@ -538,22 +559,21 @@ export function persistLocalBackup(entry: Entry, collection: Collection) {
const backend = currentBackend(configState.config);
return backend.persistLocalDraftBackup(entry, collection);
return backend.persistLocalDraftBackup(entry, collection, configState.config);
};
}
export function createDraftDuplicateFromEntry(entry: Entry) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>) => {
dispatch(
waitUntil({
predicate: ({ type }) => type === DRAFT_CREATE_EMPTY,
run: () => dispatch(draftDuplicateEntry(entry)),
}),
);
};
return {
type: DRAFT_CREATE_DUPLICATE_FROM_ENTRY,
payload: createEntry(entry.collection, '', '', {
data: entry.data,
mediaFiles: entry.mediaFiles,
}),
} as const;
}
export function retrieveLocalBackup(collection: Collection, slug: string) {
export function retrieveLocalBackup(collection: CollectionWithDefaults, slug: string) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const configState = state.config;
@ -562,7 +582,7 @@ export function retrieveLocalBackup(collection: Collection, slug: string) {
}
const backend = currentBackend(configState.config);
const { entry } = await backend.getLocalDraftBackup(collection, slug);
const { entry } = await backend.getLocalDraftBackup(collection, configState.config, slug);
if (entry) {
// load assets from backup
@ -590,7 +610,7 @@ export function retrieveLocalBackup(collection: Collection, slug: string) {
};
}
export function deleteLocalBackup(collection: Collection, slug: string) {
export function deleteLocalBackup(collection: CollectionWithDefaults, slug: string) {
return (_dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const configState = state.config;
@ -607,7 +627,7 @@ export function deleteLocalBackup(collection: Collection, slug: string) {
* Exported Thunk Action Creators
*/
export function loadEntry(collection: Collection, slug: string, silent = false) {
export function loadEntry(collection: CollectionWithDefaults, slug: string, silent = false) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
if (!silent) {
dispatch(entryLoading(collection, slug));
@ -638,14 +658,18 @@ export function loadEntry(collection: Collection, slug: string, silent = false)
};
}
export async function tryLoadEntry(state: RootState, collection: Collection, slug: string) {
export async function tryLoadEntry(
state: RootState,
collection: CollectionWithDefaults,
slug: string,
) {
const configState = state.config;
if (!configState.config) {
throw new Error('Config not loaded');
}
const backend = currentBackend(configState.config);
return backend.getEntry(state, collection, slug);
return backend.getEntry(state, collection, configState.config, slug);
}
interface AppendAction {
@ -669,13 +693,13 @@ function addAppendActionsToCursor(cursor: Cursor) {
}));
}
export function loadEntries(collection: Collection, page = 0) {
export function loadEntries(collection: CollectionWithDefaults, page = 0) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
if (collection.isFetching) {
return;
}
const state = getState();
const sortField = selectEntriesSortField(collection.name)(state);
const sortField = selectEntriesSelectedSort(state, collection.name);
if (sortField) {
return dispatch(sortByField(collection, sortField.key, sortField.direction));
}
@ -698,8 +722,10 @@ export function loadEntries(collection: Collection, page = 0) {
entries: Entry[];
} = await (loadAllEntries
? // nested collections require all entries to construct the tree
backend.listAllEntries(collection).then((entries: Entry[]) => ({ entries }))
: backend.listEntries(collection));
backend
.listAllEntries(collection, configState.config)
.then((entries: Entry[]) => ({ entries }))
: backend.listEntries(collection, configState.config));
const cleanResponse = {
...response,
@ -745,14 +771,19 @@ export function loadEntries(collection: Collection, page = 0) {
};
}
function traverseCursor(backend: Backend, cursor: Cursor, action: string) {
function traverseCursor(
backend: Backend,
cursor: Cursor,
action: string,
config: ConfigWithDefaults,
) {
if (!cursor.actions!.has(action)) {
throw new Error(`The current cursor does not support the pagination action "${action}".`);
}
return backend.traverseCursor(cursor, action);
return backend.traverseCursor(cursor, action, config);
}
export function traverseCollectionCursor(collection: Collection, action: string) {
export function traverseCollectionCursor(collection: CollectionWithDefaults, action: string) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const collectionName = collection.name;
@ -783,7 +814,12 @@ export function traverseCollectionCursor(collection: Collection, action: string)
try {
dispatch(entriesLoading(collection));
const { entries, cursor: newCursor } = await traverseCursor(backend, cursor, realAction);
const { entries, cursor: newCursor } = await traverseCursor(
backend,
cursor,
realAction,
configState.config,
);
const pagination = newCursor.meta?.page as number | null;
return dispatch(
@ -831,7 +867,7 @@ function processValue(unsafe: string) {
return escapeHtml(unsafe);
}
export function createEmptyDraft(collection: Collection, search: string) {
export function createEmptyDraft(collection: CollectionWithDefaults, search: string) {
return async (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
if ('files' in collection) {
return;
@ -886,12 +922,12 @@ export function getMediaAssets({ entry }: { entry: Entry }) {
return assets;
}
export function getSerializedEntry(collection: Collection, entry: Entry): Entry {
export function getSerializedEntry(collection: CollectionWithDefaults, entry: Entry): Entry {
/**
* Serialize the values of any fields with registered serializers, and
* update the entry and entryDraft with the serialized values.
*/
const fields = selectFields(collection, entry.slug);
const fields = getFields(collection, entry.slug);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function serializeData(data: any) {
@ -911,7 +947,7 @@ export function getSerializedEntry(collection: Collection, entry: Entry): Entry
}
export function persistEntry(
collection: Collection,
collection: CollectionWithDefaults,
rootSlug: string | undefined,
navigate: NavigateFunction,
) {
@ -919,7 +955,7 @@ export function persistEntry(
const state = getState();
const entryDraft = state.entryDraft;
const fieldsErrors = entryDraft.fieldsErrors;
const usedSlugs = selectPublishedSlugs(collection.name)(state);
const usedSlugs = selectPublishedSlugs(state, collection.name);
// Early return if draft contains validation errors
if (Object.keys(fieldsErrors).length > 0) {
@ -983,6 +1019,7 @@ export function persistEntry(
entryDraft: newEntryDraft,
assetProxies,
usedSlugs,
status: entry.status,
})
.then(async (newSlug: string) => {
dispatch(
@ -1027,7 +1064,7 @@ export function persistEntry(
};
}
export function deleteEntry(collection: Collection, slug: string) {
export function deleteEntry(collection: CollectionWithDefaults, slug: string) {
return (dispatch: ThunkDispatch<RootState, {}, AnyAction>, getState: () => RootState) => {
const state = getState();
const configState = state.config;
@ -1077,7 +1114,7 @@ export type EntriesAction = ReturnType<
| typeof entryDeleteFail
| typeof emptyDraftCreated
| typeof createDraftFromEntry
| typeof draftDuplicateEntry
| typeof createDraftDuplicateFromEntry
| typeof discardDraft
| typeof updateDraft
| typeof changeDraftField

View File

@ -1,8 +1,14 @@
/* eslint-disable import/prefer-default-export */
import { THEME_CHANGE } from '../constants';
import { THEME_CHANGE, USE_OPEN_AUTHORING } from '../constants';
export function changeTheme(theme: 'dark' | 'light') {
export function useOpenAuthoring() {
return {
type: USE_OPEN_AUTHORING,
} as const;
}
export function changeTheme(theme: string) {
return { type: THEME_CHANGE, payload: theme } as const;
}
export type GlobalUIAction = ReturnType<typeof changeTheme>;
export type GlobalUIAction = ReturnType<typeof changeTheme | typeof useOpenAuthoring>;

View File

@ -12,7 +12,14 @@ import { getMediaFile } from './mediaLibrary';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type { BaseField, Collection, Entry, Field, MediaField, UnknownField } from '../interface';
import type {
BaseField,
CollectionWithDefaults,
Entry,
Field,
MediaField,
UnknownField,
} from '../interface';
import type { RootState } from '../store';
import type AssetProxy from '../valueObjects/AssetProxy';
@ -71,7 +78,7 @@ async function loadAsset(
const promiseCache: Record<string, Promise<AssetProxy>> = {};
export function getAsset<T extends MediaField, EF extends BaseField = UnknownField>(
collection: Collection<EF> | null | undefined,
collection: CollectionWithDefaults<EF> | null | undefined,
entry: Entry | null | undefined,
path: string,
field?: T,
@ -88,7 +95,7 @@ export function getAsset<T extends MediaField, EF extends BaseField = UnknownFie
const resolvedPath = selectMediaFilePath(
state.config.config,
collection as Collection,
collection as CollectionWithDefaults,
entry,
path,
field as Field,

View File

@ -33,8 +33,8 @@ import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import type {
BaseField,
Collection,
CollectionFile,
CollectionWithDefaults,
CollectionFileWithDefaults,
DisplayURLState,
Field,
ImplementationMediaFile,
@ -57,8 +57,8 @@ export function openMediaLibrary<EF extends BaseField = UnknownField>(
allowMultiple?: boolean;
replaceIndex?: number;
config?: MediaLibraryConfig;
collection?: Collection<EF>;
collectionFile?: CollectionFile<EF>;
collection?: CollectionWithDefaults<EF>;
collectionFile?: CollectionFileWithDefaults<EF>;
field?: EF;
insertOptions?: MediaLibrarInsertOptions;
} = {},
@ -89,8 +89,8 @@ export function openMediaLibrary<EF extends BaseField = UnknownField>(
allowMultiple,
replaceIndex,
config,
collection: collection as Collection,
collectionFile: collectionFile as CollectionFile,
collection: collection as CollectionWithDefaults,
collectionFile: collectionFile as CollectionFileWithDefaults,
field: field as Field,
insertOptions,
},
@ -226,7 +226,7 @@ export function persistMedia(
}
const backend = currentBackend(config);
const files: MediaFile[] = selectMediaFiles(field)(state);
const files: MediaFile[] = selectMediaFiles(state, field);
const fileName = sanitizeSlug(file.name.toLowerCase(), config.slug);
const existingFile = files.find(existingFile => existingFile.name.toLowerCase() === fileName);

View File

@ -121,6 +121,7 @@ export function searchEntries(searchTerm: string, searchCollections: string[], p
.filter(([key, _value]) => allCollections.indexOf(key) !== -1)
.map(([_key, value]) => value),
searchTerm,
configState.config,
);
return dispatch(searchSuccess(response.entries, page));
@ -163,6 +164,7 @@ export function query(
try {
const response: SearchQueryResponse = await backend.query(
collection,
configState.config,
searchFields,
searchTerm,
file,

View File

@ -4,10 +4,11 @@ import flatten from 'lodash/flatten';
import get from 'lodash/get';
import isError from 'lodash/isError';
import uniq from 'lodash/uniq';
import { dirname } from 'path';
import { dirname, extname } from 'path';
import { DRAFT_MEDIA_FILES } from './constants/mediaLibrary';
import { resolveFormat } from './formats/formats';
import { WorkflowStatus, workflowStatusFromString } from './constants/publishModes';
import { formatExtensions, resolveFormat } from './formats/formats';
import { commitMessageFormatter, slugFormatter } from './lib/formatters';
import {
I18N_STRUCTURE_MULTIPLE_FILES,
@ -15,6 +16,7 @@ import {
formatI18nBackup,
getFilePaths,
getI18nBackup,
getI18nDataFiles,
getI18nEntry,
getI18nFiles,
getI18nFilesDepth,
@ -32,8 +34,10 @@ import {
getPathDepth,
localForage,
} from './lib/util';
import { EDITORIAL_WORKFLOW_ERROR } from './lib/util/EditorialWorkflowError';
import { getEntryBackupKey } from './lib/util/backup.util';
import {
getFields,
selectAllowDeletion,
selectAllowNewEntries,
selectEntryPath,
@ -47,10 +51,11 @@ import {
import filterEntries from './lib/util/filter.util';
import { selectMediaFilePublicPath } from './lib/util/media.util';
import { selectCustomPath, slugFromCustomPath } from './lib/util/nested.util';
import { isNullish } from './lib/util/null.util';
import { isNotNullish, isNullish } from './lib/util/null.util';
import { fileSearch, sortByScore } from './lib/util/search.util';
import set from './lib/util/set.util';
import { dateParsers, expandPath, extractTemplateVars } from './lib/widgets/stringTemplate';
import { getUseWorkflow } from './reducers/selectors/config';
import createEntry from './valueObjects/createEntry';
import type {
@ -58,9 +63,10 @@ import type {
BackendInitializer,
BackupEntry,
BaseField,
Collection,
CollectionFile,
Config,
CollectionWithDefaults,
CollectionsWithDefaults,
ConfigWithDefaults,
Credentials,
DataFile,
DisplayURL,
@ -68,6 +74,7 @@ import type {
EntryData,
EventData,
FilterRule,
FolderCollectionWithDefaults,
I18nInfo,
ImplementationEntry,
MediaField,
@ -76,6 +83,8 @@ import type {
SearchQueryResponse,
SearchResponse,
UnknownField,
UnpublishedEntry,
UnpublishedEntryDiff,
User,
ValueOrNestedValue,
} from './interface';
@ -83,6 +92,8 @@ import type { AsyncLock } from './lib/util';
import type { RootState } from './store';
import type AssetProxy from './valueObjects/AssetProxy';
const LIST_ALL_ENTRIES_CACHE_TIME = 5000;
function updatePath(entryPath: string, assetPath: string): string | null {
const pathDir = dirname(entryPath);
@ -185,19 +196,22 @@ export function expandSearchEntries(
field: string;
})[] {
// expand the entries for the purpose of the search
const expandedEntries = entries.reduce((acc, e) => {
const expandedFields = searchFields.reduce((acc, f) => {
const fields = expandPath({ data: e.data, path: f });
acc.push(...fields);
const expandedEntries = entries.reduce(
(acc, e) => {
const expandedFields = searchFields.reduce((acc, f) => {
const fields = expandPath({ data: e.data, path: f });
acc.push(...fields);
return acc;
}, [] as string[]);
for (let i = 0; i < expandedFields.length; i++) {
acc.push({ ...e, field: expandedFields[i] });
}
return acc;
}, [] as string[]);
for (let i = 0; i < expandedFields.length; i++) {
acc.push({ ...e, field: expandedFields[i] });
}
return acc;
}, [] as (Entry & { field: string })[]);
},
[] as (Entry & { field: string })[],
);
return expandedEntries;
}
@ -207,31 +221,34 @@ export function mergeExpandedEntries(entries: (Entry & { field: string })[]): En
const fields = entries.map(f => f.field);
const arrayPaths: Record<string, Set<string>> = {};
const merged = entries.reduce((acc, e) => {
if (!acc[e.slug]) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { field, ...rest } = e;
acc[e.slug] = rest;
arrayPaths[e.slug] = new Set();
}
const nestedFields = e.field.split('.');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let value = acc[e.slug].data as any;
for (let i = 0; i < nestedFields.length; i++) {
value = value[nestedFields[i]];
if (Array.isArray(value)) {
const path = nestedFields.slice(0, i + 1).join('.');
arrayPaths[e.slug] = arrayPaths[e.slug].add(path);
const merged = entries.reduce(
(acc, e) => {
if (!acc[e.slug]) {
const { field: _field, ...rest } = e;
acc[e.slug] = rest;
arrayPaths[e.slug] = new Set();
}
}
return acc;
}, {} as Record<string, Entry>);
const nestedFields = e.field.split('.');
let value: ValueOrNestedValue = acc[e.slug].data;
for (let i = 0; i < nestedFields.length; i++) {
if (isNotNullish(value)) {
value = value[nestedFields[i]];
if (Array.isArray(value)) {
const path = nestedFields.slice(0, i + 1).join('.');
arrayPaths[e.slug] = arrayPaths[e.slug].add(path);
}
}
}
return acc;
},
{} as Record<string, Entry>,
);
// this keeps the search score sorting order designated by the order in entries
// and filters non matching items
Object.keys(merged).forEach(slug => {
return Object.keys(merged).map(slug => {
let data = merged[slug].data ?? {};
for (const path of arrayPaths[slug]) {
const array = get(data, path) as unknown[];
@ -252,9 +269,12 @@ export function mergeExpandedEntries(entries: (Entry & { field: string })[]): En
data = set(data, path, filtered);
}
});
return Object.values(merged);
return {
...merged[slug],
data,
};
});
}
interface AuthStore {
@ -265,7 +285,7 @@ interface AuthStore {
interface BackendOptions<EF extends BaseField> {
backendName: string;
config: Config<EF>;
config: ConfigWithDefaults<EF>;
authStore?: AuthStore;
}
@ -285,7 +305,21 @@ export interface MediaFile {
isDirectory?: boolean;
}
function collectionDepth<EF extends BaseField>(collection: Collection<EF>) {
function selectHasMetaPath(
collection: CollectionWithDefaults,
): collection is FolderCollectionWithDefaults {
return Boolean('folder' in collection && collection.meta?.path);
}
function prepareMetaPath(path: string, collection: CollectionWithDefaults) {
if (!selectHasMetaPath(collection)) {
return path;
}
const dir = dirname(path);
return dir.slice(collection.folder!.length + 1) || '/';
}
function collectionDepth<EF extends BaseField>(collection: CollectionWithDefaults<EF>) {
let depth;
depth =
('nested' in collection && collection.nested?.depth) || getPathDepth(collection.path ?? '');
@ -297,19 +331,21 @@ function collectionDepth<EF extends BaseField>(collection: Collection<EF>) {
return depth;
}
function i18nRuleString(ruleString: string, { defaultLocale, structure }: I18nInfo): string {
function i18nRuleString(ruleString: string, { default_locale, structure }: I18nInfo): string {
if (structure === I18N_STRUCTURE_MULTIPLE_FOLDERS) {
return `${defaultLocale}\\/${ruleString}`;
return `${default_locale}\\/${ruleString}`;
}
if (structure === I18N_STRUCTURE_MULTIPLE_FILES) {
return `${ruleString}\\.${defaultLocale}\\..*`;
return `${ruleString}\\.${default_locale}\\..*`;
}
return ruleString;
}
function collectionRegex<EF extends BaseField>(collection: Collection<EF>): RegExp | undefined {
function collectionRegex<EF extends BaseField>(
collection: CollectionWithDefaults<EF>,
): RegExp | undefined {
let ruleString = '';
if ('folder' in collection && collection.path) {
@ -326,7 +362,7 @@ function collectionRegex<EF extends BaseField>(collection: Collection<EF>): RegE
export class Backend<EF extends BaseField = UnknownField, BC extends BackendClass = BackendClass> {
implementation: BC;
backendName: string;
config: Config<EF>;
config: ConfigWithDefaults<EF>;
authStore?: AuthStore;
user?: User | null;
backupSync: AsyncLock;
@ -339,7 +375,9 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
this.deleteAnonymousBackup();
this.config = config;
this.implementation = implementation.init(this.config, {
useWorkflow: getUseWorkflow(this.config as ConfigWithDefaults),
updateUserCredentials: this.updateUserCredentials,
initialWorkflowStatus: WorkflowStatus.DRAFT,
}) as BC;
this.backendName = backendName;
this.authStore = authStore;
@ -386,10 +424,6 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
return Promise.resolve(null);
}
isGitBackend() {
return this.implementation.isGitBackend?.() || false;
}
updateUserCredentials = (updatedCredentials: Credentials) => {
const storedUser = this.authStore!.retrieve();
if (storedUser && storedUser.backendName === this.backendName) {
@ -429,7 +463,27 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
getToken = () => this.implementation.getToken();
async entryExist(path: string) {
async entryExist(
collection: CollectionWithDefaults,
path: string,
slug: string,
useWorkflow: boolean,
) {
const unpublishedEntry =
useWorkflow &&
(await this.implementation
.unpublishedEntry({ collection: collection.name, slug })
.catch(error => {
if (error.name === EDITORIAL_WORKFLOW_ERROR && error.notUnderEditorialWorkflow) {
return Promise.resolve(false);
}
return Promise.reject(error);
}));
if (unpublishedEntry) {
return unpublishedEntry;
}
const publishedEntry = await this.implementation
.getEntry(path)
.then(({ data }) => data)
@ -441,9 +495,9 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
}
async generateUniqueSlug(
collection: Collection,
entryData: EntryData,
config: Config,
collection: CollectionWithDefaults,
entry: Entry,
config: ConfigWithDefaults,
usedSlugs: string[],
customPath: string | undefined,
) {
@ -452,15 +506,21 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
if (customPath) {
slug = slugFromCustomPath(collection, customPath);
} else {
slug = slugFormatter(collection, entryData, slugConfig);
const collectionFields = getFields(collection, entry.slug);
slug = slugFormatter(collection, entry.data, slugConfig, collectionFields);
}
let i = 1;
let uniqueSlug = slug;
// Check for duplicate slug in loaded entities store first before repo
// Check for duplicate slug in loaded entries store first before repo
while (
usedSlugs.includes(uniqueSlug) ||
(await this.entryExist(selectEntryPath(collection, uniqueSlug) as string))
(await this.entryExist(
collection,
selectEntryPath(collection, uniqueSlug) as string,
uniqueSlug,
getUseWorkflow(config),
))
) {
uniqueSlug = `${slug}${sanitizeChar(' ', slugConfig)}${i++}`;
}
@ -469,7 +529,8 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
processEntries<EF extends BaseField>(
loadedEntries: ImplementationEntry[],
collection: Collection<EF>,
collection: CollectionWithDefaults<EF>,
config: ConfigWithDefaults<EF>,
): Entry[] {
const entries = loadedEntries.map(loadedEntry =>
createEntry(
@ -484,7 +545,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
},
),
);
const formattedEntries = entries.map(this.entryWithFormat(collection));
const formattedEntries = entries.map(this.entryWithFormat(collection, config));
// If this collection has a "filter" property, filter entries accordingly
const collectionFilter = collection.filter;
const filteredEntries = collectionFilter
@ -500,7 +561,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
return filteredEntries;
}
async listEntries(collection: Collection) {
async listEntries(collection: CollectionWithDefaults, config: ConfigWithDefaults) {
const extension = selectFolderEntryExtension(collection);
let listMethod: () => Promise<ImplementationEntry[]>;
if ('folder' in collection) {
@ -528,18 +589,19 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
collection,
});
return {
entries: this.processEntries(loadedEntries, collection),
entries: this.processEntries(loadedEntries, collection, config),
pagination: cursor.meta?.page,
cursor,
};
}
// The same as listEntries, except that if a cursor with the "next"
// action available is returned, it calls "next" on the cursor and
// 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<EF extends BaseField>(collection: Collection<EF>) {
backendPromise: Record<string, { expires: number; data?: Entry[]; promise?: Promise<Entry[]> }> =
{};
async listAllEntriesExecutor<EF extends BaseField>(
collection: CollectionWithDefaults<EF>,
config: ConfigWithDefaults<EF>,
): Promise<Entry[]> {
if ('folder' in collection && collection.folder && this.implementation.allEntriesByFolder) {
const depth = collectionDepth(collection);
const extension = selectFolderEntryExtension(collection);
@ -550,21 +612,80 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
depth,
collectionRegex(collection),
)
.then(entries => this.processEntries(entries, collection));
.then(entries => this.processEntries(entries, collection, config));
}
const response = await this.listEntries(collection as Collection);
const response = await this.listEntries(
collection as CollectionWithDefaults,
config as ConfigWithDefaults,
);
const { entries } = response;
let { cursor } = response;
while (cursor && cursor.actions?.has('next')) {
const { entries: newEntries, cursor: newCursor } = await this.traverseCursor(cursor, 'next');
const { entries: newEntries, cursor: newCursor } = await this.traverseCursor(
cursor,
'next',
config as ConfigWithDefaults,
);
entries.push(...newEntries);
cursor = newCursor;
}
return entries;
}
async search(collections: Collection[], searchTerm: string): Promise<SearchResponse> {
// The same as listEntries, except that if a cursor with the "next"
// action available is returned, it calls "next" on the cursor and
// 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<EF extends BaseField>(
collection: CollectionWithDefaults<EF>,
config: ConfigWithDefaults<EF>,
): Promise<Entry[]> {
const now = new Date().getTime();
if (collection.name in this.backendPromise) {
const cachedRequest = this.backendPromise[collection.name];
if (cachedRequest && cachedRequest.expires >= now) {
if (cachedRequest.data) {
return Promise.resolve(cachedRequest.data);
}
if (cachedRequest.promise) {
return cachedRequest.promise;
}
}
delete this.backendPromise[collection.name];
}
const p = new Promise<Entry[]>(resolve => {
this.listAllEntriesExecutor(collection, config).then(entries => {
const responseNow = new Date().getTime();
this.backendPromise[collection.name] = {
expires: responseNow + LIST_ALL_ENTRIES_CACHE_TIME,
data: entries,
};
resolve(entries);
});
});
this.backendPromise[collection.name] = {
expires: now + LIST_ALL_ENTRIES_CACHE_TIME,
promise: p,
};
return p;
}
printError(error: Error) {
return `\n\n${error.stack}`;
}
async search(
collections: CollectionWithDefaults[],
searchTerm: string,
config: ConfigWithDefaults,
): Promise<SearchResponse> {
// Perform a local search by requesting all entries. For each
// collection, load it, search, and call onCollectionResults with
// its results.
@ -596,7 +717,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
];
}
const filteredSearchFields = searchFields.filter(Boolean) as string[];
const collectionEntries = await this.listAllEntries(collection);
const collectionEntries = await this.listAllEntries(collection, config);
return fuzzy.filter(searchTerm, collectionEntries, {
extract: extractSearchFields(uniq(filteredSearchFields)),
});
@ -611,9 +732,9 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
const entries = await Promise.all(collectionEntriesRequests).then(arrays => flatten(arrays));
if (errors.length > 0) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
throw new Error({ message: 'Errors occurred while searching entries locally!', errors });
throw new Error(
`Errors occurred while searching entries locally!${errors.map(this.printError)}`,
);
}
const hits = entries
@ -624,13 +745,17 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
}
async query<EF extends BaseField>(
collection: Collection<EF>,
collection: CollectionWithDefaults<EF>,
config: ConfigWithDefaults<EF>,
searchFields: string[],
searchTerm: string,
file?: string,
limit?: number,
): Promise<SearchQueryResponse> {
const entries = await this.listAllEntries(collection as Collection);
const entries = await this.listAllEntries(
collection as CollectionWithDefaults,
config as ConfigWithDefaults,
);
if (file) {
let hits = fileSearch(
entries.find(e => e.slug === file),
@ -663,13 +788,17 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
return { query: searchTerm, hits: merged };
}
traverseCursor(cursor: Cursor, action: string): Promise<{ entries: Entry[]; cursor: Cursor }> {
traverseCursor(
cursor: Cursor,
action: string,
config: ConfigWithDefaults,
): Promise<{ entries: Entry[]; cursor: Cursor }> {
const [data, unwrappedCursor] = cursor.unwrapData();
// TODO: stop assuming all cursors are for collections
const collection = data.collection as Collection;
const collection = data.collection as CollectionWithDefaults;
return this.implementation.traverseCursor!(unwrappedCursor, action).then(
async ({ entries, cursor: newCursor }) => ({
entries: this.processEntries(entries, collection),
entries: this.processEntries(entries, collection, config),
cursor: Cursor.create(newCursor).wrapData({
cursorType: 'collectionEntries',
collection,
@ -679,7 +808,8 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
}
async getLocalDraftBackup(
collection: Collection,
collection: CollectionWithDefaults,
config: ConfigWithDefaults,
slug: string,
): Promise<{ entry: Entry | null }> {
const key = getEntryBackupKey(collection.name, slug);
@ -701,7 +831,10 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
const label = selectFileEntryLabel(collection, slug);
const formatRawData = (raw: string) => {
return this.entryWithFormat(collection)(
return this.entryWithFormat(
collection,
config,
)(
createEntry(collection.name, slug, path, {
raw,
label,
@ -719,11 +852,15 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
return { entry };
}
async persistLocalDraftBackup(entry: Entry, collection: Collection) {
async persistLocalDraftBackup(
entry: Entry,
collection: CollectionWithDefaults,
config: ConfigWithDefaults,
) {
try {
await this.backupSync.acquire();
const key = getEntryBackupKey(collection.name, entry.slug);
const raw = this.entryToRaw(collection, entry);
const raw = this.entryToRaw(collection, entry, config);
if (!raw.trim()) {
return;
@ -742,7 +879,9 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
let i18n;
if (hasI18n(collection)) {
i18n = getI18nBackup(collection, entry, entry => this.entryToRaw(collection, entry));
i18n = getI18nBackup(collection, entry, entry =>
this.entryToRaw(collection, entry, config),
);
}
await localForage.setItem<BackupEntry>(key, {
@ -760,7 +899,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
}
}
async deleteLocalDraftBackup(collection: Collection, slug: string) {
async deleteLocalDraftBackup(collection: CollectionWithDefaults, slug: string) {
try {
await this.backupSync.acquire();
await localForage.removeItem(getEntryBackupKey(collection.name, slug));
@ -783,7 +922,8 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
async getEntry<EF extends BaseField>(
state: RootState<EF>,
collection: Collection<EF>,
collection: CollectionWithDefaults<EF>,
config: ConfigWithDefaults<EF>,
slug: string,
) {
const path = selectEntryPath(collection, slug) as string;
@ -798,7 +938,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
mediaFiles: [],
});
entry = this.entryWithFormat(collection)(entry);
entry = this.entryWithFormat(collection, config)(entry);
entry = await this.processEntry(state, collection, entry);
return entry;
@ -833,14 +973,21 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
return Promise.reject(err);
}
entryWithFormat<EF extends BaseField>(collection: Collection<EF>) {
entryWithFormat<EF extends BaseField>(
collection: CollectionWithDefaults<EF>,
config: ConfigWithDefaults<EF>,
) {
return (entry: Entry): Entry => {
const format = resolveFormat(collection, entry);
if (entry && entry.raw !== undefined) {
const data = (format && attempt(format.fromFile.bind(format, entry.raw))) || {};
const data =
(format &&
attempt(format.fromFile.bind(format, entry.raw, config as ConfigWithDefaults))) ||
{};
if (isError(data)) {
console.error(data);
}
return Object.assign(entry, { data: isError(data) ? {} : data });
}
@ -850,7 +997,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
async processEntry<EF extends BaseField>(
state: RootState<EF>,
collection: Collection<EF>,
collection: CollectionWithDefaults<EF>,
entry: Entry,
) {
const configState = state.config;
@ -891,6 +1038,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
entryDraft: draft,
assetProxies,
usedSlugs,
unpublished = false,
status,
}: PersistArgs) {
const modifiedData = await this.invokePreSaveEvent(draft.entry, collection);
@ -906,6 +1054,8 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
const newEntry = entryDraft.entry.newRecord ?? false;
const useWorkflow = getUseWorkflow(config);
const customPath = selectCustomPath(draft.entry, collection, rootSlug, config.slug);
let dataFile: DataFile;
@ -915,7 +1065,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
}
const slug = await this.generateUniqueSlug(
collection,
entryDraft.entry.data,
entryDraft.entry,
config,
usedSlugs,
customPath,
@ -929,14 +1079,15 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
dataFile = {
path,
slug,
raw: this.entryToRaw(collection, entryDraft.entry),
raw: this.entryToRaw(collection, entryDraft.entry, config),
};
} else {
const slug = entryDraft.entry.slug;
dataFile = {
path: entryDraft.entry.path,
slug: customPath ? slugFromCustomPath(collection, customPath) : slug,
raw: this.entryToRaw(collection, entryDraft.entry),
// for workflow entries we refresh the slug on publish
slug: customPath && !useWorkflow ? slugFromCustomPath(collection, customPath) : slug,
raw: this.entryToRaw(collection, entryDraft.entry, config),
newPath: customPath,
};
}
@ -950,7 +1101,7 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
collection,
extension,
entryDraft.entry,
(draftData: Entry) => this.entryToRaw(collection, draftData),
(draftData: Entry) => this.entryToRaw(collection, draftData, config),
path,
slug,
newPath,
@ -958,24 +1109,34 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
}
const user = (await this.currentUser()) as User;
const commitMessage = commitMessageFormatter(newEntry ? 'create' : 'update', config, {
collection,
slug,
path,
authorLogin: user.login,
authorName: user.name,
});
const commitMessage = commitMessageFormatter(
newEntry ? 'create' : 'update',
config,
{
collection,
slug,
path,
authorLogin: user.login,
authorName: user.name,
},
user.useOpenAuthoring,
);
const collectionName = collection.name;
const updatedOptions = { status };
const opts = {
newEntry,
commitMessage,
collectionName,
...updatedOptions,
useWorkflow,
unpublished,
status,
};
if (!useWorkflow) {
await this.invokePrePublishEvent(entryDraft.entry, collection);
}
await this.implementation.persistEntry(
{
dataFiles,
@ -986,6 +1147,10 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
await this.invokePostSaveEvent(entryDraft.entry, collection);
if (!useWorkflow) {
await this.invokePostPublishEvent(entryDraft.entry, collection);
}
return slug;
}
@ -994,31 +1159,46 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
return { entry, author: { login, name } };
}
async invokePreSaveEvent(entry: Entry, collection: Collection): Promise<EntryData> {
async invokePrePublishEvent(entry: Entry, collection: CollectionWithDefaults) {
const eventData = await this.getEventData(entry);
return await invokeEvent({ name: 'prePublish', collection: collection.name, data: eventData });
}
async invokePostPublishEvent(entry: Entry, collection: CollectionWithDefaults) {
const eventData = await this.getEventData(entry);
return await invokeEvent({ name: 'postPublish', collection: collection.name, data: eventData });
}
async invokePreSaveEvent(entry: Entry, collection: CollectionWithDefaults): Promise<EntryData> {
const eventData = await this.getEventData(entry);
return await invokeEvent({ name: 'preSave', collection: collection.name, data: eventData });
}
async invokePostSaveEvent(entry: Entry, collection: Collection): Promise<void> {
async invokePostSaveEvent(entry: Entry, collection: CollectionWithDefaults): Promise<void> {
const eventData = await this.getEventData(entry);
await invokeEvent({ name: 'postSave', collection: collection.name, data: eventData });
}
async persistMedia(config: Config, file: AssetProxy) {
async persistMedia(config: ConfigWithDefaults, file: AssetProxy) {
const user = (await this.currentUser()) as User;
const options = {
commitMessage: commitMessageFormatter('uploadMedia', config, {
path: file.path,
authorLogin: user.login,
authorName: user.name,
}),
commitMessage: commitMessageFormatter(
'uploadMedia',
config,
{
path: file.path,
authorLogin: user.login,
authorName: user.name,
},
user.useOpenAuthoring,
),
};
return this.implementation.persistMedia(file, options);
}
async deleteEntry<EF extends BaseField>(
state: RootState<EF>,
collection: Collection<EF>,
collection: CollectionWithDefaults<EF>,
slug: string,
) {
const configState = state.config;
@ -1034,13 +1214,18 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
}
const user = (await this.currentUser()) as User;
const commitMessage = commitMessageFormatter('delete', configState.config, {
collection,
slug,
path,
authorLogin: user.login,
authorName: user.name,
});
const commitMessage = commitMessageFormatter(
'delete',
configState.config,
{
collection,
slug,
path,
authorLogin: user.login,
authorName: user.name,
},
user.useOpenAuthoring,
);
let paths = [path];
if (hasI18n(collection)) {
@ -1049,24 +1234,29 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
await this.implementation.deleteFiles(paths, commitMessage);
}
async deleteMedia(config: Config, path: string) {
async deleteMedia(config: ConfigWithDefaults, path: string) {
const user = (await this.currentUser()) as User;
const commitMessage = commitMessageFormatter('deleteMedia', config, {
path,
authorLogin: user.login,
authorName: user.name,
});
const commitMessage = commitMessageFormatter(
'deleteMedia',
config,
{
path,
authorLogin: user.login,
authorName: user.name,
},
user.useOpenAuthoring,
);
return this.implementation.deleteFiles([path], commitMessage);
}
entryToRaw(collection: Collection, entry: Entry): string {
entryToRaw(collection: CollectionWithDefaults, entry: Entry, config: ConfigWithDefaults): string {
const format = resolveFormat(collection, entry);
const fieldsOrder = this.fieldsOrder(collection, entry);
const fieldsComments = selectFieldsComments(collection, entry);
return format ? format.toFile(entry.data ?? {}, fieldsOrder, fieldsComments) : '';
return format ? format.toFile(entry.data ?? {}, config, fieldsOrder, fieldsComments) : '';
}
fieldsOrder(collection: Collection, entry: Entry) {
fieldsOrder(collection: CollectionWithDefaults, entry: Entry) {
if ('fields' in collection) {
return collection.fields?.map(f => f!.name) ?? [];
}
@ -1083,9 +1273,144 @@ export class Backend<EF extends BaseField = UnknownField, BC extends BackendClas
filterEntries(collection: { entries: Entry[] }, filterRule: FilterRule | FilterRule[]) {
return filterEntries(collection.entries, filterRule, undefined);
}
/**
* Editorial Workflows
*/
async processUnpublishedEntry(
collection: CollectionWithDefaults,
config: ConfigWithDefaults,
entryData: UnpublishedEntry,
withMediaFiles: boolean,
) {
const { slug, openAuthoring } = entryData;
let extension: string;
if ('files' in collection) {
const file = collection.files.find(f => f?.name === slug);
extension = file ? extname(file.file) : formatExtensions['json'];
} else {
extension = selectFolderEntryExtension(collection);
}
const mediaFiles: MediaFile[] = [];
if (withMediaFiles) {
const nonDataFiles = entryData.diffs.filter(d => !d.path.endsWith(extension));
const files = await Promise.all(
nonDataFiles.map(f =>
this.implementation!.unpublishedEntryMediaFile(collection.name, slug, f.path, f.id),
),
);
mediaFiles.push(...files.map(f => ({ ...f, draft: true })));
}
const dataFiles = entryData.diffs.filter(d => d.path.endsWith(extension));
dataFiles.sort((a, b) => a.path.length - b.path.length);
const formatData = (data: string, path: string, newFile: boolean) => {
const entry = createEntry(collection.name, slug, path, {
raw: data,
isModification: !newFile,
label: collection && selectFileEntryLabel(collection, slug),
mediaFiles,
updatedOn: entryData.updatedAt,
author: entryData.pullRequestAuthor,
status: workflowStatusFromString(entryData.status),
meta: { path: prepareMetaPath(path, collection) },
openAuthoring,
});
return this.entryWithFormat(collection, config)(entry);
};
const readAndFormatDataFile = async (dataFile: UnpublishedEntryDiff) => {
const data = await this.implementation.unpublishedEntryDataFile(
collection.name,
entryData.slug,
dataFile.path,
dataFile.id,
);
return formatData(data, dataFile.path, dataFile.newFile);
};
// if the unpublished entry has no diffs, return the original
if (dataFiles.length <= 0) {
const loadedEntry = await this.implementation.getEntry(
selectEntryPath(collection, slug) as string,
);
return formatData(loadedEntry.data, loadedEntry.file.path, false);
} else if (hasI18n(collection)) {
// we need to read all locales files and not just the changes
const path = selectEntryPath(collection, slug) as string;
const i18nFiles = getI18nDataFiles(collection, extension, path, slug, dataFiles);
let entries = await Promise.all(
i18nFiles.map(dataFile => readAndFormatDataFile(dataFile).catch(() => null)),
);
entries = entries.filter(Boolean);
const grouped = await groupEntries(collection, extension, entries as Entry[]);
return grouped[0];
} else {
return readAndFormatDataFile(dataFiles[0]);
}
}
async unpublishedEntries(collections: CollectionsWithDefaults, config: ConfigWithDefaults) {
const ids = await this.implementation.unpublishedEntries();
const entries = (
await Promise.all(
ids.map(async id => {
const entryData = await this.implementation.unpublishedEntry({ id });
const collectionName = entryData.collection;
const collection = Object.values(collections).find(c => c.name === collectionName);
if (!collection) {
console.warn(`Missing collection '${collectionName}' for unpublished entry '${id}'`);
return null;
}
return this.processUnpublishedEntry(collection, config, entryData, false);
}),
)
).filter(Boolean) as Entry[];
return { pagination: 0, entries };
}
async unpublishedEntry(
state: RootState,
collection: CollectionWithDefaults,
config: ConfigWithDefaults,
slug: string,
) {
const entryData = await this.implementation.unpublishedEntry({
collection: collection.name,
slug,
});
let entry = await this.processUnpublishedEntry(collection, config, entryData, true);
entry = await this.processEntry(state, collection, entry);
return entry;
}
persistUnpublishedEntry(args: PersistArgs) {
return this.persistEntry({ ...args, unpublished: true });
}
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: WorkflowStatus) {
return this.implementation.updateUnpublishedEntryStatus(collection, slug, newStatus);
}
deleteUnpublishedEntry(collection: string, slug: string) {
return this.implementation.deleteUnpublishedEntry(collection, slug);
}
async publishUnpublishedEntry(collection: CollectionWithDefaults, entry: Entry) {
await this.invokePrePublishEvent(entry, collection);
await this.implementation.publishUnpublishedEntry(collection.name, entry.slug);
await this.invokePostPublishEvent(entry, collection);
}
}
export function resolveBackend<EF extends BaseField>(config?: Config<EF>) {
export function resolveBackend<EF extends BaseField>(config?: ConfigWithDefaults<EF>) {
if (!config?.backend.name) {
throw new Error('No backend defined in configuration');
}
@ -1104,7 +1429,7 @@ export function resolveBackend<EF extends BaseField>(config?: Config<EF>) {
export const currentBackend = (function () {
let backend: Backend;
return <EF extends BaseField = UnknownField>(config: Config<EF>) => {
return <EF extends BaseField = UnknownField>(config: ConfigWithDefaults<EF>) => {
if (backend) {
return backend;
}

View File

@ -1,12 +1,15 @@
import { oneLine } from 'common-tags';
import flow from 'lodash/flow';
import get from 'lodash/get';
import { dirname } from 'path';
import { parse } from 'what-the-diff';
import { PreviewState } from '@staticcms/core/constants/enums';
import {
APIError,
basename,
Cursor,
EditorialWorkflowError,
localForage,
readFile,
readFileMetadata,
@ -16,8 +19,20 @@ import {
throwOnConflictingBranches,
unsentRequest,
} from '@staticcms/core/lib/util';
import {
branchFromContentKey,
CMS_BRANCH_PREFIX,
DEFAULT_PR_BODY,
generateContentKey,
isCMSLabel,
labelToStatus,
MERGE_COMMIT_MESSAGE,
parseContentKey,
statusToLabel,
} from '@staticcms/core/lib/util/APIUtils';
import type { DataFile, PersistOptions } from '@staticcms/core/interface';
import type { DataFile, PersistOptions, UnpublishedEntry } from '@staticcms/core';
import type { WorkflowStatus } from '@staticcms/core/constants/publishModes';
import type { ApiRequest, FetchError } from '@staticcms/core/lib/util';
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
@ -28,6 +43,9 @@ interface Config {
repo?: string;
requestFunction?: (req: ApiRequest) => Promise<Response>;
hasWriteAccess?: () => Promise<boolean>;
squashMerges: boolean;
initialWorkflowStatus: WorkflowStatus;
cmsLabelPrefix: string;
}
interface CommitAuthor {
@ -35,6 +53,96 @@ interface CommitAuthor {
email: string;
}
enum BitBucketPullRequestState {
MERGED = 'MERGED',
SUPERSEDED = 'SUPERSEDED',
OPEN = 'OPEN',
DECLINED = 'DECLINED',
}
type BitBucketPullRequest = {
description: string;
id: number;
title: string;
state: BitBucketPullRequestState;
updated_on: string;
summary: {
raw: string;
};
source: {
commit: {
hash: string;
};
branch: {
name: string;
};
};
destination: {
commit: {
hash: string;
};
branch: {
name: string;
};
};
author: BitBucketUser;
};
type BitBucketPullRequests = {
size: number;
page: number;
pagelen: number;
next: string;
preview: string;
values: BitBucketPullRequest[];
};
type BitBucketPullComment = {
content: {
raw: string;
};
};
type BitBucketPullComments = {
size: number;
page: number;
pagelen: number;
next: string;
preview: string;
values: BitBucketPullComment[];
};
enum BitBucketPullRequestStatusState {
Successful = 'SUCCESSFUL',
Failed = 'FAILED',
InProgress = 'INPROGRESS',
Stopped = 'STOPPED',
}
type BitBucketPullRequestStatus = {
uuid: string;
name: string;
key: string;
refname: string;
url: string;
description: string;
state: BitBucketPullRequestStatusState;
};
type BitBucketPullRequestStatues = {
size: number;
page: number;
pagelen: number;
next: string;
preview: string;
values: BitBucketPullRequestStatus[];
};
type DeleteEntry = {
path: string;
delete: true;
};
type BitBucketFile = {
id: string;
type: string;
@ -81,6 +189,8 @@ type BitBucketCommit = {
export const API_NAME = 'Bitbucket';
const APPLICATION_JSON = 'application/json; charset=utf-8';
function replace404WithEmptyResponse(err: FetchError) {
if (err && err.status === 404) {
console.info('[StaticCMS] This 404 was expected and handled appropriately.');
@ -97,6 +207,9 @@ export default class API {
requestFunction: (req: ApiRequest) => Promise<Response>;
repoURL: string;
commitAuthor?: CommitAuthor;
mergeStrategy: string;
initialWorkflowStatus: WorkflowStatus;
cmsLabelPrefix: string;
constructor(config: Config) {
this.apiRoot = config.apiRoot || 'https://api.bitbucket.org/2.0';
@ -106,6 +219,9 @@ export default class API {
// Allow overriding this.hasWriteAccess
this.hasWriteAccess = config.hasWriteAccess || this.hasWriteAccess;
this.repoURL = this.repo ? `/repositories/${this.repo}` : '';
this.mergeStrategy = config.squashMerges ? 'squash' : 'merge_commit';
this.initialWorkflowStatus = config.initialWorkflowStatus;
this.cmsLabelPrefix = config.cmsLabelPrefix;
}
buildRequest = (req: ApiRequest) => {
@ -231,7 +347,7 @@ export default class API {
}
async isShaExistsInBranch(branch: string, sha: string) {
const { values }: { values: BitBucketCommit[] } = await this.requestJSON({
const response: { values: BitBucketCommit[] } = await this.requestJSON({
url: `${this.repoURL}/commits`,
params: { include: branch, pagelen: '100' },
}).catch(e => {
@ -239,7 +355,7 @@ export default class API {
return [];
});
return values.some(v => v.hash === sha);
return response?.values?.some(v => v.hash === sha);
}
getEntriesAndCursor = (jsonResponse: BitBucketSrcResult) => {
@ -359,7 +475,11 @@ export default class API {
branch: filesBranch,
parseText: false,
});
formData.append(file.path.replace(sourceDir, destDir), content, basename(file.path));
formData.append(
file.path.replace(sourceDir, destDir),
content as Blob,
basename(file.path),
);
}
}
@ -412,6 +532,11 @@ export default class API {
options: PersistOptions,
) {
const files = [...dataFiles, ...mediaFiles];
if (options.useWorkflow) {
const slug = dataFiles[0].slug;
return this.editorialWorkflowGit(files as (DataFile | AssetProxy)[], slug, options);
}
return this.uploadFiles(files, { commitMessage: options.commitMessage, branch: this.branch });
}
@ -460,4 +585,248 @@ export default class API {
unsentRequest.withBody(body, unsentRequest.withMethod('POST', `${this.repoURL}/src`)),
);
};
/**
* Editorial Workflow
*/
async listUnpublishedBranches() {
console.info(
'%c Checking for Unpublished entries',
'line-height: 30px;text-align: center;font-weight: bold',
);
const pullRequests = await this.getPullRequests();
const branches = pullRequests.map(mr => mr.source.branch.name);
return branches;
}
async getPullRequestLabel(id: number) {
const comments: BitBucketPullComments = await this.requestJSON({
url: `${this.repoURL}/pullrequests/${id}/comments`,
params: {
pagelen: '100',
},
});
return comments.values.map(c => c.content.raw)[comments.values.length - 1];
}
async getPullRequests(sourceBranch?: string) {
const sourceQuery = sourceBranch
? `source.branch.name = "${sourceBranch}"`
: `source.branch.name ~ "${CMS_BRANCH_PREFIX}/"`;
const pullRequests: BitBucketPullRequests = await this.requestJSON({
url: `${this.repoURL}/pullrequests`,
params: {
pagelen: '50',
q: oneLine`
source.repository.full_name = "${this.repo}"
AND state = "${BitBucketPullRequestState.OPEN}"
AND destination.branch.name = "${this.branch}"
AND comment_count > 0
AND ${sourceQuery}
`,
},
});
const labels = await Promise.all(
pullRequests.values.map(pr => this.getPullRequestLabel(pr.id)),
);
return pullRequests.values.filter((_, index) => isCMSLabel(labels[index], this.cmsLabelPrefix));
}
async getBranchPullRequest(branch: string) {
const pullRequests = await this.getPullRequests(branch);
if (pullRequests.length <= 0) {
throw new EditorialWorkflowError('content is not under editorial workflow', true);
}
return pullRequests[0];
}
async retrieveUnpublishedEntryData(contentKey: string): Promise<UnpublishedEntry> {
const { collection, slug } = parseContentKey(contentKey);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
const diffs = await this.getDifferences(branch);
const label = await this.getPullRequestLabel(pullRequest.id);
const status = labelToStatus(label, this.cmsLabelPrefix);
const updatedAt = pullRequest.updated_on;
const pullRequestAuthor = pullRequest.author.display_name;
return {
collection,
slug,
status,
// TODO: get real id
diffs: diffs
.filter(d => d.status !== 'deleted')
.map(d => ({ path: d.path, newFile: d.newFile, id: '' })),
updatedAt,
pullRequestAuthor,
openAuthoring: false,
};
}
async addPullRequestComment(pullRequest: BitBucketPullRequest, comment: string) {
await this.requestJSON({
method: 'POST',
url: `${this.repoURL}/pullrequests/${pullRequest.id}/comments`,
headers: { 'Content-Type': APPLICATION_JSON },
body: JSON.stringify({
content: {
raw: comment,
},
}),
});
}
async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: WorkflowStatus) {
const contentKey = generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
await this.addPullRequestComment(pullRequest, statusToLabel(newStatus, this.cmsLabelPrefix));
}
async declinePullRequest(pullRequest: BitBucketPullRequest) {
await this.requestJSON({
method: 'POST',
url: `${this.repoURL}/pullrequests/${pullRequest.id}/decline`,
});
}
async deleteBranch(branch: string) {
await this.request({
method: 'DELETE',
url: `${this.repoURL}/refs/branches/${branch}`,
});
}
async deleteUnpublishedEntry(collectionName: string, slug: string) {
const contentKey = generateContentKey(collectionName, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
await this.declinePullRequest(pullRequest);
await this.deleteBranch(branch);
}
async mergePullRequest(pullRequest: BitBucketPullRequest) {
await this.requestJSON({
method: 'POST',
url: `${this.repoURL}/pullrequests/${pullRequest.id}/merge`,
headers: { 'Content-Type': APPLICATION_JSON },
body: JSON.stringify({
message: MERGE_COMMIT_MESSAGE,
close_source_branch: true,
merge_strategy: this.mergeStrategy,
}),
});
}
async publishUnpublishedEntry(collectionName: string, slug: string) {
const contentKey = generateContentKey(collectionName, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
await this.mergePullRequest(pullRequest);
}
async getPullRequestStatuses(pullRequest: BitBucketPullRequest) {
const statuses: BitBucketPullRequestStatues = await this.requestJSON({
url: `${this.repoURL}/pullrequests/${pullRequest.id}/statuses`,
params: {
pagelen: '100',
},
});
return statuses.values;
}
async getStatuses(collectionName: string, slug: string) {
const contentKey = generateContentKey(collectionName, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
const statuses = await this.getPullRequestStatuses(pullRequest);
return statuses.map(({ key, state, url }) => ({
context: key,
state:
state === BitBucketPullRequestStatusState.Successful
? PreviewState.Success
: PreviewState.Other,
target_url: url,
}));
}
async createPullRequest(branch: string, commitMessage: string, status: WorkflowStatus) {
const pullRequest: BitBucketPullRequest = await this.requestJSON({
method: 'POST',
url: `${this.repoURL}/pullrequests`,
headers: { 'Content-Type': APPLICATION_JSON },
body: JSON.stringify({
title: commitMessage,
source: {
branch: {
name: branch,
},
},
destination: {
branch: {
name: this.branch,
},
},
description: DEFAULT_PR_BODY,
close_source_branch: true,
}),
});
// use comments for status labels
await this.addPullRequestComment(pullRequest, statusToLabel(status, this.cmsLabelPrefix));
}
async editorialWorkflowGit(
files: (DataFile | AssetProxy)[],
slug: string,
options: PersistOptions,
) {
const contentKey = generateContentKey(options.collectionName as string, slug);
const branch = branchFromContentKey(contentKey);
const unpublished = options.unpublished || false;
if (!unpublished) {
const defaultBranchSha = await this.branchCommitSha(this.branch);
await this.uploadFiles(files, {
commitMessage: options.commitMessage,
branch,
parentSha: defaultBranchSha,
});
await this.createPullRequest(
branch,
options.commitMessage,
options.status || this.initialWorkflowStatus,
);
} else {
// mark files for deletion
const diffs = await this.getDifferences(branch);
const toDelete: DeleteEntry[] = [];
for (const diff of diffs.filter(d => d.binary && d.status !== 'deleted')) {
if (!files.some(file => file.path === diff.path)) {
toDelete.push({ path: diff.path, delete: true });
}
}
await this.uploadFiles([...files, ...toDelete], {
commitMessage: options.commitMessage,
branch,
});
}
}
async getUnpublishedEntrySha(collection: string, slug: string) {
const contentKey = generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
return pullRequest.destination.commit.hash;
}
}

View File

@ -3,11 +3,12 @@ import React, { useCallback, useMemo, useState } from 'react';
import Login from '@staticcms/core/components/login/Login';
import { ImplicitAuthenticator, NetlifyAuthenticator } from '@staticcms/core/lib/auth';
import useTranslate from '@staticcms/core/lib/hooks/useTranslate';
import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
import type { AuthenticationPageProps } from '@staticcms/core';
import type { FC, MouseEvent } from 'react';
const BitbucketAuthenticationPage = ({
const BitbucketAuthenticationPage: FC<AuthenticationPageProps> = ({
inProgress = false,
config,
base_url,
@ -15,8 +16,9 @@ const BitbucketAuthenticationPage = ({
authEndpoint,
clearHash,
onLogin,
t,
}: TranslatedProps<AuthenticationPageProps>) => {
}) => {
const t = useTranslate();
const [loginError, setLoginError] = useState<string | null>(null);
const [auth, authSettings] = useMemo(() => {

View File

@ -0,0 +1,43 @@
import { WorkflowStatus } from '@staticcms/core/constants/publishModes';
import { CMS_BRANCH_PREFIX } from '@staticcms/core/lib/util/APIUtils';
import API from '../API';
global.fetch = jest.fn().mockRejectedValue(new Error('should not call fetch inside tests'));
describe('bitbucket API', () => {
beforeEach(() => {
jest.resetAllMocks();
});
test('should get preview statuses', async () => {
const api = new API({
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: CMS_BRANCH_PREFIX,
});
const pr = { id: 1 };
const statuses = [
{ key: 'deploy', state: 'SUCCESSFUL', url: 'deploy-url' },
{ key: 'build', state: 'FAILED' },
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(api as any).getBranchPullRequest = jest.fn(() => Promise.resolve(pr));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(api as any).getPullRequestStatuses = jest.fn(() => Promise.resolve(statuses));
const collectionName = 'posts';
const slug = 'title';
await expect(api.getStatuses(collectionName, slug)).resolves.toEqual([
{ context: 'deploy', state: 'success', target_url: 'deploy-url' },
{ context: 'build', state: 'other' },
]);
expect(api.getBranchPullRequest).toHaveBeenCalledTimes(1);
expect(api.getBranchPullRequest).toHaveBeenCalledWith(`cms/posts/title`);
expect(api.getPullRequestStatuses).toHaveBeenCalledTimes(1);
expect(api.getPullRequestStatuses).toHaveBeenCalledWith(pr);
});
});

View File

@ -2,6 +2,7 @@ import { stripIndent } from 'common-tags';
import trimStart from 'lodash/trimStart';
import semaphore from 'semaphore';
import { WorkflowStatus } from '@staticcms/core/constants/publishModes';
import { NetlifyAuthenticator } from '@staticcms/core/lib/auth';
import {
AccessTokenError,
@ -23,23 +24,31 @@ import {
runWithLock,
unsentRequest,
} from '@staticcms/core/lib/util';
import { getPreviewStatus } from '@staticcms/core/lib/util/API';
import {
branchFromContentKey,
contentKeyFromBranch,
generateContentKey,
} from '@staticcms/core/lib/util/APIUtils';
import { unpublishedEntries } from '@staticcms/core/lib/util/implementation';
import API, { API_NAME } from './API';
import AuthenticationPage from './AuthenticationPage';
import GitLfsClient from './git-lfs-client';
import type { Semaphore } from 'semaphore';
import type {
BackendEntry,
BackendClass,
Config,
BackendEntry,
ConfigWithDefaults,
Credentials,
DisplayURL,
ImplementationFile,
PersistOptions,
UnpublishedEntry,
User,
} from '@staticcms/core/interface';
} from '@staticcms/core';
import type { ApiRequest, AsyncLock, Cursor, FetchError } from '@staticcms/core/lib/util';
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
import type { Semaphore } from 'semaphore';
const MAX_CONCURRENT_DOWNLOADS = 10;
@ -61,6 +70,7 @@ export default class BitbucketBackend implements BackendClass {
proxied: boolean;
API: API | null;
updateUserCredentials: (args: { token: string; refresh_token: string }) => Promise<null>;
initialWorkflowStatus: WorkflowStatus;
};
repo: string;
branch: string;
@ -73,15 +83,19 @@ export default class BitbucketBackend implements BackendClass {
refreshedTokenPromise?: Promise<string>;
authenticator?: NetlifyAuthenticator;
_mediaDisplayURLSem?: Semaphore;
squashMerges: boolean;
cmsLabelPrefix: string;
previewContext: string;
largeMediaURL: string;
_largeMediaClientPromise?: Promise<GitLfsClient>;
authType: string;
constructor(config: Config, options = {}) {
constructor(config: ConfigWithDefaults, options = {}) {
this.options = {
proxied: false,
API: null,
updateUserCredentials: async () => null,
initialWorkflowStatus: WorkflowStatus.DRAFT,
...options,
};
@ -105,14 +119,13 @@ export default class BitbucketBackend implements BackendClass {
config.backend.large_media_url || `https://bitbucket.org/${config.backend.repo}/info/lfs`;
this.token = '';
this.mediaFolder = config.media_folder;
this.squashMerges = config.backend.squash_merges || false;
this.cmsLabelPrefix = config.backend.cms_label_prefix || '';
this.previewContext = config.backend.preview_context || '';
this.lock = asyncLock();
this.authType = config.backend.auth_type || '';
}
isGitBackend() {
return true;
}
async status() {
const api = await fetch(BITBUCKET_STATUS_ENDPOINT)
.then(res => res.json())
@ -168,6 +181,9 @@ export default class BitbucketBackend implements BackendClass {
branch: this.branch,
repo: this.repo,
apiRoot: this.apiRoot,
squashMerges: this.squashMerges,
cmsLabelPrefix: this.cmsLabelPrefix,
initialWorkflowStatus: this.options.initialWorkflowStatus,
});
const isCollab = await this.api.hasWriteAccess().catch(error => {
@ -535,4 +551,99 @@ export default class BitbucketBackend implements BackendClass {
file: fileObj,
};
}
/**
* Editorial Workflow
*/
async unpublishedEntries() {
const listEntriesKeys = () =>
this.api!.listUnpublishedBranches().then(branches =>
branches.map(branch => contentKeyFromBranch(branch)),
);
const ids = await unpublishedEntries(listEntriesKeys);
return ids;
}
async unpublishedEntry({
id,
collection,
slug,
}: {
id?: string;
collection?: string;
slug?: string;
}): Promise<UnpublishedEntry> {
if (id) {
const data = await this.api!.retrieveUnpublishedEntryData(id);
return data;
} else if (collection && slug) {
const entryId = generateContentKey(collection, slug);
const data = await this.api!.retrieveUnpublishedEntryData(entryId);
return data;
} else {
throw new Error('Missing unpublished entry id or collection and slug');
}
}
getBranch(collection: string, slug: string) {
const contentKey = generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);
return branch;
}
async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const data = (await this.api!.readFile(path, id, { branch })) as string;
return data;
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const mediaFile = await this.loadMediaFile(path, id, { branch });
return mediaFile;
}
async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: WorkflowStatus) {
// updateUnpublishedEntryStatus is a transactional operation
return runWithLock(
this.lock,
() => this.api!.updateUnpublishedEntryStatus(collection, slug, newStatus),
'Failed to acquire update entry status lock',
);
}
async deleteUnpublishedEntry(collection: string, slug: string) {
// deleteUnpublishedEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.deleteUnpublishedEntry(collection, slug),
'Failed to acquire delete entry lock',
);
}
async publishUnpublishedEntry(collection: string, slug: string) {
// publishUnpublishedEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.publishUnpublishedEntry(collection, slug),
'Failed to acquire publish entry lock',
);
}
async getDeployPreview(collection: string, slug: string) {
try {
const statuses = await this.api!.getStatuses(collection, slug);
const deployStatus = getPreviewStatus(statuses, this.previewContext);
if (deployStatus) {
const { target_url: url, state } = deployStatus;
return { url, status: state };
} else {
return null;
}
} catch (e) {
return null;
}
}
}

View File

@ -1,8 +1,10 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import Login from '@staticcms/core/components/login/Login';
import useTranslate from '@staticcms/core/lib/hooks/useTranslate';
import type { AuthenticationPageProps, TranslatedProps, User } from '@staticcms/core/interface';
import type { AuthenticationPageProps, User } from '@staticcms/core';
import type { FC } from 'react';
function useNetlifyIdentifyEvent(eventName: 'login', callback: (login: User) => void): void;
function useNetlifyIdentifyEvent(eventName: 'logout', callback: () => void): void;
@ -17,12 +19,13 @@ function useNetlifyIdentifyEvent(
}, [callback, eventName]);
}
export interface GitGatewayAuthenticationPageProps
extends TranslatedProps<AuthenticationPageProps> {
export interface GitGatewayAuthenticationPageProps extends AuthenticationPageProps {
handleAuth: (email: string, password: string) => Promise<User | string>;
}
const GitGatewayAuthenticationPage = ({ onLogin, t }: GitGatewayAuthenticationPageProps) => {
const GitGatewayAuthenticationPage: FC<GitGatewayAuthenticationPageProps> = ({ onLogin }) => {
const t = useTranslate();
const [loggingIn, setLoggingIn] = useState(false);
const [loggedIn, setLoggedIn] = useState(false);
const [errors, setErrors] = useState<{

View File

@ -4,7 +4,7 @@ import { API as GithubAPI } from '../github';
import type { FetchError } from '@staticcms/core/lib/util';
import type { Config as GitHubConfig } from '../github/API';
type Config = GitHubConfig & {
export type GitHubApiOptions = GitHubConfig & {
apiRoot: string;
tokenPromise: () => Promise<string>;
commitAuthor: { name: string };
@ -16,12 +16,12 @@ export default class API extends GithubAPI {
commitAuthor: { name: string };
isLargeMedia: (filename: string) => Promise<boolean>;
constructor(config: Config) {
super(config);
this.apiRoot = config.apiRoot;
this.tokenPromise = config.tokenPromise;
this.commitAuthor = config.commitAuthor;
this.isLargeMedia = config.isLargeMedia;
constructor(options: GitHubApiOptions) {
super(options);
this.apiRoot = options.apiRoot;
this.tokenPromise = options.tokenPromise;
this.commitAuthor = options.commitAuthor;
this.isLargeMedia = options.isLargeMedia;
this.repoURL = '';
this.originRepoURL = '';
}

View File

@ -0,0 +1,63 @@
/**
* @jest-environment jsdom
*/
import '@testing-library/jest-dom';
import { act } from '@testing-library/react';
import React from 'react';
import { resolveBackend } from '@staticcms/core/backend';
import { createMockConfig } from '@staticcms/test/data/config.mock';
import { renderWithProviders } from '@staticcms/test/test-utils';
import GitGatewayAuthenticationPage from '../AuthenticationPage';
import type { GitGatewayAuthenticationPageProps } from '../AuthenticationPage';
jest.mock('@staticcms/core/backend');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).netlifyIdentity = {
currentUser: jest.fn(),
on: jest.fn(),
close: jest.fn(),
};
describe('GitGatewayAuthenticationPage', () => {
const props: GitGatewayAuthenticationPageProps = {
onLogin: jest.fn(),
inProgress: false,
config: createMockConfig({ logo_url: 'logo_url', collections: [] }),
handleAuth: jest.fn(),
};
beforeEach(() => {
(resolveBackend as jest.Mock).mockResolvedValue(null);
jest.clearAllMocks();
jest.resetModules();
});
it('should render with identity error', () => {
const { queryByTestId } = renderWithProviders(<GitGatewayAuthenticationPage {...props} />);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const errorCallback = (window as any).netlifyIdentity.on.mock.calls.find(
(call: string[]) => call[0] === 'error',
)[1];
act(() => {
errorCallback(
new Error('Failed to load settings from https://site.netlify.com/.netlify/identity'),
);
});
expect(queryByTestId('login-button')).toBeInTheDocument();
expect(queryByTestId('login-error')).toBeInTheDocument();
});
it('should render with no identity error', () => {
const { queryByTestId } = renderWithProviders(<GitGatewayAuthenticationPage {...props} />);
expect(queryByTestId('login-button')).toBeInTheDocument();
expect(queryByTestId('login-error')).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,110 @@
import { WorkflowStatus } from '@staticcms/core/constants/publishModes';
import API from '../GitHubAPI';
import type { GitHubApiOptions } from '../GitHubAPI';
const createApi = (options: Partial<GitHubApiOptions> = {}) => {
return new API({
apiRoot: 'https://site.netlify.com/.netlify/git/github',
tokenPromise: () => Promise.resolve('token'),
squashMerges: true,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: 'CMS',
isLargeMedia: () => Promise.resolve(false),
commitAuthor: { name: 'Bob' },
...options,
});
};
describe('github API', () => {
describe('request', () => {
const fetch = jest.fn();
beforeEach(() => {
global.fetch = fetch;
});
afterEach(() => {
jest.resetAllMocks();
});
it('should fetch url with authorization header', async () => {
const api = createApi();
fetch.mockResolvedValue({
text: jest.fn().mockResolvedValue('some response'),
ok: true,
status: 200,
headers: { get: () => '' },
});
const result = await api.request('/some-path');
expect(result).toEqual('some response');
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith('https://site.netlify.com/.netlify/git/github/some-path', {
cache: 'no-cache',
headers: {
Authorization: 'Bearer token',
'Content-Type': 'application/json; charset=utf-8',
},
});
});
it('should throw error on not ok response with message property', async () => {
const api = createApi({
apiRoot: 'https://site.netlify.com/.netlify/git/github',
tokenPromise: () => Promise.resolve('token'),
});
fetch.mockResolvedValue({
text: jest.fn().mockResolvedValue({ message: 'some error' }),
ok: false,
status: 404,
headers: { get: () => '' },
});
await expect(api.request('some-path')).rejects.toThrow(
expect.objectContaining({
message: 'some error',
name: 'API_ERROR',
status: 404,
api: 'Git Gateway',
}),
);
});
it('should throw error on not ok response with msg property', async () => {
const api = createApi({
apiRoot: 'https://site.netlify.com/.netlify/git/github',
tokenPromise: () => Promise.resolve('token'),
});
fetch.mockResolvedValue({
text: jest.fn().mockResolvedValue({ msg: 'some error' }),
ok: false,
status: 404,
headers: { get: () => '' },
});
await expect(api.request('some-path')).rejects.toThrow(
expect.objectContaining({
message: 'some error',
name: 'API_ERROR',
status: 404,
api: 'Git Gateway',
}),
);
});
});
describe('nextUrlProcessor', () => {
it('should re-write github url', () => {
const api = createApi({
apiRoot: 'https://site.netlify.com/.netlify/git/github',
});
expect(api.nextUrlProcessor()('https://api.github.com/repositories/10000/pulls')).toEqual(
'https://site.netlify.com/.netlify/git/github/pulls',
);
});
});
});

View File

@ -5,6 +5,8 @@ import intersection from 'lodash/intersection';
import pick from 'lodash/pick';
import React, { useCallback } from 'react';
import { PreviewState } from '@staticcms/core/constants/enums';
import { WorkflowStatus } from '@staticcms/core/constants/publishModes';
import {
AccessTokenError,
APIError,
@ -28,17 +30,18 @@ import type {
AuthenticationPageProps,
BackendClass,
BackendEntry,
Config,
ConfigWithDefaults,
Credentials,
DisplayURL,
DisplayURLObject,
ImplementationFile,
PersistOptions,
TranslatedProps,
UnpublishedEntry,
User,
} from '@staticcms/core/interface';
} from '@staticcms/core';
import type { ApiRequest, Cursor } from '@staticcms/core/lib/util';
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
import type { FC } from 'react';
import type { Client } from './netlify-lfs-client';
const STATUS_PAGE = 'https://www.netlifystatus.com';
@ -112,10 +115,18 @@ interface NetlifyUser extends Credentials {
user_metadata: { full_name: string; avatar_url: string };
}
async function apiGet(path: string) {
const apiRoot = 'https://api.netlify.com/api/v1/sites';
const response = await fetch(`${apiRoot}/${path}`).then(res => res.json());
return response;
}
export default class GitGateway implements BackendClass {
config: Config;
config: ConfigWithDefaults;
api?: GitHubAPI | GitLabAPI | BitBucketAPI;
branch: string;
squashMerges: boolean;
cmsLabelPrefix: string;
mediaFolder?: string;
transformImages: boolean;
gatewayUrl: string;
@ -131,15 +142,19 @@ export default class GitGateway implements BackendClass {
options: {
proxied: boolean;
API: GitHubAPI | GitLabAPI | BitBucketAPI | null;
initialWorkflowStatus: WorkflowStatus;
};
constructor(config: Config, options = {}) {
constructor(config: ConfigWithDefaults, options = {}) {
this.options = {
proxied: true,
API: null,
initialWorkflowStatus: WorkflowStatus.DRAFT,
...options,
};
this.config = config;
this.branch = config.backend.branch?.trim() || 'main';
this.squashMerges = config.backend.squash_merges || false;
this.cmsLabelPrefix = config.backend.cms_label_prefix || '';
this.mediaFolder = config.media_folder;
const { use_large_media_transforms_in_media_library: transformImages = true } = config.backend;
this.transformImages = transformImages;
@ -163,10 +178,6 @@ export default class GitGateway implements BackendClass {
this.backend = null;
}
isGitBackend() {
return true;
}
async status() {
const api = await fetch(GIT_GATEWAY_STATUS_ENDPOINT)
.then(res => res.json())
@ -299,6 +310,9 @@ export default class GitGateway implements BackendClass {
tokenPromise: this.tokenPromise!,
commitAuthor: pick(userData, ['name', 'email']),
isLargeMedia: (filename: string) => this.isLargeMediaFile(filename),
squashMerges: this.squashMerges,
cmsLabelPrefix: this.cmsLabelPrefix,
initialWorkflowStatus: this.options.initialWorkflowStatus,
};
if (this.backendType === 'github') {
@ -333,7 +347,7 @@ export default class GitGateway implements BackendClass {
}
authComponent() {
const WrappedAuthenticationPage = (props: TranslatedProps<AuthenticationPageProps>) => {
const WrappedAuthenticationPage: FC<AuthenticationPageProps> = props => {
const handleAuth = useCallback(
async (email: string, password: string): Promise<User | string> => {
try {
@ -542,4 +556,87 @@ export default class GitGateway implements BackendClass {
traverseCursor(cursor: Cursor, action: string) {
return this.backend!.traverseCursor!(cursor, action);
}
/**
* Editorial Workflow
*/
unpublishedEntries() {
return this.backend!.unpublishedEntries();
}
unpublishedEntry({
id,
collection,
slug,
}: {
id?: string;
collection?: string;
slug?: string;
}): Promise<UnpublishedEntry> {
return this.backend!.unpublishedEntry({ id, collection, slug });
}
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: WorkflowStatus) {
return this.backend!.updateUnpublishedEntryStatus(collection, slug, newStatus);
}
deleteUnpublishedEntry(collection: string, slug: string) {
return this.backend!.deleteUnpublishedEntry(collection, slug);
}
publishUnpublishedEntry(collection: string, slug: string) {
return this.backend!.publishUnpublishedEntry(collection, slug);
}
async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
return this.backend!.unpublishedEntryDataFile(collection, slug, path, id);
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
const isLargeMedia = await this.isLargeMediaFile(path);
if (isLargeMedia) {
const branch = this.backend!.getBranch(collection, slug);
const { url, blob } = await this.getLargeMediaDisplayURL({ path, id }, branch);
return {
id,
name: basename(path),
path,
url,
displayURL: url,
file: new File([blob], basename(path)),
size: blob.size,
};
} else {
return this.backend!.unpublishedEntryMediaFile(collection, slug, path, id);
}
}
async getDeployPreview(collection: string, slug: string) {
let preview = await this.backend!.getDeployPreview(collection, slug);
if (!preview) {
try {
// if the commit doesn't have a status, try to use Netlify API directly
// this is useful when builds are queue up in Netlify and don't have a commit status yet
// and only works with public logs at the moment
// TODO: get Netlify API Token and use it to access private logs
const siteId = new URL(localStorage.getItem('netlifySiteURL') || '').hostname;
const site = await apiGet(siteId);
const deploys: { state: string; commit_ref: string; deploy_url: string }[] = await apiGet(
`${site.id}/deploys?per_page=100`,
);
if (deploys.length > 0) {
const ref = await this.api!.getUnpublishedEntrySha(collection, slug);
const deploy = deploys.find(d => d.commit_ref === ref);
if (deploy) {
preview = {
status: deploy.state === 'ready' ? PreviewState.Success : PreviewState.Other,
url: deploy.deploy_url,
};
}
}
// eslint-disable-next-line no-empty
} catch (e) {}
}
return preview;
}
}

View File

@ -13,7 +13,7 @@ import {
unsentRequest,
} from '@staticcms/core/lib/util';
import type { DataFile, PersistOptions } from '@staticcms/core/interface';
import type { DataFile, PersistOptions } from '@staticcms/core';
import type { ApiRequest, FetchError } from '@staticcms/core/lib/util';
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
import type { Semaphore } from 'semaphore';

View File

@ -3,17 +3,19 @@ import React, { useCallback, useMemo, useState } from 'react';
import Login from '@staticcms/core/components/login/Login';
import { PkceAuthenticator } from '@staticcms/core/lib/auth';
import useTranslate from '@staticcms/core/lib/hooks/useTranslate';
import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
import type { AuthenticationPageProps } from '@staticcms/core';
import type { FC, MouseEvent } from 'react';
const GiteaAuthenticationPage = ({
const GiteaAuthenticationPage: FC<AuthenticationPageProps> = ({
inProgress = false,
config,
clearHash,
onLogin,
t,
}: TranslatedProps<AuthenticationPageProps>) => {
}) => {
const t = useTranslate();
const [loginError, setLoginError] = useState<string | null>(null);
const auth = useMemo(() => {

View File

@ -1,7 +1,7 @@
import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from '@staticcms/core/lib/util/Cursor';
import GiteaImplementation from '../implementation';
import type { Config, UnknownField } from '@staticcms/core';
import type { ConfigWithDefaults, UnknownField } from '@staticcms/core';
import type API from '../API';
import type { AssetProxy } from '@staticcms/core/valueObjects';
@ -14,7 +14,7 @@ describe('gitea backend implementation', () => {
repo: 'owner/repo',
api_root: 'https://try.gitea.io/api/v1',
},
} as Config<UnknownField>;
} as ConfigWithDefaults<UnknownField>;
const createObjectURL = jest.fn();
global.URL = {

View File

@ -23,13 +23,15 @@ import AuthenticationPage from './AuthenticationPage';
import type {
BackendClass,
BackendEntry,
Config,
ConfigWithDefaults,
Credentials,
DisplayURL,
ImplementationFile,
ImplementationMediaFile,
PersistOptions,
UnpublishedEntry,
User,
} from '@staticcms/core/interface';
} from '@staticcms/core';
import type { AsyncLock } from '@staticcms/core/lib/util';
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
import type { Semaphore } from 'semaphore';
@ -60,7 +62,7 @@ export default class Gitea implements BackendClass {
};
_mediaDisplayURLSem?: Semaphore;
constructor(config: Config, options = {}) {
constructor(config: ConfigWithDefaults, options = {}) {
this.options = {
proxied: false,
API: null,
@ -82,11 +84,6 @@ export default class Gitea implements BackendClass {
this.mediaFolder = config.media_folder;
this.lock = asyncLock();
}
isGitBackend() {
return true;
}
async status() {
const auth =
(await this.api
@ -405,4 +402,36 @@ export default class Gitea implements BackendClass {
cursor: result.cursor,
};
}
async unpublishedEntries(): Promise<string[]> {
throw new Error('Editorial workflow is not yet available for Gitea');
}
async unpublishedEntry(): Promise<UnpublishedEntry> {
throw new Error('Editorial workflow is not yet available for Gitea');
}
async unpublishedEntryDataFile(): Promise<string> {
throw new Error('Editorial workflow is not yet available for Gitea');
}
async unpublishedEntryMediaFile(): Promise<ImplementationMediaFile> {
throw new Error('Editorial workflow is not yet available for Gitea');
}
async updateUnpublishedEntryStatus(): Promise<void> {
throw new Error('Editorial workflow is not yet available for Gitea');
}
async publishUnpublishedEntry(): Promise<void> {
throw new Error('Editorial workflow is not yet available for Gitea');
}
async deleteUnpublishedEntry(): Promise<void> {
throw new Error('Editorial workflow is not yet available for Gitea');
}
async getDeployPreview(): Promise<{ url: string; status: string } | null> {
throw new Error('Editorial workflow is not yet available for Gitea');
}
}

View File

@ -7,8 +7,11 @@ import trim from 'lodash/trim';
import trimStart from 'lodash/trimStart';
import { dirname } from 'path';
import { PreviewState } from '@staticcms/core/constants/enums';
import { WorkflowStatus } from '@staticcms/core/constants/publishModes';
import {
APIError,
EditorialWorkflowError,
basename,
generateContentKey,
getAllResponses,
@ -16,10 +19,21 @@ import {
parseContentKey,
readFileMetadata,
requestWithBackoff,
throwOnConflictingBranches,
unsentRequest,
} from '@staticcms/core/lib/util';
import {
CMS_BRANCH_PREFIX,
DEFAULT_PR_BODY,
MERGE_COMMIT_MESSAGE,
branchFromContentKey,
isCMSLabel,
labelToStatus,
statusToLabel,
} from '@staticcms/core/lib/util/APIUtils';
import { GitHubCommitStatusState, PullRequestState } from './types';
import type { DataFile, PersistOptions } from '@staticcms/core/interface';
import type { DataFile, PersistOptions, UnpublishedEntry } from '@staticcms/core';
import type { ApiRequest, FetchError } from '@staticcms/core/lib/util';
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
import type { Semaphore } from 'semaphore';
@ -31,22 +45,44 @@ import type {
GitGetBlobResponse,
GitGetTreeResponse,
GitHubAuthor,
GitHubCommitStatus,
GitHubCommitter,
GitHubCompareCommit,
GitHubCompareCommits,
GitHubCompareFiles,
GitHubPull,
GitHubUser,
GitListMatchingRefsResponse,
GitListMatchingRefsResponseItem,
GitUpdateRefResponse,
PullsCreateResponse,
PullsGetResponseLabelsItem,
PullsListResponse,
PullsMergeResponse,
PullsUpdateBranchResponse,
ReposCompareCommitsResponse,
ReposCompareCommitsResponseFilesItem,
ReposGetBranchResponse,
ReposGetResponse,
ReposListCommitsResponse,
TreeFile,
} from './types';
export const API_NAME = 'GitHub';
export const MOCK_PULL_REQUEST = -1;
export interface Config {
apiRoot?: string;
token?: string;
branch?: string;
useOpenAuthoring?: boolean;
openAuthoringEnabled?: boolean;
repo?: string;
originRepo?: string;
squashMerges: boolean;
initialWorkflowStatus: WorkflowStatus;
cmsLabelPrefix: string;
}
type Override<T, U> = Pick<T, Exclude<keyof T, keyof U>> & U;
@ -89,6 +125,33 @@ type MediaFile = {
path: string;
};
function withCmsLabel(pr: GitHubPull, cmsLabelPrefix: string) {
return pr.labels.some(l => isCMSLabel(l.name, cmsLabelPrefix));
}
function getTreeFiles(files: GitHubCompareFiles) {
const treeFiles = files.reduce(
(arr, file) => {
if (file.status === 'removed') {
// delete the file
arr.push({ sha: null, path: file.filename });
} else if (file.status === 'renamed') {
// delete the previous file
arr.push({ sha: null, path: file.previous_filename as string });
// add the renamed file
arr.push({ sha: file.sha, path: file.filename });
} else {
// add the file
arr.push({ sha: file.sha, path: file.filename });
}
return arr;
},
[] as { sha: string | null; path: string }[],
);
return treeFiles;
}
export type Diff = {
path: string;
newFile: boolean;
@ -100,6 +163,8 @@ export default class API {
apiRoot: string;
token: string;
branch: string;
useOpenAuthoring?: boolean;
openAuthoringEnabled?: boolean;
repo: string;
originRepo: string;
repoOwner: string;
@ -108,6 +173,9 @@ export default class API {
originRepoName: string;
repoURL: string;
originRepoURL: string;
mergeMethod: string;
initialWorkflowStatus: WorkflowStatus;
cmsLabelPrefix: string;
_userPromise?: Promise<GitHubUser>;
_metadataSemaphore?: Semaphore;
@ -118,9 +186,11 @@ export default class API {
this.apiRoot = config.apiRoot || 'https://api.github.com';
this.token = config.token || '';
this.branch = config.branch || 'main';
this.useOpenAuthoring = config.useOpenAuthoring;
this.repo = config.repo || '';
this.originRepo = config.originRepo || this.repo;
this.repoURL = `/repos/${this.repo}`;
// when not in 'useOpenAuthoring' mode originRepoURL === repoURL
this.originRepoURL = `/repos/${this.originRepo}`;
const [repoParts, originRepoParts] = [this.repo.split('/'), this.originRepo.split('/')];
@ -129,6 +199,11 @@ export default class API {
this.originRepoOwner = originRepoParts[0];
this.originRepoName = originRepoParts[1];
this.mergeMethod = config.squashMerges ? 'squash' : 'merge';
this.cmsLabelPrefix = config.cmsLabelPrefix;
this.initialWorkflowStatus = config.initialWorkflowStatus;
this.openAuthoringEnabled = config.openAuthoringEnabled;
}
static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Static CMS';
@ -265,11 +340,27 @@ export default class API {
}
generateContentKey(collectionName: string, slug: string) {
return generateContentKey(collectionName, slug);
const contentKey = generateContentKey(collectionName, slug);
if (this.useOpenAuthoring) {
return `${this.repo}/${contentKey}`;
}
return contentKey;
}
getContentKeySlug(contentKey: string) {
let key = contentKey;
const parts = contentKey.split(this.repoName);
if (parts.length > 1) {
key = parts[1];
}
return key.replace(/^\//g, '').replace(/^cms\//g, '');
}
parseContentKey(contentKey: string) {
return parseContentKey(contentKey);
return parseContentKey(this.getContentKeySlug(contentKey));
}
async readFile(
@ -379,17 +470,27 @@ export default class API {
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const files: (DataFile | AssetProxy)[] = mediaFiles.concat(dataFiles as any);
const files = [...mediaFiles, ...dataFiles] as unknown[] as TreeFile[];
const uploadPromises = files.map(file => this.uploadBlob(file));
await Promise.all(uploadPromises);
return (
this.getDefaultBranch()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.then(branchData => this.updateTree(branchData.commit.sha, files as any))
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then(response => this.patchBranch(this.branch, response.sha))
);
if (options.useWorkflow) {
/**
* Editorial Workflow
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mediaFilesList = (mediaFiles as any[]).map(({ sha, path }) => ({
path: trimStart(path, '/'),
sha,
}));
const slug = dataFiles[0].slug;
return this.editorialWorkflowGit(files, slug, mediaFilesList, options);
}
return this.getDefaultBranch()
.then(branchData => this.updateTree(branchData.commit.sha, files))
.then(changeTree => this.commit(options.commitMessage, changeTree))
.then(response => this.patchBranch(this.branch, response.sha));
}
async getFileSha(path: string, { repoURL = this.repoURL, branch = this.branch } = {}) {
@ -415,6 +516,10 @@ export default class API {
}
async deleteFiles(paths: string[], message: string) {
if (this.useOpenAuthoring) {
return Promise.reject('Cannot delete published entries as an Open Authoring user!');
}
const branchData = await this.getDefaultBranch();
const files = paths.map(path => ({ path, sha: null }));
const changeTree = await this.updateTree(branchData.commit.sha, files);
@ -430,12 +535,13 @@ export default class API {
return result;
}
async patchRef(type: string, name: string, sha: string) {
async patchRef(type: string, name: string, sha: string, opts: { force?: boolean } = {}) {
const force = opts.force || false;
const result: GitUpdateRefResponse = await this.request(
`${this.repoURL}/git/refs/${type}/${encodeURIComponent(name)}`,
{
method: 'PATCH',
body: JSON.stringify({ sha }),
body: JSON.stringify({ sha, force }),
},
);
return result;
@ -454,8 +560,16 @@ export default class API {
return result;
}
patchBranch(branchName: string, sha: string) {
return this.patchRef('heads', branchName, sha);
assertCmsBranch(branchName: string) {
return branchName.startsWith(`${CMS_BRANCH_PREFIX}/`);
}
patchBranch(branchName: string, sha: string, opts: { force?: boolean } = {}) {
const force = opts.force || false;
if (force && !this.assertCmsBranch(branchName)) {
throw Error(`Only CMS branches can be force updated, cannot force update ${branchName}`);
}
return this.patchRef('heads', branchName, sha, { force });
}
async getHeadReference(head: string) {
@ -558,4 +672,588 @@ export default class API {
});
return result;
}
/**
* Editorial Workflow
*/
async listUnpublishedBranches() {
console.info(
'%c Checking for Unpublished entries',
'line-height: 30px;text-align: center;font-weight: bold',
);
let branches: string[];
if (this.useOpenAuthoring) {
// open authoring branches can exist without a pr
const cmsBranches: GitListMatchingRefsResponse = await this.getOpenAuthoringBranches();
branches = cmsBranches.map(b => b.ref.slice('refs/heads/'.length));
// filter irrelevant branches
const branchesWithFilter = await Promise.all(
branches.map(b => this.filterOpenAuthoringBranches(b)),
);
branches = branchesWithFilter.filter(b => b.filter).map(b => b.branch);
} else if (this.openAuthoringEnabled) {
const cmsPullRequests = await this.getPullRequests(
undefined,
PullRequestState.Open,
() => true,
);
branches = cmsPullRequests.map(pr => pr.head.ref);
} else {
const cmsPullRequests = await this.getPullRequests(undefined, PullRequestState.Open, pr =>
withCmsLabel(pr, this.cmsLabelPrefix),
);
branches = cmsPullRequests.map(pr => pr.head.ref);
}
return branches;
}
async getOpenAuthoringBranches() {
return this.requestAllPages<GitListMatchingRefsResponseItem>(
`${this.repoURL}/git/refs/heads/cms/${this.repo}`,
).catch(() => [] as GitListMatchingRefsResponseItem[]);
}
filterOpenAuthoringBranches = async (branch: string) => {
try {
const pullRequest = await this.getBranchPullRequest(branch);
const { state: currentState, merged_at: mergedAt } = pullRequest;
if (
pullRequest.number !== MOCK_PULL_REQUEST &&
currentState === PullRequestState.Closed &&
mergedAt
) {
// pr was merged, delete branch
await this.deleteBranch(branch);
return { branch, filter: false };
} else {
return { branch, filter: true };
}
} catch (e) {
return { branch, filter: false };
}
};
async getPullRequests(
head: string | undefined,
state: PullRequestState,
predicate: (pr: GitHubPull) => boolean,
) {
const pullRequests: PullsListResponse = await this.requestAllPages(
`${this.originRepoURL}/pulls`,
{
params: {
...(head ? { head: await this.getHeadReference(head) } : {}),
base: this.branch,
state,
per_page: 100,
},
},
);
return pullRequests.filter(pr => {
return pr.head.ref.startsWith(`${CMS_BRANCH_PREFIX}/`) && predicate(pr);
});
}
deleteBranch(branchName: string) {
return this.deleteRef('heads', branchName).catch((err: Error) => {
// If the branch doesn't exist, then it has already been deleted -
// deletion should be idempotent, so we can consider this a
// success.
if (err.message === 'Reference does not exist') {
return Promise.resolve();
}
console.error(err);
return Promise.reject(err);
});
}
async getBranchPullRequest(branch: string) {
if (this.useOpenAuthoring) {
const pullRequests = await this.getPullRequests(branch, PullRequestState.All, () => true);
return this.getOpenAuthoringPullRequest(branch, pullRequests);
} else if (this.openAuthoringEnabled) {
const pullRequests = await this.getPullRequests(undefined, PullRequestState.Open, pr => {
return this.getContentKeySlug(pr.head.ref) === this.getContentKeySlug(branch);
});
if (pullRequests.length <= 0) {
throw new EditorialWorkflowError('content is not under editorial workflow', true);
}
return pullRequests[0];
} else {
const pullRequests = await this.getPullRequests(branch, PullRequestState.Open, pr =>
withCmsLabel(pr, this.cmsLabelPrefix),
);
if (pullRequests.length <= 0) {
throw new EditorialWorkflowError('content is not under editorial workflow', true);
}
return pullRequests[0];
}
}
async getOpenAuthoringPullRequest(branch: string, pullRequests: GitHubPull[]) {
// we can't use labels when using open authoring
// since the contributor doesn't have access to set labels
// a branch without a pr (or a closed pr) means a 'draft' entry
// a branch with an opened pr means a 'pending_review' entry
const data = await this.getBranch(branch).catch(() => {
throw new EditorialWorkflowError('content is not under editorial workflow', true);
});
// since we get all (open and closed) pull requests by branch name, make sure to filter by head sha
const pullRequest = pullRequests.filter(pr => pr.head.sha === data.commit.sha)[0];
// if no pull request is found for the branch we return a mocked one
if (!pullRequest) {
try {
return {
head: { sha: data.commit.sha },
number: MOCK_PULL_REQUEST,
labels: [{ name: statusToLabel(this.initialWorkflowStatus, this.cmsLabelPrefix) }],
state: PullRequestState.Open,
} as GitHubPull;
} catch (e) {
throw new EditorialWorkflowError('content is not under editorial workflow', true);
}
} else {
pullRequest.labels = pullRequest.labels.filter(l => !isCMSLabel(l.name, this.cmsLabelPrefix));
const cmsLabel =
pullRequest.state === PullRequestState.Closed
? { name: statusToLabel(this.initialWorkflowStatus, this.cmsLabelPrefix) }
: { name: statusToLabel(WorkflowStatus.PENDING_REVIEW, this.cmsLabelPrefix) };
pullRequest.labels.push(cmsLabel as PullsGetResponseLabelsItem);
return pullRequest;
}
}
async getBranch(branch: string) {
const result: ReposGetBranchResponse = await this.request(
`${this.repoURL}/branches/${encodeURIComponent(branch)}`,
);
return result;
}
async retrieveUnpublishedEntryData(contentKey: string): Promise<UnpublishedEntry> {
const { collection, slug } = this.parseContentKey(contentKey);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
const [{ files }, pullRequestAuthor] = await Promise.all([
this.getDifferences(this.branch, pullRequest.head.sha),
this.getPullRequestAuthor(pullRequest),
]);
const diffs = await Promise.all(files.map(file => this.diffFromFile(file)));
const label = pullRequest.labels.find(l => isCMSLabel(l.name, this.cmsLabelPrefix)) as {
name: string;
};
const status = label
? labelToStatus(label.name, this.cmsLabelPrefix)
: WorkflowStatus.PENDING_REVIEW;
const updatedAt = pullRequest.updated_at;
return {
collection,
slug,
status,
diffs: diffs.map(d => ({ path: d.path, newFile: d.newFile, id: d.sha })),
updatedAt,
pullRequestAuthor,
openAuthoring:
!pullRequest.head.ref.includes(this.repo) && pullRequest.head.ref.includes(this.repoName),
};
}
async getDifferences(from: string, to: string) {
// retry this as sometimes GitHub returns an initial 404 on cross repo compare
const attempts = this.useOpenAuthoring ? 10 : 1;
for (let i = 1; i <= attempts; i++) {
try {
const result: ReposCompareCommitsResponse = await this.request(
`${this.originRepoURL}/compare/${from}...${to}`,
);
return result;
} catch (e) {
if (i === attempts) {
console.warn(`Reached maximum number of attempts '${attempts}' for getDifferences`);
throw e;
}
await new Promise(resolve => setTimeout(resolve, i * 500));
}
}
throw new APIError('Not Found', 404, API_NAME);
}
async getPullRequestAuthor(pullRequest: GitHubPull) {
if (!pullRequest.user?.login) {
return;
}
try {
const user: GitHubUser = await this.request(`/users/${pullRequest.user.login}`);
return user.name || user.login;
} catch {
return;
}
}
// async since it is overridden in a child class
async diffFromFile(diff: ReposCompareCommitsResponseFilesItem): Promise<Diff> {
return {
path: diff.filename,
newFile: diff.status === 'added',
sha: diff.sha,
// media files diffs don't have a patch attribute, except svg files
// renamed files don't have a patch attribute too
binary: (diff.status !== 'renamed' && !diff.patch) || diff.filename.endsWith('.svg'),
};
}
async publishUnpublishedEntry(collectionName: string, slug: string) {
const contentKey = this.generateContentKey(collectionName, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
await this.mergePR(pullRequest);
await this.deleteBranch(branch);
}
async mergePR(pullrequest: GitHubPull) {
console.info('%c Merging PR', 'line-height: 30px;text-align: center;font-weight: bold');
try {
const result: PullsMergeResponse = await this.request(
`${this.originRepoURL}/pulls/${pullrequest.number}/merge`,
{
method: 'PUT',
body: JSON.stringify({
commit_message: MERGE_COMMIT_MESSAGE,
sha: pullrequest.head.sha,
merge_method: this.mergeMethod,
}),
},
);
return result;
} catch (error) {
if (error instanceof APIError && error.status === 405) {
return this.forceMergePR(pullrequest);
} else {
throw error;
}
}
}
async forceMergePR(pullRequest: GitHubPull) {
const result = await this.getDifferences(pullRequest.base.sha, pullRequest.head.sha);
const files = getTreeFiles(result.files as GitHubCompareFiles);
let commitMessage = 'Automatically generated. Merged on Static CMS\n\nForce merge of:';
files.forEach(file => {
commitMessage += `\n* "${file.path}"`;
});
console.info(
'%c Automatic merge not possible - Forcing merge.',
'line-height: 30px;text-align: center;font-weight: bold',
);
return this.getDefaultBranch()
.then(branchData => this.updateTree(branchData.commit.sha, files))
.then(changeTree => this.commit(commitMessage, changeTree))
.then(response => this.patchBranch(this.branch, response.sha));
}
async deleteUnpublishedEntry(collectionName: string, slug: string) {
const contentKey = this.generateContentKey(collectionName, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
if (pullRequest.number !== MOCK_PULL_REQUEST) {
await this.closePR(pullRequest.number);
}
await this.deleteBranch(branch);
}
async closePR(number: number) {
console.info('%c Deleting PR', 'line-height: 30px;text-align: center;font-weight: bold');
const result: PullsUpdateBranchResponse = await this.request(
`${this.originRepoURL}/pulls/${number}`,
{
method: 'PATCH',
body: JSON.stringify({
state: PullRequestState.Closed,
}),
},
);
return result;
}
async updatePullRequestLabels(number: number, labels: string[]) {
await this.request(`${this.repoURL}/issues/${number}/labels`, {
method: 'PUT',
body: JSON.stringify({ labels }),
});
}
async setPullRequestStatus(pullRequest: GitHubPull, newStatus: WorkflowStatus) {
const labels = [
...pullRequest.labels
.filter(label => !isCMSLabel(label.name, this.cmsLabelPrefix))
.map(l => l.name),
statusToLabel(newStatus, this.cmsLabelPrefix),
];
await this.updatePullRequestLabels(pullRequest.number, labels);
}
async createPR(title: string, head: string) {
const result: PullsCreateResponse = await this.request(`${this.originRepoURL}/pulls`, {
method: 'POST',
body: JSON.stringify({
title,
body: DEFAULT_PR_BODY,
head: await this.getHeadReference(head),
base: this.branch,
}),
});
return result;
}
async openPR(number: number) {
console.info('%c Re-opening PR', 'line-height: 30px;text-align: center;font-weight: bold');
const result: PullsUpdateBranchResponse = await this.request(
`${this.originRepoURL}/pulls/${number}`,
{
method: 'PATCH',
body: JSON.stringify({
state: PullRequestState.Open,
}),
},
);
return result;
}
async updateUnpublishedEntryStatus(
collectionName: string,
slug: string,
newStatus: WorkflowStatus,
) {
const contentKey = this.generateContentKey(collectionName, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
if (!this.useOpenAuthoring) {
await this.setPullRequestStatus(pullRequest, newStatus);
} else {
if (status === 'pending_publish') {
throw new Error('Open Authoring entries may not be set to the status "pending_publish".');
}
if (pullRequest.number !== MOCK_PULL_REQUEST) {
const { state } = pullRequest;
if (state === PullRequestState.Open && newStatus === 'draft') {
await this.closePR(pullRequest.number);
}
if (state === PullRequestState.Closed && newStatus === 'pending_review') {
await this.openPR(pullRequest.number);
}
} else if (newStatus === 'pending_review') {
const branch = branchFromContentKey(contentKey);
// get the first commit message as the pr title
const diff = await this.getDifferences(this.branch, await this.getHeadReference(branch));
const title = diff.commits[0]?.commit?.message || API.DEFAULT_COMMIT_MESSAGE;
await this.createPR(title, branch);
}
}
}
/**
* Retrieve statuses for a given SHA. Unrelated to the editorial workflow
* concept of entry "status". Useful for things like deploy preview links.
*/
async getStatuses(collectionName: string, slug: string) {
const contentKey = this.generateContentKey(collectionName, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
const sha = pullRequest.head.sha;
const resp: { statuses: GitHubCommitStatus[] } = await this.request(
`${this.originRepoURL}/commits/${sha}/status`,
);
return resp.statuses.map(s => ({
context: s.context,
target_url: s.target_url,
state:
s.state === GitHubCommitStatusState.Success ? PreviewState.Success : PreviewState.Other,
}));
}
async editorialWorkflowGit(
files: TreeFile[],
slug: string,
mediaFilesList: MediaFile[],
options: PersistOptions,
) {
const contentKey = this.generateContentKey(options.collectionName as string, slug);
const branch = branchFromContentKey(contentKey);
const unpublished = options.unpublished || false;
if (!unpublished) {
const branchData = await this.getDefaultBranch();
const changeTree = await this.updateTree(branchData.commit.sha, files);
const commitResponse = await this.commit(options.commitMessage, changeTree);
if (this.useOpenAuthoring) {
await this.createBranch(branch, commitResponse.sha);
} else {
const pr = await this.createBranchAndPullRequest(
branch,
commitResponse.sha,
options.commitMessage,
);
await this.setPullRequestStatus(pr, options.status || this.initialWorkflowStatus);
}
} else {
// Entry is already on editorial review workflow - commit to existing branch
const { files: diffFiles } = await this.getDifferences(
this.branch,
await this.getHeadReference(branch),
);
const diffs = await Promise.all(diffFiles.map(file => this.diffFromFile(file)));
// mark media files to remove
const mediaFilesToRemove: { path: string; sha: string | null }[] = [];
for (const diff of diffs.filter(d => d.binary)) {
if (!mediaFilesList.some(file => file.path === diff.path)) {
mediaFilesToRemove.push({ path: diff.path, sha: null });
}
}
// rebase the branch before applying new changes
const rebasedHead = await this.rebaseBranch(branch);
const treeFiles = mediaFilesToRemove.concat(files);
const changeTree = await this.updateTree(rebasedHead.sha, treeFiles, branch);
const commit = await this.commit(options.commitMessage, changeTree);
return this.patchBranch(branch, commit.sha, { force: true });
}
}
async backupBranch(branchName: string) {
try {
const existingBranch = await this.getBranch(branchName);
await this.createBranch(
existingBranch.name.replace(
new RegExp(`${CMS_BRANCH_PREFIX}/`),
`${CMS_BRANCH_PREFIX}_${Date.now()}/`,
),
existingBranch.commit.sha,
);
} catch (e) {
console.warn(e);
}
}
async createBranch(branchName: string, sha: string) {
try {
const result = await this.createRef('heads', branchName, sha);
return result;
} catch (e) {
if (e instanceof Error) {
const message = String(e.message || '');
if (message === 'Reference update failed') {
await throwOnConflictingBranches(branchName, name => this.getBranch(name), API_NAME);
} else if (
message === 'Reference already exists' &&
branchName.startsWith(`${CMS_BRANCH_PREFIX}/`)
) {
try {
// this can happen if the branch wasn't deleted when the PR was merged
// we backup the existing branch just in case and patch it with the new sha
await this.backupBranch(branchName);
const result = await this.patchBranch(branchName, sha, { force: true });
return result;
} catch (e) {
console.error(e);
}
}
}
throw e;
}
}
async createBranchAndPullRequest(branchName: string, sha: string, commitMessage: string) {
await this.createBranch(branchName, sha);
return this.createPR(commitMessage, branchName);
}
/**
* Rebase an array of commits one-by-one, starting from a given base SHA
*/
async rebaseCommits(baseCommit: GitHubCompareCommit, commits: GitHubCompareCommits) {
/**
* If the parent of the first commit already matches the target base,
* return commits as is.
*/
if (commits.length === 0 || commits[0].parents[0].sha === baseCommit.sha) {
const head = last(commits) as GitHubCompareCommit;
return head;
} else {
/**
* Re-create each commit over the new base, applying each to the previous,
* changing only the parent SHA and tree for each, but retaining all other
* info, such as the author/committer data.
*/
const newHeadPromise = commits.reduce((lastCommitPromise, commit) => {
return lastCommitPromise.then(newParent => {
const parent = newParent;
const commitToRebase = commit;
return this.rebaseSingleCommit(parent, commitToRebase);
});
}, Promise.resolve(baseCommit));
return newHeadPromise;
}
}
async rebaseSingleCommit(baseCommit: GitHubCompareCommit, commit: GitHubCompareCommit) {
// first get the diff between the commits
const result = await this.getDifferences(commit.parents[0].sha, commit.sha);
const files = getTreeFiles(result.files as GitHubCompareFiles);
// only update the tree if changes were detected
if (files.length > 0) {
// create a tree with baseCommit as the base with the diff applied
const tree = await this.updateTree(baseCommit.sha, files);
const { message, author, committer } = commit.commit;
// create a new commit from the updated tree
const newCommit = await this.createCommit(
message,
tree.sha,
[baseCommit.sha],
author,
committer,
);
return newCommit as unknown as GitHubCompareCommit;
} else {
return commit;
}
}
async rebaseBranch(branch: string) {
try {
// Get the diff between the default branch the published branch
const { base_commit: baseCommit, commits } = await this.getDifferences(
this.branch,
await this.getHeadReference(branch),
);
// Rebase the branch based on the diff
const rebasedHead = await this.rebaseCommits(baseCommit, commits);
return rebasedHead;
} catch (error) {
console.error(error);
throw error;
}
}
async getUnpublishedEntrySha(collection: string, slug: string) {
const contentKey = this.generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch);
return pullRequest.head.sha;
}
}

View File

@ -0,0 +1,26 @@
.CMS_Github_AuthenticationPage_fork-approve-container {
@apply flex
flex-col
flex-nowrap
justify-around
flex-grow-[0.2];
}
.CMS_Github_AuthenticationPage_fork-text {
@apply max-w-[600px]
w-full
px-2
my-2
justify-center
items-center
text-center;
}
.CMS_Github_AuthenticationPage_fork-buttons {
@apply flex
flex-col
flex-nowrap
justify-around
items-center
gap-2;
}

View File

@ -1,22 +1,83 @@
import { Github as GithubIcon } from '@styled-icons/simple-icons/Github';
import React, { useCallback, useState } from 'react';
import Button from '@staticcms/core/components/common/button/Button';
import Login from '@staticcms/core/components/login/Login';
import { NetlifyAuthenticator } from '@staticcms/core/lib/auth';
import useCurrentBackend from '@staticcms/core/lib/hooks/useCurrentBackend';
import useTranslate from '@staticcms/core/lib/hooks/useTranslate';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
import type { AuthenticationPageProps, User } from '@staticcms/core';
import type { FC, MouseEvent } from 'react';
import type GitHub from './implementation';
const GitHubAuthenticationPage = ({
import './AuthenticationPage.css';
const classes = generateClassNames('Github_AuthenticationPage', [
'fork-approve-container',
'fork-text',
'fork-buttons',
]);
const GitHubAuthenticationPage: FC<AuthenticationPageProps> = ({
inProgress = false,
config,
base_url,
siteId,
authEndpoint,
onLogin,
t,
}: TranslatedProps<AuthenticationPageProps>) => {
}) => {
const t = useTranslate();
const [loginError, setLoginError] = useState<string | null>(null);
const [forkState, setForkState] = useState<{
requestingFork?: boolean;
findingFork?: boolean;
approveFork?: () => void;
}>();
const { requestingFork = false, findingFork = false, approveFork } = forkState ?? {};
const backend = useCurrentBackend();
const getPermissionToFork = useCallback(() => {
return new Promise<boolean>(resolve => {
setForkState({
findingFork: true,
requestingFork: true,
approveFork: () => {
setForkState({
findingFork: true,
requestingFork: false,
});
resolve(true);
},
});
});
}, []);
const loginWithOpenAuthoring = useCallback(
(userData: User): Promise<void> => {
if (backend?.backendName !== 'github') {
return Promise.resolve();
}
const githubBackend = backend.implementation as GitHub;
setForkState({ findingFork: true });
return githubBackend
.authenticateWithFork({ userData, getPermissionToFork })
.then(() => {
setForkState({ findingFork: false });
})
.catch(() => {
setForkState({ findingFork: false });
console.error('Cannot create fork');
});
},
[backend?.backendName, backend?.implementation, getPermissionToFork],
);
const handleLogin = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
@ -28,18 +89,25 @@ const GitHubAuthenticationPage = ({
};
const auth = new NetlifyAuthenticator(cfg);
const { auth_scope: authScope = '' } = config.backend;
const { auth_scope: authScope = '', open_authoring: openAuthoringEnabled } = config.backend;
const scope = authScope || 'repo';
const scope = authScope || (openAuthoringEnabled ? 'public_repo' : 'repo');
auth.authenticate({ provider: 'github', scope }, (err, data) => {
if (err) {
setLoginError(err.toString());
} else if (data) {
return;
}
if (data) {
if (openAuthoringEnabled) {
return loginWithOpenAuthoring(data).then(() => onLogin(data));
}
onLogin(data);
}
});
},
[authEndpoint, base_url, config.backend, onLogin, siteId],
[authEndpoint, base_url, config.backend, loginWithOpenAuthoring, onLogin, siteId],
);
return (
@ -47,8 +115,18 @@ const GitHubAuthenticationPage = ({
login={handleLogin}
label={t('auth.loginWithGitHub')}
icon={GithubIcon}
inProgress={inProgress}
inProgress={inProgress || findingFork || requestingFork}
error={loginError}
buttonContent={
requestingFork ? (
<div className={classes['fork-approve-container']}>
<p className={classes['fork-text']}>{t('workflow.openAuthoring.forkRequired')}</p>
<div className={classes['fork-buttons']}>
<Button onClick={approveFork}>{t('workflow.openAuthoring.forkRepo')}</Button>
</div>
</div>
) : null
}
/>
);
};

View File

@ -1,6 +1,7 @@
import { Base64 } from 'js-base64';
import API from '../API';
import { WorkflowStatus } from '@staticcms/core/constants/publishModes';
import type { Options } from '../API';
@ -24,7 +25,13 @@ describe('github API', () => {
describe('updateTree', () => {
it('should create tree with nested paths', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const api = new API({
branch: 'master',
repo: 'owner/repo',
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: '',
});
api.createTree = jest.fn().mockImplementation(() => Promise.resolve({ sha: 'newTreeSha' }));
@ -69,7 +76,14 @@ describe('github API', () => {
});
it('should fetch url with authorization header', async () => {
const api = new API({ branch: 'gh-pages', repo: 'my-repo', token: 'token' });
const api = new API({
branch: 'gh-pages',
repo: 'my-repo',
token: 'token',
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: '',
});
fetch.mockResolvedValue({
text: jest.fn().mockResolvedValue('some response'),
@ -90,7 +104,14 @@ describe('github API', () => {
});
it('should throw error on not ok response', async () => {
const api = new API({ branch: 'gh-pages', repo: 'my-repo', token: 'token' });
const api = new API({
branch: 'gh-pages',
repo: 'my-repo',
token: 'token',
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: '',
});
fetch.mockResolvedValue({
text: jest.fn().mockResolvedValue({ message: 'some error' }),
@ -110,7 +131,14 @@ describe('github API', () => {
});
it('should allow overriding requestHeaders to return a promise ', async () => {
const api = new API({ branch: 'gh-pages', repo: 'my-repo', token: 'token' });
const api = new API({
branch: 'gh-pages',
repo: 'my-repo',
token: 'token',
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: '',
});
api.requestHeaders = jest.fn().mockResolvedValue({
Authorization: 'promise-token',
@ -138,7 +166,13 @@ describe('github API', () => {
describe('persistFiles', () => {
it('should update tree, commit and patch branch when useWorkflow is false', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const api = new API({
branch: 'master',
repo: 'owner/repo',
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: '',
});
const responses = {
// upload the file
@ -226,6 +260,7 @@ describe('github API', () => {
{
body: JSON.stringify({
sha: 'commit-sha',
force: false,
}),
method: 'PATCH',
},
@ -235,7 +270,13 @@ describe('github API', () => {
describe('listFiles', () => {
it('should get files by depth', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const api = new API({
branch: 'master',
repo: 'owner/repo',
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: '',
});
const tree = [
{
@ -315,7 +356,13 @@ describe('github API', () => {
});
});
it('should get files and folders', async () => {
const api = new API({ branch: 'master', repo: 'owner/repo' });
const api = new API({
branch: 'master',
repo: 'owner/repo',
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: '',
});
const tree = [
{

View File

@ -1,9 +1,9 @@
import Cursor, { CURSOR_COMPATIBILITY_SYMBOL } from '@staticcms/core/lib/util/Cursor';
import GitHubImplementation from '../implementation';
import type { Config, UnknownField } from '@staticcms/core';
import type API from '../API';
import type { ConfigWithDefaults, UnknownField } from '@staticcms/core';
import type { AssetProxy } from '@staticcms/core/valueObjects';
import type API from '../API';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare const global: any;
@ -14,7 +14,7 @@ describe('github backend implementation', () => {
repo: 'owner/repo',
api_root: 'https://api.github.com',
},
} as Config<UnknownField>;
} as ConfigWithDefaults<UnknownField>;
const createObjectURL = jest.fn();
global.URL = {
@ -100,6 +100,44 @@ describe('github backend implementation', () => {
});
});
describe('unpublishedEntry', () => {
const generateContentKey = jest.fn();
const retrieveUnpublishedEntryData = jest.fn();
const mockAPI = {
generateContentKey,
retrieveUnpublishedEntryData,
} as unknown as API;
it('should return unpublished entry data', async () => {
const gitHubImplementation = new GitHubImplementation(config);
gitHubImplementation.api = mockAPI;
generateContentKey.mockReturnValue('contentKey');
const data = {
collection: 'collection',
slug: 'slug',
status: 'draft',
diffs: [],
updatedAt: 'updatedAt',
};
retrieveUnpublishedEntryData.mockResolvedValue(data);
const collection = 'posts';
const slug = 'slug';
await expect(gitHubImplementation.unpublishedEntry({ collection, slug })).resolves.toEqual(
data,
);
expect(generateContentKey).toHaveBeenCalledTimes(1);
expect(generateContentKey).toHaveBeenCalledWith('posts', 'slug');
expect(retrieveUnpublishedEntryData).toHaveBeenCalledTimes(1);
expect(retrieveUnpublishedEntryData).toHaveBeenCalledWith('contentKey');
});
});
describe('entriesByFolder', () => {
const listFiles = jest.fn();
const readFile = jest.fn();

View File

@ -2,6 +2,7 @@ import { stripIndent } from 'common-tags';
import trimStart from 'lodash/trimStart';
import semaphore from 'semaphore';
import { WorkflowStatus } from '@staticcms/core/constants/publishModes';
import {
asyncLock,
basename,
@ -17,19 +18,24 @@ import {
runWithLock,
unsentRequest,
} from '@staticcms/core/lib/util';
import { getPreviewStatus } from '@staticcms/core/lib/util/API';
import { branchFromContentKey, contentKeyFromBranch } from '@staticcms/core/lib/util/APIUtils';
import { unpublishedEntries } from '@staticcms/core/lib/util/implementation';
import API, { API_NAME } from './API';
import AuthenticationPage from './AuthenticationPage';
import type {
BackendClass,
BackendEntry,
Config,
ConfigWithDefaults,
Credentials,
DisplayURL,
ImplementationFile,
PersistOptions,
UnpublishedEntry,
UnpublishedEntryMediaFile,
User,
} from '@staticcms/core/interface';
} from '@staticcms/core';
import type { AsyncLock } from '@staticcms/core/lib/util';
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
import type { Semaphore } from 'semaphore';
@ -56,23 +62,32 @@ export default class GitHub implements BackendClass {
options: {
proxied: boolean;
API: API | null;
useWorkflow?: boolean;
initialWorkflowStatus: WorkflowStatus;
};
originRepo: string;
repo?: string;
openAuthoringEnabled: boolean;
useOpenAuthoring?: boolean;
alwaysForkEnabled: boolean;
branch: string;
apiRoot: string;
mediaFolder?: string;
previewContext: string;
token: string | null;
squashMerges: boolean;
cmsLabelPrefix: string;
_currentUserPromise?: Promise<GitHubUser>;
_userIsOriginMaintainerPromises?: {
[key: string]: Promise<boolean>;
};
_mediaDisplayURLSem?: Semaphore;
constructor(config: Config, options = {}) {
constructor(config: ConfigWithDefaults, options = {}) {
this.options = {
proxied: false,
API: null,
initialWorkflowStatus: WorkflowStatus.DRAFT,
...options,
};
@ -84,18 +99,28 @@ export default class GitHub implements BackendClass {
}
this.api = this.options.API || null;
this.repo = this.originRepo = config.backend.repo || '';
this.openAuthoringEnabled = config.backend.open_authoring || false;
if (this.openAuthoringEnabled) {
if (!this.options.useWorkflow) {
throw new Error(
'backend.open_authoring is true but publish_mode is not set to editorial_workflow.',
);
}
this.originRepo = config.backend.repo || '';
} else {
this.repo = this.originRepo = config.backend.repo || '';
}
this.alwaysForkEnabled = config.backend.always_fork || false;
this.branch = config.backend.branch?.trim() || 'main';
this.apiRoot = config.backend.api_root || 'https://api.github.com';
this.token = '';
this.squashMerges = config.backend.squash_merges || false;
this.cmsLabelPrefix = config.backend.cms_label_prefix || '';
this.mediaFolder = config.media_folder;
this.previewContext = config.backend.preview_context || '';
this.lock = asyncLock();
}
isGitBackend() {
return true;
}
async status() {
const api = await fetch(GITHUB_STATUS_ENDPOINT)
.then(res => res.json())
@ -134,7 +159,35 @@ export default class GitHub implements BackendClass {
}
restoreUser(user: User) {
return this.authenticate(user);
return this.openAuthoringEnabled
? this.authenticateWithFork({ userData: user, getPermissionToFork: () => true }).then(() =>
this.authenticate(user),
)
: this.authenticate(user);
}
async pollUntilForkExists({ repo, token }: { repo: string; token: string }) {
const pollDelay = 250; // milliseconds
let repoExists = false;
while (!repoExists) {
repoExists = await fetch(`${this.apiRoot}/repos/${repo}`, {
headers: { Authorization: `token ${token}` },
})
.then(() => true)
.catch(err => {
if (err && err.status === 404) {
console.info('This 404 was expected and handled appropriately.');
return false;
} else {
return Promise.reject(err);
}
});
// wait between polls
if (!repoExists) {
await new Promise(resolve => setTimeout(resolve, pollDelay));
}
}
return Promise.resolve();
}
async currentUser({ token }: { token: string }) {
@ -172,6 +225,65 @@ export default class GitHub implements BackendClass {
return this._userIsOriginMaintainerPromises[username];
}
async forkExists({ token }: { token: string }) {
try {
const currentUser = await this.currentUser({ token });
const repoName = this.originRepo.split('/')[1];
const repo = await fetch(`${this.apiRoot}/repos/${currentUser.login}/${repoName}`, {
method: 'GET',
headers: {
Authorization: `token ${token}`,
},
}).then(res => res.json());
// https://developer.github.com/v3/repos/#get
// The parent and source objects are present when the repository is a fork.
// parent is the repository this repository was forked from, source is the ultimate source for the network.
const forkExists =
repo.fork === true &&
repo.parent &&
repo.parent.full_name.toLowerCase() === this.originRepo.toLowerCase();
return forkExists;
} catch {
return false;
}
}
async authenticateWithFork({
userData,
getPermissionToFork,
}: {
userData: User;
getPermissionToFork: () => Promise<boolean> | boolean;
}) {
if (!this.openAuthoringEnabled) {
throw new Error('Cannot authenticate with fork; Open Authoring is turned off.');
}
const token = userData.token as string;
// Origin maintainers should be able to use the CMS normally. If alwaysFork
// is enabled we always fork (and avoid the origin maintainer check)
if (!this.alwaysForkEnabled && (await this.userIsOriginMaintainer({ token }))) {
this.repo = this.originRepo;
this.useOpenAuthoring = false;
return Promise.resolve();
}
if (!(await this.forkExists({ token }))) {
await getPermissionToFork();
}
const fork = await fetch(`${this.apiRoot}/repos/${this.originRepo}/forks`, {
method: 'POST',
headers: {
Authorization: `token ${token}`,
},
}).then(res => res.json());
this.useOpenAuthoring = true;
this.repo = fork.full_name;
return this.pollUntilForkExists({ repo: fork.full_name, token });
}
async authenticate(state: Credentials) {
this.token = state.token as string;
const apiCtor = API;
@ -181,6 +293,11 @@ export default class GitHub implements BackendClass {
repo: this.repo,
originRepo: this.originRepo,
apiRoot: this.apiRoot,
squashMerges: this.squashMerges,
cmsLabelPrefix: this.cmsLabelPrefix,
useOpenAuthoring: this.useOpenAuthoring,
openAuthoringEnabled: this.openAuthoringEnabled,
initialWorkflowStatus: this.options.initialWorkflowStatus,
});
const user = await this.api!.user();
const isCollab = await this.api!.hasWriteAccess().catch(error => {
@ -203,7 +320,7 @@ export default class GitHub implements BackendClass {
}
// Authorized user
return { ...user, token: state.token as string };
return { ...user, token: state.token as string, useOpenAuthoring: this.useOpenAuthoring };
}
logout() {
@ -299,7 +416,7 @@ export default class GitHub implements BackendClass {
}
entriesByFiles(files: ImplementationFile[]) {
const repoURL = this.api!.repoURL;
const repoURL = this.useOpenAuthoring ? this.api!.originRepoURL : this.api!.repoURL;
const readFile = (path: string, id: string | null | undefined) =>
this.api!.readFile(path, id, { repoURL }).catch(() => '') as Promise<string>;
@ -435,4 +552,117 @@ export default class GitHub implements BackendClass {
cursor: result.cursor,
};
}
/**
* Editorial Workflow
*/
async unpublishedEntries() {
const listEntriesKeys = () =>
this.api!.listUnpublishedBranches().then(branches =>
branches.map(branch => contentKeyFromBranch(branch)),
);
const ids = await unpublishedEntries(listEntriesKeys);
return ids;
}
async unpublishedEntry({
id,
collection,
slug,
}: {
id?: string;
collection?: string;
slug?: string;
}): Promise<UnpublishedEntry> {
if (id) {
return this.api!.retrieveUnpublishedEntryData(id);
} else if (collection && slug) {
const entryId = this.api!.generateContentKey(collection, slug);
return this.api!.retrieveUnpublishedEntryData(entryId);
} else {
throw new Error('Missing unpublished entry id or collection and slug');
}
}
getBranch(collection: string, slug: string) {
const contentKey = this.api!.generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);
return branch;
}
async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const data = (await this.api!.readFile(path, id, { branch })) as string;
return data;
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const mediaFile = await this.loadMediaFile(branch, { path, id });
return mediaFile;
}
async getDeployPreview(collection: string, slug: string) {
try {
const statuses = await this.api!.getStatuses(collection, slug);
const deployStatus = getPreviewStatus(statuses, this.previewContext);
if (deployStatus) {
const { target_url: url, state } = deployStatus;
return { url, status: state };
} else {
return null;
}
} catch (e) {
return null;
}
}
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: WorkflowStatus) {
// updateUnpublishedEntryStatus is a transactional operation
return runWithLock(
this.lock,
() => this.api!.updateUnpublishedEntryStatus(collection, slug, newStatus),
'Failed to acquire update entry status lock',
);
}
deleteUnpublishedEntry(collection: string, slug: string) {
// deleteUnpublishedEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.deleteUnpublishedEntry(collection, slug),
'Failed to acquire delete entry lock',
);
}
publishUnpublishedEntry(collection: string, slug: string) {
// publishUnpublishedEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.publishUnpublishedEntry(collection, slug),
'Failed to acquire publish entry lock',
);
}
async loadMediaFile(branch: string, file: UnpublishedEntryMediaFile) {
const readFile = (
path: string,
id: string | null | undefined,
{ parseText }: { parseText: boolean },
) => this.api!.readFile(path, id, { branch, parseText });
const blob = await getMediaAsBlob(file.path, file.id, readFile);
const name = basename(file.path);
const fileObj = blobToFileObj(name, blob);
return {
id: file.id,
displayURL: URL.createObjectURL(fileObj),
path: file.path,
name,
size: fileObj.size,
file: fileObj,
};
}
}

File diff suppressed because it is too large Load Diff

View File

@ -4,9 +4,11 @@ import result from 'lodash/result';
import trimStart from 'lodash/trimStart';
import { dirname } from 'path';
import { PreviewState } from '@staticcms/core/constants/enums';
import {
APIError,
Cursor,
EditorialWorkflowError,
localForage,
parseLinkHeader,
readFile,
@ -16,8 +18,20 @@ import {
throwOnConflictingBranches,
unsentRequest,
} from '@staticcms/core/lib/util';
import {
CMS_BRANCH_PREFIX,
DEFAULT_PR_BODY,
MERGE_COMMIT_MESSAGE,
branchFromContentKey,
generateContentKey,
isCMSLabel,
labelToStatus,
parseContentKey,
statusToLabel,
} from '@staticcms/core/lib/util/APIUtils';
import type { DataFile, PersistOptions } from '@staticcms/core/interface';
import type { DataFile, PersistOptions, UnpublishedEntry } from '@staticcms/core';
import type { WorkflowStatus } from '@staticcms/core/constants/publishModes';
import type { ApiRequest, FetchError } from '@staticcms/core/lib/util';
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
@ -28,6 +42,9 @@ export interface Config {
token?: string;
branch?: string;
repo?: string;
squashMerges: boolean;
initialWorkflowStatus: WorkflowStatus;
cmsLabelPrefix: string;
}
export interface CommitAuthor {
@ -74,6 +91,55 @@ type GitLabCommitDiff = {
deleted_file: boolean;
};
enum GitLabCommitStatuses {
Pending = 'pending',
Running = 'running',
Success = 'success',
Failed = 'failed',
Canceled = 'canceled',
}
type GitLabCommitStatus = {
status: GitLabCommitStatuses;
name: string;
author: {
username: string;
name: string;
};
description: null;
sha: string;
ref: string;
target_url: string;
};
type GitLabMergeRebase = {
rebase_in_progress: boolean;
merge_error: string;
};
type GitLabMergeRequest = {
id: number;
iid: number;
title: string;
description: string;
state: string;
merged_by: {
name: string;
username: string;
};
merged_at: string;
created_at: string;
updated_at: string;
target_branch: string;
source_branch: string;
author: {
name: string;
username: string;
};
labels: string[];
sha: string;
};
type GitLabRepo = {
shared_with_groups: { group_access_level: number }[] | null;
permissions: {
@ -126,6 +192,9 @@ export default class API {
repo: string;
repoURL: string;
commitAuthor?: CommitAuthor;
squashMerges: boolean;
initialWorkflowStatus: WorkflowStatus;
cmsLabelPrefix: string;
constructor(config: Config) {
this.apiRoot = config.apiRoot || 'https://gitlab.com/api/v4';
@ -133,6 +202,9 @@ export default class API {
this.branch = config.branch || 'main';
this.repo = config.repo || '';
this.repoURL = `/projects/${encodeURIComponent(this.repo)}`;
this.squashMerges = config.squashMerges;
this.initialWorkflowStatus = config.initialWorkflowStatus;
this.cmsLabelPrefix = config.cmsLabelPrefix;
}
withAuthorizationHeaders = (req: ApiRequest) => {
@ -444,6 +516,12 @@ export default class API {
async persistFiles(dataFiles: DataFile[], mediaFiles: AssetProxy[], options: PersistOptions) {
const files = [...dataFiles, ...mediaFiles];
const items = await this.getCommitItems(files, this.branch);
if (options.useWorkflow) {
const slug = dataFiles[0].slug;
return this.editorialWorkflowGit(files, slug, options);
}
return this.uploadAndCommit(items, {
commitMessage: options.commitMessage,
});
@ -465,14 +543,17 @@ export default class API {
};
async getFileId(path: string, branch: string) {
const request = await this.request({
const response = await this.request({
method: 'HEAD',
url: `${this.repoURL}/repository/files/${encodeURIComponent(path)}`,
params: { ref: branch },
});
const blobId = request.headers.get('X - Gitlab - Blob - Id') as string;
return blobId;
try {
return response.headers.get('X - Gitlab - Blob - Id') as string;
} catch {
return '';
}
}
async isFileExists(path: string, branch: string) {
@ -542,4 +623,255 @@ export default class API {
});
return refs.some(r => r.name === branch);
}
/**
* Editorial Workflow
*/
async listUnpublishedBranches() {
console.info(
'%c Checking for Unpublished entries',
'line-height: 30px;text-align: center;font-weight: bold',
);
const mergeRequests = await this.getMergeRequests();
const branches = mergeRequests.map(mr => mr.source_branch);
return branches;
}
async getMergeRequests(sourceBranch?: string) {
const mergeRequests: GitLabMergeRequest[] = await this.requestJSON({
url: `${this.repoURL}/merge_requests`,
params: {
state: 'opened',
labels: 'Any',
per_page: '100',
target_branch: this.branch,
...(sourceBranch ? { source_branch: sourceBranch } : {}),
},
});
return mergeRequests.filter(
mr =>
mr.source_branch.startsWith(CMS_BRANCH_PREFIX) &&
mr.labels.some(l => isCMSLabel(l, this.cmsLabelPrefix)),
);
}
async getBranchMergeRequest(branch: string) {
const mergeRequests = await this.getMergeRequests(branch);
if (mergeRequests.length <= 0) {
throw new EditorialWorkflowError('content is not under editorial workflow', true);
}
return mergeRequests[0];
}
async retrieveUnpublishedEntryData(contentKey: string): Promise<UnpublishedEntry> {
const { collection, slug } = parseContentKey(contentKey);
const branch = branchFromContentKey(contentKey);
const mergeRequest = await this.getBranchMergeRequest(branch);
const diffs = await this.getDifferences(mergeRequest.sha);
const diffsWithIds = await Promise.all(
diffs.map(async d => {
const { path, newFile } = d;
const id = await this.getFileId(path, branch);
return { id, path, newFile };
}),
);
const label = mergeRequest.labels.find(l => isCMSLabel(l, this.cmsLabelPrefix)) as string;
const status = labelToStatus(label, this.cmsLabelPrefix);
const updatedAt = mergeRequest.updated_at;
const pullRequestAuthor = mergeRequest.author.name;
return {
collection,
slug,
status,
diffs: diffsWithIds,
updatedAt,
pullRequestAuthor,
openAuthoring: false,
};
}
async updateMergeRequestLabels(mergeRequest: GitLabMergeRequest, labels: string[]) {
await this.requestJSON({
method: 'PUT',
url: `${this.repoURL}/merge_requests/${mergeRequest.iid}`,
params: {
labels: labels.join(','),
},
});
}
async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: WorkflowStatus) {
const contentKey = generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);
const mergeRequest = await this.getBranchMergeRequest(branch);
const labels = [
...mergeRequest.labels.filter(label => !isCMSLabel(label, this.cmsLabelPrefix)),
statusToLabel(newStatus, this.cmsLabelPrefix),
];
await this.updateMergeRequestLabels(mergeRequest, labels);
}
async deleteBranch(branch: string) {
await this.request({
method: 'DELETE',
url: `${this.repoURL}/repository/branches/${encodeURIComponent(branch)}`,
});
}
async closeMergeRequest(mergeRequest: GitLabMergeRequest) {
await this.requestJSON({
method: 'PUT',
url: `${this.repoURL}/merge_requests/${mergeRequest.iid}`,
params: {
state_event: 'close',
},
});
}
async deleteUnpublishedEntry(collectionName: string, slug: string) {
const contentKey = generateContentKey(collectionName, slug);
const branch = branchFromContentKey(contentKey);
const mergeRequest = await this.getBranchMergeRequest(branch);
await this.closeMergeRequest(mergeRequest);
await this.deleteBranch(branch);
}
async getMergeRequestStatues(mergeRequest: GitLabMergeRequest, branch: string) {
const statuses: GitLabCommitStatus[] = await this.requestJSON({
url: `${this.repoURL}/repository/commits/${mergeRequest.sha}/statuses`,
params: {
ref: branch,
},
});
return statuses;
}
async mergeMergeRequest(mergeRequest: GitLabMergeRequest) {
await this.requestJSON({
method: 'PUT',
url: `${this.repoURL}/merge_requests/${mergeRequest.iid}/merge`,
params: {
merge_commit_message: MERGE_COMMIT_MESSAGE,
squash_commit_message: MERGE_COMMIT_MESSAGE,
squash: String(this.squashMerges),
should_remove_source_branch: 'true',
},
});
}
async publishUnpublishedEntry(collectionName: string, slug: string) {
const contentKey = generateContentKey(collectionName, slug);
const branch = branchFromContentKey(contentKey);
const mergeRequest = await this.getBranchMergeRequest(branch);
await this.mergeMergeRequest(mergeRequest);
}
async getStatuses(collectionName: string, slug: string) {
const contentKey = generateContentKey(collectionName, slug);
const branch = branchFromContentKey(contentKey);
const mergeRequest = await this.getBranchMergeRequest(branch);
const statuses: GitLabCommitStatus[] = await this.getMergeRequestStatues(mergeRequest, branch);
return statuses.map(({ name, status, target_url }) => ({
context: name,
state: status === GitLabCommitStatuses.Success ? PreviewState.Success : PreviewState.Other,
target_url,
}));
}
async createMergeRequest(branch: string, commitMessage: string, status: WorkflowStatus) {
await this.requestJSON({
method: 'POST',
url: `${this.repoURL}/merge_requests`,
params: {
source_branch: branch,
target_branch: this.branch,
title: commitMessage,
description: DEFAULT_PR_BODY,
labels: statusToLabel(status, this.cmsLabelPrefix),
remove_source_branch: 'true',
squash: String(this.squashMerges),
},
});
}
async rebaseMergeRequest(mergeRequest: GitLabMergeRequest) {
let rebase: GitLabMergeRebase = await this.requestJSON({
method: 'PUT',
url: `${this.repoURL}/merge_requests/${mergeRequest.iid}/rebase?skip_ci=true`,
});
let i = 1;
while (rebase.rebase_in_progress) {
await new Promise(resolve => setTimeout(resolve, 1000));
rebase = await this.requestJSON({
url: `${this.repoURL}/merge_requests/${mergeRequest.iid}`,
params: {
include_rebase_in_progress: 'true',
},
});
if (!rebase.rebase_in_progress || i > 30) {
break;
}
i++;
}
if (rebase.rebase_in_progress) {
throw new APIError('Timed out rebasing merge request', null, API_NAME);
} else if (rebase.merge_error) {
throw new APIError(`Rebase error: ${rebase.merge_error}`, null, API_NAME);
}
}
async editorialWorkflowGit(
files: (DataFile | AssetProxy)[],
slug: string,
options: PersistOptions,
) {
const contentKey = generateContentKey(options.collectionName as string, slug);
const branch = branchFromContentKey(contentKey);
const unpublished = options.unpublished || false;
if (!unpublished) {
const items = await this.getCommitItems(files, this.branch);
await this.uploadAndCommit(items, {
commitMessage: options.commitMessage,
branch,
newBranch: true,
});
await this.createMergeRequest(
branch,
options.commitMessage,
options.status || this.initialWorkflowStatus,
);
} else {
const mergeRequest = await this.getBranchMergeRequest(branch);
await this.rebaseMergeRequest(mergeRequest);
const [items, diffs] = await Promise.all([
this.getCommitItems(files, branch),
this.getDifferences(branch),
]);
// mark files for deletion
for (const diff of diffs.filter(d => d.binary)) {
if (!items.some(item => item.path === diff.path)) {
items.push({ action: CommitAction.DELETE, path: diff.newPath });
}
}
await this.uploadAndCommit(items, {
commitMessage: options.commitMessage,
branch,
});
}
}
async getUnpublishedEntrySha(collection: string, slug: string) {
const contentKey = generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);
const mergeRequest = await this.getBranchMergeRequest(branch);
return mergeRequest.sha;
}
}

View File

@ -3,28 +3,26 @@ import React, { useCallback, useMemo, useState } from 'react';
import Login from '@staticcms/core/components/login/Login';
import { NetlifyAuthenticator, PkceAuthenticator } from '@staticcms/core/lib/auth';
import useTranslate from '@staticcms/core/lib/hooks/useTranslate';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import type {
AuthenticationPageProps,
AuthenticatorConfig,
TranslatedProps,
} from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
import type { AuthenticationPageProps, AuthenticatorConfig } from '@staticcms/core';
import type { FC, MouseEvent } from 'react';
const clientSideAuthenticators = {
pkce: (config: AuthenticatorConfig) => new PkceAuthenticator(config),
} as const;
const GitLabAuthenticationPage = ({
const GitLabAuthenticationPage: FC<AuthenticationPageProps> = ({
inProgress = false,
config,
siteId,
authEndpoint,
clearHash,
onLogin,
t,
}: TranslatedProps<AuthenticationPageProps>) => {
}) => {
const t = useTranslate();
const [loginError, setLoginError] = useState<string | null>(null);
const auth = useMemo(() => {

View File

@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { WorkflowStatus } from '@staticcms/core/constants/publishModes';
import API, { getMaxAccess } from '../API';
import { CMS_BRANCH_PREFIX } from '@staticcms/core/lib/util/APIUtils';
global.fetch = jest.fn().mockRejectedValue(new Error('should not call fetch inside tests'));
@ -14,7 +16,12 @@ describe('GitLab API', () => {
describe('hasWriteAccess', () => {
test('should return true on project access_level >= 30', async () => {
const api = new API({ repo: 'repo' });
const api = new API({
repo: 'repo',
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: '',
});
api.requestJSON = jest
.fn()
@ -24,7 +31,12 @@ describe('GitLab API', () => {
});
test('should return false on project access_level < 30', async () => {
const api = new API({ repo: 'repo' });
const api = new API({
repo: 'repo',
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: '',
});
api.requestJSON = jest
.fn()
@ -34,7 +46,12 @@ describe('GitLab API', () => {
});
test('should return true on group access_level >= 30', async () => {
const api = new API({ repo: 'repo' });
const api = new API({
repo: 'repo',
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: '',
});
api.requestJSON = jest
.fn()
@ -44,7 +61,12 @@ describe('GitLab API', () => {
});
test('should return false on group access_level < 30', async () => {
const api = new API({ repo: 'repo' });
const api = new API({
repo: 'repo',
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: '',
});
api.requestJSON = jest
.fn()
@ -54,7 +76,12 @@ describe('GitLab API', () => {
});
test('should return true on shared group access_level >= 40', async () => {
const api = new API({ repo: 'repo' });
const api = new API({
repo: 'repo',
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: '',
});
api.requestJSON = jest.fn().mockResolvedValueOnce({
permissions: { project_access: null, group_access: null },
shared_with_groups: [{ group_access_level: 10 }, { group_access_level: 40 }],
@ -66,7 +93,12 @@ describe('GitLab API', () => {
});
test('should return true on shared group access_level >= 30, developers can merge and push', async () => {
const api = new API({ repo: 'repo' });
const api = new API({
repo: 'repo',
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: '',
});
const requestJSONMock = (api.requestJSON = jest.fn());
requestJSONMock.mockResolvedValueOnce({
@ -82,7 +114,12 @@ describe('GitLab API', () => {
});
test('should return false on shared group access_level < 30,', async () => {
const api = new API({ repo: 'repo' });
const api = new API({
repo: 'repo',
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: '',
});
const requestJSONMock = (api.requestJSON = jest.fn());
requestJSONMock.mockResolvedValueOnce({
@ -98,7 +135,12 @@ describe('GitLab API', () => {
});
test("should return false on shared group access_level >= 30, developers can't merge", async () => {
const api = new API({ repo: 'repo' });
const api = new API({
repo: 'repo',
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: '',
});
const requestJSONMock = (api.requestJSON = jest.fn());
requestJSONMock.mockResolvedValueOnce({
@ -114,7 +156,12 @@ describe('GitLab API', () => {
});
test("should return false on shared group access_level >= 30, developers can't push", async () => {
const api = new API({ repo: 'repo' });
const api = new API({
repo: 'repo',
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: '',
});
const requestJSONMock = (api.requestJSON = jest.fn());
requestJSONMock.mockResolvedValueOnce({
@ -130,7 +177,12 @@ describe('GitLab API', () => {
});
test('should return false on shared group access_level >= 30, error getting branch', async () => {
const api = new API({ repo: 'repo' });
const api = new API({
repo: 'repo',
squashMerges: false,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: '',
});
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
@ -152,6 +204,41 @@ describe('GitLab API', () => {
});
});
describe('getStatuses', () => {
test('should get preview statuses', async () => {
const api = new API({
repo: 'repo',
squashMerges: true,
initialWorkflowStatus: WorkflowStatus.DRAFT,
cmsLabelPrefix: CMS_BRANCH_PREFIX,
});
const mr = { sha: 'sha' };
const statuses = [
{ name: 'deploy', status: 'success', target_url: 'deploy-url' },
{ name: 'build', status: 'pending' },
];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(api as any).getBranchMergeRequest = jest.fn(() => Promise.resolve(mr));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(api as any).getMergeRequestStatues = jest.fn(() => Promise.resolve(statuses));
const collectionName = 'posts';
const slug = 'title';
await expect(api.getStatuses(collectionName, slug)).resolves.toEqual([
{ context: 'deploy', state: 'success', target_url: 'deploy-url' },
{ context: 'build', state: 'other' },
]);
expect(api.getBranchMergeRequest).toHaveBeenCalledTimes(1);
expect(api.getBranchMergeRequest).toHaveBeenCalledWith('cms/posts/title');
expect(api.getMergeRequestStatues).toHaveBeenCalledTimes(1);
expect(api.getMergeRequestStatues).toHaveBeenCalledWith(mr, 'cms/posts/title');
});
});
describe('getMaxAccess', () => {
it('should return group with max access level', () => {
const groups = [

View File

@ -0,0 +1,633 @@
/**
* @jest-environment jsdom
*/
import { oneLine, stripIndent } from 'common-tags';
import Cursor from '@staticcms/core/lib/util/Cursor';
import {
createMockFilesCollectionWithDefaults,
createMockFolderCollectionWithDefaults,
} from '@staticcms/test/data/collections.mock';
import { createMockConfig } from '@staticcms/test/data/config.mock';
import mockFetch from '@staticcms/test/mockFetch';
import AuthenticationPage from '../AuthenticationPage';
import GitLab from '../implementation';
import type {
Backend as BackendType,
LocalStorageAuthStore as LocalStorageAuthStoreType,
} from '@staticcms/core/backend';
import type {
ConfigWithDefaults,
FilesCollectionWithDefaults,
FolderCollectionWithDefaults,
} from '@staticcms/core';
import type { RootState } from '@staticcms/core/store';
import type { FetchMethod } from '@staticcms/test/mockFetch';
jest.mock('@staticcms/core/backend');
const { Backend, LocalStorageAuthStore } = jest.requireActual('@staticcms/core/backend') as {
Backend: typeof BackendType;
LocalStorageAuthStore: typeof LocalStorageAuthStoreType;
};
function generateEntries(path: string, length: number) {
const entries = Array.from({ length }, (_val, idx) => {
const count = idx + 1;
const id = `00${count}`.slice(-3);
const fileName = `test${id}.md`;
return { id, fileName, filePath: `${path}/${fileName}` };
});
return {
tree: entries.map(({ id, fileName, filePath }) => ({
id: `d8345753a1d935fa47a26317a503e73e1192d${id}`,
name: fileName,
type: 'blob',
path: filePath,
mode: '100644',
})),
files: entries.reduce(
(acc, { id, filePath }) => ({
...acc,
[filePath]: stripIndent`
---
title: test ${id}
---
# test ${id}
`,
}),
{},
),
};
}
const manyEntries = generateEntries('many-entries', 500);
interface TreeEntry {
id: string;
name: string;
type: string;
path: string;
mode: string;
}
interface MockRepo {
tree: Record<string, TreeEntry[]>;
files: Record<string, string>;
}
const mockRepo: MockRepo = {
tree: {
'/': [
{
id: '5d0620ebdbc92068a3e866866e928cc373f18429',
name: 'content',
type: 'tree',
path: 'content',
mode: '040000',
},
],
content: [
{
id: 'b1a200e48be54fde12b636f9563d659d44c206a5',
name: 'test1.md',
type: 'blob',
path: 'content/test1.md',
mode: '100644',
},
{
id: 'd8345753a1d935fa47a26317a503e73e1192d623',
name: 'test2.md',
type: 'blob',
path: 'content/test2.md',
mode: '100644',
},
],
'many-entries': manyEntries.tree,
},
files: {
'content/test1.md': stripIndent`
{
"title": "test"
}
# test
`,
'content/test2.md': stripIndent`
{
"title": "test2"
}
# test 2
`,
...manyEntries.files,
},
};
const resp: Record<string, Record<string, object | Promise<object>>> = {
user: {
success: {
id: 1,
},
},
branch: {
success: {
name: 'main',
commit: {
id: 1,
},
},
},
project: {
success: {
permissions: {
project_access: {
access_level: 30,
},
},
},
readOnly: {
permissions: {
project_access: {
access_level: 10,
},
},
},
},
};
function mockApi(apiRoot: string) {
return mockFetch(apiRoot);
}
describe('gitlab backend', () => {
const apiRoot = 'https://gitlab.com/api/v4';
const api = mockApi(apiRoot);
let authStore: InstanceType<typeof LocalStorageAuthStoreType>;
const repo = 'foo/bar';
const collectionContentConfig = createMockFolderCollectionWithDefaults({
name: 'foo',
folder: 'content',
format: 'json-frontmatter',
fields: [{ name: 'title', widget: 'string' }],
}) as unknown as FolderCollectionWithDefaults;
const collectionManyEntriesConfig = createMockFolderCollectionWithDefaults({
name: 'foo',
folder: 'many-entries',
format: 'json-frontmatter',
fields: [{ name: 'title', widget: 'string' }],
}) as unknown as FolderCollectionWithDefaults;
const collectionFilesConfig = createMockFilesCollectionWithDefaults({
name: 'foo',
files: [
{
label: 'foo',
name: 'foo',
file: 'content/test1.json',
fields: [{ name: 'title', widget: 'string' }],
},
{
label: 'bar',
name: 'bar',
file: 'content/test2.json',
fields: [{ name: 'title', widget: 'string' }],
},
],
}) as unknown as FilesCollectionWithDefaults;
const defaultConfig = createMockConfig({
backend: {
name: 'gitlab',
repo,
},
collections: [],
}) as ConfigWithDefaults;
const mockCredentials = { token: 'MOCK_TOKEN' };
const expectedRepo = encodeURIComponent(repo);
const expectedRepoUrl = `/projects/${expectedRepo}`;
function resolveBackend(config: ConfigWithDefaults) {
authStore = new LocalStorageAuthStore();
return new Backend(
{
init: (...args) => new GitLab(...args),
},
{
backendName: 'gitlab',
config,
authStore,
},
);
}
interface InterceptAuthOptions {
userResponse?: (typeof resp)['user'][string];
projectResponse?: (typeof resp)['project'][string];
}
function interceptAuth({ userResponse, projectResponse }: InterceptAuthOptions = {}) {
api
.when('GET', '/user')
.query(true)
.reply({ status: 200, json: userResponse ?? resp.user.success });
api
.when('GET', expectedRepoUrl)
.query(true)
.reply({ status: 200, json: projectResponse || resp.project.success });
}
function interceptBranch({ branch = 'main' } = {}) {
api
.when('GET', `${expectedRepoUrl}/repository/branches/${encodeURIComponent(branch)}`)
.query(true)
.reply({ status: 200, json: resp.branch.success });
}
function parseQuery(uri: string) {
const query = uri.split('?')[1];
if (!query) {
return {};
}
return query.split('&').reduce(
(acc, q) => {
const [key, value] = q.split('=');
acc[key] = value;
return acc;
},
{} as Record<string, string>,
);
}
interface CreateHeadersOptions {
basePath: string;
path: string;
page: string;
perPage: string;
pageCount: string;
totalCount: string;
}
function createHeaders({
basePath,
path,
page,
perPage,
pageCount,
totalCount,
}: CreateHeadersOptions) {
const pageNum = parseInt(page, 10);
const pageCountNum = parseInt(pageCount, 10);
const url = `${apiRoot}${basePath}`;
function link(linkPage: string | number) {
return `<${url}?id=${expectedRepo}&page=${linkPage}&path=${path}&per_page=${perPage}&recursive=false>`;
}
const linkHeader = oneLine`
${link(1)}; rel="first",
${link(pageCount)}; rel="last",
${pageNum === 1 ? '' : `${link(pageNum - 1)}; rel="prev",`}
${pageNum === pageCountNum ? '' : `${link(pageNum + 1)}; rel="next",`}
`.slice(0, -1);
return {
'X-Page': page,
'X-Total-Pages': pageCount,
'X-Per-Page': perPage,
'X-Total': totalCount,
Link: linkHeader,
};
}
interface InterceptCollectionOptions {
verb?: FetchMethod;
repeat?: number;
page?: string;
}
function interceptCollection(
collection: FolderCollectionWithDefaults,
{ verb = 'GET', repeat = 1, page: expectedPage }: InterceptCollectionOptions = {},
) {
const url = `${expectedRepoUrl}/repository/tree`;
const { folder } = collection;
const tree = mockRepo.tree[folder];
api
.when(verb, url)
.query(searchParams => {
const path = searchParams.get('path');
const page = searchParams.get('page');
if (path !== folder) {
return false;
}
if (
expectedPage &&
page &&
(Array.isArray(page) || parseInt(page, 10) !== parseInt(expectedPage, 10))
) {
return false;
}
return true;
})
.repeat(repeat)
.reply(uri => {
const { page = '1', per_page = '20' } = parseQuery(uri);
const perPage = parseInt(per_page, 10);
const parsedPage = parseInt(page, 10);
const pageCount = tree.length <= perPage ? 1 : Math.round(tree.length / perPage);
const pageLastIndex = parsedPage * perPage;
const pageFirstIndex = pageLastIndex - perPage;
const resp = tree.slice(pageFirstIndex, pageLastIndex);
return {
status: 200,
json: verb === 'HEAD' ? null : resp,
headers: createHeaders({
basePath: url,
path: folder,
page,
perPage: `${perPage}`,
pageCount: `${pageCount}`,
totalCount: `${tree.length}`,
}),
};
});
}
function interceptFiles(path: string) {
const url = `${expectedRepoUrl}/repository/files/${encodeURIComponent(path)}/raw`;
api.when('GET', url).query(true).reply({ status: 200, json: mockRepo.files[path] });
api
.when('GET', `${expectedRepoUrl}/repository/commits`)
.query(searchParams => searchParams.get('path') === path)
.reply({
status: 200,
json: {
author_name: 'author_name',
author_email: 'author_email',
authored_date: 'authored_date',
},
});
}
async function sharedSetup() {
const backend = resolveBackend(defaultConfig);
interceptAuth();
await backend.authenticate(mockCredentials);
interceptCollection(collectionManyEntriesConfig, { verb: 'HEAD' });
interceptCollection(collectionContentConfig, { verb: 'HEAD' });
return backend;
}
it('throws if configuration does not include repo', () => {
expect(() =>
resolveBackend(createMockConfig({ backend: { name: 'gitlab' }, collections: [] })),
).toThrowErrorMatchingInlineSnapshot(
`"The GitLab backend needs a "repo" in the backend configuration."`,
);
});
describe('authComponent', () => {
it('returns authentication page component', () => {
const backend = resolveBackend(defaultConfig);
expect(backend.authComponent()).toEqual(AuthenticationPage);
});
});
describe('authenticate', () => {
it('throws if user does not have access to project', async () => {
const backend = resolveBackend(defaultConfig);
interceptAuth({ projectResponse: resp.project.readOnly });
await expect(
backend.authenticate(mockCredentials),
).rejects.toThrowErrorMatchingInlineSnapshot(
`"Your GitLab user account does not have access to this repo."`,
);
});
it('stores and returns user object on success', async () => {
const backendName = defaultConfig.backend.name;
const backend = resolveBackend(defaultConfig);
interceptAuth();
const user = await backend.authenticate(mockCredentials);
expect(authStore.retrieve()).toEqual(user);
expect(user).toEqual({ ...resp.user.success, ...mockCredentials, backendName });
});
});
describe('currentUser', () => {
it('returns null if no user', async () => {
const backend = resolveBackend(defaultConfig);
const user = await backend.currentUser();
expect(user).toEqual(null);
});
it('returns the stored user if exists', async () => {
const backendName = defaultConfig.backend.name;
const backend = resolveBackend(defaultConfig);
interceptAuth();
await backend.authenticate(mockCredentials);
const user = await backend.currentUser();
expect(user).toEqual({ ...resp.user.success, ...mockCredentials, backendName });
});
});
describe('getToken', () => {
it('returns the token for the current user', async () => {
const backend = resolveBackend(defaultConfig);
interceptAuth();
await backend.authenticate(mockCredentials);
const token = await backend.getToken();
expect(token).toEqual(mockCredentials.token);
});
});
describe('logout', () => {
it('sets token to null', async () => {
const backend = resolveBackend(defaultConfig);
interceptAuth();
await backend.authenticate(mockCredentials);
await backend.logout();
const token = await backend.getToken();
expect(token).toEqual(null);
});
});
describe('getEntry', () => {
it('returns an entry from folder collection', async () => {
const backend = await sharedSetup();
const entryTree = mockRepo.tree[collectionContentConfig.folder][0];
const slug = entryTree.path.split('/').pop()?.replace('.md', '') ?? '';
interceptFiles(entryTree.path);
interceptCollection(collectionContentConfig);
const config = createMockConfig({ collections: [createMockFolderCollectionWithDefaults()] });
const entry = await backend.getEntry(
{
config: {
config,
},
integrations: [],
entryDraft: {},
mediaLibrary: {},
} as unknown as RootState,
collectionContentConfig,
config,
slug,
);
expect(entry).toEqual(expect.objectContaining({ path: entryTree.path }));
});
});
describe('listEntries', () => {
it('returns entries from folder collection', async () => {
const backend = await sharedSetup();
const tree = mockRepo.tree[collectionContentConfig.folder];
tree.forEach(file => interceptFiles(file.path));
interceptCollection(collectionContentConfig);
const entries = await backend.listEntries(collectionContentConfig, defaultConfig);
expect(entries).toEqual({
cursor: expect.any(Cursor),
pagination: 1,
entries: expect.arrayContaining(
tree.map(file => expect.objectContaining({ path: file.path })),
),
});
expect(entries.entries).toHaveLength(2);
});
it('returns all entries from folder collection', async () => {
const backend = await sharedSetup();
const tree = mockRepo.tree[collectionManyEntriesConfig.folder];
interceptBranch();
tree.forEach(file => interceptFiles(file.path));
interceptCollection(collectionManyEntriesConfig, { repeat: 5 });
const entries = await backend.listAllEntries(collectionManyEntriesConfig, defaultConfig);
expect(entries).toEqual(
expect.arrayContaining(tree.map(file => expect.objectContaining({ path: file.path }))),
);
expect(entries).toHaveLength(500);
}, 7000);
it('returns entries from file collection', async () => {
const backend = await sharedSetup();
const { files } = collectionFilesConfig;
files.forEach(file => interceptFiles(file.file));
const entries = await backend.listEntries(collectionFilesConfig, defaultConfig);
expect(entries).toEqual({
cursor: expect.any(Cursor),
entries: expect.arrayContaining(
files.map(file => expect.objectContaining({ path: file.file })),
),
});
expect(entries.entries).toHaveLength(2);
});
it('returns first page from paginated folder collection tree', async () => {
const backend = await sharedSetup();
const tree = mockRepo.tree[collectionManyEntriesConfig.folder];
const pageTree = tree.slice(0, 20);
pageTree.forEach(file => interceptFiles(file.path));
interceptCollection(collectionManyEntriesConfig, { page: '1' });
const entries = await backend.listEntries(collectionManyEntriesConfig, defaultConfig);
expect(entries.entries).toEqual(
expect.arrayContaining(pageTree.map(file => expect.objectContaining({ path: file.path }))),
);
expect(entries.entries).toHaveLength(20);
});
});
describe('traverseCursor', () => {
it('returns complete last page of paginated tree', async () => {
const backend = await sharedSetup();
const tree = mockRepo.tree[collectionManyEntriesConfig.folder];
tree.slice(0, 20).forEach(file => interceptFiles(file.path));
interceptCollection(collectionManyEntriesConfig, { page: '1' });
const entries = await backend.listEntries(collectionManyEntriesConfig, defaultConfig);
const nextPageTree = tree.slice(20, 40);
nextPageTree.forEach(file => interceptFiles(file.path));
interceptCollection(collectionManyEntriesConfig, { page: '2' });
const nextPage = await backend.traverseCursor(entries.cursor, 'next', defaultConfig);
expect(nextPage.entries).toEqual(
expect.arrayContaining(
nextPageTree.map(file => expect.objectContaining({ path: file.path })),
),
);
expect(nextPage.entries).toHaveLength(20);
const lastPageTree = tree.slice(-20);
lastPageTree.forEach(file => interceptFiles(file.path));
interceptCollection(collectionManyEntriesConfig, { page: '25' });
const lastPage = await backend.traverseCursor(nextPage.cursor, 'last', defaultConfig);
expect(lastPage.entries).toEqual(
expect.arrayContaining(
lastPageTree.map(file => expect.objectContaining({ path: file.path })),
),
);
expect(lastPage.entries).toHaveLength(20);
});
});
describe('filterFile', () => {
it('should return true for nested file with matching depth', () => {
const backend = resolveBackend(defaultConfig);
expect(
(backend.implementation as GitLab).filterFile(
'content/posts',
{ name: 'index.md', path: 'content/posts/dir1/dir2/index.md' },
'md',
3,
),
).toBe(true);
});
it('should return false for nested file with non matching depth', () => {
const backend = resolveBackend(defaultConfig);
expect(
(backend.implementation as GitLab).filterFile(
'content/posts',
{ name: 'index.md', path: 'content/posts/dir1/dir2/index.md' },
'md',
2,
),
).toBe(false);
});
});
afterEach(() => {
authStore.logout();
api.reset();
expect(authStore.retrieve()).toEqual(null);
});
});

View File

@ -3,6 +3,7 @@ import trim from 'lodash/trim';
import trimStart from 'lodash/trimStart';
import semaphore from 'semaphore';
import { WorkflowStatus } from '@staticcms/core/constants/publishModes';
import {
allEntriesByFolder,
asyncLock,
@ -18,22 +19,31 @@ import {
localForage,
runWithLock,
} from '@staticcms/core/lib/util';
import { getPreviewStatus } from '@staticcms/core/lib/util/API';
import {
branchFromContentKey,
contentKeyFromBranch,
generateContentKey,
} from '@staticcms/core/lib/util/APIUtils';
import { unpublishedEntries } from '@staticcms/core/lib/util/implementation';
import API, { API_NAME } from './API';
import AuthenticationPage from './AuthenticationPage';
import type { Semaphore } from 'semaphore';
import type { AsyncLock, Cursor } from '@staticcms/core/lib/util';
import type {
Config,
BackendClass,
BackendEntry,
ConfigWithDefaults,
Credentials,
DisplayURL,
BackendEntry,
BackendClass,
ImplementationFile,
PersistOptions,
UnpublishedEntry,
UnpublishedEntryMediaFile,
User,
} from '@staticcms/core/interface';
} from '@staticcms/core';
import type { AsyncLock, Cursor } from '@staticcms/core/lib/util';
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
import type { Semaphore } from 'semaphore';
const MAX_CONCURRENT_DOWNLOADS = 10;
@ -43,19 +53,25 @@ export default class GitLab implements BackendClass {
options: {
proxied: boolean;
API: API | null;
initialWorkflowStatus: WorkflowStatus;
};
repo: string;
branch: string;
useOpenAuthoring?: boolean;
apiRoot: string;
token: string | null;
squashMerges: boolean;
cmsLabelPrefix: string;
mediaFolder?: string;
previewContext: string;
_mediaDisplayURLSem?: Semaphore;
constructor(config: Config, options = {}) {
constructor(config: ConfigWithDefaults, options = {}) {
this.options = {
proxied: false,
API: null,
initialWorkflowStatus: WorkflowStatus.DRAFT,
...options,
};
@ -72,14 +88,13 @@ export default class GitLab implements BackendClass {
this.branch = config.backend.branch || 'main';
this.apiRoot = config.backend.api_root || 'https://gitlab.com/api/v4';
this.token = '';
this.squashMerges = config.backend.squash_merges || false;
this.cmsLabelPrefix = config.backend.cms_label_prefix || '';
this.mediaFolder = config.media_folder;
this.previewContext = config.backend.preview_context || '';
this.lock = asyncLock();
}
isGitBackend() {
return true;
}
async status() {
const auth =
(await this.api
@ -108,8 +123,12 @@ export default class GitLab implements BackendClass {
branch: this.branch,
repo: this.repo,
apiRoot: this.apiRoot,
squashMerges: this.squashMerges,
cmsLabelPrefix: this.cmsLabelPrefix,
initialWorkflowStatus: this.options.initialWorkflowStatus,
});
const user = await this.api.user();
const isCollab = await this.api.hasWriteAccess().catch((error: Error) => {
error.message = stripIndent`
Repo "${this.repo}" not found.
@ -313,4 +332,118 @@ export default class GitLab implements BackendClass {
};
});
}
/**
* Editorial Workflow
*/
async unpublishedEntries() {
const listEntriesKeys = () =>
this.api!.listUnpublishedBranches().then(branches =>
branches.map(branch => contentKeyFromBranch(branch)),
);
const ids = await unpublishedEntries(listEntriesKeys);
return ids;
}
async unpublishedEntry({
id,
collection,
slug,
}: {
id?: string;
collection?: string;
slug?: string;
}): Promise<UnpublishedEntry> {
if (id) {
return this.api!.retrieveUnpublishedEntryData(id);
} else if (collection && slug) {
const entryId = generateContentKey(collection, slug);
return this.api!.retrieveUnpublishedEntryData(entryId);
} else {
throw new Error('Missing unpublished entry id or collection and slug');
}
}
getBranch(collection: string, slug: string) {
const contentKey = generateContentKey(collection, slug);
const branch = branchFromContentKey(contentKey);
return branch;
}
async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const data = (await this.api!.readFile(path, id, { branch })) as string;
return data;
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
const branch = this.getBranch(collection, slug);
const mediaFile = await this.loadMediaFile(branch, { path, id });
return mediaFile;
}
async updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: WorkflowStatus) {
// updateUnpublishedEntryStatus is a transactional operation
return runWithLock(
this.lock,
() => this.api!.updateUnpublishedEntryStatus(collection, slug, newStatus),
'Failed to acquire update entry status lock',
);
}
async deleteUnpublishedEntry(collection: string, slug: string) {
// deleteUnpublishedEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.deleteUnpublishedEntry(collection, slug),
'Failed to acquire delete entry lock',
);
}
async publishUnpublishedEntry(collection: string, slug: string) {
// publishUnpublishedEntry is a transactional operation
return runWithLock(
this.lock,
() => this.api!.publishUnpublishedEntry(collection, slug),
'Failed to acquire publish entry lock',
);
}
async getDeployPreview(collection: string, slug: string) {
try {
const statuses = await this.api!.getStatuses(collection, slug);
const deployStatus = getPreviewStatus(statuses, this.previewContext);
if (deployStatus) {
const { target_url: url, state } = deployStatus;
return { url, status: state };
} else {
return null;
}
} catch (e) {
return null;
}
}
loadMediaFile(branch: string, file: UnpublishedEntryMediaFile) {
const readFile = (
path: string,
id: string | null | undefined,
{ parseText }: { parseText: boolean },
) => this.api!.readFile(path, id, { branch, parseText });
return getMediaAsBlob(file.path, null, readFile).then(blob => {
const name = basename(file.path);
const fileObj = blobToFileObj(name, blob);
return {
id: file.path,
displayURL: URL.createObjectURL(fileObj),
path: file.path,
name,
size: fileObj.size,
file: fileObj,
};
});
}
}

View File

@ -2,13 +2,10 @@ import React, { useCallback } from 'react';
import Login from '@staticcms/core/components/login/Login';
import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
import type { AuthenticationPageProps } from '@staticcms/core';
import type { FC, MouseEvent } from 'react';
const AuthenticationPage = ({
inProgress = false,
onLogin,
}: TranslatedProps<AuthenticationPageProps>) => {
const AuthenticationPage: FC<AuthenticationPageProps> = ({ inProgress = false, onLogin }) => {
const handleLogin = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();

View File

@ -1,17 +1,24 @@
import { APIError, basename, blobToFileObj, unsentRequest } from '@staticcms/core/lib/util';
import {
APIError,
EditorialWorkflowError,
basename,
blobToFileObj,
unsentRequest,
} from '@staticcms/core/lib/util';
import AuthenticationPage from './AuthenticationPage';
import type {
BackendEntry,
BackendClass,
Config,
BackendEntry,
ConfigWithDefaults,
DisplayURL,
ImplementationEntry,
ImplementationFile,
PersistOptions,
User,
ImplementationMediaFile,
} from '@staticcms/core/interface';
PersistOptions,
UnpublishedEntry,
User,
} from '@staticcms/core';
import type { Cursor } from '@staticcms/core/lib/util';
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
@ -51,8 +58,9 @@ export default class ProxyBackend implements BackendClass {
publicFolder?: string;
options: {};
branch: string;
cmsLabelPrefix?: string;
constructor(config: Config, options = {}) {
constructor(config: ConfigWithDefaults, options = {}) {
if (!config.backend.proxy_url) {
throw new Error('The Proxy backend needs a "proxy_url" in the backend configuration.');
}
@ -62,10 +70,7 @@ export default class ProxyBackend implements BackendClass {
this.mediaFolder = config.media_folder;
this.publicFolder = config.public_folder;
this.options = options;
}
isGitBackend() {
return false;
this.cmsLabelPrefix = config.backend.cms_label_prefix;
}
status() {
@ -213,4 +218,86 @@ export default class ProxyBackend implements BackendClass {
): Promise<ImplementationEntry[]> {
return this.entriesByFolder(folder, extension, depth);
}
unpublishedEntries(): Promise<string[]> {
return this.request({
action: 'unpublishedEntries',
params: { branch: this.branch },
});
}
async unpublishedEntry({
id,
collection,
slug,
}: {
id?: string;
collection?: string;
slug?: string;
}) {
try {
const entry: UnpublishedEntry = await this.request({
action: 'unpublishedEntry',
params: { branch: this.branch, id, collection, slug, cmsLabelPrefix: this.cmsLabelPrefix },
});
return entry;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
if (e.status === 404) {
throw new EditorialWorkflowError('content is not under editorial workflow', true);
}
throw e;
}
}
async unpublishedEntryDataFile(collection: string, slug: string, path: string, id: string) {
const { data } = await this.request<{ data: string }>({
action: 'unpublishedEntryDataFile',
params: { branch: this.branch, collection, slug, path, id },
});
return data;
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string, id: string) {
const file = await this.request<MediaFile>({
action: 'unpublishedEntryMediaFile',
params: { branch: this.branch, collection, slug, path, id },
});
return deserializeMediaFile(file);
}
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
return this.request<void>({
action: 'updateUnpublishedEntryStatus',
params: {
branch: this.branch,
collection,
slug,
newStatus,
cmsLabelPrefix: this.cmsLabelPrefix,
},
});
}
publishUnpublishedEntry(collection: string, slug: string) {
return this.request<void>({
action: 'publishUnpublishedEntry',
params: { branch: this.branch, collection, slug },
});
}
deleteUnpublishedEntry(collection: string, slug: string) {
return this.request<void>({
action: 'deleteUnpublishedEntry',
params: { branch: this.branch, collection, slug },
});
}
getDeployPreview(collection: string, slug: string) {
return this.request<{ url: string; status: string } | null>({
action: 'getDeployPreview',
params: { branch: this.branch, collection, slug },
});
}
}

View File

@ -2,14 +2,14 @@ import React, { useCallback, useEffect } from 'react';
import Login from '@staticcms/core/components/login/Login';
import type { AuthenticationPageProps, TranslatedProps } from '@staticcms/core/interface';
import type { MouseEvent } from 'react';
import type { AuthenticationPageProps } from '@staticcms/core';
import type { FC, MouseEvent } from 'react';
const AuthenticationPage = ({
const AuthenticationPage: FC<AuthenticationPageProps> = ({
inProgress = false,
config,
onLogin,
}: TranslatedProps<AuthenticationPageProps>) => {
}) => {
useEffect(() => {
/**
* Allow login screen to be skipped for demo purposes.

View File

@ -0,0 +1,272 @@
import { createMockConfig } from '@staticcms/test/data/config.mock';
import TestBackend, { getFolderFiles } from '../implementation';
import { resolveBackend } from '@staticcms/core/backend';
jest.mock('@staticcms/core/backend');
describe('test backend implementation', () => {
let backend: TestBackend;
beforeEach(() => {
backend = new TestBackend(createMockConfig({ collections: [] }));
(resolveBackend as jest.Mock).mockResolvedValue(null);
jest.resetModules();
});
describe('getEntry', () => {
it('should get entry by path', async () => {
window.repoFiles = {
posts: {
'some-post.md': {
path: 'path/to/some-post.md',
content: 'post content',
},
},
};
await expect(backend.getEntry('posts/some-post.md')).resolves.toEqual({
file: { path: 'posts/some-post.md', id: null },
data: 'post content',
});
});
it('should get entry by nested path', async () => {
window.repoFiles = {
posts: {
dir1: {
dir2: {
'some-post.md': {
path: 'path/to/some-post.md',
content: 'post content',
},
},
},
},
};
await expect(backend.getEntry('posts/dir1/dir2/some-post.md')).resolves.toEqual({
file: { path: 'posts/dir1/dir2/some-post.md', id: null },
data: 'post content',
});
});
});
describe('persistEntry', () => {
it('should persist entry', async () => {
window.repoFiles = {};
const entry = {
dataFiles: [{ path: 'posts/some-post.md', raw: 'content', slug: 'some-post.md' }],
assets: [],
};
await backend.persistEntry(entry, { newEntry: true, commitMessage: 'Persist file' });
expect(window.repoFiles).toEqual({
posts: {
'some-post.md': {
content: 'content',
path: 'posts/some-post.md',
},
},
});
});
it('should persist entry and keep existing unrelated entries', async () => {
window.repoFiles = {
pages: {
'other-page.md': {
path: 'path/to/other-page.md',
content: 'content',
},
},
posts: {
'other-post.md': {
path: 'path/to/other-post.md',
content: 'content',
},
},
};
const entry = {
dataFiles: [{ path: 'posts/new-post.md', raw: 'content', slug: 'new-post.md' }],
assets: [],
};
await backend.persistEntry(entry, { newEntry: true, commitMessage: 'Persist file' });
expect(window.repoFiles).toEqual({
pages: {
'other-page.md': {
path: 'path/to/other-page.md',
content: 'content',
},
},
posts: {
'new-post.md': {
path: 'posts/new-post.md',
content: 'content',
},
'other-post.md': {
path: 'path/to/other-post.md',
content: 'content',
},
},
});
});
it('should persist nested entry', async () => {
window.repoFiles = {};
const slug = 'dir1/dir2/some-post.md';
const path = `posts/${slug}`;
const entry = { dataFiles: [{ path, raw: 'content', slug }], assets: [] };
await backend.persistEntry(entry, { newEntry: true, commitMessage: 'Persist file' });
expect(window.repoFiles).toEqual({
posts: {
dir1: {
dir2: {
'some-post.md': {
content: 'content',
path: 'posts/dir1/dir2/some-post.md',
},
},
},
},
});
});
it('should update existing nested entry', async () => {
window.repoFiles = {
posts: {
dir1: {
dir2: {
'some-post.md': {
path: 'path/to/some-post.md',
mediaFiles: { file1: { path: 'path/to/media/file1.txt', content: 'file1' } },
content: 'content',
},
},
},
},
};
const slug = 'dir1/dir2/some-post.md';
const path = `posts/${slug}`;
const entry = { dataFiles: [{ path, raw: 'new content', slug }], assets: [] };
await backend.persistEntry(entry, { newEntry: false, commitMessage: 'Persist file' });
expect(window.repoFiles).toEqual({
posts: {
dir1: {
dir2: {
'some-post.md': {
path: 'posts/dir1/dir2/some-post.md',
content: 'new content',
},
},
},
},
});
});
});
describe('deleteFiles', () => {
it('should delete entry by path', async () => {
window.repoFiles = {
posts: {
'some-post.md': {
path: 'path/to/some-post.md',
content: 'post content',
},
},
};
await backend.deleteFiles(['posts/some-post.md']);
expect(window.repoFiles).toEqual({
posts: {},
});
});
it('should delete entry by nested path', async () => {
window.repoFiles = {
posts: {
dir1: {
dir2: {
'some-post.md': {
path: 'path/to/some-post.md',
content: 'post content',
},
},
},
},
};
await backend.deleteFiles(['posts/dir1/dir2/some-post.md']);
expect(window.repoFiles).toEqual({
posts: {
dir1: {
dir2: {},
},
},
});
});
});
describe('getFolderFiles', () => {
it('should get files by depth', () => {
const tree = {
pages: {
'root-page.md': {
path: 'pages/root-page.md',
content: 'root page content',
},
dir1: {
'nested-page-1.md': {
path: 'pages/dir1/nested-page-1.md',
content: 'nested page 1 content',
},
dir2: {
'nested-page-2.md': {
path: 'pages/dir1/dir2/nested-page-2.md',
content: 'nested page 2 content',
},
},
},
},
};
expect(getFolderFiles(tree, 'pages', 'md', 1)).toEqual([
{
path: 'pages/root-page.md',
content: 'root page content',
},
]);
expect(getFolderFiles(tree, 'pages', 'md', 2)).toEqual([
{
path: 'pages/dir1/nested-page-1.md',
content: 'nested page 1 content',
},
{
path: 'pages/root-page.md',
content: 'root page content',
},
]);
expect(getFolderFiles(tree, 'pages', 'md', 3)).toEqual([
{
path: 'pages/dir1/dir2/nested-page-2.md',
content: 'nested page 2 content',
},
{
path: 'pages/dir1/nested-page-1.md',
content: 'nested page 1 content',
},
{
path: 'pages/root-page.md',
content: 'root page content',
},
]);
});
});
});

View File

@ -5,40 +5,68 @@ import unset from 'lodash/unset';
import { basename, dirname } from 'path';
import { v4 as uuid } from 'uuid';
import { Cursor, CURSOR_COMPATIBILITY_SYMBOL } from '@staticcms/core/lib/util';
import {
Cursor,
CURSOR_COMPATIBILITY_SYMBOL,
EditorialWorkflowError,
} from '@staticcms/core/lib/util';
import { isNullish } from '@staticcms/core/lib/util/null.util';
import { isNotEmpty } from '@staticcms/core/lib/util/string.util';
import AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
import AuthenticationPage from './AuthenticationPage';
import type { WorkflowStatus } from '@staticcms/core/constants/publishModes';
import type {
BackendClass,
BackendEntry,
Config,
ConfigWithDefaults,
DataFile,
DisplayURL,
ImplementationEntry,
ImplementationFile,
ImplementationMediaFile,
PersistOptions,
UnpublishedEntry,
User,
} from '@staticcms/core/interface';
import type AssetProxy from '@staticcms/core/valueObjects/AssetProxy';
} from '@staticcms/core';
type RepoFile = { path: string; content: string | AssetProxy; isDirectory?: boolean };
type RepoTree = { [key: string]: RepoFile | RepoTree };
type Diff = {
id: string;
originalPath?: string;
path: string;
newFile: boolean;
status: string;
content: string | AssetProxy;
};
type UnpublishedRepoEntry = {
slug: string;
collection: string;
status: WorkflowStatus;
diffs: Diff[];
updatedAt: string;
};
declare global {
interface Window {
repoFiles: RepoTree;
repoFilesUnpublished: { [key: string]: UnpublishedRepoEntry };
}
}
window.repoFiles = window.repoFiles || {};
window.repoFilesUnpublished = window.repoFilesUnpublished || [];
function getFile(path: string, tree: RepoTree) {
function getFile(path: string, tree: RepoTree): RepoFile | undefined {
const segments = path.split('/');
let obj: RepoTree = tree;
while (obj && segments.length) {
obj = obj[segments.shift() as string] as RepoTree;
}
return (obj as unknown as RepoFile) || {};
return (obj as unknown as RepoFile) || undefined;
}
function writeFile(path: string, content: string | AssetProxy, tree: RepoTree) {
@ -122,17 +150,13 @@ export function getFolderFiles(
export default class TestBackend implements BackendClass {
mediaFolder?: string;
options: {};
options: { initialWorkflowStatus?: string };
constructor(config: Config, options = {}) {
constructor(config: ConfigWithDefaults, options = {}) {
this.options = options;
this.mediaFolder = config.media_folder;
}
isGitBackend() {
return false;
}
status() {
return Promise.resolve({ auth: { status: true }, api: { status: true, statusPage: '' } });
}
@ -210,7 +234,7 @@ export default class TestBackend implements BackendClass {
return Promise.all(
files.map(file => ({
file,
data: getFile(file.path, window.repoFiles).content as string,
data: (getFile(file.path, window.repoFiles)?.content ?? '') as string,
})),
);
}
@ -218,11 +242,28 @@ export default class TestBackend implements BackendClass {
getEntry(path: string) {
return Promise.resolve({
file: { path, id: null },
data: getFile(path, window.repoFiles).content as string,
data: (getFile(path, window.repoFiles)?.content ?? '') as string,
});
}
async persistEntry(entry: BackendEntry) {
async persistEntry(entry: BackendEntry, options: PersistOptions) {
if (options.useWorkflow) {
const slug = entry.dataFiles[0].slug;
const key = `${options.collectionName}/${slug}`;
const currentEntry = window.repoFilesUnpublished[key];
const status = currentEntry?.status || options.status || this.options.initialWorkflowStatus;
this.addOrUpdateUnpublishedEntry(
key,
entry.dataFiles,
entry.assets,
slug,
options.collectionName as string,
status,
);
return Promise.resolve();
}
entry.dataFiles.forEach(dataFile => {
const { path, newPath, raw } = dataFile;
@ -291,7 +332,7 @@ export default class TestBackend implements BackendClass {
path: assetProxy.path,
url,
displayURL: url,
fileObj,
file: fileObj,
};
return normalizedAsset;
@ -331,4 +372,131 @@ export default class TestBackend implements BackendClass {
getMediaDisplayURL(_displayURL: DisplayURL): Promise<string> {
throw new Error('Not supported');
}
/**
* Editorial Workflow
*/
unpublishedEntries() {
return Promise.resolve(Object.keys(window.repoFilesUnpublished));
}
unpublishedEntry({
id,
collection,
slug,
}: {
id?: string;
collection?: string;
slug?: string;
}): Promise<UnpublishedEntry> {
if (id) {
const parts = id.split('/');
collection = parts[0];
slug = parts[1];
}
const entry = window.repoFilesUnpublished[`${collection}/${slug}`];
if (!entry) {
return Promise.reject(
new EditorialWorkflowError('content is not under editorial workflow', true),
);
}
return Promise.resolve({
...entry,
openAuthoring: false,
});
}
async unpublishedEntryDataFile(collection: string, slug: string, path: string) {
const entry = window.repoFilesUnpublished[`${collection}/${slug}`];
const file = entry.diffs.find(d => d.path === path);
return file?.content as string;
}
async unpublishedEntryMediaFile(collection: string, slug: string, path: string) {
const entry = window.repoFilesUnpublished[`${collection}/${slug}`];
const file = entry.diffs.find(d => d.path === path);
return this.normalizeAsset(file?.content as AssetProxy);
}
deleteUnpublishedEntry(collection: string, slug: string) {
delete window.repoFilesUnpublished[`${collection}/${slug}`];
return Promise.resolve();
}
async addOrUpdateUnpublishedEntry(
key: string,
dataFiles: DataFile[],
assetProxies: AssetProxy[],
slug: string,
collection: string,
status: WorkflowStatus,
) {
const diffs: Diff[] = [];
dataFiles.forEach(dataFile => {
const { path, newPath, raw } = dataFile;
const currentDataFile = window.repoFilesUnpublished[key]?.diffs.find(d => d.path === path);
const originalPath = currentDataFile ? currentDataFile.originalPath : path;
diffs.push({
originalPath,
id: newPath || path,
path: newPath || path,
newFile: isNullish(getFile(originalPath as string, window.repoFiles)),
status: 'added',
content: raw,
});
});
assetProxies.forEach(a => {
const asset = this.normalizeAsset(a);
diffs.push({
id: asset.id,
path: asset.path,
newFile: true,
status: 'added',
content: new AssetProxy(asset),
});
});
window.repoFilesUnpublished[key] = {
slug,
collection,
status,
diffs,
updatedAt: new Date().toISOString(),
};
}
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: WorkflowStatus) {
window.repoFilesUnpublished[`${collection}/${slug}`].status = newStatus;
return Promise.resolve();
}
publishUnpublishedEntry(collection: string, slug: string) {
const key = `${collection}/${slug}`;
const unpubEntry = window.repoFilesUnpublished[key];
delete window.repoFilesUnpublished[key];
const tree = window.repoFiles;
unpubEntry.diffs.forEach(d => {
if (d.originalPath && !d.newFile) {
const originalPath = d.originalPath;
const sourceDir = dirname(originalPath);
const destDir = dirname(d.path);
const toMove = getFolderFiles(tree, originalPath.split('/')[0], '', 100).filter(f =>
f.path.startsWith(sourceDir),
);
toMove.forEach(f => {
deleteFile(f.path, tree);
writeFile(f.path.replace(sourceDir, destDir), f.content, tree);
});
}
writeFile(d.path, d.content, tree);
});
return Promise.resolve();
}
async getDeployPreview() {
return null;
}
}

View File

@ -6,26 +6,24 @@ import { I18n } from 'react-polyglot';
import { connect, Provider } from 'react-redux';
import { HashRouter as Router } from 'react-router-dom';
import 'what-input';
import { authenticateUser } from './actions/auth';
import { loadConfig } from './actions/config';
import App from './components/App';
import './components/entry-editor/widgets';
import ErrorBoundary from './components/ErrorBoundary';
import addExtensions from './extensions';
import useMeta from './lib/hooks/useMeta';
import useTranslate from './lib/hooks/useTranslate';
import { getPhrases } from './lib/phrases';
import { selectLocale } from './reducers/selectors/config';
import { store } from './store';
import useMeta from './lib/hooks/useMeta';
import type { AnyAction } from '@reduxjs/toolkit';
import type { FC } from 'react';
import type { ConnectedProps } from 'react-redux';
import type { BaseField, Config, UnknownField } from './interface';
import type { RootState } from './store';
import './styles/datetime/calendar.css';
import './styles/datetime/clock.css';
import './styles/datetime/datetime.css';
import './styles/main.css';
const ROOT_ID = 'nc-root';
@ -45,7 +43,9 @@ import ReactDOM from 'react-dom';
// @ts-ignore
ReactDOM.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED.usingClientEntryPoint = true;
const TranslatedApp = ({ locale, config }: AppRootProps) => {
const TranslatedApp: FC<AppRootProps> = ({ locale, config }) => {
const t = useTranslate();
useMeta({ name: 'viewport', content: 'width=device-width, initial-scale=1.0' });
if (!config) {
@ -54,7 +54,7 @@ const TranslatedApp = ({ locale, config }: AppRootProps) => {
return (
<I18n locale={locale} messages={getPhrases(locale)}>
<ErrorBoundary showBackup config={config}>
<ErrorBoundary showBackup config={config} t={t}>
<Router>
<App />
</Router>
@ -117,7 +117,7 @@ function bootstrap<F extends BaseField = UnknownField>(opts?: {
if (config.backend.name !== 'git-gateway') {
store.dispatch(authenticateUser() as unknown as AnyAction);
}
}) as AnyAction,
}) as unknown as AnyAction,
);
/**

View File

@ -1,6 +1,4 @@
import { createTheme, ThemeProvider } from '@mui/material/styles';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import {
Navigate,
@ -17,25 +15,32 @@ import TopBarProgress from 'react-topbar-progress-indicator';
import { loginUser as loginUserAction } from '@staticcms/core/actions/auth';
import { discardDraft } from '@staticcms/core/actions/entries';
import { currentBackend } from '@staticcms/core/backend';
import { loadUnpublishedEntries } from '../actions/editorialWorkflow';
import { changeTheme } from '../actions/globalUI';
import useDefaultPath from '../lib/hooks/useDefaultPath';
import useTranslate from '../lib/hooks/useTranslate';
import { invokeEvent } from '../lib/registry';
import { getDefaultPath } from '../lib/util/collection.util';
import { isNotNullish } from '../lib/util/null.util';
import { isEmpty } from '../lib/util/string.util';
import { generateClassNames } from '../lib/util/theming.util';
import { selectTheme } from '../reducers/selectors/globalUI';
import { selectUseWorkflow } from '../reducers/selectors/config';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import NotFoundPage from './NotFoundPage';
import CollectionRoute from './collections/CollectionRoute';
import { Alert } from './common/alert/Alert';
import { Confirm } from './common/confirm/Confirm';
import Loader from './common/progress/Loader';
import EditorRoute from './entry-editor/EditorRoute';
import MediaPage from './media-library/MediaPage';
import NotFoundPage from './NotFoundPage';
import Page from './page/Page';
import Snackbars from './snackbar/Snackbars';
import ThemeManager from './theme/ThemeManager';
import useTheme from './theme/hooks/useTheme';
import Dashboard from './workflow/Dashboard';
import type { Credentials, TranslatedProps } from '@staticcms/core/interface';
import type { Credentials } from '@staticcms/core';
import type { RootState } from '@staticcms/core/store';
import type { ComponentType } from 'react';
import type { FC } from 'react';
import type { ConnectedProps } from 'react-redux';
import './App.css';
@ -65,38 +70,21 @@ function EditEntityRedirect() {
return <Navigate to={`/collections/${name}/entries/${params['*']}`} />;
}
const App = ({
const App: FC<AppProps> = ({
auth,
user,
config,
collections,
loginUser,
isFetching,
t,
scrollSyncEnabled,
}: TranslatedProps<AppProps>) => {
}) => {
const t = useTranslate();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const mode = useAppSelector(selectTheme);
const theme = React.useMemo(
() =>
createTheme({
palette: {
mode,
primary: {
main: 'rgb(37 99 235)',
},
...(mode === 'dark' && {
background: {
paper: 'rgb(15 23 42)',
},
}),
},
}),
[mode],
);
const theme = useTheme();
const configError = useCallback(
(error?: string) => {
@ -154,12 +142,12 @@ const App = ({
authEndpoint={config.config.backend.auth_endpoint}
config={config.config}
clearHash={() => navigate('/', { replace: true })}
t={t}
/>
);
}, [AuthComponent, auth.error, auth.isFetching, config.config, handleLogin, navigate, t]);
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
const useWorkflow = useAppSelector(selectUseWorkflow);
const defaultPath = useDefaultPath(collections);
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
@ -176,19 +164,12 @@ const App = ({
}, [dispatch, pathname, searchParams]);
useEffect(() => {
// On page load or when changing themes, best to add inline in `head` to avoid FOUC
if (
localStorage.getItem('color-theme') === 'dark' ||
(!('color-theme' in localStorage) &&
window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.documentElement.classList.add('dark');
dispatch(changeTheme('dark'));
} else {
document.documentElement.classList.remove('dark');
dispatch(changeTheme('light'));
if (!user || !useWorkflow) {
return;
}
}, [dispatch]);
dispatch(loadUnpublishedEntries(collections));
}, [collections, dispatch, useWorkflow, user]);
const [prevUser, setPrevUser] = useState(user);
useEffect(() => {
@ -214,6 +195,10 @@ const App = ({
{isFetching && <TopBarProgress />}
<Routes>
<Route path="/" element={<Navigate to={defaultPath} />} />
<Route
path="/dashboard"
element={useWorkflow ? <Dashboard /> : <Navigate to={defaultPath} />}
/>
<Route path="/search" element={<Navigate to={defaultPath} />} />
<Route path="/collections/:name/search/" element={<CollectionSearchRedirect />} />
<Route
@ -247,7 +232,7 @@ const App = ({
</Routes>
</>
);
}, [authenticationPage, collections, defaultPath, isFetching, user]);
}, [authenticationPage, collections, defaultPath, isFetching, useWorkflow, user]);
useEffect(() => {
setTimeout(() => {
@ -255,6 +240,20 @@ const App = ({
});
}, []);
useEffect(() => {
const defaultTheme = config.config?.theme?.default_theme;
if (isEmpty(defaultTheme)) {
return;
}
const themeName = localStorage.getItem('color-theme');
if (isNotNullish(themeName)) {
return;
}
dispatch(changeTheme(defaultTheme));
}, [config.config?.theme?.default_theme, dispatch]);
if (!config.config) {
return configError(t('app.app.configNotFound'));
}
@ -268,7 +267,7 @@ const App = ({
}
return (
<ThemeProvider theme={theme}>
<ThemeManager theme={theme} element={document.documentElement}>
<ScrollSync key="scroll-sync" enabled={scrollSyncEnabled}>
<>
<div key="back-to-top-anchor" id="back-to-top-anchor" />
@ -282,7 +281,7 @@ const App = ({
</div>
</>
</ScrollSync>
</ThemeProvider>
</ThemeManager>
);
};
@ -308,4 +307,4 @@ const mapDispatchToProps = {
const connector = connect(mapStateToProps, mapDispatchToProps);
export type AppProps = ConnectedProps<typeof connector>;
export default connector(translate()(App) as ComponentType<AppProps>);
export default connector(App);

View File

@ -1,8 +1,8 @@
.CMS_ErrorBoundary_root {
background: var(--background-dark);
@apply flex
flex-col
bg-slate-50
dark:bg-slate-900
min-h-screen
gap-2;
}
@ -21,8 +21,9 @@
}
.CMS_ErrorBoundary_report-link {
@apply text-blue-500
hover:underline;
color: var(--primary-main);
@apply hover:underline;
}
.CMS_ErrorBoundary_content {

View File

@ -2,14 +2,14 @@ import cleanStack from 'clean-stack';
import copyToClipboard from 'copy-text-to-clipboard';
import truncate from 'lodash/truncate';
import React, { Component } from 'react';
import { translate } from 'react-polyglot';
import yaml from 'yaml';
import { localForage } from '@staticcms/core/lib/util';
import useTranslate from '../lib/hooks/useTranslate';
import { generateClassNames } from '../lib/util/theming.util';
import type { Config, TranslatedProps } from '@staticcms/core/interface';
import type { ComponentClass, ReactNode } from 'react';
import type { Config, TranslatedProps } from '@staticcms/core';
import type { FC, ReactNode } from 'react';
import './ErrorBoundary.css';
@ -86,7 +86,9 @@ interface RecoveredEntryProps {
entry: string;
}
const RecoveredEntry = ({ entry, t }: TranslatedProps<RecoveredEntryProps>) => {
const RecoveredEntry: FC<RecoveredEntryProps> = ({ entry }) => {
const t = useTranslate();
console.info('[StaticCMS] Recovered entry', entry);
return (
<>
@ -197,11 +199,11 @@ class ErrorBoundary extends Component<TranslatedProps<ErrorBoundaryProps>, Error
<br key={`error-break-${index}`} />,
])}
</p>
{backup && showBackup && <RecoveredEntry key="backup" entry={backup} t={t} />}
{backup && showBackup && <RecoveredEntry key="backup" entry={backup} />}
</div>
</div>
);
}
}
export default translate()(ErrorBoundary) as ComponentClass<ErrorBoundaryProps>;
export default ErrorBoundary;

View File

@ -1,7 +1,7 @@
.CMS_MainView_root {
@apply flex
bg-slate-50
dark:bg-slate-900;
background: var(--background-dark);
@apply flex;
}
.CMS_MainView_body {

View File

@ -7,8 +7,8 @@ import BottomNavigation from './navbar/BottomNavigation';
import Navbar from './navbar/Navbar';
import Sidebar from './navbar/Sidebar';
import type { ReactNode } from 'react';
import type { Breadcrumb, Collection } from '../interface';
import type { FC, ReactNode } from 'react';
import type { Breadcrumb, CollectionWithDefaults } from '../interface';
import './MainView.css';
@ -37,10 +37,10 @@ interface MainViewProps {
noMargin?: boolean;
noScroll?: boolean;
children: ReactNode;
collection?: Collection;
collection?: CollectionWithDefaults;
}
const MainView = ({
const MainView: FC<MainViewProps> = ({
children,
breadcrumbs,
showQuickCreate = false,
@ -49,7 +49,7 @@ const MainView = ({
noScroll = false,
navbarActions,
collection,
}: MainViewProps) => {
}) => {
return (
<>
<Navbar
@ -66,6 +66,7 @@ const MainView = ({
showLeftNav && classes['show-left-nav'],
noMargin && classes['no-margin'],
noScroll && classes['no-scroll'],
'CMS_Scrollbar_root',
)}
>
{children}

View File

@ -1,10 +1,12 @@
import React from 'react';
import { translate } from 'react-polyglot';
import type { ComponentType } from 'react';
import type { TranslateProps } from 'react-polyglot';
import useTranslate from '../lib/hooks/useTranslate';
import type { FC } from 'react';
const NotFoundPage: FC = () => {
const t = useTranslate();
const NotFoundPage = ({ t }: TranslateProps) => {
return (
<div>
<h2>{t('app.notFoundPage.header')}</h2>
@ -12,4 +14,4 @@ const NotFoundPage = ({ t }: TranslateProps) => {
);
};
export default translate()(NotFoundPage) as ComponentType<{}>;
export default NotFoundPage;

View File

@ -60,12 +60,12 @@
}
.CMS_Collection_header {
color: var(--text-primary);
@apply text-xl
font-semibold
flex
items-center
text-gray-800
dark:text-gray-300
gap-2
md:w-auto;
}

View File

@ -16,7 +16,8 @@ import type {
SortMap,
ViewFilter,
ViewGroup,
} from '@staticcms/core/interface';
} from '@staticcms/core';
import type { FC } from 'react';
interface CollectionControlsProps {
viewStyle: ViewStyle;
@ -32,7 +33,7 @@ interface CollectionControlsProps {
onGroupClick?: (filter: ViewGroup) => void;
}
const CollectionControls = ({
const CollectionControls: FC<CollectionControlsProps> = ({
viewStyle,
onChangeViewStyle,
sortableFields,
@ -44,7 +45,7 @@ const CollectionControls = ({
onGroupClick,
filter,
group,
}: CollectionControlsProps) => {
}) => {
const showGroupControl = useMemo(
() => Boolean(viewGroups && onGroupClick && group && viewGroups.length > 0),
[group, onGroupClick, viewGroups],

View File

@ -1,10 +1,10 @@
import React, { useMemo } from 'react';
import { translate } from 'react-polyglot';
import { useParams } from 'react-router-dom';
import useEntries from '@staticcms/core/lib/hooks/useEntries';
import useIcon from '@staticcms/core/lib/hooks/useIcon';
import useNewEntryUrl from '@staticcms/core/lib/hooks/useNewEntryUrl';
import useTranslate from '@staticcms/core/lib/hooks/useTranslate';
import {
selectEntryCollectionTitle,
selectFolderEntryExtension,
@ -14,14 +14,16 @@ import { addFileTemplateFields } from '@staticcms/core/lib/widgets/stringTemplat
import Button from '../common/button/Button';
import collectionClasses from './Collection.classes';
import type { Collection, Entry, TranslatedProps } from '@staticcms/core/interface';
import type { CollectionWithDefaults, Entry } from '@staticcms/core';
import type { FC } from 'react';
interface CollectionHeaderProps {
collection: Collection;
collection: CollectionWithDefaults;
}
const CollectionHeader: FC<TranslatedProps<CollectionHeaderProps>> = ({ collection, t }) => {
const CollectionHeader: FC<CollectionHeaderProps> = ({ collection }) => {
const t = useTranslate();
const collectionLabel = collection.label;
const collectionLabelSingular = collection.label_singular;
@ -35,10 +37,13 @@ const CollectionHeader: FC<TranslatedProps<CollectionHeaderProps>> = ({ collecti
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<string, Entry>);
const entriesByPath = entries.reduce(
(acc, entry) => {
acc[entry.path] = entry;
return acc;
},
{} as Record<string, Entry>,
);
if (isNotEmpty(filterTerm)) {
const extension = selectFolderEntryExtension(collection);
@ -72,7 +77,7 @@ const CollectionHeader: FC<TranslatedProps<CollectionHeaderProps>> = ({ collecti
{newEntryUrl ? (
<Button to={newEntryUrl} className={collectionClasses['new-entry-button']}>
{t('collection.collectionTop.newButton', {
collectionLabel: collectionLabelSingular || pluralLabel,
collectionLabel: collectionLabelSingular ?? pluralLabel,
})}
</Button>
) : null}
@ -80,4 +85,4 @@ const CollectionHeader: FC<TranslatedProps<CollectionHeaderProps>> = ({ collecti
);
};
export default translate()(CollectionHeader) as FC<CollectionHeaderProps>;
export default CollectionHeader;

View File

@ -5,7 +5,7 @@ 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 { CollectionWithDefaults } from '@staticcms/core';
import type { FC } from 'react';
const MultiSearchCollectionPage: FC = () => {
@ -26,7 +26,7 @@ const MultiSearchCollectionPage: FC = () => {
};
interface SingleCollectionPageProps {
collection: Collection;
collection: CollectionWithDefaults;
isSearchResults?: boolean;
isSingleSearchResult?: boolean;
}
@ -62,7 +62,7 @@ const SingleCollectionPage: FC<SingleCollectionPageProps> = ({
};
interface CollectionPageProps {
collection?: Collection;
collection?: CollectionWithDefaults;
isSearchResults?: boolean;
isSingleSearchResult?: boolean;
}

View File

@ -1,29 +1,30 @@
import React, { useMemo } from 'react';
import React from 'react';
import { Navigate, useParams, useSearchParams } from 'react-router-dom';
import useDefaultPath from '@staticcms/core/lib/hooks/useDefaultPath';
import {
selectCollection,
selectCollections,
} from '@staticcms/core/reducers/selectors/collections';
import { useAppSelector } from '@staticcms/core/store/hooks';
import { getDefaultPath } from '../../lib/util/collection.util';
import CollectionPage from './CollectionPage';
import type { FC } from 'react';
interface CollectionRouteProps {
isSearchResults?: boolean;
isSingleSearchResult?: boolean;
}
const CollectionRoute = ({ isSearchResults, isSingleSearchResult }: CollectionRouteProps) => {
const CollectionRoute: FC<CollectionRouteProps> = ({ isSearchResults, isSingleSearchResult }) => {
const { name, searchTerm } = useParams();
const [searchParams] = useSearchParams();
const noRedirect = searchParams.has('noredirect');
const collectionSelector = useMemo(() => selectCollection(name), [name]);
const collection = useAppSelector(collectionSelector);
const collection = useAppSelector(state => selectCollection(state, name));
const collections = useAppSelector(selectCollections);
const defaultPath = useMemo(() => getDefaultPath(collections), [collections]);
const defaultPath = useDefaultPath(collections);
if (!searchTerm && (!name || !collection)) {
return <Navigate to={defaultPath} />;

View File

@ -13,48 +13,47 @@
}
.CMS_CollectionSearch_icon {
color: var(--text-secondary);
@apply w-5
h-5
text-gray-500
dark:text-gray-400;
h-5;
}
.CMS_CollectionSearch_input {
color: var(--text-primary);
background: color-mix(in srgb, var(--background-light) 50%, transparent);
border-color: color-mix(in srgb, var(--background-divider) 50%, transparent);
@apply block
w-full
p-1.5
pl-10
text-sm
text-gray-800
border
border-gray-300
rounded-lg
bg-gray-50
focus-visible:outline-none
focus:ring-4
focus:ring-gray-200
dark:bg-gray-700
dark:border-gray-600
dark:placeholder-gray-400
dark:text-white
dark:focus:ring-slate-700;
focus:ring-4;
&:placeholder {
color: var(--text-disabled);
}
&:focus {
--tw-ring-color: color-mix(in srgb, var(--primary-light) 50%, transparent);
}
}
.CMS_CollectionSearch_search-in {
background: var(--background-light);
@apply absolute
overflow-auto
rounded-md
bg-white
text-base
shadow-md
ring-1
ring-black
ring-opacity-5
focus:outline-none
sm:text-sm
z-[1300]
dark:bg-slate-700
dark:shadow-lg;
z-[1300];
}
.CMS_CollectionSearch_search-in-content {
@ -64,17 +63,22 @@
}
.CMS_CollectionSearch_search-in-label {
color: var(--text-secondary);
@apply text-base
text-slate-500
dark:text-slate-400
py-2
px-3;
}
.CMS_CollectionSearch_search-in-option {
color: var(--text-primary);
&:hover {
color: var(--text-secondary);
background: var(--primary-main);
}
@apply cursor-pointer
hover:bg-blue-500
hover:text-gray-100
py-2
px-3;
}

View File

@ -1,12 +1,12 @@
import { Popper as BasePopper } from '@mui/base/Popper';
import { Search as SearchIcon } from '@styled-icons/material/Search';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { translate } from 'react-polyglot';
import useTranslate from '@staticcms/core/lib/hooks/useTranslate';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import type { Collection, Collections, TranslatedProps } from '@staticcms/core/interface';
import type { ChangeEvent, FocusEvent, KeyboardEvent, MouseEvent } from 'react';
import type { CollectionWithDefaults, CollectionsWithDefaults } from '@staticcms/core';
import type { ChangeEvent, FC, FocusEvent, KeyboardEvent, MouseEvent } from 'react';
import './CollectionSearch.css';
@ -23,19 +23,20 @@ export const classes = generateClassNames('CollectionSearch', [
]);
interface CollectionSearchProps {
collections: Collections;
collection?: Collection;
collections: CollectionsWithDefaults;
collection?: CollectionWithDefaults;
searchTerm: string | undefined;
onSubmit: (query: string, collection?: string) => void;
}
const CollectionSearch = ({
const CollectionSearch: FC<CollectionSearchProps> = ({
collections: collectionsMap,
collection,
searchTerm = '',
onSubmit,
t,
}: TranslatedProps<CollectionSearchProps>) => {
}) => {
const t = useTranslate();
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>();
const [query, setQuery] = useState(searchTerm);
const [anchorEl, setAnchorEl] = useState<HTMLInputElement | HTMLTextAreaElement | null>(null);
@ -211,4 +212,4 @@ const CollectionSearch = ({
);
};
export default translate()(CollectionSearch);
export default CollectionSearch;

View File

@ -1,25 +1,26 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import {
changeViewStyle as changeViewStyleAction,
filterByField as filterByFieldAction,
groupByField as groupByFieldAction,
sortByField as sortByFieldAction,
changeViewStyle,
filterByField,
groupByField,
sortByField,
} from '@staticcms/core/actions/entries';
import { SORT_DIRECTION_ASCENDING } from '@staticcms/core/constants';
import useTranslate from '@staticcms/core/lib/hooks/useTranslate';
import {
selectSortableFields,
selectViewFilters,
selectViewGroups,
getSortableFields,
getViewFilters,
getViewGroups,
} from '@staticcms/core/lib/util/collection.util';
import { selectCollections } from '@staticcms/core/reducers/selectors/collections';
import {
selectEntriesFilter,
selectEntriesGroup,
selectEntriesSort,
selectViewStyle,
} from '@staticcms/core/reducers/selectors/entries';
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
import Card from '../common/card/Card';
import collectionClasses from './Collection.classes';
import CollectionControls from './CollectionControls';
@ -27,41 +28,50 @@ import CollectionHeader from './CollectionHeader';
import EntriesCollection from './entries/EntriesCollection';
import EntriesSearch from './entries/EntriesSearch';
import type {
Collection,
SortDirection,
TranslatedProps,
ViewFilter,
ViewGroup,
} from '@staticcms/core/interface';
import type { RootState } from '@staticcms/core/store';
import type { ComponentType } from 'react';
import type { ConnectedProps } from 'react-redux';
import type { ViewStyle } from '@staticcms/core/constants/views';
import type { CollectionWithDefaults, SortDirection, ViewFilter, ViewGroup } from '@staticcms/core';
import type { FC } from 'react';
import './Collection.css';
const CollectionView = ({
collection,
collections,
export interface CollectionViewProps {
isSearchResults?: boolean;
isSingleSearchResult?: boolean;
name?: string;
searchTerm?: string;
filterTerm?: string;
}
const CollectionView: FC<CollectionViewProps> = ({
name: collectionName,
isSearchResults,
isSingleSearchResult,
searchTerm,
sortableFields,
sortByField,
sort,
viewFilters,
viewGroups,
filterTerm,
t,
filterByField,
groupByField,
filter,
group,
changeViewStyle,
viewStyle,
}: TranslatedProps<CollectionViewProps>) => {
searchTerm = '',
filterTerm = '',
}) => {
const t = useTranslate();
const dispatch = useAppDispatch();
const collections = useAppSelector(selectCollections);
const collection = useMemo(
() =>
(collectionName ? collections[collectionName] : collections[0]) as
| CollectionWithDefaults
| undefined,
[collectionName, collections],
);
const viewStyle = useAppSelector(selectViewStyle);
const sort = useAppSelector(state => selectEntriesSort(state, collectionName));
const viewFilters = useMemo(() => getViewFilters(collection), [collection]);
const viewGroups = useMemo(() => getViewGroups(collection), [collection]);
const sortableFields = useMemo(() => getSortableFields(collection, t), [collection, t]);
const filter = useAppSelector(state => selectEntriesFilter(state, collection?.name));
const group = useAppSelector(state => selectEntriesGroup(state, collection?.name));
const [readyToLoad, setReadyToLoad] = useState(false);
const [prevCollection, setPrevCollection] = useState<Collection | null>();
const [prevCollection, setPrevCollection] = useState<CollectionWithDefaults | null>();
useEffect(() => {
setPrevCollection(collection);
@ -89,6 +99,7 @@ const CollectionView = ({
key="search"
collections={searchCollections}
searchTerm={searchTerm}
filterTerm={filterTerm}
viewStyle={viewStyle}
/>
);
@ -120,23 +131,30 @@ const CollectionView = ({
const onSortClick = useCallback(
async (key: string, direction?: SortDirection) => {
collection && (await sortByField(collection, key, direction));
collection && (await dispatch(sortByField(collection, key, direction)));
},
[collection, sortByField],
[collection, dispatch],
);
const onFilterClick = useCallback(
async (filter: ViewFilter) => {
collection && (await filterByField(collection, filter));
collection && (await dispatch(filterByField(collection, filter)));
},
[collection, filterByField],
[collection, dispatch],
);
const onGroupClick = useCallback(
async (group: ViewGroup) => {
collection && (await groupByField(collection, group));
collection && (await dispatch(groupByField(collection, group)));
},
[collection, groupByField],
[collection, dispatch],
);
const onChangeViewStyle = useCallback(
(viewStyle: ViewStyle) => {
dispatch(changeViewStyle(viewStyle));
},
[dispatch],
);
useEffect(() => {
@ -155,7 +173,9 @@ const CollectionView = ({
}
const defaultSort = collection?.sortable_fields?.default;
if (!defaultSort || !defaultSort.field) {
const defaultViewGroupName = collection?.view_groups?.default;
const defaultViewFilterName = collection?.view_filters?.default;
if (!defaultViewGroupName && !defaultViewFilterName && (!defaultSort || !defaultSort.field)) {
if (!readyToLoad) {
setReadyToLoad(true);
}
@ -166,9 +186,27 @@ const CollectionView = ({
let alive = true;
const sortEntries = () => {
const sortGroupFilterEntries = () => {
setTimeout(async () => {
await onSortClick(defaultSort.field, defaultSort.direction ?? SORT_DIRECTION_ASCENDING);
if (defaultSort && defaultSort.field) {
await onSortClick(defaultSort.field, defaultSort.direction ?? SORT_DIRECTION_ASCENDING);
}
if (defaultViewGroupName) {
const defaultViewGroup = viewGroups?.groups.find(g => g.name === defaultViewGroupName);
if (defaultViewGroup) {
await onGroupClick(defaultViewGroup);
}
}
if (defaultViewFilterName) {
const defaultViewFilter = viewFilters?.filters.find(
f => f.name === defaultViewFilterName,
);
if (defaultViewFilter) {
await onFilterClick(defaultViewFilter);
}
}
if (alive) {
setReadyToLoad(true);
@ -176,12 +214,22 @@ const CollectionView = ({
});
};
sortEntries();
sortGroupFilterEntries();
return () => {
alive = false;
};
}, [collection, onSortClick, prevCollection, readyToLoad, sort]);
}, [
collection,
onFilterClick,
onGroupClick,
onSortClick,
prevCollection,
readyToLoad,
sort,
viewFilters?.filters,
viewGroups?.groups,
]);
const collectionDescription = collection?.description;
@ -193,19 +241,19 @@ const CollectionView = ({
<div className={collectionClasses['search-query']}>
<div>{t(searchResultKey, { searchTerm, collection: collection?.label })}</div>
</div>
<CollectionControls viewStyle={viewStyle} onChangeViewStyle={changeViewStyle} />
<CollectionControls viewStyle={viewStyle} onChangeViewStyle={onChangeViewStyle} />
</>
) : (
<>
{collection ? <CollectionHeader collection={collection} /> : null}
<CollectionControls
viewStyle={viewStyle}
onChangeViewStyle={changeViewStyle}
onChangeViewStyle={onChangeViewStyle}
sortableFields={sortableFields}
onSortClick={onSortClick}
sort={sort}
viewFilters={viewFilters ?? []}
viewGroups={viewGroups ?? []}
viewFilters={viewFilters?.filters ?? []}
viewGroups={viewGroups?.groups ?? []}
onFilterClick={onFilterClick}
onGroupClick={onGroupClick}
filter={filter}
@ -224,59 +272,4 @@ const CollectionView = ({
);
};
interface CollectionViewOwnProps {
isSearchResults?: boolean;
isSingleSearchResult?: boolean;
name?: string;
searchTerm?: string;
filterTerm?: string;
}
function mapStateToProps(state: RootState, ownProps: TranslatedProps<CollectionViewOwnProps>) {
const { collections } = state;
const {
isSearchResults,
isSingleSearchResult,
name,
searchTerm = '',
filterTerm = '',
t,
} = ownProps;
const collection = (name ? collections[name] : collections[0]) as Collection | undefined;
const sort = selectEntriesSort(state, collection?.name);
const sortableFields = selectSortableFields(collection, t);
const viewFilters = selectViewFilters(collection);
const viewGroups = selectViewGroups(collection);
const filter = selectEntriesFilter(collection?.name)(state);
const group = selectEntriesGroup(state, collection?.name);
const viewStyle = selectViewStyle(state);
return {
isSearchResults,
isSingleSearchResult,
name,
searchTerm,
filterTerm,
collection,
collections,
sort,
sortableFields,
viewFilters,
viewGroups,
filter,
group,
viewStyle,
};
}
const mapDispatchToProps = {
sortByField: sortByFieldAction,
filterByField: filterByFieldAction,
changeViewStyle: changeViewStyleAction,
groupByField: groupByFieldAction,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type CollectionViewProps = ConnectedProps<typeof connector>;
export default translate()(connector(CollectionView)) as ComponentType<CollectionViewOwnProps>;
export default CollectionView;

View File

@ -4,11 +4,11 @@
}
.CMS_FilterControl_filter-label {
color: var(--text-primary);
@apply ml-2
text-sm
font-medium
text-gray-800
dark:text-gray-300;
font-medium;
}
.CMS_FilterControl_list-root {
@ -18,25 +18,25 @@
}
.CMS_FilterControl_list-label {
color: var(--text-primary);
@apply text-lg
font-bold
text-gray-800
dark:text-white;
font-bold;
}
.CMS_FilterControl_list-filter {
color: var(--text-primary);
@apply ml-1.5
font-medium
flex
items-center
text-gray-800
dark:text-gray-300;
items-center;
}
.CMS_FilterControl_list-filter-label {
color: var(--text-primary);
@apply ml-2
text-base
font-medium
text-gray-800
dark:text-gray-300;
font-medium;
}

View File

@ -1,13 +1,14 @@
import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot';
import useTranslate from '@staticcms/core/lib/hooks/useTranslate';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import Checkbox from '../common/checkbox/Checkbox';
import Menu from '../common/menu/Menu';
import MenuGroup from '../common/menu/MenuGroup';
import MenuItemButton from '../common/menu/MenuItemButton';
import type { FilterMap, TranslatedProps, ViewFilter } from '@staticcms/core/interface';
import type { FC, MouseEvent } from 'react';
import type { FilterMap, ViewFilter } from '@staticcms/core';
import type { MouseEvent, FC } from 'react';
import './FilterControl.css';
@ -28,13 +29,14 @@ export interface FilterControlProps {
onFilterClick: ((viewFilter: ViewFilter) => void) | undefined;
}
const FilterControl = ({
const FilterControl: FC<FilterControlProps> = ({
filter = {},
viewFilters = [],
variant = 'menu',
onFilterClick,
t,
}: TranslatedProps<FilterControlProps>) => {
}) => {
const t = useTranslate();
const anyActive = useMemo(() => Object.keys(filter).some(key => filter[key]?.active), [filter]);
const handleFilterClick = useCallback(
@ -79,8 +81,11 @@ const FilterControl = ({
<Menu
key="filter-by-menu"
label={t('collection.collectionTop.filterBy')}
color={anyActive ? 'primary' : 'secondary'}
variant={anyActive ? 'contained' : 'outlined'}
rootClassName={classes.root}
aria-label="filter options dropdown"
data-testid="filter-by"
>
<MenuGroup>
{viewFilters.map(viewFilter => {
@ -90,14 +95,14 @@ const FilterControl = ({
<MenuItemButton
key={viewFilter.id}
onClick={handleFilterClick(viewFilter)}
className={classes.filter}
rootClassName={classes.filter}
data-testid={`filter-by-option-${viewFilter.label}`}
>
<input
<Checkbox
key={`${labelId}-${checked}`}
id={labelId}
type="checkbox"
value=""
checked={checked}
size="sm"
readOnly
/>
<label className={classes['filter-label']}>{viewFilter.label}</label>
@ -109,4 +114,4 @@ const FilterControl = ({
);
};
export default translate()(FilterControl) as FC<FilterControlProps>;
export default FilterControl;

View File

@ -10,34 +10,35 @@
}
.CMS_GroupControl_list-label {
color: var(--text-primary);
@apply text-lg
font-bold
text-gray-800
dark:text-white;
font-bold;
}
.CMS_GroupControl_list-option {
color: var(--text-primary);
@apply ml-0.5
font-medium
flex
items-center
text-gray-800
dark:text-gray-300;
items-center;
}
.CMS_GroupControl_list-option-label {
color: var(--text-primary);
@apply ml-2
text-base
font-medium
text-gray-800
dark:text-gray-300;
font-medium;
}
.CMS_GroupControl_list-option-checked-icon {
color: var(--primary-main);
@apply ml-2
w-6
h-6
text-blue-500;
h-6;
}
.CMS_GroupControl_list-option-not-checked {

View File

@ -1,13 +1,13 @@
import { Check as CheckIcon } from '@styled-icons/material/Check';
import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot';
import useTranslate from '@staticcms/core/lib/hooks/useTranslate';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import Menu from '../common/menu/Menu';
import MenuGroup from '../common/menu/MenuGroup';
import MenuItemButton from '../common/menu/MenuItemButton';
import type { GroupMap, TranslatedProps, ViewGroup } from '@staticcms/core/interface';
import type { GroupMap, ViewGroup } from '@staticcms/core';
import type { FC, MouseEvent } from 'react';
import './GroupControl.css';
@ -30,13 +30,14 @@ export interface GroupControlProps {
onGroupClick: ((viewGroup: ViewGroup) => void) | undefined;
}
const GroupControl = ({
const GroupControl: FC<GroupControlProps> = ({
viewGroups = [],
group = {},
variant = 'menu',
onGroupClick,
t,
}: TranslatedProps<GroupControlProps>) => {
}) => {
const t = useTranslate();
const activeGroup = useMemo(() => Object.values(group).find(f => f.active === true), [group]);
const handleGroupClick = useCallback(
@ -76,8 +77,11 @@ const GroupControl = ({
return (
<Menu
label={t('collection.collectionTop.groupBy')}
color={activeGroup ? 'primary' : 'secondary'}
variant={activeGroup ? 'contained' : 'outlined'}
rootClassName={classes.root}
aria-label="group by options dropdown"
data-testid="group-by"
>
<MenuGroup>
{viewGroups.map(viewGroup => (
@ -85,7 +89,8 @@ const GroupControl = ({
key={viewGroup.id}
onClick={() => onGroupClick?.(viewGroup)}
endIcon={viewGroup.id === activeGroup?.id ? CheckIcon : undefined}
className={classes.option}
rootClassName={classes.option}
data-testid={`group-by-option-${viewGroup.label}`}
>
{viewGroup.label}
</MenuItemButton>
@ -95,4 +100,4 @@ const GroupControl = ({
);
};
export default translate()(GroupControl) as FC<GroupControlProps>;
export default GroupControl;

View File

@ -4,7 +4,7 @@
& > .CMS_NestedCollection_link {
& .CMS_NestedCollection_node-children-icon {
@apply rotate-90
transform;
transform;
}
}
}
@ -29,7 +29,7 @@
& > .CMS_NestedCollection_link {
& .CMS_NestedCollection_node-children-icon {
@apply rotate-90
transform;
transform;
}
}
}
@ -51,9 +51,7 @@
.CMS_NestedCollection_node-children-icon {
@apply transition-transform
h-5
w-5
group-focus-within/active-list:text-blue-500
group-hover/active-list:text-blue-500;
w-5;
}
.CMS_NestedCollection_node-children {

View File

@ -10,9 +10,9 @@ import { getTreeData } from '@staticcms/core/lib/util/nested.util';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import NavLink from '../navbar/NavLink';
import type { Collection, Entry } from '@staticcms/core/interface';
import type { CollectionWithDefaults, Entry } from '@staticcms/core';
import type { TreeNodeData } from '@staticcms/core/lib/util/nested.util';
import type { MouseEvent } from 'react';
import type { FC, MouseEvent } from 'react';
import './NestedCollection.css';
@ -38,7 +38,7 @@ function getNodeTitle(node: TreeNodeData) {
}
interface TreeNodeProps {
collection: Collection;
collection: CollectionWithDefaults;
treeData: TreeNodeData[];
rootIsActive: boolean;
path: string;
@ -46,14 +46,14 @@ interface TreeNodeProps {
onToggle: ({ node, expanded }: { node: TreeNodeData; expanded: boolean }) => void;
}
const TreeNode = ({
const TreeNode: FC<TreeNodeProps> = ({
collection,
treeData,
rootIsActive,
path,
depth = 0,
onToggle,
}: TreeNodeProps) => {
}) => {
const collectionName = collection.name;
const handleClick = useCallback(
@ -184,18 +184,18 @@ export function updateNode(
}
export interface NestedCollectionProps {
collection: Collection;
collection: CollectionWithDefaults;
filterTerm: string;
}
const NestedCollection = ({ collection, filterTerm }: NestedCollectionProps) => {
const NestedCollection: FC<NestedCollectionProps> = ({ collection, filterTerm }) => {
const entries = useEntries(collection);
const [treeData, setTreeData] = useState<TreeNodeData[]>(getTreeData(collection, entries));
const [useFilter, setUseFilter] = useState(true);
const [prevRootIsActive, setPrevRootIsActive] = useState(false);
const [prevCollection, setPrevCollection] = useState<Collection | null>(null);
const [prevCollection, setPrevCollection] = useState<CollectionWithDefaults | null>(null);
const [prevEntries, setPrevEntries] = useState<Entry[] | null>(null);
const [prevPath, setPrevPath] = useState<string | null>(null);

View File

@ -13,34 +13,35 @@
}
.CMS_SortControl_list-label {
color: var(--text-primary);
@apply text-lg
font-bold
text-gray-800
dark:text-white;
font-bold;
}
.CMS_SortControl_list-option {
color: var(--text-primary);
@apply ml-0.5
font-medium
flex
items-center
text-gray-800
dark:text-gray-300;
items-center;
}
.CMS_SortControl_list-option-label {
color: var(--text-primary);
@apply ml-2
text-base
font-medium
text-gray-800
dark:text-gray-300;
font-medium;
}
.CMS_SortControl_list-option-sorted-icon {
color: var(--primary-main);
@apply ml-2
w-6
h-6
text-blue-500;
h-6;
}
.CMS_SortControl_list-option-not-sorted {

View File

@ -1,24 +1,19 @@
import { KeyboardArrowDown as KeyboardArrowDownIcon } from '@styled-icons/material/KeyboardArrowDown';
import { KeyboardArrowUp as KeyboardArrowUpIcon } from '@styled-icons/material/KeyboardArrowUp';
import React, { useCallback, useMemo } from 'react';
import { translate } from 'react-polyglot';
import {
SORT_DIRECTION_ASCENDING,
SORT_DIRECTION_DESCENDING,
SORT_DIRECTION_NONE,
} from '@staticcms/core/constants';
import useTranslate from '@staticcms/core/lib/hooks/useTranslate';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import Menu from '../common/menu/Menu';
import MenuGroup from '../common/menu/MenuGroup';
import MenuItemButton from '../common/menu/MenuItemButton';
import type {
SortableField,
SortDirection,
SortMap,
TranslatedProps,
} from '@staticcms/core/interface';
import type { SortableField, SortDirection, SortMap } from '@staticcms/core';
import type { FC, MouseEvent } from 'react';
import './SortControl.css';
@ -52,13 +47,14 @@ export interface SortControlProps {
onSortClick: ((key: string, direction?: SortDirection) => Promise<void>) | undefined;
}
const SortControl = ({
const SortControl: FC<SortControlProps> = ({
fields = [],
sort = {},
variant = 'menu',
onSortClick,
t,
}: TranslatedProps<SortControlProps>) => {
}) => {
const t = useTranslate();
const selectedSort = useMemo(() => {
if (!sort) {
return { key: undefined, direction: undefined };
@ -120,8 +116,10 @@ const SortControl = ({
return (
<Menu
label={t('collection.collectionTop.sortBy')}
color={selectedSort.key ? 'primary' : 'secondary'}
variant={selectedSort.key ? 'contained' : 'outlined'}
rootClassName={classes.root}
aria-label="sort options dropdown"
>
<MenuGroup>
{fields.map(field => {
@ -139,7 +137,7 @@ const SortControl = ({
: KeyboardArrowDownIcon
: undefined
}
className={classes.option}
rootClassName={classes.option}
>
{field.label ?? field.name}
</MenuItemButton>
@ -150,4 +148,4 @@ const SortControl = ({
);
};
export default translate()(SortControl) as FC<SortControlProps>;
export default SortControl;

View File

@ -1,7 +1,7 @@
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
const entriesClasses = generateClassNames('Entries', [
'root',
'no-entries',
'group',
'group-content-wrapper',
'group-content',

View File

@ -1,9 +1,10 @@
.CMS_Entries_root {
.CMS_Entries_no-entries {
color: var(--warning-contrast-color);
background: var(--warning-main);
@apply py-2
px-3
rounded-md
bg-yellow-300/75
dark:bg-yellow-800/75
text-sm;
}
@ -32,13 +33,13 @@
}
.CMS_Entries_entry-listing-loading {
background: color-mix(in srgb, var(--background-dark) 50%, transparent);
@apply absolute
inset-0
flex
items-center
justify-center
bg-slate-50/50
dark:bg-slate-900/50;
justify-center;
}
.CMS_Entries_entry-listing-grid {
@ -70,18 +71,16 @@
}
.CMS_Entries_entry-listing-table {
background: var(--background-main);
border-color: var(--background-light);
@apply relative
max-h-full
h-full
overflow-hidden
p-1.5
bg-white
shadow-sm
border
border-gray-100
dark:bg-slate-800
dark:border-gray-700/40
dark:shadow-md
rounded-xl;
}
@ -91,14 +90,9 @@
overflow-auto;
}
.CMS_Entries_entry-listing-table-row {
@apply hover:bg-gray-200
dark:hover:bg-slate-700/70;
}
.CMS_Entries_entry-listing-local-backup {
color: var(--primary-light);
@apply w-5
h-5
text-blue-600
dark:text-blue-300;
h-5;
}

View File

@ -1,13 +1,14 @@
import React, { useMemo } from 'react';
import { translate } from 'react-polyglot';
import Loader from '@staticcms/core/components/common/progress/Loader';
import useTranslate from '@staticcms/core/lib/hooks/useTranslate';
import entriesClasses from './Entries.classes';
import EntryListing from './EntryListing';
import type { CollectionWithDefaults, CollectionsWithDefaults, Entry } from '@staticcms/core';
import type { ViewStyle } from '@staticcms/core/constants/views';
import type { Collection, Collections, Entry, TranslatedProps } from '@staticcms/core/interface';
import type Cursor from '@staticcms/core/lib/util/Cursor';
import type { FC } from 'react';
import './Entries.css';
@ -22,26 +23,27 @@ export interface BaseEntriesProps {
}
export interface SingleCollectionEntriesProps extends BaseEntriesProps {
collection: Collection;
collection: CollectionWithDefaults;
}
export interface MultipleCollectionEntriesProps extends BaseEntriesProps {
collections: Collections;
collections: CollectionsWithDefaults;
}
export type EntriesProps = SingleCollectionEntriesProps | MultipleCollectionEntriesProps;
const Entries = ({
const Entries: FC<EntriesProps> = ({
entries,
isFetching,
viewStyle,
cursor,
filterTerm,
handleCursorActions,
t,
page,
...otherProps
}: TranslatedProps<EntriesProps>) => {
}) => {
const t = useTranslate();
const loadingMessages = useMemo(
() => [
t('collection.entries.loadingEntries'),
@ -84,7 +86,7 @@ const Entries = ({
);
}
return <div className={entriesClasses.root}>{t('collection.entries.noEntries')}</div>;
return <div className={entriesClasses['no-entries']}>{t('collection.entries.noEntries')}</div>;
};
export default translate()(Entries);
export default Entries;

View File

@ -1,23 +1,24 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { translate } from 'react-polyglot';
import { connect } from 'react-redux';
import { loadEntries, traverseCollectionCursor } from '@staticcms/core/actions/entries';
import useEntries from '@staticcms/core/lib/hooks/useEntries';
import useGroups from '@staticcms/core/lib/hooks/useGroups';
import useTranslate from '@staticcms/core/lib/hooks/useTranslate';
import { Cursor } from '@staticcms/core/lib/util';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { selectUseWorkflow } from '@staticcms/core/reducers/selectors/config';
import { selectCollectionEntriesCursor } from '@staticcms/core/reducers/selectors/cursors';
import { selectEntriesLoaded, selectIsFetching } from '@staticcms/core/reducers/selectors/entries';
import { useAppDispatch } from '@staticcms/core/store/hooks';
import { useAppDispatch, useAppSelector } from '@staticcms/core/store/hooks';
import Button from '../../common/button/Button';
import Entries from './Entries';
import entriesClasses from './Entries.classes';
import type { ViewStyle } from '@staticcms/core/constants/views';
import type { Collection, Entry, GroupOfEntries, TranslatedProps } from '@staticcms/core/interface';
import type { CollectionWithDefaults, Entry, GroupOfEntries } from '@staticcms/core';
import type { RootState } from '@staticcms/core/store';
import type { ComponentType } from 'react';
import type { FC } from 'react';
import type { t } from 'react-polyglot';
import type { ConnectedProps } from 'react-redux';
@ -56,17 +57,18 @@ export function filterNestedEntries(path: string, collectionFolder: string, entr
return filtered;
}
const EntriesCollection = ({
const EntriesCollection: FC<EntriesCollectionProps> = ({
collection,
filterTerm,
isFetching,
viewStyle,
cursor,
page,
t,
entriesLoaded,
readyToLoad,
}: TranslatedProps<EntriesCollectionProps>) => {
}) => {
const t = useTranslate();
const dispatch = useAppDispatch();
const [prevReadyToLoad, setPrevReadyToLoad] = useState(false);
@ -75,6 +77,7 @@ const EntriesCollection = ({
const groups = useGroups(collection.name);
const entries = useEntries(collection);
const useWorkflow = useAppSelector(selectUseWorkflow);
const filteredEntries = useMemo(() => {
if ('nested' in collection) {
@ -97,7 +100,15 @@ const EntriesCollection = ({
setPrevReadyToLoad(readyToLoad);
setPrevCollection(collection);
}, [collection, dispatch, entriesLoaded, prevCollection, prevReadyToLoad, readyToLoad]);
}, [
collection,
dispatch,
entriesLoaded,
prevCollection,
prevReadyToLoad,
readyToLoad,
useWorkflow,
]);
const handleCursorActions = useCallback(
(action: string) => {
@ -128,6 +139,8 @@ const EntriesCollection = ({
variant={index === selectedGroup ? 'contained' : 'text'}
onClick={handleGroupClick(index)}
className={entriesClasses['group-button']}
aria-label={`group by ${title}`}
data-testid={`group-by-${title}`}
>
{title}
</Button>
@ -141,7 +154,6 @@ const EntriesCollection = ({
collection={collection}
entries={getGroupEntries(filteredEntries, groups[selectedGroup].paths)}
isFetching={isFetching}
collectionName={collection.label}
viewStyle={viewStyle}
cursor={cursor}
handleCursorActions={handleCursorActions}
@ -158,7 +170,6 @@ const EntriesCollection = ({
collection={collection}
entries={filteredEntries}
isFetching={isFetching}
collectionName={collection.label}
viewStyle={viewStyle}
cursor={cursor}
handleCursorActions={handleCursorActions}
@ -169,7 +180,7 @@ const EntriesCollection = ({
};
interface EntriesCollectionOwnProps {
collection: Collection;
collection: CollectionWithDefaults;
viewStyle: ViewStyle;
readyToLoad: boolean;
filterTerm: string;
@ -193,4 +204,4 @@ const mapDispatchToProps = {};
const connector = connect(mapStateToProps, mapDispatchToProps);
export type EntriesCollectionProps = ConnectedProps<typeof connector>;
export default connector(translate()(EntriesCollection) as ComponentType<EntriesCollectionProps>);
export default connector(EntriesCollection);

View File

@ -11,20 +11,22 @@ import { selectSearchedEntries } from '@staticcms/core/reducers/selectors/entrie
import Entries from './Entries';
import type { ViewStyle } from '@staticcms/core/constants/views';
import type { Collections } from '@staticcms/core/interface';
import type { CollectionsWithDefaults } from '@staticcms/core';
import type { RootState } from '@staticcms/core/store';
import type { FC } from 'react';
import type { ConnectedProps } from 'react-redux';
const EntriesSearch = ({
const EntriesSearch: FC<EntriesSearchProps> = ({
collections,
entries,
isFetching,
page,
searchTerm,
filterTerm,
viewStyle,
searchEntries,
clearSearch,
}: EntriesSearchProps) => {
}) => {
const collectionNames = useMemo(() => Object.keys(collections), [collections]);
const getCursor = useCallback(() => {
@ -64,23 +66,25 @@ const EntriesSearch = ({
entries={entries}
isFetching={isFetching}
viewStyle={viewStyle}
filterTerm={filterTerm}
/>
);
};
interface EntriesSearchOwnProps {
searchTerm: string;
collections: Collections;
filterTerm: string;
collections: CollectionsWithDefaults;
viewStyle: ViewStyle;
}
function mapStateToProps(state: RootState, ownProps: EntriesSearchOwnProps) {
const { searchTerm, collections, viewStyle } = ownProps;
const { searchTerm, filterTerm, collections, viewStyle } = ownProps;
const collectionNames = Object.keys(collections);
const isFetching = state.search.isFetching;
const page = state.search.page;
const entries = selectSearchedEntries(state, collectionNames);
return { isFetching, page, collections, viewStyle, entries, searchTerm };
return { isFetching, page, collections, viewStyle, entries, searchTerm, filterTerm };
}
const mapDispatchToProps = {

View File

@ -3,6 +3,15 @@
w-full
relative
overflow-visible;
&.CMS_EntryCard_no-margin {
& .CMS_EntryCard_content-wrapper {
inset: unset;
@apply p-0
w-full;
}
}
}
.CMS_EntryCard_content-wrapper {
@ -14,7 +23,8 @@
.CMS_EntryCard_content {
@apply p-1
h-full
w-full;
w-full
relative;
}
.CMS_EntryCard_card {
@ -23,18 +33,47 @@
.CMS_EntryCard_card-content {
@apply flex
w-full
items-center
justify-between;
flex-col
w-full;
}
.CMS_EntryCard_card-summary {
@apply truncate;
.CMS_EntryCard_summary-wrapper {
@apply flex
gap-1
w-full
flex-nowrap
items-center;
}
.CMS_EntryCard_summary {
color: var(--text-primary);
@apply truncate
flex-grow
font-bold;
}
.CMS_EntryCard_description {
color: var(--text-primary);
@apply truncate
text-sm;
}
.CMS_EntryCard_date {
color: var(--text-secondary);
@apply truncate
text-sm;
}
.CMS_EntryCard_local-backup-icon {
color: var(--primary-light);
@apply w-5
h-5
text-blue-600
dark:text-blue-300;
flex-shrink-0;
}
.CMS_EntryCard_workflow-status {
}

View File

@ -1,62 +1,87 @@
import { Info as InfoIcon } from '@styled-icons/material-outlined/Info';
import format from 'date-fns/format';
import parse from 'date-fns/parse';
import React, { useEffect, useMemo, useState } from 'react';
import useTranslate from '@staticcms/core/lib/hooks/useTranslate';
import { getPreviewCard } from '@staticcms/core/lib/registry';
import { getEntryBackupKey } from '@staticcms/core/lib/util/backup.util';
import classNames from '@staticcms/core/lib/util/classNames.util';
import {
selectEntryCollectionTitle,
selectFields,
getFields,
selectTemplateName,
} from '@staticcms/core/lib/util/collection.util';
import localForage from '@staticcms/core/lib/util/localForage';
import { generateClassNames } from '@staticcms/core/lib/util/theming.util';
import { selectConfig } from '@staticcms/core/reducers/selectors/config';
import { selectTheme } from '@staticcms/core/reducers/selectors/globalUI';
import { useAppSelector } from '@staticcms/core/store/hooks';
import Card from '../../common/card/Card';
import CardActionArea from '../../common/card/CardActionArea';
import CardContent from '../../common/card/CardContent';
import CardMedia from '../../common/card/CardMedia';
import useWidgetsFor from '../../common/widget/useWidgetsFor';
import WorkflowStatusPill from '../../workflow/WorkflowStatusPill';
import type {
BackupEntry,
Collection,
CollectionWithDefaults,
DateTimeFormats,
Entry,
FileOrImageField,
TranslatedProps,
} from '@staticcms/core/interface';
import type { FC } from 'react';
} from '@staticcms/core';
import type { FC, ReactNode } from 'react';
import './EntryCard.css';
export const classes = generateClassNames('EntryCard', [
'root',
'no-margin',
'content-wrapper',
'content',
'card',
'card-content',
'card-summary',
'summary-wrapper',
'summary',
'description',
'date',
'local-backup-icon',
'workflow-status',
]);
export interface EntryCardProps {
entry: Entry;
imageFieldName?: string | null | undefined;
collection: Collection;
imageFieldName: string | null | undefined;
descriptionFieldName: string | null | undefined;
dateFieldName: string | null | undefined;
dateFormats: DateTimeFormats | undefined;
collection: CollectionWithDefaults;
noMargin?: boolean;
backTo?: string;
children?: ReactNode;
useWorkflow: boolean;
}
const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
const EntryCard: FC<EntryCardProps> = ({
collection,
entry,
imageFieldName,
t,
descriptionFieldName,
dateFieldName,
dateFormats,
noMargin = false,
backTo,
children,
useWorkflow,
}) => {
const t = useTranslate();
const entryData = entry.data;
const path = useMemo(
() => `/collections/${collection.name}/entries/${entry.slug}`,
[collection.name, entry.slug],
() =>
`/collections/${collection.name}/entries/${entry.slug}${backTo ? `?backTo=${backTo}` : ''}`,
[backTo, collection.name, entry.slug],
);
const imageField = useMemo(
@ -73,15 +98,40 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
let i = imageFieldName ? (entryData?.[imageFieldName] as string | undefined) : undefined;
if (i) {
i = encodeURI(i.trim());
i = i.trim();
}
return i;
}, [entryData, imageFieldName]);
const description = useMemo(() => {
let d = descriptionFieldName
? (entryData?.[descriptionFieldName] as string | undefined)
: undefined;
if (d) {
d = d.trim();
}
return d;
}, [entryData, descriptionFieldName]);
const date = useMemo(() => {
let d = dateFieldName ? (entryData?.[dateFieldName] as string | undefined) : undefined;
if (d && dateFormats) {
const date = parse(d, dateFormats.storageFormat, new Date());
if (!isNaN(date.getTime())) {
d = format(date, dateFormats.displayFormat);
}
}
return d;
}, [dateFieldName, entryData, dateFormats]);
const summary = useMemo(() => selectEntryCollectionTitle(collection, entry), [collection, entry]);
const fields = selectFields(collection, entry.slug);
const fields = useMemo(() => getFields(collection, entry.slug), [collection, entry.slug]);
const config = useAppSelector(selectConfig);
@ -97,8 +147,6 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
[templateName],
);
const theme = useAppSelector(selectTheme);
const [hasLocalBackup, setHasLocalBackup] = useState(false);
useEffect(() => {
if (config?.disable_local_backup) {
@ -130,7 +178,7 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
if (PreviewCardComponent) {
return (
<div className={classes.root}>
<div className={classNames(classes.root, noMargin && classes['no-margin'])}>
<div className={classes['content-wrapper']}>
<div className={classes.content}>
<Card>
@ -141,10 +189,10 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
entry={entry}
widgetFor={widgetFor}
widgetsFor={widgetsFor}
theme={theme}
hasLocalBackup={hasLocalBackup}
/>
</CardActionArea>
{children}
</Card>
</div>
</div>
@ -153,7 +201,7 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
}
return (
<div className={classes.root}>
<div className={classNames(classes.root, noMargin && classes['no-margin'])}>
<div className={classes['content-wrapper']}>
<div className={classes.content}>
<Card className={classes.card} title={summary}>
@ -169,16 +217,27 @@ const EntryCard: FC<TranslatedProps<EntryCardProps>> = ({
) : null}
<CardContent>
<div className={classes['card-content']}>
<div className={classes['card-summary']}>{summary}</div>
{hasLocalBackup ? (
<InfoIcon
className={classes['local-backup-icon']}
title={t('ui.localBackup.hasLocalBackup')}
/>
) : null}
<div className={classes['summary-wrapper']}>
<div className={classes['summary']}>{summary}</div>
{hasLocalBackup ? (
<InfoIcon
className={classes['local-backup-icon']}
title={t('ui.localBackup.hasLocalBackup')}
/>
) : null}
{useWorkflow ? (
<WorkflowStatusPill
status={entry.status}
className={classes['workflow-status']}
/>
) : null}
</div>
{description ? <div className={classes.description}>{description}</div> : null}
{date ? <div className={classes.date}>{String(date)}</div> : null}
</div>
</CardContent>
</CardActionArea>
{children}
</Card>
</div>
</div>

View File

@ -2,21 +2,23 @@ import React, { useCallback, useMemo } from 'react';
import { VIEW_STYLE_TABLE } from '@staticcms/core/constants/views';
import useTranslate from '@staticcms/core/lib/hooks/useTranslate';
import { selectFields, selectInferredField } from '@staticcms/core/lib/util/collection.util';
import { getInferredFields, getFields } from '@staticcms/core/lib/util/collection.util';
import { isNullish } from '@staticcms/core/lib/util/null.util';
import { toTitleCaseFromKey } from '@staticcms/core/lib/util/string.util';
import { getDatetimeFormats } from '@staticcms/datetime/datetime.util';
import entriesClasses from './Entries.classes';
import EntryListingGrid from './EntryListingGrid';
import EntryListingTable from './EntryListingTable';
import type { ViewStyle } from '@staticcms/core/constants/views';
import type {
Collection,
CollectionEntryData,
Collections,
CollectionWithDefaults,
CollectionsWithDefaults,
DateTimeField,
Entry,
Field,
} from '@staticcms/core/interface';
} from '@staticcms/core';
import type Cursor from '@staticcms/core/lib/util/Cursor';
import type { FC } from 'react';
@ -31,11 +33,11 @@ export interface BaseEntryListingProps {
}
export interface SingleCollectionEntryListingProps extends BaseEntryListingProps {
collection: Collection;
collection: CollectionWithDefaults;
}
export interface MultipleCollectionEntryListingProps extends BaseEntryListingProps {
collections: Collections;
collections: CollectionsWithDefaults;
}
export type EntryListingProps =
@ -62,26 +64,7 @@ const EntryListing: FC<EntryListingProps> = ({
}, [handleCursorActions, hasMore]);
const inferFields = useCallback(
(
collection?: Collection,
): {
titleField?: string | null;
descriptionField?: string | null;
imageField?: string | null;
remainingFields?: Field[];
} => {
if (!collection) {
return {};
}
const titleField = selectInferredField(collection, 'title');
const descriptionField = selectInferredField(collection, 'description');
const imageField = selectInferredField(collection, 'image');
const fields = selectFields(collection);
const inferredFields = [titleField, descriptionField, imageField];
const remainingFields = fields && fields.filter(f => inferredFields.indexOf(f.name) === -1);
return { titleField, descriptionField, imageField, remainingFields };
},
(collection?: CollectionWithDefaults) => getInferredFields(collection),
[],
);
@ -110,10 +93,13 @@ const EntryListing: FC<EntryListingProps> = ({
}
const fieldNames = otherProps.collection.summary_fields;
const collectionFields = selectFields(otherProps.collection).reduce((acc, f) => {
acc[f.name] = f;
return acc;
}, {} as Record<string, Field>);
const collectionFields = getFields(otherProps.collection).reduce(
(acc, f) => {
acc[f.name] = f;
return acc;
},
{} as Record<string, Field>,
);
return fieldNames.map(summaryField => {
const field = collectionFields[summaryField];
@ -130,9 +116,21 @@ const EntryListing: FC<EntryListingProps> = ({
if ('collection' in otherProps) {
const inferredFields = inferFields(otherProps.collection);
const dateField =
'fields' in otherProps.collection
? (otherProps.collection.fields?.find(
f => f.name === inferredFields.date && f.widget === 'datetime',
) as DateTimeField)
: undefined;
const formats = getDatetimeFormats(dateField);
return entries.map(entry => ({
collection: otherProps.collection,
imageFieldName: inferredFields.imageField,
imageFieldName: inferredFields.image,
descriptionFieldName: inferredFields.description,
dateFieldName: inferredFields.date,
dateFormats: formats,
viewStyle,
entry,
key: entry.slug,
@ -146,13 +144,26 @@ const EntryListing: FC<EntryListingProps> = ({
coll => coll.name === collectionName,
);
const collectionLabel = !isSingleCollectionInList ? collection?.label : undefined;
const inferredFields = inferFields(collection);
const dateField =
collection && 'fields' in collection
? (collection.fields?.find(
f => f.name === inferredFields.date && f.widget === 'datetime',
) as DateTimeField)
: undefined;
const formats = getDatetimeFormats(dateField);
const collectionLabel = !isSingleCollectionInList ? collection?.label : undefined;
return collection
? {
collection,
entry,
imageFieldName: inferredFields.imageField,
imageFieldName: inferredFields.image,
descriptionFieldName: inferredFields.description,
dateFieldName: inferredFields.date,
dateFormats: formats,
viewStyle,
collectionLabel,
key: entry.slug,
@ -173,7 +184,6 @@ const EntryListing: FC<EntryListingProps> = ({
loadNext={handleLoadMore}
canLoadMore={Boolean(hasMore && handleLoadMore)}
isLoadingEntries={isLoadingEntries}
t={t}
/>
</div>
);
@ -186,7 +196,6 @@ const EntryListing: FC<EntryListingProps> = ({
onLoadMore={handleLoadMore}
canLoadMore={Boolean(hasMore && handleLoadMore)}
isLoadingEntries={isLoadingEntries}
t={t}
/>
);
};

View File

@ -8,38 +8,39 @@ import {
COLLECTION_CARD_MARGIN,
COLLECTION_CARD_WIDTH,
} from '@staticcms/core/constants/views';
import useTranslate from '@staticcms/core/lib/hooks/useTranslate';
import { getPreviewCard } from '@staticcms/core/lib/registry';
import classNames from '@staticcms/core/lib/util/classNames.util';
import { selectTemplateName } from '@staticcms/core/lib/util/collection.util';
import { isNotNullish } from '@staticcms/core/lib/util/null.util';
import { selectUseWorkflow } from '@staticcms/core/reducers/selectors/config';
import { useAppSelector } from '@staticcms/core/store/hooks';
import entriesClasses from './Entries.classes';
import EntryCard from './EntryCard';
import type { CollectionEntryData } from '@staticcms/core/interface';
import type { CollectionEntryData } from '@staticcms/core';
import type { FC } from 'react';
import type { t } from 'react-polyglot';
import type { GridChildComponentProps } from 'react-window';
export interface EntryListingCardGridProps {
scrollContainerRef: React.MutableRefObject<HTMLDivElement | null>;
entryData: CollectionEntryData[];
onScroll: () => void;
t: t;
}
export interface CardGridItemData {
columnCount: number;
cardHeights: number[];
entryData: CollectionEntryData[];
t: t;
useWorkflow: boolean;
}
const CardWrapper = ({
const CardWrapper: FC<GridChildComponentProps<CardGridItemData>> = ({
rowIndex,
columnIndex,
style,
data: { columnCount, cardHeights, entryData, t },
}: GridChildComponentProps<CardGridItemData>) => {
data: { columnCount, cardHeights, entryData, useWorkflow },
}) => {
const left = useMemo(
() =>
parseFloat(
@ -82,7 +83,10 @@ const CardWrapper = ({
collection={data.collection}
entry={data.entry}
imageFieldName={data.imageFieldName}
t={t}
descriptionFieldName={data.descriptionFieldName}
dateFieldName={data.dateFieldName}
dateFormats={data.dateFormats}
useWorkflow={useWorkflow}
/>
</div>
);
@ -92,8 +96,11 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
entryData,
scrollContainerRef,
onScroll,
t,
}) => {
const t = useTranslate();
const useWorkflow = useAppSelector(selectUseWorkflow);
const [version, setVersion] = useState(0);
const handleResize = useCallback(() => {
@ -195,6 +202,7 @@ const EntryListingCardGrid: FC<EntryListingCardGridProps> = ({
entryData,
cardHeights,
columnCount,
useWorkflow,
t,
} as CardGridItemData
}

Some files were not shown because too many files have changed in this diff Show More